렉시컬 스코프란 무엇일까?

코드가 작성된 위치에 따라 변수 접근 범위가 결정되는 정적 스코프의 의미와 동작 원리를 이해합니다

입문 15분 렉시컬 스코프 정적 스코프 변수 접근 스코프 체인

렉시컬 스코프(Lexical Scope)는 자바스크립트에서 변수가 어느 범위에서 유효한지를 결정하는 핵심 규칙으로, 함수가 어디서 호출되는가가 아니라 코드 상에서 어디에 작성되었는가에 따라 변수 접근 범위가 정해집니다. 이 개념은 “정적 스코프”라고도 불리며, 코드를 실행하기 전에 이미 스코프 구조가 확정된다는 특징을 가집니다. 렉시컬 스코프를 이해하지 못하면, 변수가 예상치 못한 값을 가지거나 참조 오류가 발생하는 버그를 마주하게 됩니다. 클로저, 모듈 패턴, 콜백 등 자바스크립트의 강력한 기능들이 모두 렉시컬 스코프 위에서 동작하므로, 이 개념은 중급 이상의 자바스크립트 실력을 갖추기 위한 필수 토대입니다.

핵심 특징

  • 📌 코드 작성 위치 기반 결정: 스코프는 함수가 정의된 위치를 기준으로 컴파일 시점에 결정되며, 런타임 호출 위치와는 무관합니다
  • 중첩 스코프와 스코프 체인: 안쪽 함수는 자신을 감싸는 바깥 함수의 변수에 접근할 수 있으며, 이 탐색 경로를 스코프 체인이라고 합니다
  • 단방향 접근: 바깥 스코프에서는 안쪽 스코프의 변수에 접근할 수 없으며, 접근은 안에서 바깥 방향으로만 이루어집니다
  • 전역 스코프와 지역 스코프 구분: 가장 바깥에 위치한 전역 스코프는 모든 코드에서 접근 가능하지만, 함수나 블록 내부에 선언된 지역 변수는 해당 스코프 안에서만 유효합니다
  • 🔗 클로저의 기반 원리: 함수가 자신이 선언된 렉시컬 환경을 기억하는 클로저는 렉시컬 스코프가 있기에 가능한 메커니즘입니다

실무에서의 영향

렉시컬 스코프는 실무 코드에서 매일 마주하는 개념입니다. 이벤트 핸들러나 비동기 콜백 함수 내에서 외부 변수에 접근할 때, 그 동작 방식이 예측 가능한 이유가 바로 렉시컬 스코프 덕분입니다. 모듈 패턴이나 IIFE(즉시 실행 함수)를 사용하여 변수를 외부로부터 보호하는 캡슐화 기법도 이 원리에 기반합니다. 또한 리액트의 훅(Hook)이나 커스텀 훅에서 상태값이나 핸들러가 올바르게 참조되는 것, 그리고 반복문에서 클로저가 의도한 값을 캡처하도록 코드를 작성하는 것 모두 렉시컬 스코프에 대한 이해에서 출발합니다. 렉시컬 스코프를 정확히 파악하면 예측 가능한 코드를 작성할 수 있고, 변수 충돌과 의도치 않은 전역 오염을 방지하며, 유지보수하기 쉬운 구조적인 코드를 설계하는 능력이 크게 향상됩니다.


핵심 개념

렉시컬 스코프 - 작성 위치가 범위를 결정한다

입문

변수를 “어디서 사용하느냐”가 아니라 “어디에 써놓았느냐”로 범위가 결정돼요. 마치 도서관 책처럼, 책이 꽂힌 위치(서가)가 그 책의 소속을 결정하는 것과 같아요!

📍 스코프란 무엇인가요? 스코프는 변수가 ‘살 수 있는 집’이에요. 집 안에서 선언된 물건은 그 집 안에서만 쓸 수 있어요. 자바스크립트에서 중괄호 {} 로 둘러싸인 공간이 하나의 집이 되는 거예요.

🏠 렉시컬이라는 말은 무슨 뜻인가요? ‘렉시컬(Lexical)‘은 ‘코드 글자(문자)‘라는 뜻이에요. 즉, 렉시컬 스코프는 “코드를 눈으로 봤을 때 어느 중괄호 안에 써있는가”로 범위가 결정된다는 뜻이에요. 함수를 나중에 다른 곳에서 부른다고 해도 범위는 바뀌지 않아요.

