렉시컬 환경의 구조는?

실행 컨텍스트의 핵심 구성 요소인 렉시컬 환경의 환경 레코드와 외부 렉시컬 환경 참조 구조를 이해합니다

중급 15분 렉시컬 환경 환경 레코드 외부 참조 구조

렉시컬 환경(Lexical Environment)은 JavaScript 엔진이 변수와 함수를 관리하는 내부 자료구조로, 실행 컨텍스트의 핵심 구성 요소입니다. 코드를 실행할 때 변수를 어디서 찾아야 하는지, 스코프 체인이 어떻게 형성되는지를 결정하는 것이 바로 렉시컬 환경의 역할입니다. 이 구조를 이해하면 호이스팅, 클로저, 스코프 체인과 같은 JavaScript의 핵심 동작 원리를 명확하게 파악할 수 있습니다. 렉시컬 환경은 환경 레코드(Environment Record)와 외부 렉시컬 환경 참조(Outer Lexical Environment Reference)라는 두 가지 주요 컴포넌트로 구성되며, 이들이 협력하여 변수 해석과 스코프 체인을 구현합니다.

핵심 구조

  • 환경 레코드: 현재 스코프에 선언된 변수와 함수를 저장하는 실제 저장소로, 선언적 환경 레코드와 객체 환경 레코드로 구분됩니다
  • 외부 렉시컬 환경 참조: 상위 스코프의 렉시컬 환경을 가리키는 링크로, 스코프 체인의 기반을 형성합니다
  • this 바인딩: 함수 실행 컨텍스트의 경우 this 값을 저장하며, 함수 호출 방식에 따라 달라집니다
  • 계층적 구조: 전역 렉시컬 환경부터 시작하여 함수, 블록 스코프까지 중첩된 계층 구조를 형성합니다
  • 생성 시점과 생명주기: 실행 컨텍스트 생성 단계에서 초기화되며, 컨텍스트가 종료되면 가비지 컬렉션 대상이 됩니다

실무에서의 영향

렉시컬 환경의 구조를 정확히 이해하면 변수 선언 방식(var, let, const)에 따른 동작 차이를 예측할 수 있고, 클로저가 발생하는 메커니즘을 명확히 파악할 수 있습니다. 특히 React의 이벤트 핸들러나 콜백 함수에서 발생하는 스코프 관련 버그를 사전에 방지할 수 있으며, 메모리 누수가 발생할 수 있는 클로저 패턴을 식별하고 최적화할 수 있습니다. 또한 모듈 시스템의 동작 원리를 이해하여 import/export가 어떻게 네임스페이스를 격리하는지 파악할 수 있으며, 번들러(Webpack, Vite)가 생성하는 코드의 스코프 구조를 분석할 수 있습니다. 디버깅 시에는 개발자 도구의 Scope 패널을 효과적으로 활용하여 변수 해석 과정을 추적할 수 있고, 복잡한 중첩 함수에서 발생하는 예상치 못한 변수 값 문제를 빠르게 진단할 수 있습니다.


핵심 개념

환경 레코드 (Environment Record)

입문

환경 레코드는 변수와 함수를 보관하는 ‘저장 공간’이에요. 여러분이 사용하는 서랍장과 비슷하다고 생각하면 됩니다!

📦 환경 레코드가 뭔가요? 환경 레코드는 코드에서 선언한 변수와 함수를 실제로 저장하는 공간이에요. 마치 학교 사물함에 책과 준비물을 넣어두는 것처럼, JavaScript 엔진도 변수와 함수를 환경 레코드라는 저장소에 보관해요.

🗂️ 어떤 종류가 있나요? 환경 레코드는 크게 두 종류가 있어요. ‘선언적 환경 레코드’는 여러분이 let, const, function으로 만든 것들을 보관하는 일반 서랍이고, ‘객체 환경 레코드’는 전역 변수처럼 특별한 것들을 보관하는 특별한 서랍이에요.

🎯 왜 중요한가요? 환경 레코드가 없다면 JavaScript는 여러분이 만든 변수를 어디에 저장해야 할지 몰라요. 마치 사물함 없이 학교에 다니면 책을 어디 두어야 할지 모르는 것처럼요. 환경 레코드 덕분에 변수 이름으로 값을 찾을 수 있어요.

💡 어떻게 작동하나요? 코드를 실행하기 전에 JavaScript 엔진은 먼저 환경 레코드를 만들어요. 그리고 코드에서 let x = 10 같은 선언을 발견하면, 환경 레코드에 ‘x’라는 이름으로 10이라는 값을 저장해요. 나중에 x를 사용하면 환경 레코드에서 찾아서 가져오는 거죠!

