10번 봐도 이해 안 되는 Lexical Scope 이야기

10번 봐도 이해 안 되는 Lexical Scope 이야기

태그
Javascript
최종 수정일
Last updated April 29, 2026
Slug
lexical-scope
Date
April 27, 2026
Published
Published

렉시컬 스코프, 함수가 선언된 자리에서 시작하는 이야기

notion image
JavaScript의 스코프 규칙은 lexical scoping입니다. "lexical"이라는 단어 하나에서 클로저, stale closure, 그리고 일부 메모리 누수 패턴이 모두 파생됩니다. 이 글은 렉시컬 스코프가 왜 그런 이름을 가지게 되었는지부터 시작해, ECMAScript 스펙의 LexicalEnvironment, 그리고 메모리 관점에서 클로저가 무엇인지까지 한 번에 정리합니다.

왜 "lexical"인가

스코프는 변수를 어디에서 참조할 수 있는지에 대한 규칙입니다. 이 규칙을 결정하는 방식에는 크게 두 가지가 있습니다. dynamic scope는 함수가 호출된 위치를 따라 변수를 찾고, lexical scope는 함수가 선언된 위치를 따라 변수를 찾습니다. JavaScript는 후자를 택했습니다. lexical이라는 단어는 lexicon, 즉 소스 코드의 텍스트에서 유래한 표현입니다.
"The name lexical refers to the fact that lexical scoping uses the location where a variable is declared within the source code to determine where that variable is available." — MDN, Closures - Lexical scoping
다음 코드는 두 스코프 방식의 차이를 가장 단순하게 보여줍니다.
const x = "global"; function whoAmI() { console.log(x); } function caller() { const x = "local"; whoAmI(); } caller();
caller는 어떤 응답을 낼까요? whoAmI 는 어떤 x를 택할까요?JavaScript는 "global"을 출력합니다. whoAmI가 선언된 위치에서는 caller 안의 x가 보이지 않기 때문입니다. 만약 dynamic scope였다면 whoAmI는 자신을 호출한 callerx를 찾아 "local"을 출력했을 것입니다.
이 차이를 한 문장으로 압축하면 이렇습니다. 함수는 자기가 태어난 자리, 즉 선언 시점의 주변 환경을 평생 들고 다닙니다. 다른 곳에서 호출되더라도, 변수를 찾을 때는 호출자의 환경이 아니라 자기 출생지의 환경을 펼쳐 봅니다.

LexicalEnvironment의 구조

