인트로
자바스크립트를 공부하다 보면 클로저(Closure)라는 용어를 자주 접하게 됩니다.
또한 자바스크립트의 중요한 특징 중 하나로 “함수는 자신의 환경을 기억한다”는 설명도 많이 등장합니다.
그렇다면 이 두 개념은 어떻게 연결되어 있을까요?
이 글에서는 렉시컬 환경과 클로저의 관계를 중심으로, 클로저가 어떤 원리로 동작하는지 알아보고자 합니다.
렉시컬 환경 (Lexical Env)
코드가 작성된 위치 기준으로 만들어진 변수 저장 환경
즉, 선언된 위치 기준으로 접근 가능한 모든 변수 공간을 렉시컬 환경이라고 말합니다.
렉시컬 환경은 실행 컨텍스트를 구성하는 컴포넌트이며, 스코프와 식별자를 관리합니다.
실행 컨텍스트 내부 구조
실행 컨텍스트 (Execution Context)
├── LexicalEnvironment
│ └── Environment Record
│ ├── 식별자 바인딩 (let, const, class, 함수 매개변수 등)
│ └── [[ThisValue]] ← FunctionER/GlobalER의 경우
│ └── [[OuterEnv]] ← 바깥 렉시컬 환경 참조
├── VariableEnvironment
│ └── Environment Record (var, 함수 선언)
│ └── [[OuterEnv]]
└── (Realm, ScriptOrModule 등 기타 필드)
⭐️ 렉시컬 환경 이라는 뜻을 이해하기가 어려워요.
환경 : 주위의 사물 또는 사정, 거주하는 주위의 외계, 생활체를 둘러싸고 그것과 일정한 접촉을 유지하고 있는 외계 - 국어사전
“나”로 비유 : "나는 어떤 환경에서 자랐나" = 내 주변의 조건과 맥락 전체
내가 나로 있기 위해서는, 외부 환경만 있어야 되는 것이 아니라 내가 어떤 것을 좋아하는지 어떤 요소가 있는지도 함께 봐야합니다. 그런 의미에서 나의 렉시컬 환경은 나에 대한 조건과 맥락도 함께 포함된다고 할 수 있습니다.
나를 코드로 대입해보자면 : 함수(코드)가 실행되기 위해 필요한 모든 정보와 조건의 집합
이 코드가 동작하려면 뭐가 필요한가? → 어떤 변수가 있는지, 그 변수의 값은 뭔지, 바깥에 뭐가 있는지
렉시컬 환경 = 자기 자신의 환경 레코드(Environment Record) + Outer Environment Reference 체인
렉시컬 환경은 객체 형태로 스코프를 구분하며, 식별자를 키로 하며 식별자에 바인딩된 값을 관리합니다.
예를 들어, <Yong의 렉시컬 환경>을 확인해보겠습니다.
아래, Yong은 렉시컬 환경을 객체 형태로 스코프를 구분하고 있습니다. Yong은 phone과 mac을 갖고 있고, University에 다니고 있습니다.
Yong {
phone : ...
mac : ...
외부 환경 참조(부모) -> University
}
이때 phone, mac은 Yong의 렉시컬 환경의 환경 레코드(Env Record)라고 할 수 있으며, 외부 렉시컬 환경 참조(Outer Env Ref)는 University 입니다.
만약 여기서 Yong의 전체 렉시컬 환경을 나타내보자면, 스코프 체인까지 더해서 ran의 전체 렉시컬 환경을 표현해보겠습니다.
Yong {
phone: 'iPhone'
mac: 'MacBook'
외부 환경 참조(부모) → University {
강의실: 'A동 301호'
wifi: 'University_5G'
외부 환경 참조(부모) → global {
도시: '서울'
나라: '한국'
}
}
}
링크드 리스트처럼 렉시컬 환경의 Outer Ref가 모두 연결되어있다는 것을 확인할 수 있고, 이를 스코프 체인이라고 합니다.
여기서 알 수 있는 점은, 모든 실행 컨텍스트의 렉시컬 환경은
(1) 환경 레코드(Environment Record),
(2) 외부 환경 참조(Outer Reference, 해당 실행 컨텍스트가 선언된 위치를 기준으로 한 환경)
두 가지 요소로 구성된다는 점입니다.
실행 컨텍스트의 기본 구조 : 실행컨텍스트의 렉시컬 환경의 형태
실행 컨텍스트 {
a: ... //환경 레코드 : Environment Record
외부 환경 참조(부모) : //Outer Lexical Environment Reference
}
선언된 위치에 따라 다른 렉시컬 환경
코드는 선언된 곳과 호출되는 곳이 다를 수 있습니다. 렉시컬 환경이란 선언된 위치를 기준으로 접근 가능한 모든 변수 공간을 말합니다. 그렇다면 "선언된 위치를 기준으로 한다"는 건 정확히 어떤 의미일까요? 호출된 위치를 기준으로 할 때와는 무엇이 다를까요?
foo()의 호출위치는 같지만, foo()의 선언된 위치가 다른 케이스 2가지 코드
아래에는 2가지 케이스가 있습니다. foo()가 bar 안에서 호출된다는 점은 같지만, foo()가 선언된 위치가 서로 다릅니다.
1. foo가 전역에 선언된 경우
foo() 함수는 전역 스코프에 선언되어 있지만, bar 안에서 호출됩니다. 그런데 출력값은 100이 아니라 1입니다. foo()가 선언된 위치가 전역이기 때문입니다.
객체로 표현한 foo의 렉시컬 환경을 살펴보면, 외부 렉시컬 환경 참조(Outer Environment Reference)가 전역 렉시컬 환경을 가리키고 있습니다. 따라서 foo 내부의 x는 상위 스코프인 전역 환경에서 값을 찾게 되고, 1이 출력됩니다.
const x = 1;
function foo() {
console.log(x);
}
function bar() {
const x = 100;
foo(); // foo는 bar 안에서 호출됐지만
}
bar(); // → 1 출력
//foo의 렉시컬 환경
foo [[Environment]] {
(없음) ← foo 환경 레코드 (지역변수 없음)
Outer → 전역 LE { ← foo가 선언된 위치가 전역이라서
x: 1
foo: [함수 객체]
bar: [함수 객체]
}
}
2. foo가 bar 안에 선언된 경우
이번에는 foo가 bar 안에 선언되어 있고, bar 안에서 호출됩니다. 이 코드의 출력값은 100입니다. foo()가 선언된 위치가 bar 내부이기 때문입니다.
객체로 표현한 foo의 렉시컬 환경을 살펴보면, 외부 렉시컬 환경 참조가 bar의 렉시컬 환경을 가리키고 있습니다. 따라서 foo 내부의 x는 상위 스코프인 bar 환경에서 값을 찾게 되고, bar의 환경 레코드에 저장된 100이 출력됩니다.
const x = 1;
function bar() {
const x = 100;
function foo() {
console.log(x);
}
foo();
}
bar(); // → 100
foo [[Environment]] {
(없음) ← foo 환경 레코드 (지역변수 없음)
Outer → bar LE { ← foo가 선언된 위치가 bar 안이라서!
x: 100
foo: [함수 객체]
Outer → 전역 LE {
x: 1
}
}
}
위 두 가지 케이스를 통해 코드가 선언된 위치에 따라 상위 스코프가 달라진다는 것을 확인했습니다. 함수가 선언된 위치의 렉시컬 환경을 계속 기억한다는 이 특성은, 클로저의 핵심 원리이기도 합니다.
클로저
클로저는 자바스크립트만의 고유 개념이 아닙니다. 함수를 일급 객체로 취급하고 렉시컬 스코프를 따르는 언어라면 어디서든 나타나는 특성입니다. MDN은 클로저를 이렇게 정의합니다.
“A closure is the combination of a function and the lexical environment within which that function was declared”
클로저는 함수와 그 함수가 선언된 렉시컬 환경과의 조합
여기서 핵심적으로 봐야 할 부분은 함수가 선언된 렉시컬 환경(Lexical Environment)입니다.
이 개념은 사실 서로 다른 용어로 반복해서 등장해왔습니다.
- 함수가 선언된 위치의 스코프
- 상위 스코프(Outer Scope)
- 실행 컨텍스트의 외부 렉시컬 환경 참조(Outer Lexical Environment Reference)
이 세 가지는 모두 결국 함수가 어디에서 정의되었는가를 기준으로 스코프를 결정한다는 동일한 의미를 가집니다.
즉, 자바스크립트는 함수를 호출한 위치가 아니라 선언된 위치를 기준으로 변수 탐색 범위를 결정합니다.
그렇다면 아래 코드에서는 어떤 값이 출력될까요? 1일까요, 10일까요?
const x = 1;
function outer() {
const x = 10;
const inner = function () {
console.log(x);
};
return inner //outer는 inner 함수 객체 반환
}
const innerFunc = outer();
innerFunc();
outer 함수를 호출하면 중첩 함수 inner를 반환하고 실행이 종료됩니다. 이때 outer의 실행 컨텍스트는 콜 스택에서 제거되고, outer는 생명주기를 마감합니다.
일반적으로 함수 실행이 끝나면 해당 함수의 지역 변수들도 함께 사라집니다. 더 이상 참조할 곳이 없으니 가비지 컬렉터가 메모리에서 수거하기 때문입니다.
하지만 inner 함수가 outer의 변수 x를 참조하고 있다면 누군가 아직 x를 참조하고 있기 때문에, 자바스크립트 엔진은 outer의 렉시컬 환경을 메모리에서 제거하지 않고 유지합니다. 덕분에 outer이 생명주기를 마감했음에도 inner는 여전히 x = 10에 접근할 수 있게 됩니다.
외부 함수보다 내부 함수가 더 오래 살아남아, 이미 종료된 외부 함수의 변수를 참조할 수 있는 현상. 그리고 이 내부 함수를 우리는 클로저(closure)라고 부릅니다.
다시 말해, inner 함수는 단순히 함수 코드만 반환된 것이 아니라 자신이 선언될 당시의 렉시컬 환경까지 함께 들고 반환된 것입니다. 그래서 innerFunc()를 호출하면 변수 x는 전역이 아닌 outer 스코프에서 탐색되어 10이 출력됩니다.
아래는 코드의 전체 실행 흐름입니다.
1. 전역 실행 컨텍스트 생성 단계
global LE(렉시컬 환경) {
x: uninitialized (const라서 TDZ)
outer: [함수 객체]
innerFunc: uninitialized
}
2. 전역 실행 컨텍스트 실행 단계
x = 1 할당
outer() 호출 → outer 실행 컨텍스트 생성
3. outer() 실행 컨텍스트 생성 단계
outer LE(렉시컬 환경) {
x: uninitialized
inner: uninitialized
Outer Reference → Global LE(전역 렉시컬 환경)[부모 스코프]
}
4. outer() 실행 컨텍스트 실행 단계
x = 10 할당
inner = function() { console.log(x) } 할당
return inner → outer 콜 스택에서 pop(소멸)
inner의 스코프 체인으로 연결된 전체 렉시컬 환경
inner [[Environment]] {
x → (없음)
Outer(외부 환경 참조 : 부모 스코프) → outer LE {
x → 10
inner → [함수 객체]
Outer(외부 환경 참조 : 부모 스코프) → global LE {
x → 1
outer → [함수 객체]
innerFunc → [함수 객체]
}
}
}
모든 함수는 클로저다.
자바스크립트의 모든 함수는 자신이 선언된 위치의 상위 스코프(외부 렉시컬 환경)를 기억합니다. 자바스크립트가 렉시컬 스코프를 기반으로 동작하기 때문입니다.
함수는 생성될 때 항상 자신의 외부 렉시컬 환경을 참조하므로 이론적으로는 모든 함수가 클로저라고 볼 수 있습니다.
하지만 일반적으로 모든 함수를 클로저라고 하지는 않는다.
내부 함수가, 외부 함수의 변수를 참조하고 그 외부 함수의 실행이 종료된 이후에도 해당 변수에 계속 접근할 수 있는 경우 즉, 단순히 외부 환경을 기억하고 생명주기를 넘어서 그 환경을 실제로 사용하면서 유지하는 상황을 클로저라고 부르는 것이 일반적입니다.
코드의 3가지 유형
1. 상위 스코프의 식별자를 참조하지 않는 경우 클로저가 아니다.
function foo() {
const x = 1;
const y = 2;
function bar() {
const z=3;
debugger;
//상위 스코프의 식별자를 참조하지 않는다.
console.log(z);
}
return bar;
}
const bar = foo();
bar();