📦 호출 위치와는 상관없나요? 맞아요! 예를 들어 서울에서 태어난(선언된) 사람은, 부산에 놀러가서(호출되어서) 일을 해도 주민등록상 주소는 서울이에요. 함수도 마찬가지로, 어디서 불리든 자신이 태어난(작성된) 곳의 변수를 봐요.

🔍 왜 이게 중요한가요? 코드를 읽는 것만으로 “이 변수는 여기에 있겠구나”를 예측할 수 있어요. 실행해보지 않아도 변수의 범위를 미리 알 수 있어서 버그를 찾기 훨씬 쉬워요. 이것이 바로 렉시컬 스코프의 큰 장점이에요!

중급

렉시컬 스코프(Lexical Scope)는 함수가 선언된 위치(작성 시점)를 기준으로 변수의 유효 범위가 결정되는 규칙입니다. 동적 스코프(Dynamic Scope)와 달리, 함수가 어디서 호출되는지는 스코프 결정에 영향을 주지 않습니다.

정적 스코프(Static Scope)라고도 불리는 이유 컴파일(파싱) 단계에서 이미 스코프 구조가 확정되기 때문에 “정적(static)“이라고 불립니다. 자바스크립트 엔진은 코드를 실행하기 전에 각 식별자(변수, 함수)가 어느 스코프에 속하는지 이미 알고 있습니다.

const name = 'Global';

function outer() {
  const name = 'Outer';

  function inner() {
    // inner는 outer 안에 작성되었으므로
    // outer의 name을 봅니다 (호출 위치 무관)
    console.log(name); // 'Outer'
  }

  return inner;
}

const fn = outer();
fn(); // 'Outer' - 전역에서 호출해도 결과는 동일

호출 위치가 달라도 동일한 결과 위 예시에서 inner 함수는 outer 안에 작성되었으므로, 전역에서 fn()으로 호출해도 name은 항상 'Outer'입니다. 이것이 렉시컬 스코프의 핵심 동작입니다.

심화

렉시컬 스코프는 ECMAScript 명세의 렉시컬 환경(Lexical Environment) 모델의 근간이며, 파서(Parser)가 소스 텍스트를 분석하는 컴파일 단계에서 스코프 구조가 정적으로 결정됩니다.

ECMAScript 명세의 렉시컬 환경 구조 ECMAScript 2023 명세 Section 9.1(Environment Records)에 따르면, 렉시컬 스코프는 환경 레코드(Environment Record)와 외부 환경 참조(Outer Lexical Environment Reference)의 연결로 구현됩니다. 각 함수 정의 시점에 해당 함수 객체의 내부 슬롯 [[Environment]]에 정의 시점의 렉시컬 환경이 캡처됩니다.

함수 호출 시 생성되는 함수 환경 레코드(Function Environment Record)는 [[OuterEnv]] 필드를 통해 정의 시점의 환경 레코드를 참조합니다. 이는 호출 스택(Call Stack)과는 독립적인 정적 연결(Static Link)이며, 동적 스코프에서 사용하는 동적 연결(Dynamic Link)과 구별됩니다.

파서 단계에서의 스코프 분석 V8 엔진의 파서(Ignition + Sparkplug 파이프라인)는 소스 코드를 AST(Abstract Syntax Tree)로 변환하면서 각 식별자의 스코프 귀속을 결정하는 스코프 분석(Scope Analysis)을 수행합니다. 이 결과는 각 AST 노드에 스코프 정보로 첨부되어, 이후 바이트코드 생성 시 올바른 환경 슬롯 인덱스를 참조하도록 최적화됩니다.

동적 스코프와의 설계 철학 차이 Perl, Bash 같은 동적 스코프 언어에서는 콜 스택을 거슬러 올라가며 변수를 탐색합니다. 렉시컬 스코프는 코드 텍스트만으로 변수 귀속을 결정하므로, 컴파일 타임 최적화(레지스터 할당, 인라인 캐싱)가 훨씬 효율적이며 프로그래머가 코드를 읽는 것만으로 동작을 예측할 수 있어 가독성과 유지보수성이 크게 향상됩니다.

스코프 체인 - 안에서 바깥으로의 변수 탐색

입문

변수를 찾을 때 자바스크립트는 “내 방 -> 부모 방 -> 할아버지 방” 순서로 찾아 올라가요. 이 탐색 경로를 스코프 체인이라고 해요. 찾으면 멈추고, 가장 바깥(전역)까지 없으면 오류가 나요!