중급

환경 레코드(Environment Record)는 렉시컬 환경의 핵심 컴포넌트로, 현재 스코프에 선언된 식별자(변수, 함수, 클래스)와 그 값의 매핑을 저장하는 자료구조입니다.

환경 레코드의 종류

  1. 선언적 환경 레코드(Declarative Environment Record)

    • let, const, function, class 선언을 저장
    • 함수 스코프와 블록 스코프에서 사용
    • 가장 일반적인 형태의 환경 레코드
  2. 객체 환경 레코드(Object Environment Record)

    • 전역 객체(window, global)의 프로퍼티로 관리
    • var 선언과 전역 함수 선언에 사용
    • with 문에서도 사용 (현재는 비권장)
function example() {
  let x = 10;      // 선언적 환경 레코드에 저장
  const y = 20;    // 선언적 환경 레코드에 저장

  console.log(x, y); // 10, 20
}

// 함수 실행 시 새로운 선언적 환경 레코드 생성
// 레코드 구조: { x: 10, y: 20 }

환경 레코드의 역할

환경 레코드는 식별자 바인딩(Identifier Binding)을 관리합니다. 즉, 변수 이름과 실제 값을 연결하는 작업을 수행하죠. 변수를 참조할 때 JavaScript 엔진은 현재 환경 레코드에서 해당 식별자를 검색하고, 없으면 외부 렉시컬 환경 참조를 통해 상위 스코프로 검색을 확장합니다.

// 전역 스코프
var globalVar = 100;  // 객체 환경 레코드 (window.globalVar)

function test() {
  console.log(window.globalVar); // 100
  console.log(globalVar);         // 100 (동일한 값)
}

// var로 선언한 전역 변수는 window 객체의 프로퍼티가 됨

심화

환경 레코드는 ECMAScript 명세 9.1절 Environment Records에서 정의된 추상 자료구조로, 식별자 바인딩의 생성, 조회, 수정, 삭제를 담당하는 인터페이스입니다.

ECMAScript 명세 기반 환경 레코드 계층 구조

ECMAScript 2023, Section 9.1에 따르면, 환경 레코드는 다음과 같은 계층 구조를 가집니다:

  1. Environment Record (추상 베이스 클래스)

    • HasBinding(N): 식별자 N의 바인딩 존재 여부 확인
    • CreateMutableBinding(N, D): 변경 가능한 바인딩 생성 (let, var)
    • CreateImmutableBinding(N, S): 변경 불가능한 바인딩 생성 (const)
    • InitializeBinding(N, V): 바인딩 초기화
    • SetMutableBinding(N, V, S): 바인딩 값 변경
    • GetBindingValue(N, S): 바인딩 값 조회
  2. Declarative Environment Record

    • 내부적으로 Map 또는 Hash Table 자료구조 사용
    • 직접 메모리 주소 참조로 O(1) 검색 성능
    • TDZ(Temporal Dead Zone) 구현을 위한 uninitialized 상태 지원
  3. Object Environment Record

    • binding object (전역 객체 등)를 래핑
    • 프로퍼티 접근으로 바인딩 조회 (간접 참조)
    • Object.defineProperty로 동적 프로퍼티 추가 가능
  4. Function Environment Record (Declarative 확장)

    • [[ThisValue]]: this 바인딩 저장
    • [[FunctionObject]]: 함수 객체 참조
    • [[NewTarget]]: new.target 값 저장
  5. Global Environment Record (복합 구조)

    • Object Environment Record + Declarative Environment Record
    • var/function 선언 → Object ER (전역 객체)
    • let/const/class 선언 → Declarative ER (전역 스코프)

V8 엔진 구현과 최적화

V8 엔진은 환경 레코드를 Context 객체로 구현합니다:

Context 구조 최적화

  • 고정 크기 슬롯(Fixed Size Slots): 함수 내 변수 개수가 컴파일 타임에 결정되면 고정 크기 배열로 할당
  • 동적 확장(Dynamic Extension): 평가 시점에 변수가 추가되면 해시 테이블로 전환
  • 인라인 캐싱(Inline Caching): 반복 접근 시 슬롯 인덱스를 캐싱하여 O(1) 접근 보장

메모리 레이아웃 최적화

  • Context Snapshot: 클로저 생성 시 필요한 변수만 복사 (전체 컨텍스트 복사 방지)
  • Escape Analysis: 함수 외부로 탈출하지 않는 변수는 스택 할당 (힙 할당 회피)
  • Pointer Compression (V8 9.0+): 64비트 시스템에서 포인터를 32비트로 압축하여 메모리 사용량 40% 감소

