선언과 할당이 분리되는 순간, 호이스팅이 시작된다. var부터 let/const까지, 자바스크립트 실행 컨텍스트의 작동 원리를 파헤쳐보자.
변수 선언과 할당, 그리고 호이스팅🪝
자바스크립트 엔진은 코드를 실행하기 전, 컴파일 단계에서 먼저 변수 선언을 스캔한다. 이때 var로 선언된 변수는 스코프 최상단으로 "끌어올려진다(hoisted)". 그러나 선언만 올라갈 뿐, 할당은 원래 자리에 그대로 남는다.
인터프리터가 코드를 실행하기 전에 함수, 변수, 클래스 또는 임포트(import)의 선언문을 해당 범위의 맨 위로 끌어올리는 것처럼 보이는 현상을 뜻합니다.
var의 호이스팅
// 우리가 작성하는 코드
console.log(name); // 에러가 아니라 undefined
var name = "Alice";
console.log(name); // "Alice"
엔진이 실제로 처리하는 순서
[컴파일] var name; // 선언만 최상단으로 끌어올림, 값은 undefined
[런타임] console.log(name); // → undefined
[런타임] name = "Alice"; // 이제서야 할당
[런타임] console.log(name); // → "Alice"
이처럼 var는 선언 전에 접근해도 ReferenceError가 아닌 undefined를 반환한다. 의도치 않은 동작을 유발하기 쉬운 이유다.
let의 호이스팅 처리 순서
[컴파일]name 선언 인식 // 초기화는 아직 안 함
[TDZ]console.log(name); // → ReferenceError!
[런타임]let name = "Alice"; // 이 시점에 초기화 + 할당
[런타임]console.log(name); // → "Alice"
let 은 호이스팅되지만 TDZ에 갇힘
선언은 컴파일 단계에 인식되지만, 초기화는 실행 흐름이 선언문에 도달할 때까지 이루어지지 않는다. 그 사이 구간이 TDZ(Temporal Dead Zone)이고, 이 구간에 접근하면 ReferenceError가 발생한다.
const x; // SyntaxError: Missing initializer
const y = 1; // OK
y = 2; // TypeError: Assignment to constant variable
const은 let과 동일하지만 선언 시 반드시 할당
TDZ 동작은 let과 동일하다. 단, 선언과 동시에 반드시 값을 할당해야 하며, 이후 재할당이 불가능하다.
셋 다 호이스팅은 일어난다. 차이는 초기화 시점이다. var는 컴파일 단계에 undefined로 초기화되고, let/const는 런타임에 선언문에 도달해야 초기화된다.
함수 선언과 함수 표현식의 차이⚓️
함수도 호이스팅된다. 그런데 함수 선언문과 함수 표현식은 완전히 다르게 동작한다. 함수 선언문은 함수 전체가 올라가고, 표현식은 변수 선언부만 올라간다. 표현식을 어떤 키워드(var, let, const)로 선언하느냐에 따라 동작이 또 달라진다.
var — undefined 반환
greet();
// TypeError:
// greet is not a function
var greet = function() {
console.log("Hi");
};
let — TDZ 에러
greet();
// ReferenceError:
// Cannot access
// before initialization
let greet = function() {
console.log("Hi");
};
const — TDZ 에러
greet();
// ReferenceError:
// Cannot access
// before initialization
const greet = function() {
console.log("Hi");
};
에러 종류가 다르다
var 표현식은 undefined로 호이스팅되어 TypeError, let/const 표현식은 TDZ에 걸려 ReferenceError가 발생한다.
sayHi(); // TypeError: sayHi is not a function
const sayHi = () => console.log("Hi");
// const는 아예 TDZ(일시적 사각지대)에 걸린다 (4섹션 참고)
화살표 함수도 동일하다.
console.log(typeof foo); // "function" (함수가 우선)
var foo = "bar";
function foo() {}
console.log(typeof foo); // "string" (할당 후엔 변수값)
또한 네이밍을 같을 경우 호이스팅 우선 순위는 변수보다 함수가 우선이다.
스코프: 변수가 살아있는 범위🐟
스코프는 변수가 접근 가능한 유효 범위다. 자바스크립트는 렉시컬(정적) 스코프를 따른다. 함수가 어디서 호출되느냐가 아니라 어디서 정의되었느냐가 스코프를 결정한다.
for, if, while 블록 안의 var는 블록을 벗어나도 살아있다. 함수 경계만 스코프로 인정하기 때문이다.
for (var i = 0; i < 3; i++) {}
console.log(i); // 3 — 블록 밖에서도 살아있음!
for (let j = 0; j < 3; j++) {}
console.log(j); // ReferenceError — 블록 안에서만 유효
스코프 체인
변수를 찾을 때 엔진은 현재 스코프에서 시작해 상위 스코프로 거슬러 올라간다. 이 탐색 경로를 스코프 체인이라 한다.
const a = "전역";
function outer() {
const b = "outer";
function inner() {
const c = "inner";
console.log(a, b, c); // 셋 다 접근 가능
}
inner();
console.log(c); // ReferenceError — 상위에선 하위 접근 불가
}
outer();
let, const와 블록 레벨 스코프🐆
ES6에서 등장한 let과 const는 블록 {}을 스코프 단위로 인식한다. var가 함수 경계만 인정하는 것과 달리 if / for / while 블록도 독립된 스코프로 취급한다.
let — 재할당 가능
let count = 0;
count = 1; // OK
count++; // OK
const — 재할당 불가
const MAX = 100;
MAX = 200; // TypeError!
// 단, 객체 내부는 변경 가능
const obj = { x: 1 };
obj.x = 2; // OK (참조는 유지됨)
기본값으로 const를 쓰고, 재할당이 필요한 경우만 let을 쓴다. var는 레거시 코드 유지 외에는 사용하지 않는다.
// var: 모든 클로저가 같은 i를 참조
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// 출력: 3, 3, 3
// let: 각 반복마다 새로운 i가 생성됨
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// 출력: 0, 1, 2
var는 함수 스코프를 가지기 때문에 반복문이 끝난 후 하나의 동일한 변수 i를 참조한다. 따라서 setTimeout이 실행될 시점에는 이미 i가 3이 되어 있어 모든 출력이 3이 된다.
반면 let은 블록 스코프를 가지며, 반복문이 실행될 때마다 새로운 i가 생성된다. 이로 인해 각 setTimeout은 서로 다른 i를 참조하게 되어 0, 1, 2가 순서대로 출력된다.

