console.log(x)var x = 10 이전에 써도 에러가 아닌 undefined가 나옵니다. 코드가 위에서 아래로 실행된다면 왜 이런 일이 생기는 걸까요?

호이스팅, 스코프, 클로저는 모두 실행 컨텍스트 라는 하나의 구조에서 비롯됩니다. 이 구조를 이해하면 세 개념이 한 줄기로 연결됩니다.

실행 컨텍스트란

실행 컨텍스트(Execution Context)는 코드가 실행되기 위한 환경 정보를 담은 객체 입니다.

자바스크립트 엔진은 코드를 실행할 때 다음 세 종류의 실행 컨텍스트를 생성합니다.

  • 전역 실행 컨텍스트(Global Execution Context): 스크립트가 로드되면 가장 먼저 생성됩니다. window(브라우저) 또는 globalThis가 바인딩됩니다.
  • ** 함수 실행 컨텍스트(Function Execution Context)**: 함수가 호출될 때마다 새로 생성됩니다.
  • **Eval 실행 컨텍스트 **: eval() 호출 시 생성됩니다. 실무에서는 거의 쓰지 않습니다.

콜 스택과 실행 흐름

실행 컨텍스트는 ** 콜 스택(Call Stack)**이라는 LIFO 구조에 쌓입니다.

JS
function first() {
  console.log('first');
  second();
}

function second() {
  console.log('second');
}

first();

위 코드의 콜 스택 변화를 따라가 보면 이렇습니다.

  1. 전역 컨텍스트가 스택에 푸시
  2. first() 호출 → first 컨텍스트 푸시
  3. second() 호출 → second 컨텍스트 푸시
  4. second 종료 → second 컨텍스트 팝
  5. first 종료 → first 컨텍스트 팝
  6. 전역 컨텍스트만 남음

이 흐름을 따라가면 함수 호출이 중첩될 때 어떤 순서로 실행되고 종료되는지 파악할 수 있습니다.

실행 컨텍스트의 내부 구조

ES2015+ 스펙 기준으로 실행 컨텍스트는 크게 두 가지 컴포넌트를 가집니다.

LexicalEnvironment (렉시컬 환경)

렉시컬 환경은 ** 식별자-값 매핑을 관리하는 구조 **입니다. 내부적으로 세 부분으로 구성됩니다.

  • Environment Record: 현재 스코프의 변수, 함수 선언을 저장
  • Outer Lexical Environment Reference: 상위 스코프에 대한 참조 (스코프 체인의 핵심)
  • This Binding: this
JS
// 전역 렉시컬 환경 (개념적 표현)
GlobalLexicalEnvironment = {
  EnvironmentRecord: {
    x: 10,
    greet: <function>
  },
  outer: null  // 전역은 상위가 없음
}

VariableEnvironment

var로 선언된 변수를 관리합니다. ES2015 이전에는 LexicalEnvironment와 동일했지만, let/const가 도입되면서 역할이 분리되었습니다.

  • let, const → LexicalEnvironment에서 관리
  • var → VariableEnvironment에서 관리

스코프와 스코프 체인

스코프(Scope)는 ** 변수에 접근할 수 있는 범위 **입니다.

자바스크립트는 ** 렉시컬 스코프(Lexical Scope)**, 즉 정적 스코프를 따릅니다. 함수가 어디서 호출되었는지가 아니라, ** 어디서 선언되었는지 **에 따라 상위 스코프가 결정됩니다.

JS
const x = 'global';

function outer() {
  const x = 'outer';

  function inner() {
    console.log(x); // 'outer' — inner가 선언된 위치의 상위 스코프
  }

  inner();
}

outer();

스코프 체인

변수를 참조할 때 현재 스코프에 없으면 ** 상위 렉시컬 환경을 따라 올라갑니다 **. 이 연결을 스코프 체인이라고 합니다.

PLAINTEXT
inner의 LexicalEnvironment
  → outer의 LexicalEnvironment
    → Global LexicalEnvironment
      → null (끝)

스코프 체인은 실행 컨텍스트의 outer 참조를 통해 구현됩니다. 변수를 찾지 못하면 outer를 따라 한 단계씩 올라가며, 전역까지 도달해도 없으면 ReferenceError가 발생합니다.

호이스팅의 진짜 모습

호이스팅(Hoisting)은 "선언이 끌어올려진다"는 비유적 표현입니다. 실제로는 ** 실행 컨텍스트 생성 단계에서 선언부가 먼저 환경 레코드에 등록 **되는 것입니다.

var의 호이스팅

JS
console.log(a); // undefined
var a = 5;
console.log(a); // 5

생성 단계에서 aundefined로 초기화됩니다. 그래서 선언 전에 접근해도 에러가 아닌 undefined가 나옵니다.

let/const의 호이스팅과 TDZ

JS
console.log(b); // ReferenceError: Cannot access 'b' before initialization
let b = 10;

letconst도 호이스팅됩니다. 하지만 Temporal Dead Zone(TDZ) 때문에 선언문 이전에 접근하면 에러가 발생합니다.

TDZ는 "스코프 시작 ~ 선언문 실행" 구간입니다.

JS
{
  // TDZ 시작
  console.log(c); // ReferenceError
  let c = 20;     // TDZ 끝, 초기화
}

함수 호이스팅

함수 선언문은 선언과 동시에 초기화됩니다. 함수 표현식은 변수 호이스팅 규칙을 따릅니다.

JS
// 함수 선언문 — 정상 동작
greet(); // "hello"
function greet() {
  console.log("hello");
}

// 함수 표현식 — TypeError
sayHi(); // TypeError: sayHi is not a function
var sayHi = function () {
  console.log("hi");
};

블록 스코프 vs 함수 스코프

var는 함수 스코프, letconst는 블록 스코프를 가집니다.

JS
function example() {
  if (true) {
    var a = 1;   // 함수 스코프 → 함수 전체에서 접근 가능
    let b = 2;   // 블록 스코프 → if 블록 안에서만 접근 가능
    const c = 3; // 블록 스코프
  }
  console.log(a); // 1
  console.log(b); // ReferenceError
}

for 루프와 var의 조합은 스코프 차이를 극명하게 보여주는 사례입니다.

JS
// 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은 각 반복마다 새로운 바인딩이 생기므로 의도한 대로 동작합니다.

클로저와의 연결

클로저는 실행 컨텍스트의 렉시컬 환경이 유지되기 때문에 가능합니다.

JS
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 선언문 실행 시점까지 접근 불가 구간
댓글 로딩 중...