성능 특성

  • Declarative ER 접근: O(1) - 직접 슬롯 인덱스 참조
  • Object ER 접근: O(1) ~ O(log n) - 객체 프로퍼티 검색 (Hidden Class 최적화 적용 시 O(1))
  • Global ER 접근: 복합 검색 (Declarative 우선, 없으면 Object ER 검색)

외부 렉시컬 환경 참조 (Outer Lexical Environment Reference)

입문

외부 렉시컬 환경 참조는 ‘상위 서랍장으로 가는 화살표’예요. 현재 서랍에 원하는 물건이 없으면 화살표를 따라 위층 서랍장을 찾아가는 거죠!

🔗 외부 참조가 뭔가요? 외부 렉시컬 환경 참조는 현재 환경에서 찾을 수 없는 변수를 찾기 위해 상위 환경으로 연결해주는 링크예요. 마치 자기 방에 없는 물건을 찾기 위해 거실로 가는 것처럼, JavaScript도 현재 스코프에 없는 변수를 찾기 위해 상위 스코프로 이동해요.

🏠 어떻게 연결되나요? 함수 안에 함수가 있다고 생각해보세요. 안쪽 함수는 바깥쪽 함수를 ‘외부 참조’로 가지고 있어요. 그리고 바깥쪽 함수는 전역 스코프를 외부 참조로 가지고 있죠. 이렇게 계속 연결되면서 체인처럼 이어져요!

🔍 왜 필요한가요? 외부 참조가 없다면 함수는 자기 안에 선언된 변수만 사용할 수 있어요. 하지만 외부 참조 덕분에 바깥쪽에서 선언한 변수도 사용할 수 있고, 전역 변수에도 접근할 수 있어요. 이게 바로 ‘스코프 체인’이에요!

⛓️ 어떻게 검색하나요? 변수를 찾을 때 JavaScript는 이렇게 작동해요: 먼저 현재 환경에서 찾아보고, 없으면 외부 참조를 따라 한 단계 위로 올라가요. 거기도 없으면 또 한 단계 위로… 이렇게 전역까지 올라가다가 그래도 없으면 에러가 나요!

중급

외부 렉시컬 환경 참조(Outer Lexical Environment Reference)는 현재 렉시컬 환경의 상위 스코프를 가리키는 링크로, 스코프 체인(Scope Chain)의 기반을 형성합니다.

외부 참조의 동작 원리

변수를 참조할 때 JavaScript 엔진은 다음 순서로 검색합니다:

  1. 현재 환경 레코드에서 식별자 검색
  2. 발견되지 않으면 외부 렉시컬 환경 참조를 따라 이동
  3. 상위 환경 레코드에서 검색
  4. 전역 렉시컬 환경까지 반복
  5. 전역에도 없으면 ReferenceError 발생
const global = 'I am global';

function outer() {
  const outerVar = 'I am outer';

  function inner() {
    const innerVar = 'I am inner';

    console.log(innerVar);   // 현재 환경에서 찾음
    console.log(outerVar);   // 외부 참조 → outer 환경에서 찾음
    console.log(global);     // 외부 참조 → 전역 환경에서 찾음
  }

  inner();
}

outer();
// "I am inner"
// "I am outer"
// "I am global"

렉시컬 스코핑(Lexical Scoping)

외부 렉시컬 환경 참조는 함수가 정의될 때 결정됩니다 (호출될 때가 아닙니다). 이를 렉시컬 스코핑 또는 정적 스코핑(Static Scoping)이라고 합니다. 함수가 어디서 호출되든, 정의된 위치의 스코프를 기억합니다.

const x = 1;

function foo() {
  console.log(x);  // 정의 시점의 x를 참조
}

function bar() {
  const x = 10;
  foo();  // 여기서 호출하지만 x는 1을 출력
}

bar(); // 1 (10이 아님)

// foo의 외부 참조는 정의 시점에 전역을 가리킴
// bar 내부에서 호출해도 전역의 x를 참조

클로저와 외부 참조

클로저는 외부 렉시컬 환경 참조를 통해 구현됩니다. 내부 함수가 외부 함수의 변수를 참조하면, 외부 함수 실행이 끝나도 외부 렉시컬 환경이 메모리에 유지됩니다.

function createCounter() {
  let count = 0;  // outer 환경에 저장

  return function increment() {
    count++;  // outer 환경 참조 유지
    return count;
  };
}

const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2

// increment 함수가 createCounter의
// 렉시컬 환경을 계속 참조하므로 count가 유지됨

심화