foo()는 bar를 반환함과 즉시 콜 스택에서 pop이 되어 생명주기를 마감합니다.
bar의 console.log는 z를 필요로 하지만 bar의 환경 레코드 안에 z가 존재하기 때문에 외부 렉시컬 환경 참조를 따라 상위 스코프를 참조하지 않아도 식별자를 찾을 수 있습니다. 이러한 경우 클로저라고 하지 않습니다.
Scope
Local ← bar의 환경 레코드
this: Window
z: 3 ← bar 안에서 선언한 const z = 3
Script ← foo의 환경 레코드
Global ← 전역 환경 레코드
Window
Call Stack
bar <- 현재 실행 컨텍스트
(anonymous) //전역 실행 컨텍스트의 실행단계에서 bar()를 실행
2. 내부 함수 bar는 클로저였지만, 외부 함수보다 일찍 소멸되기 때문에 클로저의 본질에 부합되지 않는다.
function foo() {
const x = 1;
function bar() {
debugger;
//상위 스코프의 식별자를 참조하지 않는다.
console.log(x);
}
bar();
}
foo();

foo() 내부에서 bar()를 선언하고 호출합니다. bar는 외부 변수 x를 참조하고 있기 때문에 스펙상으로는 클로저가 맞습니다.
실제로 브라우저 개발자 도구의 Scope 패널을 확인해보면 Closure 항목에 x: 1이 표시되는 걸 확인할 수 있습니다.
하지만 이 코드의 콜 스택 흐름을 먼저 확인해보면,
- 전역 실행 컨텍스트가 콜 스택에 쌓입니다.
- foo() 호출 → foo의 실행 컨텍스트가 그 위에 쌓입니다.
- foo 내부에서 bar() 호출 → bar의 실행 컨텍스트가 맨 위에 쌓입니다.
bar가 x를 참조하는 시점에 foo의 실행 컨텍스트는 아직 콜 스택에 존재합니다. 즉, foo가 아직 살아있는 상태에서 bar가 x에 접근하는 것이기 때문에, '외부 함수보다 내부 함수가 더 오래 살아남는다'는 클로저의 실용적 정의에는 부합하지 않습니다.
스펙상으로는 클로저가 맞지만, 일반적인 스코프 체인 탐색만으로도 충분히 설명되는 경우입니다.
💡 클로저의 본질에는 부합하지 않다는데, 브라우저 디버깅에 표시된 Closure는 뭔가요?
foo가 콜 스택에 살아있는데도 브라우저가 Closure로 표시하는 건, bar가 foo의 렉시컬 환경을 Outer Reference로 참조하고 있기 때문
브라우저는 "Outer Reference로 상위 스코프를 참조하는 구조" 자체를 Closure로 표시
Scope
Local ← bar의 환경 레코드
this: Window
Closure (foo) ← foo의 환경 레코드 (살아있음!)
x: 1
Global ← 전역
Window
3. 중첩 함수 bar는 외부 함수보다 더 오래 유지(생명주기가 길다.)되고 상위 스코프의 식별자를 참조하기 때문에 클로저이다.
const x = 1;
function outer() {
const x = 10;
const y = 20;
const inner = function () {
console.log(x);
};
return inner //outer는 inner 함수 반환
}
const innerFunc = outer();
innerFunc();

