렉시컬 스코프, 컴파일과 실행 과정

김유현·5일 전
3
post-thumbnail

렉시컬 스코프(Lexical Scope): "소스 코드의 물리적 배치(정의 위치)에 의해 스코프 트리가 컴파일 시점에 만들어지고, 실행 중 식별자 해석은 그 트리를 '정의 시점' 기준으로 거슬러 올란간다."


1. 컴파일(Preparation) 단계에 무슨 일이 일어나나

1-1. 파싱 & AST(Abstract Syntax Tree, 추상 구문 트리) 생성

  1. 소스 코드를 토큰화
    • 토크나이징/렉싱:
      • 문자열을 토큰이라 불리는 의미 있는 조각으로 쪼갬
      • ex) var a = 2; => var, a, =, 2 로 분리됨
      • 토크나이징: 단순히 문자열을 토큰으로 분리하는 과정
      • 렉싱: 문맥과 상태를 고려해 토큰을 해석하는 과정
  2. 구문 분석(파싱)
    • 토큰 배열을 프로그램 문법 구조를 반영하는 중첩 원소 기반의 트리인 AST로 바꿈.
  3. AST(Abstract Syntax Tree) 생성
소스 코드
  └─ 토큰화(토크나이징/렉싱)
      └─ 구문 분석(파싱)
          └─ AST


출처: 공식 문서

1-2. 스코프 트리 & 바인딩 테이블 구성

  • AST를 순회하며 스코프 생성 지점(function, block, module 등)을 기준으로 스코프 트리를 만든다.
  • 각 스코프에는 "환경 레코드(Environment Record)"가 매달린다.
    • var: 함수 스코프에 바인딩
    • let/const: 블록 스코프에 바인딩
    • 함수 선언문: 해당 스코프에 함수 식별자 바인딩(어디서 선언되었는지에 따라 함수, 블록, 전역 스코프에 해당될 수 있다)

스코프 트리를 다이어그램으로 표시하면 아래와 같다

[Global Scope]
 ├─ bindings: { foo: var, A: function, x: let }
 └─ children:
     └─ [Function Scope A]
         ├─ bindings: { y: var, z: let }
         └─ children:
             └─ [Block Scope {...}]

이후 엔진에서 컴파일된 코드를 실행하는 과정에서
인터프리터(Ignition) 모드인지, JIT(Just-In-Time)모드인지에 따라

  • 바이트 코드를 하나씩 읽어가면서 동작을 수행
  • 생성된 바이트코드를 기반으로 native code(기계어)로 컴파일하여 수행

하게 된다.

1-3. 함수 객체와 "정의 시점"의 환경(Environment) 연결

  • 함수 객체가 만들어질 때, 엔진은 "그 함수가 정의된 스코프(정의 시점의 렉시컬 환경)"에 대한 참조를 함수에 붙인다.

    • [[Environment]] 구조체라는 곳에 자신이 정의된 환경(상위 스코프)를 저장한다.

    • 이것 덕분에 함수는 "자신이 정의된 위치의 스코프로 거슬러 올라가" 식별자를 찾을 수 있음.

    • const x = 1;
       function foo() {
           const x = 10;
      
           // 상위 스코프는 함수 정의 환경(위치)에 따라 결정된다.
           // 함수 호출 위치와 상위 스코프는 아무런 관계가 없다.
           bar();
       }
      
       // 함수 bar는 자신의 상위 스코프, 즉 전역 렉시컬 환경을 [[Environment]]에 저장하여 기억한다.
       function bar() {
           console.log(x);
       }
      
       foo();	// 1
       bar();	// 1
    • 해당 코드는 아래와 같은 그림으로 표현할 수 있다.

    • foobar 함수에 존재하는 [[Environment]] 구조체에는 함수 정의가 평가된 시점, 즉 전역 코드 평가 시점에 실행 컨텍스트의 렉시컬 환경인 전역 렉시컬 환경의 참조가 저장된다.

    • 이 상황에서 함수가 호출되면 함수 내부로 코드의 제어권이 이동한다.(실행 단계)

    • 그리고 아래 순서와 같이 함수 코드를 평가하기 시작한다.

      1. 함수 실행 콘텍스트 생성
      2. 함수 렉시컬 환경 생성
        • 2.1. 함수 환경 레코드 생성
        • 2.2. this 바인딩
        • 2.3. 외부 렉시컬 환경에 대한 참조 결정
    • 이 때 2.3. 외부 렉시컬 환경에 대한 참조에는 아까 저장했던 [[Environment]] 구조체의 렉시컬 환경의 참조가 할당된다.(그림에서 ②와 ③에 해당됨)

    • 함수가 호출되면 실행 컨텍스트를 만들고 새 렉시컬 환경을 설정한다.

    • bar 함수를 호출하면 렉시컬 환경이 포함하는 Environment Recordx라는 식별자가 존재하는지 파악한 후 존재하지 않을 경우 상위(Outer) 스코프로 이동하여 x를 찾는다.

  • 이 참조가 나중에 클로저가 되는 토대다. (실행 시점이 아니라 정의 시점이 핵심!)


2. 실행 단계에 무슨 일이 일어나나

앞서 1-3에서 설명했던 내용들 중

이 상황에서 함수가 호출되면 함수 내부로 코드의 제어권이 이동한다.(실행 단계)