외부 렉시컬 환경 참조는 ECMAScript 명세 9.1절의 [[OuterEnv]] 내부 슬롯으로 구현되며, 스코프 체인의 정적 링크 구조(Static Link Structure)를 형성합니다.

ECMAScript 명세 기반 외부 참조 메커니즘

ECMAScript 2023, Section 9.4.3 GetIdentifierReference에 따르면, 식별자 해석(Identifier Resolution)은 다음 알고리즘을 따릅니다:

GetIdentifierReference(env, name, strict)
1. If env is null:
   a. Return Reference { base: undefined, name, strict }
2. Let exists = env.HasBinding(name)
3. If exists is true:
   a. Return Reference { base: env, name, strict }
4. Else:
   a. Let outer = env.[[OuterEnv]]
   b. Return GetIdentifierReference(outer, name, strict)

이는 재귀적 탐색 구조로, 시간 복잡도는 O(d)입니다 (d = 스코프 체인 깊이).

렉시컬 스코핑의 정적 바인딩

외부 참조는 함수 객체 생성 시점에 [[Environment]] 내부 슬롯에 캡처됩니다 (ECMAScript 10.2.1 [[Call]]):

  1. 함수 정의 시: FunctionCreate 추상 연산이 현재 실행 컨텍스트의 LexicalEnvironment를 [[Environment]]에 저장
  2. 함수 호출 시: NewFunctionEnvironment 추상 연산이 [[Environment]]를 새 환경의 [[OuterEnv]]로 설정
  3. 결과: 호출 위치와 무관하게 정의 위치의 스코프 체인 형성

클로저의 메모리 모델

클로저는 렉시컬 환경의 참조 카운트를 증가시켜 GC(Garbage Collection)를 방지합니다:

참조 그래프 구조

[Global Context]
      ↓ (outer ref)
[outer() Context] ← [increment Function Object].[[Environment]]
      ↓ (outer ref)
[increment() Context]

외부 함수 실행이 완료되어도 inner 함수 객체가 [[Environment]]로 outer 컨텍스트를 참조하므로, outer의 렉시컬 환경이 메모리에 유지됩니다.

V8 엔진 구현 최적화

Context Chain vs Scope Info

V8은 두 가지 메커니즘을 병행합니다:

  1. Context Chain (런타임)

    • 실행 시점에 [[OuterEnv]] 포인터를 따라 체인 탐색
    • 동적 변수 접근 시 사용
  2. Scope Info (컴파일 타임)

    • 파싱 단계에서 변수의 스코프 깊이와 슬롯 인덱스를 계산
    • 정적 변수 접근은 직접 슬롯 인덱스로 변환 (O(1) 접근)

Escape Analysis와 컨텍스트 최적화

  • Non-escaping Variables: 클로저에 캡처되지 않는 변수는 스택 할당 (함수 종료 시 자동 해제)
  • Escaping Variables: 클로저에 캡처되는 변수만 힙의 Context 객체로 승격
  • Partial Context: 클로저가 일부 변수만 참조하면 필요한 변수만 복사 (전체 컨텍스트 유지 방지)

성능 특성

  • 정적 변수 접근: O(1) - 컴파일 타임에 슬롯 인덱스 결정
  • 동적 변수 접근(eval, with): O(d) - 런타임 체인 탐색
  • 클로저 생성 비용: O(n) - n = 캡처된 변수 개수

렉시컬 환경의 계층 구조

입문

렉시컬 환경은 ‘상자 속의 상자’ 구조로 되어 있어요. 작은 상자가 큰 상자 안에 들어있고, 그 큰 상자가 더 큰 상자 안에 들어있는 것처럼요!

📦 계층 구조가 뭔가요? 계층 구조는 여러 층으로 쌓여있는 구조를 말해요. 마치 러시아 인형(마트료시카)처럼, 작은 인형 안에 더 작은 인형이 들어있고, 그 안에 또 더 작은 인형이 들어있는 것처럼 렉시컬 환경도 층층이 중첩되어 있어요.

🏢 어떻게 쌓여있나요? 가장 바깥쪽에는 ‘전역 렉시컬 환경’이 있어요. 이건 건물 1층이라고 생각하면 돼요. 그 안에 함수가 실행되면 2층이 만들어지고, 그 함수 안에 또 함수가 있으면 3층이 만들어지는 식이에요. 블록 스코프(if, for 같은 중괄호)도 새로운 층을 만들어요!