앞서 클로저를 설명할 때 보여드린 코드입니다. outer의 실행 컨텍스트는 이미 콜 스택에서 제거됐지만, inner는 여전히 outer의 지역 변수 x를 참조하고 있습니다. 이처럼 외부 함수의 실행이 끝난 뒤에도 외부 변수를 참조할 수 있는 내부 함수를 클로저라고 부릅니다.
클로저라고 해서 outer의 모든 것이 메모리에 남아 있는 건 아닙니다. inner가 실제로 참조하는 식별자만 기억됩니다.
예를 들어 outer 안에 x, y라는 두 개의 변수가 있는데 inner가 x만 참조한다면, 실제로 메모리에 유지되는 건 x뿐입니다. (V8 같은 모던 엔진은 이런 식으로 최적화합니다)
여기서 x는 자유변수라고도 합니다.
자유 변수(Free Variable) 함수 내부에서 참조하지만, 그 함수의 스코프에서 선언되지 않은 변수를 뜻합니다. 즉, 외부 스코프에서 선언되어 내부에서 참조되는 변수예요. inner에서의 x가 바로 자유 변수입니다. 클로저는 이 자유 변수를 기억하는 함수라고도 정의할 수 있어요.
한 가지 혼동하기 쉬운 점은, 자유 변수는 외부 함수의 생명주기와 무관한 개념이라는 것입니다. 외부 함수가 아직 실행 중이어도, 내부 함수 입장에서 외부 스코프에 선언된 변수를 참조하고 있다면 그것은 자유 변수입니다.
결론
- 자바스크립트 실행 구조의 계층: 콜 스택 → 실행 컨텍스트(전역 / 함수 / eval / 모듈) → 렉시컬 환경 / 변수 환경 → 환경 레코드 / 외부 환경 참조
- 자바스크립트의 함수는 호출된 위치가 아니라 선언된 위치의 렉시컬 환경을 기억합니다. 그래서 외부 함수가 실행을 마치고 콜 스택에서 제거된 뒤에도, 내부 함수가 외부 변수를 참조하고 있다면 그 변수를 담고 있는 외부 함수의 렉시컬 환경(Environment Record)이 가비지 컬렉션 대상이 되지 않습니다.
- 이렇게 이미 종료된 외부 함수의 변수를 계속 기억하는 내부 함수를 클로저라고 부릅니다.
레퍼런스
- MDN https://developer.mozilla.org/ko/docs/Web/JavaScript/Guide/Closures
- 모던 자바 스크립트 24장 클로저
'JavaScript' 카테고리의 다른 글
| 고정 높이의 가상 스크롤 구현하기 (0) | 2026.06.23 |
|---|---|
| JSX란 무엇이고, 화면은 언제 그려질까? (0) | 2025.11.29 |
| 실행 컨텍스트로 이해하는 호이스팅과 TDZ (0) | 2025.11.26 |