에 대한 내용을 좀 더 자세히 살펴보자

2-1. 실행 컨텍스트 스택

  • 실행은 Execution Context Stack(콜스택 같은 것, 이하 EC)에 컨텍스트를 쌓고 내리며 진행.
  • 각 컨텍스트는 두 가지 핵심 포인터를 가진다고 보면 이해하기 쉽다.
    • VariableEnvironment (VE): var 바인딩 초기 스냅샷을 보관(변경사항은 반영되지 않음)
    • LexicalEnvironment (LE): 컨텍스트 내부 식별자 정보와 외부 스코프의 주소 참조(식별자 해석용 체인의 시작점)
    • 두 포인터는 아래 요소들을 포함한다.
      1. Environment Record: 실제 바인딩 테이블(예: {a: 10, b: function...})
      2. Outer Lexical Environment Reference: 바깥 스코프를 가리키는 포인터(체인)

2-2. 바인딩의 라이프사이클(호이스팅 & TDZ)

  • var: 선언은 스코프 시작 시 호이스팅되어 존재(초기값 undefined)
  • let/const: 선언은 호이스팅되지만 TDZ(Temporal Dead Zone)에 있다가 실제 선언문 평가(실행) 지점에서 초기화됨.
  • function 선언문: 스코프 시작 지점에 바인딩 & 함수 객체 할당(바로 호출 가능)

2-3. 식별자 해석 알고리즘(lookup)

  1. 현재 실행 컨텍스트의 LE에서 해당 식별자를 찾는다.
  2. 없으면 Outer Lexical Environment Reference를 따라 부모 렉시컬 환경으로 올라간다.
  3. 전역까지 가서도 없으면 ReferenceError.

예시 코드

let a = 1;
function outer() {
  let b = 2;
  function inner() {
    let c = 3;
    console.log(a, b, c); // 1 2 3
  }
  inner();
}
outer();
  • inner의 LE → 없으면 outer의 LE → 없으면 global의 LE 순서로 정의 시점 체인을 타고 올라감.

3. 렉시컬 스코프가 "정의 시점"이라는 증거들

3-1. 동적 호출 vs 정적 정의

const x = 10;
function foo() { console.log(x); }

// IIFE(Immediately Invoked Function Expression, 즉시 실행 함수)
(function () {
  const x = 99;
  foo(); // 10 (호출 위치가 아니라, foo "정의 위치"의 x를 본다)
})();
  • 위 코드를 통해 호출 위치에서 가까운 x=99가 있어도 무시하며 렉시컬 스코프는 정의 시점으로 캡쳐된 체인만 본다는 사실을 알 수 있다.

4. 클로저: "정의 시점 환경"을 들고 다니는 함수

4-1. 클로저가 생기는 순간

  • 함수가 정의될 때 엔진은 그 함수에 "외부 렉시컬 환경에 대한 참조"를 저장한다.
  • 그 함수가 나중에 외부 스코프를 떠난 뒤에도 참조된 외부 바인딩을 살려 접근할 수 있다.

예시 코드

function makeCounter() {
  let count = 0;            // ← 외부 렉시컬 환경의 바인딩
  return function () {
    count += 1;             // ← 외부 바인딩에 접근
    return count;
  };
}
const inc = makeCounter();
console.log(inc()); // 1
console.log(inc()); // 2

4-2. (주의) 메모리에 남습니다.

  • makeCounter함수가 끝나도, 반환된 함수(클로저)가 외부 바인딩을 참조하고 있으므로 GC가 수거하지 않는다.
  • 사라지면(예: inc = null) GC 대상이 됨.

5. 스코프 해석 실제 흐름 한 눈에 보기

5-1. 전체 흐름

[코드]               ──파싱──▶  [AST]
                                  │
                                  ▼
                         [스코프 트리 구성]
                          ├─ Global Scope (ER)
                          └─ Function/Block Scopes (ER...)
                                  │
                      (함수 정의 시) 함수객체 ←─── 연결 ─── 정의 시점의 LE
                                  │
                                  ▼
                         [실행 컨텍스트 스택]
                  push EC ──▶ { 
                                LexicalEnvironment → (현재 ER)
                                OuterLexicalEnvironmentReference → (부모 ER) ... 
                              }
                                  │
                         [식별자 해석 알고리즘]
              현재 ER → Outer → Outer ... → Global ER(여기서도 못 찾는다면) → ReferenceError

5-2. 정의 시점 vs 호출 시점 비교 흐름

함수 정의 시점: 함수객체.[[[Environment]]] = (정의 위치의 LE˜)

함수 호출 시점: 새 EC 생성
  EC.LE = 새 환경(파라미터/로컬)
  EC.LE.Outer = 함수객체.[[[Environment]]]   ← 정의 시점 체인을 잇는다!

요약

  • 컴파일 단계: 스코프 트리 구성, 선언 바인딩, 함수 객체 생성 + [[Environment]] 설정
  • 실행 단계: 실행 컨텍스트 스택 관리, 새로운 함수 렉시컬 환경 생성, 외부 참조 연결
  • 식별자 해석: 현재 → Outer → 전역 순서로 체인을 따라가며 탐색
  • 클로저: 정의 시점 환경을 붙잡아 실행 시점에도 외부 바인딩을 유지

참고 자료

profile
FRONTEND DEVELOPER

0개의 댓글