🔼 위로 올라가는 규칙은? 안쪽 층에서 변수를 찾을 때, 먼저 자기 층에서 찾아보고 없으면 한 층 위로 올라가요. 거기도 없으면 또 위로… 이렇게 1층(전역)까지 올라가다가 거기도 없으면 “그런 변수 없어요!”라고 에러를 내요.

🎯 왜 이렇게 만들었나요? 이 구조 덕분에 함수 안에서 바깥쪽 변수를 자연스럽게 사용할 수 있어요. 마치 2층 방에서 1층 거실에 있는 TV를 볼 수 있는 것처럼요. 하지만 반대로 1층에서 2층 방의 물건은 볼 수 없어요. 이게 바로 ‘스코프’의 원리예요!

중급

렉시컬 환경은 계층적 구조(Hierarchical Structure)를 형성하여 스코프의 중첩 관계를 표현합니다. 전역 렉시컬 환경을 루트로 하여, 함수 실행과 블록 진입 시마다 새로운 렉시컬 환경이 생성되어 트리 구조를 이룹니다.

렉시컬 환경의 계층

  1. 전역 렉시컬 환경 (Global Lexical Environment)

    • 가장 최상위 렉시컬 환경
    • 외부 참조가 null (더 이상 상위 스코프 없음)
    • 프로그램 실행 시작 시 생성, 종료 시 제거
  2. 함수 렉시컬 환경 (Function Lexical Environment)

    • 함수 호출 시마다 새로 생성
    • 외부 참조는 함수가 정의된 위치의 환경
    • 함수 실행 완료 시 제거 (클로저로 참조되지 않는 한)
  3. 블록 렉시컬 환경 (Block Lexical Environment)

    • let, const를 포함한 블록 진입 시 생성
    • if, for, while, try-catch 등의 블록마다 생성
    • 블록 종료 시 제거
// [전역 렉시컬 환경]
const global = 'global';

function outer() {
  // [outer 렉시컬 환경] → 외부 참조: 전역
  const outerVar = 'outer';

  function inner() {
    // [inner 렉시컬 환경] → 외부 참조: outer
    const innerVar = 'inner';

    if (true) {
      // [블록 렉시컬 환경] → 외부 참조: inner
      const blockVar = 'block';

      console.log(blockVar); // 현재 블록
      console.log(innerVar); // → inner
      console.log(outerVar); // → inner → outer
      console.log(global);   // → inner → outer → 전역
    }
  }

  inner();
}

outer();

계층 구조의 생성과 소멸

렉시컬 환경은 동적으로 생성되고 소멸됩니다:

  • 생성 시점: 실행 컨텍스트 생성 단계 (함수 호출, 블록 진입)
  • 소멸 시점: 실행 컨텍스트 종료 (함수 반환, 블록 탈출)
  • 예외: 클로저로 참조되면 GC 대상에서 제외되어 계속 유지
function example() {
  let x = 1;

  if (true) {
    let x = 2;  // 새로운 블록 렉시컬 환경에 저장
    console.log(x); // 2 (블록 스코프의 x)
  }

  console.log(x); // 1 (함수 스코프의 x)
}

// 블록 진입 시 새 렉시컬 환경 생성
// 블록의 x는 함수의 x를 가리지 않음 (Shadowing)

변수 섀도잉(Variable Shadowing)

하위 스코프에서 상위 스코프와 동일한 이름의 변수를 선언하면, 하위 변수가 상위 변수를 ‘가립니다’. 이를 섀도잉이라고 하며, 하위 스코프에서는 상위 동명 변수에 접근할 수 없게 됩니다.

심화

렉시컬 환경의 계층 구조는 ECMAScript 명세 9.1절에서 정의한 Environment Record의 [[OuterEnv]] 슬롯을 통해 구현되는 단방향 연결 리스트(Singly Linked List) 구조입니다.

ECMAScript 명세 기반 계층 생성 메커니즘

ECMAScript 2023, Section 9.4 Execution Contexts에 따르면, 실행 컨텍스트 생성 시 다음 절차로 렉시컬 환경 계층이 형성됩니다:

전역 실행 컨텍스트 초기화 (InitializeHostDefinedRealm)

  1. Global Environment Record 생성 (객체 ER + 선언적 ER 복합)
  2. [[OuterEnv]] = null (최상위 환경)
  3. 전역 객체(global object) 바인딩

함수 실행 컨텍스트 초기화 (OrdinaryCallEvaluateBody)

  1. NewFunctionEnvironment(F, newTarget) 호출
  2. 새 Function Environment Record 생성
  3. [[OuterEnv]] = F.[[Environment]] (함수 객체의 정의 시점 환경)
  4. [[ThisValue]] = thisArgument
  5. FunctionDeclarationInstantiation 수행 (매개변수, 지역변수 바인딩)

