"실행 컨텍스트는 변수 객체, 스코프 체인, this 바인딩으로 구성됩니다."
실행 컨텍스트를 검색하면 아주 높은 확률로 만나게 되는 문장입니다. 그런데 이 설명은 ES3 시절 명세를 기준으로 한 것이고, 지금의 ECMAScript 명세에는 변수 객체(Variable Object)도, 활성 객체(Activation Object)도, 배열 형태의 스코프 체인도 존재하지 않습니다.
용어가 낡았다는 것 자체는 큰 문제가 아닐 수도 있습니다.
문제는 이 설명으로는 실제로 마주치는 현상들이 설명되지 않는다는 점입니다.
왜 let 으로 선언한 변수에 typeof 를 쓰면 에러가 나는지,
왜 최상위 let 은 window 에 없는지, 왜 반복문 안의 var 는 클로저에서 마지막 값만 보이는지 같은 것들입니다.
이번 글에서는 실행 컨텍스트를 현재 명세 기준으로 정리하고, 그 구조가 실제 코드의 동작으로 어떻게 드러나는지를 중심으로 살펴보겠습니다.
실행 컨텍스트(Execution Context)는 JavaScript 코드가 실행되기 위해 필요한 정보를 담아두는, 명세상의 구조체입니다.
여기서 "명세상의" 라는 표현이 중요합니다. 명세는 환경 레코드가 순수한 명세용 장치이며, 구현체의 특정 산출물과 일대일로 대응할 필요가 없다고 명시하고 있습니다. 즉, V8 이 실제로 이런 이름의 객체를 메모리에 만들어두는 것은 아니고, "이렇게 동작하는 것처럼 보이면 된다" 는 모델에 가깝습니다. 그래서 실행 컨텍스트는 암기 대상이라기보다 동작을 예측하기 위한 모델로 보는 편이 맞습니다.
실행 컨텍스트가 새로 만들어지는 경우는 생각보다 적습니다.
전역 코드 스크립트가 평가(실행)될 때
함수 코드 함수가 호출될 때
모듈 코드 모듈이 평가(실행)될 때
eval 코드 eval 이 호출될 때
여기서 "평가" 는 명세 용어로, 선언되는 시점이 아니라 엔진이 그 코드를 실제로 실행하기 시작하는 시점을 뜻합니다.
브라우저 기준으로는 <script> 태그의 코드를 받아와 파싱까지 끝낸 뒤 실행을 시작하는 순간이고,
defer 나 async 가 붙으면 그 시점만 뒤로 밀립니다.
뒤에서 볼 선언 처리 단계 역시 이 평가 안에서 일어나는 일입니다.
참고로 이것은 태그 단위라서 <script> 태그가 여러 개면 태그마다 실행 컨텍스트가 새로 만들어집니다.
다만 전역 환경 레코드는 뒤에서 볼 것처럼 하나만 존재하기 때문에,
첫 번째 태그에서 선언한 변수를 두 번째 태그에서 그대로 쓸 수 있습니다.
여기에 블록은 포함되지 않습니다.
if 나 for 의 중괄호는 새로운 스코프를 만들지만 새로운 실행 컨텍스트를 만들지는 않습니다.
즉, 실행 컨텍스트와 스코프를 같은 것으로 이해하면 뒤에 나올 내용들이 잘 맞지 않게 됩니다.
만들어진 실행 컨텍스트는 스택에 쌓입니다. 우리가 흔히 Call Stack 이라고 부르는 것이 바로 이것입니다. 가장 위에 있는 것이 지금 실행 중인 컨텍스트(running execution context)라 할 수 있습니다.
function a() { b(); }
function b() { c(); }
function c() { throw new Error('here'); }
a();
| c | ← running
| b |
| a |
| 전역 |
에러 스택 트레이스가 이 순서 그대로 출력되는 이유가 여기에 있습니다. 즉, 스택 트레이스는 사실상 그 시점의 실행 컨텍스트 스택을 그대로 보여주는 것입니다.
현재 명세에서 실행 컨텍스트가 들고 있는 것은 아래와 같습니다.
code evaluation state 실행을 중단했다가 재개하기 위한 상태 (await, yield)
Function 실행 중인 함수 객체 (스크립트라면 null)
Realm 전역 객체와 내장 객체들의 집합
ScriptOrModule 실행 중인 스크립트 또는 모듈
LexicalEnvironment 식별자를 해석할 때 사용하는 환경
VariableEnvironment var 로 선언된 식별자를 담는 환경
PrivateEnvironment 클래스의 private 필드(#x)
이 중에서 코드의 동작에 직접 드러나는 것은 LexicalEnvironment 와 VariableEnvironment 두 가지입니다.
두 필드가 가리키는 것은 환경 레코드(Environment Record) 입니다.
환경 레코드는 식별자와 값을 연결해 기록해두는 곳이며, 아래와 같이 종류가 나뉩니다.
Declarative let, const, class, 함수 파라미터 등 대부분의 선언
Object with 문, 그리고 전역 객체
Function Declarative 를 확장한 것. this, new.target, super 를 추가로 가짐
Module Declarative 를 확장한 것. import 바인딩을 가짐
Global Object 와 Declarative 를 합쳐놓은 것
그리고 모든 환경 레코드는 [[OuterEnv]] 라는 필드를 가집니다.
자신을 감싸고 있는 바깥 환경 레코드를 가리키는 참조입니다.
스코프 체인은 별도로 관리되는 배열이 아니라, 이 [[OuterEnv]] 로 이어진 연결 리스트입니다.
식별자를 찾을 때는 현재 환경 레코드에서 시작해 없으면 [[OuterEnv]] 를 따라 올라가고,
전역까지 갔는데도 없으면 ReferenceError 가 발생합니다.
c 의 환경 레코드
↓ [[OuterEnv]]
b 의 환경 레코드
↓ [[OuterEnv]]
전역 환경 레코드
↓ [[OuterEnv]]
null
여기서 한 가지 짚고 넘어가면,
이 연결은 함수가 호출된 위치가 아니라 함수가 정의된 위치를 따릅니다.
위 예시에서 c 는 b 안에서 호출되었지만, c 가 전역에 정의되어 있다면 c 의 바깥 환경은 b 가 아니라 전역입니다.
실행 컨텍스트 스택은 호출 순서를 따르고, [[OuterEnv]] 는 작성된 위치를 따릅니다.
여기서 핵심은 두 구조가 서로 다르다는 점이며, 이것이 렉시컬(정적) 스코프의 실체입니다.
실행 컨텍스트가 만들어질 때, 코드를 한 줄씩 실행하기 전에 선언을 먼저 처리하는 단계가 있습니다.
명세에서는 함수의 경우 FunctionDeclarationInstantiation, 전역의 경우 GlobalDeclarationInstantiation 이라는 이름으로 정의되어 있습니다.
우리가 호이스팅이라고 부르는 현상은 이 단계에서 일어나는 일입니다.
여기서 자주 오해되는 부분이 있습니다.
let 과 const 도 이 단계에서 함께 처리됩니다.
"var 만 호이스팅된다" 는 설명은 반은 맞고 반은 틀린 이야기입니다.
핵심은 차이가 호이스팅 여부가 아니라 초기화 여부에 있다는 점입니다.
선언 종류 바인딩 생성 초기값
var O undefined 로 초기화
function O 함수 객체로 초기화
let / const O 초기화되지 않음
class O 초기화되지 않음
let 도 바인딩은 미리 만들어집니다. 다만 값이 채워지지 않은 상태로 남아 있고,
실제 선언문에 도달해야 초기화됩니다.
그 사이 구간이 TDZ(Temporal Dead Zone) 입니다.
이 구조는 아래 두 코드의 차이로 드러납니다.
console.log(typeof neverDeclared);
// 'undefined'
console.log(typeof notYet);
// ReferenceError: Cannot access 'notYet' before initialization
let notYet = 1;
typeof 는 선언되지 않은 식별자에 대해 예외를 던지지 않도록 정의되어 있습니다.
그래서 첫 번째는 안전하게 'undefined' 를 반환합니다.
두 번째는 다릅니다. notYet 은 이미 바인딩이 존재합니다.
즉, 식별자 탐색에 실패한 것이 아니라 찾는 데에는 성공했지만 초기화되지 않은 상태였던 것입니다.
그래서 typeof 의 예외 처리 경로를 타지 않고 그대로 에러가 발생합니다.
typeof 를 존재 여부 확인용 가드로 쓰는 코드가 종종 있는데,
let 과 const 앞에서는 그 가드가 동작하지 않는 이유가 여기에 있습니다.
hello(); // 정상 동작
world(); // TypeError: world is not a function
function hello() {}
var world = function () {};
hello 는 선언 처리 단계에서 함수 객체까지 만들어져 바인딩에 채워집니다.
world 는 var 이므로 바인딩만 만들어지고 값은 undefined 입니다.
그래서 ReferenceError 가 아니라 TypeError 가 발생합니다.
즉, 에러 종류만 보고도 어느 단계에서 걸린 것인지 구분할 수 있습니다.
전역 환경 레코드는 다른 것들과 다르게 두 개의 레코드를 합쳐놓은 형태입니다.
Global Environment Record
├─ Object Environment Record → globalThis(window) 의 프로퍼티
└─ Declarative Environment Record → let, const, class 바인딩
이 구조가 아래 동작을 만듭니다.
var a = 1;
let b = 2;
console.log(window.a); // 1
console.log(window.b); // undefined
console.log(b); // 2
var 로 선언한 것은 전역 객체의 프로퍼티가 되고,
let 과 const 로 선언한 것은 전역 객체와 무관한 선언적 레코드에 들어갑니다.
즉, 둘 다 전역 스코프에 있지만 저장되는 위치가 다른 것입니다.
DevTools 의 Scope 패널에서 Script 와 Global 이 따로 표시되는 것도 같은 이유입니다.
Script 가 최상위 let/const 가 들어있는 선언적 레코드이고, Global 이 전역 객체 쪽입니다.
디버깅 중에 변수가 예상한 칸에 없다면, 스코프를 잘못 본 것이 아니라 이 두 겹 중 다른 쪽을 보고 있는 경우가 많습니다.
위 동작은 클래식 스크립트 기준입니다. ES 모듈에서는 최상위 코드가 모듈 환경 레코드 위에서 실행됩니다.
// module.mjs
var a = 1;
console.log(window.a); // undefined
console.log(this); // undefined
모듈에서는 var 도 전역 객체에 올라가지 않고, 최상위 this 는 globalThis 가 아니라 undefined 입니다.
번들러를 통과한 코드는 대부분 모듈로 취급되기 때문에,
"전역 변수는 window 에 붙는다" 는 전제로 작성된 코드가 마이그레이션 과정에서 깨지는 경우가 있습니다.
프론트엔드 코드만 봐서는 원인이 보이지 않는 대표적인 사례라 할 수 있습니다.
앞에서 실행 컨텍스트는 함수 호출 단위로 만들어진다고 정리했습니다. 그렇다면 블록 스코프는 어떻게 만들어지는 것일까요?
블록에 진입하면 새로운 선언적 환경 레코드가 하나 만들어지고,
현재 실행 컨텍스트의 LexicalEnvironment 가 그것으로 잠시 교체됩니다.
블록을 빠져나오면 원래대로 되돌립니다.
즉, 실행 컨텍스트는 그대로 있고 그 안의 필드만 바뀌는 것입니다.
여기서 핵심은 VariableEnvironment 는 이때 교체되지 않는다는 점이며, 이것이 var 와 let 의 스코프가 다른 이유입니다.
function f() {
var x = 1;
let y = 1;
{
var x = 2; // VariableEnvironment 는 그대로 → 같은 바인딩
let y = 2; // LexicalEnvironment 가 교체됨 → 새로운 바인딩
}
console.log(x); // 2
console.log(y); // 1
}
두 필드가 왜 따로 존재하는지가 이 예시 하나에 담겨 있습니다.
var 는 함수 진입 시점에 만들어진 환경에 기록되고, let 은 지금 이 블록의 환경에 기록됩니다.
함수 실행이 끝나면 실행 컨텍스트는 스택에서 제거됩니다. 그런데 환경 레코드는 실행 컨텍스트에 종속된 것이 아니라, 참조하는 대상이 있으면 그대로 살아남습니다.
클로저의 정의와 React 에서의 stale closure 는 이전 글 에서 정리했으니, 이번 글에서는 "왜 남아있는가" 쪽만 이어서 살펴보겠습니다.
function counter() {
let count = 0;
return () => ++count;
}
const next = counter();
두 번째 줄이 끝난 시점에 counter() 의 실행 컨텍스트는 이미 스택에서 제거되었습니다.
그럼에도 count 가 유지되는 것은, 함수 객체가 만들어질 때
그 시점의 LexicalEnvironment 가 함수 객체의 [[Environment]] 내부 슬롯에 저장되기 때문입니다.
그리고 그 함수가 나중에 호출되면 새 실행 컨텍스트가 만들어지는데,
이때 새 환경 레코드의 [[OuterEnv]] 는 호출한 쪽이 아니라 함수 객체에 저장되어 있던 [[Environment]] 가 됩니다.
앞에서 "실행 컨텍스트 스택은 호출 순서를 따르고 [[OuterEnv]] 는 작성된 위치를 따른다" 고 정리했던 부분이
실제로 구현되는 지점이 여기입니다.
그래서 클로저에서 살아남는 것은 변수 하나가 아니라 환경 레코드 전체입니다. 클로저는 특별한 문법이 아니라, 실행 컨텍스트와 환경 레코드의 수명이 다르게 설계된 결과로 자연스럽게 생기는 것입니다.
이 관점은 메모리 문제를 볼 때도 도움이 됩니다.
참조가 유지되는 대상이 환경 레코드 전체이고, 그 레코드는 [[OuterEnv]] 로 바깥 환경까지 이어져 있기 때문에
큰 객체를 담은 변수와 콜백이 같은 스코프에 있으면 콜백이 그 변수를 쓰지 않더라도 함께 남아 있는 상황이 생길 수 있습니다.
가장 자주 인용되는 예시입니다.
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i));
}
// 3 3 3
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i));
}
// 0 1 2
var 의 경우 i 는 함수(또는 전역)의 VariableEnvironment 에 있는 하나의 바인딩입니다.
콜백 세 개가 모두 같은 바인딩을 참조하고, 실행 시점에는 이미 반복이 끝나 있으므로 셋 다 3 을 참조하게 됩니다.
let 의 경우 명세는 반복마다 새로운 환경 레코드를 만들고 직전 반복의 값을 복사해 넣습니다.
CreatePerIterationEnvironment 라는 이름으로 정의되어 있습니다.
그래서 콜백 세 개는 서로 다른 세 개의 바인딩을 참조하게 됩니다.
반복 1 환경 A i = 0 ← 첫 번째 콜백
반복 2 환경 B i = 1 ← 두 번째 콜백
반복 3 환경 C i = 2 ← 세 번째 콜백
let 으로 바꾸면 해결된다는 것만 알아도 코드는 고칠 수 있지만,
"바인딩이 하나인가 세 개인가" 로 이해해두면 비슷한 상황을 미리 알아볼 수 있습니다.
오래된 설명에서는 this 바인딩이 실행 컨텍스트의 구성 요소 중 하나로 소개됩니다.
현재 명세에서 this 는 실행 컨텍스트가 아니라 함수 환경 레코드 안에 있습니다.
함수 환경 레코드는 선언적 환경 레코드를 확장한 것이고, 아래 필드들이 추가되어 있습니다.
[[ThisValue]] this 값
[[ThisBindingStatus]] lexical | initialized | uninitialized
[[FunctionObject]] 해당 함수 객체
[[NewTarget]] new.target 값
this 를 평가할 때는 GetThisEnvironment 라는 동작이 실행됩니다.
현재 환경 레코드부터 시작해 this 바인딩을 가진 레코드를 만날 때까지 [[OuterEnv]] 를 따라 올라갑니다.
즉, 식별자를 찾는 방식과 완전히 같은 방식이라 할 수 있습니다.
화살표 함수의 동작은 여기서 나옵니다.
화살표 함수의 환경 레코드는 [[ThisBindingStatus]] 가 lexical 이라서 this 바인딩을 가지고 있지 않습니다.
그래서 탐색이 멈추지 않고 바깥으로 계속 올라갑니다.
const obj = {
user: 'yenac',
greet() {
console.log(this.user); // 'yenac'
setTimeout(function () {
console.log(this?.user); // undefined
});
setTimeout(() => {
console.log(this.user); // 'yenac'
});
},
};
obj.greet();
일반 함수로 넘긴 콜백은 자신의 함수 환경 레코드를 가지고 있고,
메서드 호출이 아닌 형태로 호출되었으므로 그 안의 this 는 obj 가 아닙니다.
반면 화살표 함수는 애초에 this 바인딩을 만들지 않으므로, greet 의 환경까지 올라가 obj 를 찾습니다.
즉, 화살표 함수가 "상위 스코프의 this 를 가져온다" 기보다는
"this 를 가지고 있지 않아서 스코프 체인을 타고 올라간다" 에 가깝습니다.
this 를 특별한 키워드가 아니라 조금 특이한 규칙을 가진 식별자로 보면 동작이 훨씬 단순해집니다.
정리하면 아래와 같습니다.
실행 컨텍스트 코드 실행에 필요한 정보를 담은 명세상의 구조체
전역 / 함수 / 모듈 / eval 단위로 생성되고 스택에 쌓임
환경 레코드 식별자와 값을 기록하는 곳
[[OuterEnv]] 로 연결되며 이 연결이 스코프 체인
호이스팅 선언 처리 단계에서 바인딩이 미리 생성되는 것
var 는 초기화까지, let/const 는 생성만 (= TDZ)
클로저 실행 컨텍스트가 사라진 뒤에도 환경 레코드가 남는 것
함수 객체의 [[Environment]] 가 참조를 유지하기 때문
this 실행 컨텍스트가 아니라 함수 환경 레코드에 있음
화살표 함수는 이 바인딩이 없어 바깥으로 탐색이 이어짐
이 개념이 실제로 필요해지는 순간은 개념을 설명해야 할 때보다 디버깅을 할 때가 많습니다.
DevTools Scope 패널에서 변수가 예상한 칸에 없을 때,
번들러를 바꾸고 나서 전역 변수를 못 찾을 때,
콜백 안의 this 가 갑자기 달라졌을 때 같은 상황을 예시로 들 수 있습니다.
결국 세 가지만 기억해두어도 대부분의 상황을 설명할 수 있습니다. 실행 컨텍스트는 호출 순서를 따르고, 환경 레코드는 작성된 위치를 따르며, 둘의 수명은 다르다 는 것입니다.