🔗 체인이 어떻게 연결되나요? 함수 안에 함수를 쓰면 마치 방 안에 방이 있는 집처럼 돼요. 가장 안쪽 방에 있는 사람은 바깥 방들에 있는 물건을 모두 쓸 수 있어요. 하지만 바깥 방에 있는 사람은 안쪽 방 물건에 접근할 수 없어요.

🔍 변수를 어떤 순서로 찾나요? 변수를 찾을 때는 항상 “현재 내가 있는 곳 -> 바로 바깥 방 -> 그 바깥 방 -> 전역” 순서로 찾아요. 현재 위치에서 먼저 찾고, 없으면 바깥으로 한 단계씩 올라가는 거예요.

🛑 가장 바깥까지 없으면 어떻게 되나요? 전역(가장 바깥)까지 찾아봤는데도 없으면 “ReferenceError: 변수이름 is not defined” 오류가 나요. 마치 집 전체를 뒤졌는데도 찾는 물건이 없는 것처럼요.

💡 같은 이름 변수가 여러 단계에 있으면요? 가장 가까운(안쪽) 스코프에서 찾은 것을 우선으로 써요! 안쪽 방에 같은 이름 물건이 있으면 바깥 방까지 가지 않고 거기서 멈춰요. 이걸 “변수 쉐도잉”이라고 해요.

중급

스코프 체인(Scope Chain)은 중첩된 스코프들이 외부 참조로 연결된 구조입니다. 변수를 탐색할 때 현재 스코프에서 시작하여 외부 스코프로 순차적으로 올라가며, 처음 발견되면 탐색을 멈춥니다.

변수 섀도잉(Variable Shadowing) 안쪽 스코프에 바깥 스코프와 동일한 이름의 변수가 있으면, 안쪽 변수가 바깥 변수를 가립니다(shadows). 이를 변수 섀도잉이라고 합니다.

const x = 'global';

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

  function inner() {
    const z = 'inner';

    // 탐색 순서: inner -> outer -> global
    console.log(z); // 'inner'  (현재 스코프)
    console.log(y); // 'outer'  (한 단계 위)
    console.log(x); // 'global' (두 단계 위)
  }

  inner();
}

outer();
const value = 'global';

function example() {
  const value = 'local'; // 전역의 value를 가림(shadow)
  console.log(value);    // 'local' - 가장 가까운 스코프 우선
}

example();
console.log(value); // 'global' - 전역의 value는 그대로

심화

스코프 체인은 ECMAScript 명세에서 외부 환경 참조(Outer Lexical Environment Reference)로 구현된 단방향 연결 리스트(Singly Linked List) 구조입니다. 식별자 결정(Identifier Resolution)은 GetIdentifierReference 추상 연산을 통해 수행됩니다.

식별자 결정 알고리즘 (ECMAScript Section 9.1.2.1) GetIdentifierReference(env, name, strict) 추상 연산은 다음 단계로 식별자를 결정합니다:

  1. env가 null이면 ReferenceError를 발생시킵니다
  2. env의 환경 레코드(Environment Record)에 name의 바인딩이 존재하는지 확인(HasBinding)
  3. 존재하면 해당 레퍼런스를 반환
  4. 존재하지 않으면 env.[[OuterEnv]]를 새 env로 설정하고 1단계로 재귀

이 재귀 탐색의 시간 복잡도는 O(d)이며, d는 스코프 중첩 깊이입니다.

V8 엔진의 컨텍스트 슬롯 최적화 V8 엔진은 파싱 단계의 스코프 분석 결과를 활용하여 클로저 변수를 Context 객체의 슬롯(slot)에 정적으로 할당합니다. 각 변수 참조는 [context_index, slot_index] 쌍으로 컴파일되므로, 런타임 스코프 체인 탐색(HashMap 조회) 없이 O(1)에 접근합니다. 단, eval이나 with 문 사용 시 이 최적화가 비활성화되어 성능이 크게 저하됩니다.

변수 섀도잉의 명세 처리 GetIdentifierReference는 첫 번째 바인딩 발견 즉시 반환하므로, 바깥 스코프의 동명 바인딩은 구조적으로 접근 불가능해집니다. TypeScript의 noShadow ESLint 규칙이나 --noImplicitAny 옵션은 이 섀도잉 패턴을 컴파일 타임에 검출하여 잠재적 혼동을 방지합니다.

단방향 접근 - 안에서 바깥만 볼 수 있다

입문