블록 실행 컨텍스트 초기화 (BlockDeclarationInstantiation)

  1. NewDeclarativeEnvironment(oldEnv) 호출
  2. 새 Declarative Environment Record 생성
  3. [[OuterEnv]] = 현재 LexicalEnvironment (블록 외부 환경)
  4. let, const 선언 바인딩 생성 (초기화되지 않은 상태 = TDZ)

계층 탐색 알고리즘의 시간 복잡도

식별자 해석의 시간 복잡도는 스코프 체인 깊이에 비례합니다:

  • Best Case: O(1) - 현재 환경 레코드에서 발견
  • Average Case: O(d/2) - d = 스코프 체인 깊이
  • Worst Case: O(d) - 전역까지 탐색 또는 ReferenceError

실제 웹 애플리케이션에서 평균 스코프 깊이는 3-5 정도로, 실용적으로는 O(1)에 근접합니다.

V8 엔진의 계층 구조 최적화

컴파일 타임 스코프 분석

V8은 파싱 단계에서 PreParser와 Parser를 통해 정적 스코프 분석을 수행합니다:

  1. Scope Chain 사전 계산: 변수가 몇 단계 위 스코프에 있는지 계산
  2. Slot Index 할당: 각 변수에 환경 레코드 내 슬롯 번호 부여
  3. 바이트코드 최적화: 변수 접근을 LdaContextSlot 명령어로 변환
    • 예: LdaContextSlot r0, 2, 3 (2단계 위, 3번 슬롯)

런타임 Context Chain 최적화

V8의 Context 객체는 다음과 같이 최적화됩니다:

  1. Fixed Context: 변수 개수가 고정되면 배열로 할당 (연속 메모리)
  2. Dictionary Context: 동적 프로퍼티 추가 시 해시 테이블로 전환
  3. Native Context: 전역 환경은 특수한 Native Context로 최적화

Inline Caching을 통한 접근 최적화

반복적인 변수 접근은 IC(Inline Cache)로 최적화됩니다:

// 첫 번째 접근: 스코프 체인 탐색 (느림)
LoadIC: variable -> lookup chain -> cache (context, slot_index)

// 두 번째 이후 접근: 캐시된 경로 사용 (빠름)
LoadIC: variable -> cache hit -> direct context[slot_index]

메모리 관리와 GC

렉시컬 환경의 생명주기는 참조 카운팅과 마킹 GC로 관리됩니다:

  • 참조 추적: Function 객체의 [[Environment]] 슬롯이 외부 환경 참조
  • Reachability 분석: GC Mark 단계에서 루트부터 도달 가능한 환경만 유지
  • 클로저 최적화: 캡처된 변수만 별도 Context로 분리 (전체 환경 유지 방지)

성능 벤치마크 (V8 12.0, 1M iterations)

  • 로컬 변수 접근: ~0.3ns (L1 캐시)
  • 1단계 외부 변수: ~0.5ns (IC 최적화)
  • 3단계 외부 변수: ~1.2ns (IC 최적화)
  • 전역 변수 접근: ~1.5ns (Native Context 직접 참조)

렉시컬 환경의 생성과 생명주기

입문

렉시컬 환경은 함수가 실행될 때 ‘태어나고’, 함수가 끝나면 ‘사라지는’ 임시 공간이에요. 마치 캠핑장에서 텐트를 치고 나중에 철수하는 것과 비슷해요!

🏕️ 언제 만들어지나요? 렉시컬 환경은 함수를 호출하는 순간 만들어져요. 함수가 시작되기 직전에 JavaScript 엔진이 “자, 이 함수를 위한 공간을 준비하자!”하고 새 환경을 만드는 거죠. 블록(if, for 같은 중괄호) 안에 들어갈 때도 새로 만들어져요.

⏱️ 언제 사라지나요? 보통은 함수가 끝나면 바로 사라져요. 캠핑이 끝나면 텐트를 철수하는 것처럼요. 하지만 특별한 경우에는 함수가 끝나도 사라지지 않고 계속 남아있을 수 있어요. 이게 바로 ‘클로저’예요!

💫 어떻게 준비하나요? 렉시컬 환경을 만들 때 JavaScript는 두 단계로 작업해요. 첫 번째 단계에서는 변수와 함수 이름들을 먼저 등록해요 (이게 호이스팅이에요). 두 번째 단계에서는 실제 코드를 실행하면서 값을 채워넣어요.

