-
JS Deep Dive 24장. 클로저개발/자바스크립트로의 깊은 잠수 2026. 8. 18. 20:21

클로저는 오마이걸의 명곡 중 하나이기도 하다. 함수를 일급 객체로 취급하면 클로저가 필요해지는 것인가?
클로저 앞부분을 읽다보니 이런 의문이 먼저 생겼습니다.
결론부터 말하자면. 일급 객체만으로 필요해지는 건 아니고. 렉시컬 스코프와 만났을 때 클로저가 필요해진다고 볼 수 있더라구요.
함수가 일급 객체라는 것은 함수를 값처럼 변수에 담고, 인수로 전달하고, 반환값으로 돌려받을 수 있다는 뜻입니다. 그러면 자연스럽게 함수가 정의된 곳과 호출되는 곳이 달라지는 상황이 생깁니다. 이때 스코프를 정의된 위치 기준으로 결정하겠다고 약속했다면(= 렉시컬 스코프), 함수는 자신이 어디서 호출되든 자신이 정의된 환경을 기억하고 있어야만 합니다. 그 기억 장치가 바로 클로저입니다.
그래서 이 글은 렉시컬 스코프에서 출발해 실행 컨텍스트를 거쳐 클로저로 도착하는 순서로 정리해 보려고 합니다.
렉시컬 스코프가 뭐냐
함수가 정의된 곳을 기준으로 스코프가 생성된다는 말입니다. 호출한 위치는 아무 상관이 없습니다.
const x = 1; function foo() { const x = 10; bar(); } function bar() { console.log(x); } foo(); // 1 bar(); // 1bar는 foo 안에서 호출됐지만 10이 아니라 1을 출력합니다. bar가 정의된 위치가 전역이기 때문입니다.
여기서 헷갈리기 쉬운 것이 있습니다. 동적인 거 뭐가 있었는데... 그래, this 바인딩입니다. this는 호출하는 방법에 따라 다르게 바인딩됩니다. 스코프와는 완전히 다른 규칙입니다. 헷갈리지 맙시다.
const obj = { name: 'obj', getName() { return this.name; }, }; const getName = obj.getName; obj.getName(); // 'obj' — 메서드로 호출 getName(); // undefined — 일반 함수로 호출같은 함수인데도 호출 방법이 달라지니 this가 달라집니다. 정의 위치는 그대로인데 말이죠.
정리하면, 렉시컬 스코프에서 중요한 것은 상위 스코프로 이어지는 단방향 스코프 체인이 어떻게 만들어지는가를 이해하는 것입니다. 이거 말고는 렉시컬 스코프가 복잡할 이유가 없습니다.
"스코프의 실체는 실행 컨텍스트의 렉시컬 환경이다"
이 문장이 처음엔 잘 안 읽혔습니다. 하나씩 풀어보겠습니다.
실행 컨텍스트라는 것은 코드가 실행되면서 벌어지는 일련의 과정이었습니다. 그 과정에서 렉시컬 환경(Lexical Environment) 이 만들어지는데, 이 렉시컬 환경은 두 가지를 가지고 있습니다.
LexicalEnvironment = { EnvironmentRecord: { // 식별자와 바인딩된 값이 key-value 쌍으로 저장되는 곳 x: 1, foo: <function object> }, OuterLexicalEnvironmentReference: <상위 렉시컬 환경에 대한 참조> }렉시컬 환경은 key-value 쌍을 가지는 객체 같은 것입니다. 그 안에 환경 레코드가 있고, 환경 레코드에 변수들이 저장되어 있습니다. 그리고 자기 자신의 외부 렉시컬 환경에 대한 참조를 갖고 있습니다. 마치 참조하는 메모리 주소를 가리키는 포인터처럼요.
이 외부 참조가 계속 이어진 것이 스코프 체인입니다. 식별자를 찾을 때 자기 환경 레코드에서 먼저 찾고, 없으면 외부 참조를 타고 올라가는 것이죠. 위로만 올라갈 수 있으니 단방향입니다.
그러니까 그 물리적인 레코드들 — 그것을 우리가 개념적으로 스코프라고 부르는 것이렷다. "스코프의 실체는 실행 컨텍스트의 렉시컬 환경이다"라는 말은 이런 뜻이었습니다.
상위 스코프의 참조는 언제 저장되는가
함수는 자신이 호출되는 환경에서도, 자신이 정의된 환경을 기억해야 합니다. 그래서 정의된 환경, 즉 상위 스코프의 참조를 저장해 둡니다. 함수 객체의 내부 슬롯 [[Environment]]가 그 자리입니다.
그럼 언제 저장할까요? 함수가 평가되는 시점입니다.
그리고 이때 저장되는 상위 스코프란, 평가되는 시점에 "실행 중인 실행 컨텍스트(running execution context)"의 렉시컬 환경입니다.
이 두 문장이 클로저를 이해하는 핵심입니다. 함수 객체가 만들어질 때, 그 순간 실행 중이던 환경을 스냅샷처럼 참조로 붙들어 둔다는 뜻이니까요.
const x = 1; function outer() { const x = 10; // 이 함수 표현식이 "평가"되는 시점 = outer의 렉시컬 환경이 실행 중 // 따라서 inner의 [[Environment]]에는 outer의 렉시컬 환경이 저장된다 const inner = function () { console.log(x); }; return inner; } const innerFunc = outer(); // outer의 실행 컨텍스트는 여기서 제거된다 innerFunc(); // 10outer는 이미 실행을 마치고 실행 컨텍스트 스택에서 제거되었습니다. 그런데도 inner는 10을 출력합니다. inner가 outer의 렉시컬 환경을 참조하고 있으니, 가비지 컬렉터가 그 렉시컬 환경을 수거하지 못하기 때문입니다.
그래서 클로저란
중첩 함수가 상위 스코프의 식별자를 참조하고 있다면 그 함수를 클로저라고 부릅니다. 그리고 그 중첩 함수가 외부 함수보다 더 오래 유지되는 경우에 클로저가 유지됩니다.
역으로 말하면, 상위 스코프의 식별자를 아무것도 참조하지 않는 중첩 함수는 이론적으로는 클로저이지만 의미가 없고, 외부 함수 안에서 쓰이고 끝나버리는 함수도 클로저라고 부를 실익이 없습니다. 그래서 보통 클로저라고 하면 위 두 조건을 모두 만족하는 경우를 가리킵니다.
클로저의 사용 예 — private 변수
클로저를 쓰다 보면 이것은 약간 객체 인스턴스의 private 변수랑 비슷한 것 같기도 합니다.
생성자 함수에서 프로퍼티로 상태를 두면 내부 변수로의 접근을 막을 수 없습니다.
function Counter() { this.count = 0; } Counter.prototype.increase = function () { return ++this.count; }; const counter = new Counter(); counter.increase(); // 1 counter.count = 100; // 외부에서 마음대로 조작 가능그런데 클로저를 쓰면 원천 차단할 수 있습니다.
function Counter() { let count = 0; // 생성자 함수의 지역 변수 this.increase = function () { return ++count; }; this.decrease = function () { return count > 0 ? --count : 0; }; } const counter = new Counter(); counter.increase(); // 1 counter.increase(); // 2 counter.count; // undefined — 접근할 방법이 없다count는 생성자 함수의 지역 변수이므로 외부에서 접근할 통로가 아예 없습니다. 오직 increase, decrease라는 클로저를 통해서만 조작할 수 있습니다.
왜 인스턴스마다 독립적일까
여기서 아까의 질문을 다시 꺼내봅시다. 클로저가 상위 스코프의 참조를 저장하는 시점이 언제라고 했지요? 함수가 평가되는 시점!
함수 정의는 그 정의문이 실제로 실행(평가)될 때마다 새로운 함수 객체를 만들어냅니다. 다시 말해 함수를 인수로 넘기거나 반환하도록 짜두면, 바깥 함수가 호출되기 전까지 안쪽 함수 정의는 평가되지 않습니다. 바깥 함수가 실행되는 시점에 비로소 평가되므로, 그때마다 새로운 렉시컬 환경이 만들어지고 새로운 클로저가 생성됩니다.
이 개념을 생성자 함수에 적용하면, 우리는 new를 호출할 때마다 은닉된 클로저 private 변수를 가진 객체 인스턴스를 쑥떡쑥떡 뽑아낼 수 있게 되는 것입니다.
const counter1 = new Counter(); const counter2 = new Counter(); counter1.increase(); // 1 counter1.increase(); // 2 counter2.increase(); // 1 — counter1과 완전히 독립적이다같은 원리를 함수 팩토리에 적용한 것이 아래 형태입니다.
function makeCounter(predicate) { let counter = 0; // makeCounter가 호출될 때마다 새로 만들어진다 return function () { counter = predicate(counter); return counter; }; } const increaser = makeCounter(n => ++n); const decreaser = makeCounter(n => --n); increaser(); // 1 increaser(); // 2 decreaser(); // -1 — increaser와 상태를 공유하지 않는다
하지만 프로토타입 체이닝과는 궁합이 나쁩니다
여기까지 오면 하나 걸리는 점이 있습니다. 위 방식은 인스턴스를 만들 때마다 메서드를 새로 생성하므로 메모리 측면에서 낭비입니다. 그래서 보통은 메서드를 프로토타입에 올리는데, 자바스크립트의 프로토타입 체이닝 기반으로 메서드를 구현하면 이런 식으로 private 변수를 만드는 것이 불가능해집니다.
function Counter() { let count = 0; } Counter.prototype.increase = function () { return ++count; // ReferenceError: count is not defined };프로토타입 메서드는 생성자 함수 바깥에서 정의되므로, 평가되는 시점의 상위 스코프가 전역입니다. 생성자 함수의 지역 변수를 볼 수가 없죠.
즉시 실행 함수로 감싸서 우회하는 방법이 있긴 합니다.
const Counter = (function () { let count = 0; // 즉시 실행 함수의 지역 변수 function Counter() {} Counter.prototype.increase = function () { return ++count; }; return Counter; }()); const c1 = new Counter(); const c2 = new Counter(); c1.increase(); // 1 c2.increase(); // 2 — 어라?그런데 보시다시피 이 경우 모든 인스턴스가 하나의 렉시컬 환경을 공유합니다. private 인스턴스 변수가 아니라 사실상 private 정적 변수가 되어버리는 것이죠. 인스턴스별 상태를 원했다면 이건 답이 아닙니다.
이런 사정 때문에 ES2022부터는 private 필드가 공식 사양으로 승격되었습니다.
class Counter { #count = 0; // 진짜 private 인스턴스 필드 increase() { return ++this.#count; } } const c1 = new Counter(); const c2 = new Counter(); c1.increase(); // 1 c2.increase(); // 1 — 인스턴스마다 독립적이다 c1.#count; // SyntaxError: Private field '#count' must be declared in an enclosing class메서드는 프로토타입에 한 번만 올라가면서도 상태는 인스턴스마다 은닉됩니다. 클로저로 씨름하던 문제가 언어 차원에서 해결된 셈입니다.
반복문의 i, 이제는 놓아주어도 됩니다
클로저 이야기에서 빠지지 않는 고전 예제입니다.
var funcs = []; for (var i = 0; i < 3; i++) { funcs.push(function () { return i; }); } console.log(funcs.map(f => f())); // [3, 3, 3]var는 함수 레벨 스코프이므로 i가 하나뿐입니다. 세 함수가 모두 같은 렉시컬 환경의 같은 i를 참조하고 있고, 반복문이 끝난 시점의 i는 3입니다.
그래서 예전에는 즉시 실행 함수로 매 반복마다 새로운 스코프를 만들어줘야 했습니다.
for (var i = 0; i < 3; i++) { funcs.push((function (id) { return function () { return id; // 반복마다 새로 만들어진 id를 참조 }; }(i))); } // [0, 1, 2]하지만 ES6에서 블록 레벨 스코프가 생기면서, 반복문 내에서 사용되는 변수 i를 클로저 고민하면서 쓸 필요가 없어졌습니다.
for (let i = 0; i < 3; i++) { funcs.push(function () { return i; }); } // [0, 1, 2]let으로 선언한 반복문은 반복마다 새로운 렉시컬 환경을 생성하고 이전 반복의 값을 복사해 넣습니다. 각 함수가 서로 다른 i를 참조하게 되는 것이죠. 결국 자바스크립트가 언어 차원에서 우리 대신 클로저를 만들어 주고 있는 셈입니다.
정리
- 함수가 일급 객체라서 정의 위치와 호출 위치가 달라질 수 있고, 렉시컬 스코프를 지키려면 정의된 환경을 기억해야 합니다. 그래서 클로저가 필연이 됩니다.
- 스코프의 실체는 실행 컨텍스트의 렉시컬 환경이고, 그 안의 환경 레코드에 식별자가, 외부 렉시컬 환경 참조에 스코프 체인이 담깁니다.
- 함수는 평가되는 시점에 실행 중인 실행 컨텍스트의 렉시컬 환경을 상위 스코프로 저장합니다.
- 중첩 함수가 상위 스코프의 식별자를 참조하고, 외부 함수보다 오래 살아남으면 클로저가 유지됩니다.
- 클로저로 private 변수를 흉내낼 수 있지만 프로토타입 메서드와는 궁합이 나쁘고, 그래서 ES2022의 # private 필드가 등장했습니다.
- let의 블록 레벨 스코프 덕분에 반복문에서 클로저를 직접 고민할 일은 사라졌습니다.
클로저는 특별한 문법이 아니라, 렉시컬 스코프를 끝까지 지키려는 언어의 태도가 만들어낸 결과물이었습니다.
'개발 > 자바스크립트로의 깊은 잠수' 카테고리의 다른 글
JS Deep Dive 23장. 실행 컨텍스트 (1) (0) 2026.07.21 JS Deep Dive 22장. this (0) 2026.07.14 JS Deep Dive 20장. strict mode (1) 2026.07.07 JS Deep Dive 18장. 함수와 일급 객체 (0) 2026.06.16 자바스크립트의 함수를 잘못 사용하는 방법 (0) 2026.06.09