실행 컨텍스트와 스코프 — 자바스크립트가 코드를 읽는 방식
console.log(x)를var x = 10이전에 써도 에러가 아닌undefined가 나옵니다. 코드가 위에서 아래로 실행된다면 왜 이런 일이 생기는 걸까요?
호이스팅, 스코프, 클로저는 모두 실행 컨텍스트 라는 하나의 구조에서 비롯됩니다. 이 구조를 이해하면 세 개념이 한 줄기로 연결됩니다.
실행 컨텍스트란
실행 컨텍스트(Execution Context)는 코드가 실행되기 위한 환경 정보를 담은 객체 입니다.
자바스크립트 엔진은 코드를 실행할 때 다음 세 종류의 실행 컨텍스트를 생성합니다.
- 전역 실행 컨텍스트(Global Execution Context): 스크립트가 로드되면 가장 먼저 생성됩니다.
window(브라우저) 또는globalThis가 바인딩됩니다. - ** 함수 실행 컨텍스트(Function Execution Context)**: 함수가 호출될 때마다 새로 생성됩니다.
- **Eval 실행 컨텍스트 **:
eval()호출 시 생성됩니다. 실무에서는 거의 쓰지 않습니다.
콜 스택과 실행 흐름
실행 컨텍스트는 ** 콜 스택(Call Stack)**이라는 LIFO 구조에 쌓입니다.
function first() {
console.log('first');
second();
}
function second() {
console.log('second');
}
first();
위 코드의 콜 스택 변화를 따라가 보면 이렇습니다.
- 전역 컨텍스트가 스택에 푸시
first()호출 → first 컨텍스트 푸시second()호출 → second 컨텍스트 푸시second종료 → second 컨텍스트 팝first종료 → first 컨텍스트 팝- 전역 컨텍스트만 남음
이 흐름을 따라가면 함수 호출이 중첩될 때 어떤 순서로 실행되고 종료되는지 파악할 수 있습니다.
실행 컨텍스트의 내부 구조
ES2015+ 스펙 기준으로 실행 컨텍스트는 크게 두 가지 컴포넌트를 가집니다.
LexicalEnvironment (렉시컬 환경)
렉시컬 환경은 ** 식별자-값 매핑을 관리하는 구조 **입니다. 내부적으로 세 부분으로 구성됩니다.
- Environment Record: 현재 스코프의 변수, 함수 선언을 저장
- Outer Lexical Environment Reference: 상위 스코프에 대한 참조 (스코프 체인의 핵심)
- This Binding:
this값
// 전역 렉시컬 환경 (개념적 표현)
GlobalLexicalEnvironment = {
EnvironmentRecord: {
x: 10,
greet: <function>
},
outer: null // 전역은 상위가 없음
}
VariableEnvironment
var로 선언된 변수를 관리합니다. ES2015 이전에는 LexicalEnvironment와 동일했지만, let/const가 도입되면서 역할이 분리되었습니다.
let,const→ LexicalEnvironment에서 관리var→ VariableEnvironment에서 관리
스코프와 스코프 체인
스코프(Scope)는 ** 변수에 접근할 수 있는 범위 **입니다.
자바스크립트는 ** 렉시컬 스코프(Lexical Scope)**, 즉 정적 스코프를 따릅니다. 함수가 어디서 호출되었는지가 아니라, ** 어디서 선언되었는지 **에 따라 상위 스코프가 결정됩니다.
const x = 'global';
function outer() {
const x = 'outer';
function inner() {
console.log(x); // 'outer' — inner가 선언된 위치의 상위 스코프
}
inner();
}
outer();
스코프 체인
변수를 참조할 때 현재 스코프에 없으면 ** 상위 렉시컬 환경을 따라 올라갑니다 **. 이 연결을 스코프 체인이라고 합니다.
inner의 LexicalEnvironment
→ outer의 LexicalEnvironment
→ Global LexicalEnvironment
→ null (끝)
스코프 체인은 실행 컨텍스트의 outer 참조를 통해 구현됩니다. 변수를 찾지 못하면 outer를 따라 한 단계씩 올라가며, 전역까지 도달해도 없으면 ReferenceError가 발생합니다.
호이스팅의 진짜 모습
호이스팅(Hoisting)은 "선언이 끌어올려진다"는 비유적 표현입니다. 실제로는 ** 실행 컨텍스트 생성 단계에서 선언부가 먼저 환경 레코드에 등록 **되는 것입니다.
var의 호이스팅
console.log(a); // undefined
var a = 5;
console.log(a); // 5
생성 단계에서 a가 undefined로 초기화됩니다. 그래서 선언 전에 접근해도 에러가 아닌 undefined가 나옵니다.
let/const의 호이스팅과 TDZ
console.log(b); // ReferenceError: Cannot access 'b' before initialization
let b = 10;
let과 const도 호이스팅됩니다. 하지만 Temporal Dead Zone(TDZ) 때문에 선언문 이전에 접근하면 에러가 발생합니다.
TDZ는 "스코프 시작 ~ 선언문 실행" 구간입니다.
{
// TDZ 시작
console.log(c); // ReferenceError
let c = 20; // TDZ 끝, 초기화
}
함수 호이스팅
함수 선언문은 선언과 동시에 초기화됩니다. 함수 표현식은 변수 호이스팅 규칙을 따릅니다.
// 함수 선언문 — 정상 동작
greet(); // "hello"
function greet() {
console.log("hello");
}
// 함수 표현식 — TypeError
sayHi(); // TypeError: sayHi is not a function
var sayHi = function () {
console.log("hi");
};
블록 스코프 vs 함수 스코프
var는 함수 스코프, let과 const는 블록 스코프를 가집니다.
function example() {
if (true) {
var a = 1; // 함수 스코프 → 함수 전체에서 접근 가능
let b = 2; // 블록 스코프 → if 블록 안에서만 접근 가능
const c = 3; // 블록 스코프
}
console.log(a); // 1
console.log(b); // ReferenceError
}
for 루프와 var의 조합은 스코프 차이를 극명하게 보여주는 사례입니다.
// var — 의도와 다른 결과
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// 3, 3, 3
// let — 매 반복마다 새로운 블록 스코프
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// 0, 1, 2
var는 함수 스코프이므로 루프가 끝난 뒤 i가 3인 상태에서 콜백이 실행됩니다. let은 각 반복마다 새로운 바인딩이 생기므로 의도한 대로 동작합니다.
클로저와의 연결
클로저는 실행 컨텍스트의 렉시컬 환경이 유지되기 때문에 가능합니다.
function makeCounter() {
let count = 0;
return function () {
return ++count;
};
}
const counter = makeCounter();
console.log(counter()); // 1
console.log(counter()); // 2
makeCounter가 종료되어도 반환된 함수가 count를 참조하고 있으므로, 렉시컬 환경이 가비지 컬렉션되지 않고 유지됩니다. 이것이 클로저입니다.
주의할 점
호이스팅을 "코드 이동"으로 이해하면 생기는 오해
호이스팅을 "선언이 코드 상단으로 올라간다"고 이해하면, let/const가 호이스팅되지 않는다고 착각하게 됩니다. 실제로는 모든 선언이 환경 레코드에 등록되지만, 초기화 시점이 다릅니다. 이 차이가 TDZ를 만듭니다.
렉시컬 스코프 vs 동적 스코프 혼동
자바스크립트는 렉시컬 스코프(정적 스코프)를 따릅니다. 함수가 ** 어디서 호출되었는지 **가 아니라 ** 어디서 선언되었는지 **가 상위 스코프를 결정합니다. 이 원칙을 모르면 클로저가 참조하는 변수가 어디 것인지 헷갈리게 됩니다.
콜 스택 오버플로우
재귀 함수에서 종료 조건을 빠뜨리면 실행 컨텍스트가 무한히 쌓여 RangeError: Maximum call stack size exceeded가 발생합니다. 꼬리 호출 최적화(TCO)는 사파리만 지원하므로, 깊은 재귀는 반복문으로 변환하는 것이 안전합니다.
정리
| 항목 | 설명 |
|---|---|
| 실행 컨텍스트 | 코드 실행에 필요한 환경 정보를 담은 객체, 콜 스택에 쌓임 |
| 렉시컬 환경 | 식별자-값 매핑 + 상위 스코프 참조(outer)를 관리 |
| 스코프 체인 | outer 참조를 따라가며 변수를 탐색하는 경로 |
| 호이스팅 | 실행 컨텍스트 생성 시 선언이 환경 레코드에 먼저 등록되는 메커니즘 |
| TDZ | 스코프 시작 ~ let/const 선언문 실행 시점까지 접근 불가 구간 |