🗑️ 왜 자동으로 사라지나요? 메모리를 아끼기 위해서예요. 만약 함수가 끝났는데도 환경이 계속 남아있으면 메모리가 금방 꽉 차버려요. 그래서 JavaScript는 ‘가비지 컬렉터’라는 청소부가 더 이상 필요 없는 환경을 자동으로 치워줘요. 하지만 클로저처럼 아직 사용 중인 환경은 놔두고요!

♻️ 다시 사용할 수 있나요? 함수를 다시 호출하면 새로운 렉시컬 환경이 새로 만들어져요. 같은 함수라도 호출할 때마다 독립적인 새 환경이 생기는 거죠. 마치 같은 캠핑장에 가더라도 매번 새로 텐트를 치는 것처럼요!

중급

렉시컬 환경의 생명주기(Lifecycle)는 실행 컨텍스트의 생성과 소멸에 연동됩니다. 함수 호출, 블록 진입, 전역 코드 실행 시작 시점에 생성되며, 해당 컨텍스트 종료 시 가비지 컬렉션 대상이 됩니다.

렉시컬 환경 생성 단계

실행 컨텍스트 생성은 다음 두 단계로 진행됩니다:

  1. 생성 단계 (Creation Phase)

    • 렉시컬 환경 생성
    • 환경 레코드 초기화
    • 변수와 함수 선언 스캔 및 등록 (호이스팅)
    • let/const는 선언만 등록 (TDZ 상태)
    • var는 undefined로 초기화
    • 함수 선언은 함수 객체로 초기화
  2. 실행 단계 (Execution Phase)

    • 코드 한 줄씩 실행
    • 변수 할당문 실행 시 값 바인딩
    • let/const 초기화 (TDZ 해제)
function example() {
  // === 생성 단계 ===
  // 렉시컬 환경 생성
  // var x: undefined (초기화됨)
  // let y: <uninitialized> (TDZ)
  // const z: <uninitialized> (TDZ)

  console.log(x); // undefined (호이스팅)
  // console.log(y); // ReferenceError (TDZ)

  var x = 10;
  let y = 20;
  const z = 30;

  // === 실행 단계 ===
  // x = 10 (값 할당)
  // y = 20 (초기화 + 값 할당, TDZ 해제)
  // z = 30 (초기화 + 값 할당, TDZ 해제)

  console.log(x, y, z); // 10, 20, 30
}

example();

렉시컬 환경 소멸 조건

렉시컬 환경은 다음 조건에서 가비지 컬렉션 대상이 됩니다:

  1. 실행 컨텍스트 종료: 함수가 return되거나 블록을 벗어남
  2. 참조 제거: 어떤 함수 객체도 해당 환경을 참조하지 않음
  3. 도달 불가능: GC 루트에서 도달할 수 없는 상태
function normal() {
  let temp = 'will be removed';
  return temp;
}

const result = normal();
// normal의 렉시컬 환경은 함수 종료 시 즉시 GC 대상
// temp 변수는 메모리에서 제거됨

function withClosure() {
  let persistent = 'will persist';

  return function inner() {
    return persistent;
  };
}

const closure = withClosure();
// withClosure의 렉시컬 환경은 유지됨
// inner 함수가 persistent를 참조하므로 GC 대상 아님

클로저와 렉시컬 환경 유지

클로저는 외부 함수의 렉시컬 환경을 참조하는 내부 함수입니다. 내부 함수 객체가 살아있는 한, 참조하는 외부 렉시컬 환경도 메모리에 유지됩니다.

let closureRef = null;

function outer() {
  let outerVar = 'I will survive';

  function inner() {
    console.log(outerVar);
  }

  closureRef = inner;
  // inner 함수 객체가 전역으로 노출됨
}

outer();
// outer 함수는 종료되었지만
// closureRef가 inner를 참조하고
// inner가 outer의 렉시컬 환경을 참조하므로
// outer의 렉시컬 환경은 계속 유지됨

closureRef(); // "I will survive"

closureRef = null;
// 이제 outer의 렉시컬 환경은 GC 대상이 됨

심화

렉시컬 환경의 생명주기는 ECMAScript 명세 9.4절 Execution Contexts의 생성과 소멸 절차, 그리고 JavaScript 엔진의 가비지 컬렉션 알고리즘에 의해 관리됩니다.

ECMAScript 명세 기반 생성 절차

ECMAScript 2023, Section 9.4.1 PrepareForOrdinaryCall에 따르면, 함수 호출 시 다음 절차로 렉시컬 환경이 생성됩니다:

