당신이 이미 겪어본 이상한 동작
아래 코드를 보자.
console.log(name); // undefined... 왜?
var name = '철수';
console.log(name); // '철수'
에러가 아니다. undefined다. 선언도 하기 전에 변수를 참조했는데 에러가 나지 않는다. 대부분의 개발자는 이걸 "호이스팅 때문"이라고 설명하고 넘어간다. 하지만 호이스팅은 현상의 이름이지, 원인이 아니다.
이번 글에서는 이 현상이 왜 생기는지를 JS 엔진이 코드를 처리하는 방식 즉 실행 컨텍스트 수준에서 완전히 해부한다.
1. JS 엔진은 코드를 두 번 훑는다
JS 엔진은 코드를 실행하기 전에 반드시 평가 단계를 먼저 거친다. 이 평가 단계와 실제 실행 단계, 총 두 번의 훑기가 일어난다.
- Creation phase (평가 단계) — 코드를 실행하기 전, 엔진이 실행 환경을 세팅하는 단계
- Execution phase (실행 단계) — 코드를 위에서 아래로 한 줄씩 실제로 실행하는 단계
var name이 undefined로 찍히는 이유가 바로 여기 있다. Creation phase에서 엔진은 var name이라는 선언을 미리 발견하고, name이라는 식별자를 등록해둔다. 단 값은 아직 undefined로만 초기화된다. Execution phase가 되어서야 = '철수' 부분이 처리된다.
코드가 올라가는 게 아니다. 엔진이 먼저 한 번 훑고 준비를 마친 다음 실행에 들어가는 것이다.
이 준비 단계의 결과물이 바로 실행 컨텍스트(Execution Context) 다.
따라서! 소스코드 평가 과정이 끝나면 비로소 선언문을 제외한 소스코드가 순차적으로 실행되기 시직한다. 즉 런타임이 시작된다. 이때 소스코드 실행에 필요한 정보’ 즉 변수나 힘수의 침조를 실행 컨텍스트가 관리히는 스코프에서 검색해서 취득한다. 그리고 변수 값의 변경 등 소스코드의 실행 결괴는 다시 실행 컨텍스트가 관리하는 스코프에 등록된다
2. 실행 컨텍스트 안에는 무엇이 있는가
실행 컨텍스트는 코드가 실행되기 위해 필요한 환경 정보를 담은 객체다. 내부에는 세 개의 슬롯이 있다.
Variable Environment
식별자 등록 장부다. var로 선언된 변수와 함수 선언문이 이 단계에서 등록된다. Creation phase에서 var 변수는 undefined로, 함수 선언문은 함수 객체 전체로 초기화된다. 이것이 함수 선언문과 함수 표현식의 동작 차이를 만드는 이유다.
// 함수 선언문 — Creation phase에서 함수 전체가 등록됨
hello(); // 'Hello!' — 정상 작동
function hello() {
console.log('Hello!');
}
// 함수 표현식 — var greet는 undefined로만 등록됨
greet(); // TypeError: greet is not a function
var greet = function() {
console.log('Hi!');
};
Lexical Environment
스코프 체인의 실체다. 두 가지 컴포넌트로 이루어진다.
- Environment Record — 현재 스코프에서 선언된 식별자들을 실제로 저장하는 공간
- Outer Lexical Environment Reference — 상위 스코프를 가리키는 참조. 이것이 스코프 체인을 만든다.
식별자를 검색할 때 엔진은 현재 Environment Record를 먼저 찾고 없으면 Outer Reference를 따라 상위 스코프로 올라간다. 전역 스코프까지 올라갔는데도 없으면 ReferenceError가 발생한다.
ThisBinding
현재 실행 컨텍스트에서 this가 가리키는 값이다. this는 선언 위치가 아닌 실행 컨텍스트가 생성되는 시점에 결정된다. 이 슬롯이 있기 때문에 같은 함수도 어떻게 호출하느냐에 따라 this가 달라진다.
3. 콜 스택 — 실행 컨텍스트의 생명주기
실행 컨텍스트는 스택 구조로 관리된다. 이를 실행 컨텍스트 스택 흔히 콜 스택(Call Stack) 이라고 부른다.
동작 원리는 단순하다.
- 전역 코드 평가 → Global EC 생성 → 스택에 push
- 함수 호출 → 새 EC 생성 → 스택에 push (현재 실행 중인 EC는 일시 중단)
- 함수 실행 완료 → 해당 EC를 스택에서 pop → 이전 EC 재개
다음 코드를 기준으로 이 흐름을 따라가 보자.
var x = 1;
function foo() {
var y = 2;
function bar() {
var z = 3;
console.log(x + y + z); // 6
}
bar();
}
foo();
단계별 흐름
① 전역 코드 평가
엔진이 전역 코드를 처음 만나면 Global Execution Context를 생성한다. Creation phase에서 x와 foo가 등록된다. x는 undefined, foo는 함수 객체로 초기화된다. 이 EC가 스택의 맨 아래에 놓인다.
[ Global EC ] ← 현재 실행 중
② 전역 코드 실행
Execution phase가 시작되면서 x = 1 할당이 처리된다. foo() 호출 라인에 도달하면 Global EC는 일시 중단되고, foo의 EC 생성이 시작된다.
③ foo 함수 코드 평가
foo의 EC가 생성된다. Creation phase에서 y와 bar가 등록된다. Outer Lexical Environment Reference는 Global EC의 Lexical Environment를 가리킨다.
[ foo EC ] ← 현재 실행 중
[ Global EC ]
④ foo 함수 코드 실행
y = 2 할당이 처리되고, bar() 호출에 도달한다. foo EC는 일시 중단된다.
⑤ bar 함수 코드 평가
bar의 EC가 생성된다. z가 등록된다. Outer Reference는 foo EC의 Lexical Environment를 가리킨다.
[ bar EC ] ← 현재 실행 중
[ foo EC ]
[ Global EC ]
⑥ bar 함수 코드 실행
z = 3이 처리된다. console.log(x + y + z)를 실행하기 위해 식별자를 검색한다.
- z → bar EC의 Environment Record에서 발견 → 3
- y → bar EC에 없음 → Outer Reference 따라 foo EC로 이동 → 발견 → 2
- x → foo EC에도 없음 → 다시 Outer Reference 따라 Global EC로 이동 → 발견 → 1
결과: 1 + 2 + 3 = 6
⑦ 함수 종료 — 스택에서 pop
bar 실행 완료 → bar EC pop → foo EC 재개 foo 실행 완료 → foo EC pop → Global EC 재개 전역 코드 실행 완료 → Global EC pop → 스택 비워짐
(시각화자료 ⬇️ 다음 단계 버튼을 누를 때마다 foo/bar 예제 기준으로 실행 컨텍스트가 콜 스택에 push/pop되는 걸 실시간으로 보여줘요)
https://claude.ai/public/artifacts/809f7f50-2cf3-40bd-a992-b79bf6f5abd8
callstack-visualizer.html
callstack-visualizer.html
claude.ai
5. 마무리 — 그래서 클로저는 왜 동작하는가
지금까지 살펴본 내용을 정리하면 이렇다.
- JS 엔진은 코드를 실행하기 전에 Creation phase를 통해 실행 컨텍스트를 먼저 준비한다
- 실행 컨텍스트는 Variable Environment, Lexical Environment, ThisBinding 세 슬롯으로 구성된다
- 실행 컨텍스트는 콜 스택으로 관리되며, 함수 호출 시 push, 반환 시 pop된다
- let/const의 TDZ는 버그를 명시적 에러로 드러내기 위한 언어 설계 의도다.
여기서 한 가지 질문이 남는다.
함수가 실행을 마치고 실행 컨텍스트가 스택에서 pop되면, 그 함수의 Lexical Environment도 사라져야 한다. 그런데 클로저는 이미 종료된 외부 함수의 변수에 접근할 수 있다. 어떻게?
실행 컨텍스트가 사라지는 것과 Lexical Environment가 사라지는 것은 다르다. 클로저가 내부 함수의 [[Environment]] 슬롯을 통해 외부 함수의 Lexical Environment를 참조하고 있는 한, 가비지 컬렉터는 그 객체를 수거하지 않는다.
이것이 클로저의 실체다.