내가 든 생각!❓
let과 const도 호이스팅이 일어난다고 하는데 어차피 TDZ 때문에 초기화 전에는 접근조차 할 수 없잖아?.. 그렇다면 let/const의 호이스팅은 실질적으로 어떤 의미가 있지?
딥다이브 해봤는데 let/const의 호이스팅은 "변수가 존재한다는 사실"을 미리 등록하는 것이고 중요한 것은 값을 쓰려고 호이스팅하는 게 아니라, 스코프를 확정짓기 위해 호이스팅하는 것이다.
자바스크립트는 위에서 말했듯 렉시컬(정적) 스코프를 따른다. 함수가 어디서 호출되느냐가 아니라 어디서 정의되었느냐가 스코프를 결정한다. 따라서 엔진은 실행 전에 스코프 안의 let/const 선언을 미리 인식해 "이 변수는 이 스코프 소속이다"를 확정해둔다. 그래야 렉시컬 스코프 규칙대로 올바른 스코프 체인을 구성할 수 있기 때문이다.
let x = "전역";
function foo() {
console.log(x); // ReferenceError!
let x = "지역";
}
foo();
만약 let이 호이스팅이 안 됐다면 console.log(x)는 스코프 체인을 타고 올라가 전역의 "전역"을 출력했을 것이다. 그러나 실제로는 ReferenceError가 발생한다. let x가 함수 스코프 안에서 호이스팅되어 "이 스코프에 x가 있다"고 이미 등록되었기 때문에, 엔진은 상위 스코프를 탐색하지 않고 현재 스코프의 x를 참조하려다 TDZ에 걸리는 것이다.