스코프는 바깥에서 안을 볼 수 없는 일방통행이에요. 안쪽 방에 있는 사람은 바깥을 볼 수 있지만, 바깥에 있는 사람은 안쪽 방을 들여다볼 수 없어요. 이 규칙 덕분에 변수를 안전하게 보호할 수 있어요!

🚪 왜 바깥에서 안을 못 보나요? 함수 안에 선언된 변수는 그 함수 안에서만 살아요. 함수가 실행될 때 만들어지고, 함수가 끝나면 사라지는 임시 변수예요. 바깥에서는 아직 만들어지지 않았거나 이미 사라진 변수에 접근하려는 것이 되어버려요.

🔒 이런 규칙이 왜 필요한가요? 만약 누구나 모든 변수에 접근할 수 있다면 어떻게 될까요? 학교 전체 학생의 시험 점수가 담긴 변수를 다른 함수에서 마음대로 바꿀 수 있다면 큰일이겠죠? 단방향 접근 규칙이 변수를 외부로부터 보호해줘요.

💡 이 규칙을 활용하면 어떤 좋은 점이 있나요? 이 규칙을 잘 활용하면 함수 안에 중요한 데이터를 안전하게 숨길 수 있어요. 바깥에서는 함수가 제공하는 특정 기능만 쓸 수 있고, 내부 구현은 볼 수 없어요. 이것이 바로 ‘캡슐화(숨기기)’ 개념의 출발점이에요.

중급

스코프 접근은 안에서 바깥 방향(inner to outer)으로만 가능하며, 바깥에서 안쪽 스코프에 직접 접근할 수 없습니다. 이 규칙이 함수 스코프 기반의 캡슐화(Encapsulation)를 가능하게 합니다.

함수 스코프를 이용한 프라이빗 변수 함수 내부에 선언된 변수는 외부에서 직접 접근이 불가능합니다. 이를 활용하면 외부에 노출하지 않을 내부 상태(private state)를 구현할 수 있습니다.

function counter() {
  let count = 0; // 외부에서 직접 접근 불가

  return {
    increment() { count++; },
    getCount()  { return count; }
  };
}

const c = counter();
c.increment();
c.increment();
console.log(c.getCount()); // 2
console.log(count);        // ReferenceError: count is not defined

모듈 패턴의 기반 위 패턴처럼 함수 스코프의 단방향 접근 특성을 이용해 외부에서 접근할 수 있는 인터페이스만 반환하고 내부 구현은 감추는 방식이 모듈 패턴(Module Pattern)의 기초입니다.

심화

단방향 스코프 접근은 ECMAScript의 렉시컬 환경 연결 구조에서 [[OuterEnv]] 참조가 단방향으로만 설정됨에 따른 구조적 결과입니다. 이 특성은 정보 은닉(Information Hiding)과 캡슐화를 언어 수준에서 지원합니다.

렉시컬 환경의 단방향 체인 구조 ECMAScript Section 9.1의 환경 레코드 명세에서, 자식 환경 레코드는 [[OuterEnv]]를 통해 부모를 참조하지만 부모 환경 레코드는 자식을 참조하는 필드가 존재하지 않습니다. 이는 의도적 설계 결정으로, 외부 스코프가 내부 스코프의 바인딩을 순회하거나 수정하는 것을 명세 수준에서 차단합니다.

모듈 시스템과의 연계 ES2015 모듈(ESM)은 이 렉시컬 스코프 단방향 접근 원칙을 모듈 경계(Module Boundary)로 확장합니다. 모듈 환경 레코드(Module Environment Record, Section 9.1.1.5)는 export 선언된 바인딩만 외부 모듈 네임스페이스 객체(Module Namespace Object)를 통해 노출하며, 비공개 바인딩은 렉시컬 스코프의 단방향 접근 특성에 의해 자연스럽게 캡슐화됩니다.

V8의 메모리 모델과 가비지 컬렉션 함수 실행이 종료되면 해당 함수의 활성화 레코드(Activation Record)는 콜 스택에서 제거됩니다. 단, 클로저가 해당 환경 레코드를 [[Environment]] 슬롯을 통해 참조하고 있으면 V8의 가비지 컬렉터(Orinoco GC)가 해당 환경을 힙(Heap)에 유지합니다. 이는 단방향 접근 특성과 결합하여, 클로저가 외부 변수를 “잡아둘(capture)” 수 있지만 외부에서는 클로저의 내부 변수를 GC 이전에도 참조할 방법이 없음을 의미합니다.