ECMAScript 스펙의 용어로 들어가 봅니다. 함수가 호출되면 새로운 실행 콘텍스트가 만들어져 콜 스택에 push됩니다. 이 실행 콘텍스트의 핵심 컴포넌트가 LexicalEnvironment이며, 두 부분으로 구성됩니다. EnvironmentRecord는 현재 스코프의 식별자(변수, 함수)와 그 값을 저장하는 영역이고, OuterEnvironmentReference는 바깥 스코프의 LexicalEnvironment에 대한 참조입니다.
앞서 본 whoAmI, caller 코드를 이 용어로 따라가 봅니다.
전역 코드가 실행되면 전역 LexicalEnvironment가 만들어지고, 함수 선언이 처리되는 시점에 각 함수 객체의 내부 슬롯 [[Environment]]에 선언 시점의 환경이 캡처됩니다.
GlobalLexEnv = { EnvironmentRecord: { x: "global", whoAmI: <function>, caller: <function>, }, OuterEnvironmentReference: null, } whoAmI.[[Environment]] = GlobalLexEnv caller.[[Environment]] = GlobalLexEnv
caller()가 호출되면 새 실행 콘텍스트가 push됩니다. 이때 LexicalEnvironment의 OuterEnvironmentReference는 호출 시점에 새로 결정되는 것이 아니라, caller.[[Environment]]를 그대로 가져옵니다.
push ExecutionContext { LexicalEnvironment: { EnvironmentRecord: { x: "local" }, OuterEnvironmentReference: caller.[[Environment]], // = GlobalLexEnv } }
이어서 caller 본문에서 whoAmI()가 호출되면 또 하나의 실행 콘텍스트가 push됩니다. 여기서 결정적인 부분이 등장합니다. 새 실행 콘텍스트의 OuterEnvironmentReference는 자신을 호출한 caller의 환경이 아니라, whoAmI선언될 때 캡처된 whoAmI.[[Environment]]입니다.
push ExecutionContext { LexicalEnvironment: { EnvironmentRecord: {}, OuterEnvironmentReference: whoAmI.[[Environment]], // = GlobalLexEnv, caller의 환경이 아니다 } }
이 상태에서 console.log(x)가 식별자 x를 찾을 때의 lookup 과정은 다음과 같이 스코프 체인을 거슬러 올라갑니다.
resolve("x"): 현재 EnvironmentRecord에서 찾기 → 없음 OuterEnvironmentReference 따라감 → GlobalLexEnv GlobalLexEnv.EnvironmentRecord["x"] → "global"
만약 OuterEnvironmentReference가 호출자인 caller의 환경을 가리켰다면 같은 lookup이 "local"을 먼저 발견했을 것입니다. 호출 위치가 아니라 선언 위치를 따른다는 lexical scoping의 정의가 OuterEnvironmentReference 한 줄에 그대로 박혀 있는 셈이고, 이것이 "함수는 선언된 환경을 기억한다"의 정확한 메커니즘입니다.

메모리에서 다시 보기

이제 클로저 이야기로 넘어갑니다. 다음은 가장 일반적인 클로저 예시입니다.
function outer() { const x = 10; function inner() { console.log(x); } return inner; } const fn = outer(); fn(); // 10
outer()가 종료되면 그 실행 콘텍스트는 콜 스택에서 pop됩니다. 직관적으로는 x도 함께 사라져야 할 것 같지만, fn()을 호출하면 10이 정상적으로 출력됩니다. 어떻게 살아남는 걸까요.
여기서 흔한 오해 하나를 짚을 필요가 있습니다. "지역 변수는 stack에 저장된다"는 설명은 JavaScript의 메모리 모델에는 맞지 않습니다. EnvironmentRecord 자체가 heap에 할당되는 객체이고, 콜 스택에 들어가는 것은 그 heap 객체에 대한 참조를 가진 실행 콘텍스트일 뿐입니다.
따라서 함수가 종료되어 실행 콘텍스트가 pop되더라도, 누군가가 그 EnvironmentRecord를 여전히 참조하고 있다면 GC가 회수하지 못합니다. JavaScript의 GC는 mark-and-sweep 방식으로 루트(전역, 콜 스택)에서 도달 가능한 객체를 살려두기 때문입니다. 위 코드에서 fn이 살아 있는 한 inner[[Environment]]를 통해 outer의 LexicalEnvironment까지 도달 경로가 이어지고, 이 체인 전체가 GC 대상에서 제외됩니다.
클로저란 정확히 이 상태를 가리킵니다. 함수 객체와, 그 함수가 선언될 당시의 LexicalEnvironment에 대한 참조의 결합입니다. 별도의 마법이 아니라 lexical scoping과 GC 도달성 규칙이 만나면 자연스럽게 따라오는 결과입니다.

실전에서 만나는 두 가지 현상

이 메모리 모델을 이해하면 두 가지가 자연스럽게 따라옵니다.
첫째, 메모리 누수입니다. 큰 객체를 캡처한 클로저를 전역이나 오래 살아있는 이벤트 리스너, 전역 캐시등 에 등록하면, 그 큰 객체는 평생 GC되지 않습니다. React에서 useEffect의 cleanup을 빠뜨려 인터벌 콜백이 컴포넌트의 state, props, 그리고 그것들이 참조하는 모든 것을 잡고 있는 사례가 전형적입니다.
둘째, stale closure입니다. 함수가 캡처하는 것은 변수의 값이 아니라 EnvironmentRecord에 대한 참조입니다. React는 매 render마다 컴포넌트 함수가 새로 호출되며 새로운 LexicalEnvironment를 만듭니다. 첫 render의 콜백은 첫 render의 환경을, 두 번째 render의 콜백은 두 번째 render의 환경을 각각 별개로 들고 있다는 뜻입니다.
다음은 stale closure를 보여주는 흔한 패턴입니다.
function Counter() { const [count, setCount] = useState(0); useEffect(() => { const id = setInterval(() => { console.log(count); }, 1000); return () => clearInterval(id); }, []); // 의존성 배열이 비어 있다 return <button onClick={() => setCount(c => c + 1)}>{count}</button>; }
버튼을 아무리 눌러도 콘솔에는 계속 0이 찍힙니다. 의존성 배열이 비어 있어 useEffect의 콜백이 첫 render에서만 실행되었고, 그 콜백의 [[Environment]]가 첫 render의 LexicalEnvironment를 가리키고 있기 때문입니다. 그 환경 안의 count는 영원히 0입니다. 이것은 React의 버그가 아니라 lexical scoping을 그대로 따른 결과입니다.

정리

렉시컬 스코프는 "함수는 선언된 위치를 따라 변수를 찾는다"는 한 줄로 요약됩니다. 그리고 이 한 줄이 ECMAScript 스펙에서는 LexicalEnvironment와 [[Environment]] 슬롯으로, 메모리에서는 heap에 살아남는 EnvironmentRecord로, React에서는 stale closure와 cleanup 누락으로 모습을 바꿔 나타납니다. 클로저는 별도의 기능이 아니라 이 규칙이 메모리와 만났을 때 자연스럽게 발생하는 현상이라는 점을 기억해 두면, 앞으로 등장하는 변형들도 같은 모델로 풀어낼 수 있습니다.

참고 자료