PrepareForOrdinaryCall(F, newTarget)

  1. calleeContext = new Execution Context 생성
  2. calleeContext.Function = F
  3. localEnv = NewFunctionEnvironment(F, newTarget)
    • Function Environment Record 생성
    • [[OuterEnv]] = F.[[Environment]]
    • [[ThisValue]] = undefined (추후 바인딩)
  4. calleeContext.LexicalEnvironment = localEnv
  5. calleeContext.VariableEnvironment = localEnv

FunctionDeclarationInstantiation(func, argumentsList)

생성 단계에서 다음 초기화가 순차적으로 수행됩니다:

  1. 매개변수 바인딩

    • 형식 매개변수를 환경 레코드에 CreateMutableBinding
    • 전달된 인수 값으로 InitializeBinding
  2. 함수 선언 호이스팅

    • FunctionDeclaration을 스캔하여 InstantiateFunctionObject
    • 함수 객체를 환경 레코드에 바인딩 (이미 존재하면 덮어씀)
  3. var 선언 호이스팅

    • var 선언을 스캔하여 CreateMutableBinding
    • undefined로 InitializeBinding
  4. let/const 선언 등록

    • let/const 선언을 스캔하여 CreateMutableBinding (let) 또는 CreateImmutableBinding (const)
    • 초기화하지 않음 (uninitialized 상태 = TDZ)

Temporal Dead Zone의 구현

TDZ는 바인딩이 생성되었지만 초기화되지 않은 상태로 구현됩니다:

  • 바인딩 생성: Creation Phase에서 HasBinding(N) → true
  • 초기화 전: GetBindingValue(N) → ReferenceError
  • 초기화 후: 실제 선언문 실행 시 InitializeBinding → 정상 접근 가능

이는 Environment Record의 내부 상태 머신으로 구현됩니다.

가비지 컬렉션과 렉시컬 환경

렉시컬 환경의 GC는 두 가지 메커니즘으로 관리됩니다:

참조 카운팅 (Reference Counting)

  • 함수 객체의 [[Environment]] 슬롯이 렉시컬 환경 참조
  • 내부 함수 생성 시 참조 카운트 증가
  • 함수 객체 소멸 시 참조 카운트 감소
  • 참조 카운트 0 → GC 대상

마크-스윕 GC (Mark-Sweep)

  • GC 루트(전역 객체, 실행 스택)에서 도달 가능성 분석
  • 도달 불가능한 렉시컬 환경을 마킹
  • Sweep 단계에서 메모리 회수

V8 엔진의 생명주기 최적화

Context Allocation 전략

V8은 렉시컬 환경을 Context 객체로 할당하며, 다음 최적화를 적용합니다:

  1. Stack Allocation (Fast Path)

    • 클로저에 캡처되지 않는 변수는 스택 할당
    • 함수 종료 시 자동 해제 (GC 불필요)
    • Escape Analysis로 컴파일 타임에 결정
  2. Heap Allocation (Slow Path)

    • 클로저에 캡처되는 변수는 힙 할당
    • GC가 관리하는 Context 객체로 승격
    • 필요한 변수만 복사 (Partial Context)

Young Generation과 Old Generation

렉시컬 환경은 V8의 세대별 GC 전략을 따릅니다:

  • Young Generation (Scavenger GC)

    • 새로 생성된 Context는 Young Gen에 할당
    • 빠른 함수 호출/반환 시 즉시 회수 (평균 수명 < 1ms)
    • Minor GC 주기: 수 밀리초 (매우 빠름)
  • Old Generation (Major GC)

    • 오래 살아남은 Context (클로저 등)는 Old Gen으로 승격
    • Major GC 주기: 수십~수백 밀리초
    • Incremental Marking으로 일시 중지 시간 단축

메모리 누수 패턴과 방지

클로저로 인한 의도치 않은 렉시컬 환경 유지를 방지하는 패턴:

// 안티패턴: 큰 데이터 전체 유지
function antiPattern() {
  const hugeData = new Array(1000000).fill('data');

  return function() {
    return hugeData.length; // hugeData 전체가 메모리에 유지됨
  };
}

// 최적화: 필요한 값만 추출
function optimized() {
  const hugeData = new Array(1000000).fill('data');
  const length = hugeData.length; // 숫자 값만 복사

  return function() {
    return length; // hugeData는 GC 대상이 됨
  };
}

성능 측정

  • Context 생성 비용: ~100-200 CPU cycles (V8 12.0)
  • Stack-allocated Context: ~50 cycles (Escape Analysis 최적화)
  • Heap-allocated Context: ~150 cycles + GC overhead
  • Young Gen GC 평균 시간: 1-5ms (1000개 Context 회수)
  • Old Gen GC 평균 시간: 50-200ms (Incremental Marking 적용 시)