함수가 실행을 마치면 그 안의 변수는 사라져야 합니다. 그런데 내부에서 만든 함수를 반환받아 나중에 호출하면, 이미 끝난 외부 함수의 변수에 여전히 접근할 수 있습니다. JavaScript가 함수와 그 주변 변수들 사이의 연결을 끊지 않고 유지하기 때문입니다.
클로저(Closure)는 함수와 그 함수가 선언된 시점의 렉시컬 환경(Lexical Environment)을 함께 기억하는 조합입니다. 내부 함수는 자신이 만들어진 시점의 외부 스코프 변수들을 계속 참조할 수 있으며, 외부 함수의 실행이 끝나 콜 스택에서 제거된 뒤에도 마찬가지입니다.
function outer() {
const message = "안녕하세요";
function inner() {
console.log(message); // outer가 끝난 뒤에도 접근 가능
}
return inner;
}
const greet = outer(); // outer 실행 완료, 콜 스택에서 제거
greet(); // "안녕하세요" — message가 살아 있음
outer는 호출이 끝난 뒤 콜 스택에서 사라집니다. 하지만 greet를 호출하면 message에 여전히 접근할 수 있습니다. inner가 message를 참조하고 있기 때문에 엔진이 해당 렉시컬 환경을 메모리에서 해제하지 않습니다.
[ outer: returned ]
|
| return
▼
[ inner: function object ]
|
| [[Environment]] ref
▼
[ outer: lexical env ]
┌──────────────────┐
│ message: "Hello" │
└──────────────────┘
|
| outer ref
▼
[ global: lexical env ]
inner 함수 객체는 내부 슬롯 [[Environment]] 에 outer의 렉시컬 환경에 대한 참조를 보관합니다. 이 참조가 살아 있는 한 가비지 컬렉터는 해당 환경을 수거하지 않습니다.
가비지 컬렉터(Garbage Collector): 더 이상 참조되지 않는 메모리를 자동으로 회수하는 JavaScript 엔진의 메모리 관리 시스템입니다.
클로저의 가장 실용적인 용도 중 하나는 호출할 때마다 상태를 유지하는 함수를 만드는 것입니다.
function makeCounter() {
let count = 0;
return {
increment() { count += 1; },
decrement() { count -= 1; },
value() { return count; },
};
}
const counter = makeCounter();
counter.increment();
counter.increment();
counter.decrement();
console.log(counter.value()); // 1
makeCounter를 호출할 때마다 독립적인 count 변수가 생성됩니다. 반환된 객체의 메서드들은 각자 자신이 만들어진 렉시컬 환경의 count를 참조하므로, 두 카운터 인스턴스는 서로 간섭하지 않습니다.
const counterA = makeCounter();
const counterB = makeCounter();
counterA.increment();
counterA.increment();
console.log(counterA.value()); // 2
console.log(counterB.value()); // 0 — 독립적
클로저를 이용하면 내부 구현을 숨기고 정해진 인터페이스를 통해서만 데이터를 다루도록 제한하는 캡슐화를 구현할 수 있습니다. 외부에서 직접 읽거나 쓸 수 없는 변수가 그 예입니다.
캡슐화(Encapsulation): 객체지향 설계의 핵심 원칙 중 하나로, 내부 상태를 숨기고 외부에는 정해진 인터페이스만 노출합니다.
function makeUser(name) {
let _loginCount = 0; // 외부에서 직접 접근 불가
return {
getName() { return name; },
login() { _loginCount += 1; },
getCount() { return _loginCount; },
};
}
const user = makeUser("홍길동");
user.login();
user.login();
console.log(user.getCount()); // 2
console.log(user._loginCount); // undefined — 직접 접근 차단
_loginCount는 makeUser의 렉시컬 환경 안에만 존재합니다. 반환된 객체에 포함되지 않았으므로 외부 코드는 이 값을 직접 조작할 수 없습니다.
반복문 안에서 클로저를 만들 때 var와 let은 전혀 다르게 동작합니다.
const funcs = [];
for (var i = 0; i < 3; i++) {
funcs.push(function () {
console.log(i);
});
}
funcs[0](); // 3
funcs[1](); // 3
funcs[2](); // 3
var로 선언한 i는 함수 스코프입니다. 반복문이 끝난 뒤 i는 3이 되고, 세 함수 모두 같은 렉시컬 환경의 i를 참조하므로 전부 3을 출력합니다.
const funcs = [];
for (let i = 0; i < 3; i++) {
funcs.push(function () {
console.log(i);
});
}
funcs[0](); // 0
funcs[1](); // 1
funcs[2](); // 2
let은 블록 스코프입니다. 반복마다 새로운 렉시컬 환경이 생성되어 각 함수가 서로 다른 i를 캡처합니다. 이것이 var와 let이 클로저 맥락에서 다르게 작동하는 핵심 이유입니다.
| 선언 | 스코프 | 반복마다 새 환경 | 출력 결과 |
|---|---|---|---|
var | 함수 | 아니오 | 전부 3 |
let | 블록 | 예 | 0, 1, 2 |
클로저는 렉시컬 환경을 참조로 붙들기 때문에, 큰 데이터를 담은 외부 변수가 함수와 함께 살아남아 메모리 누수로 이어질 수 있습니다.
메모리 누수(Memory Leak): 더 이상 필요하지 않은 데이터가 참조 때문에 가비지 컬렉터에 의해 회수되지 못하고 메모리를 계속 점유하는 현상입니다.
function processLargeData() {
const bigArray = new Array(1_000_000).fill("데이터");
return function getValue(index) {
return bigArray[index]; // bigArray 전체가 메모리에 유지됨
};
}
const getValue = processLargeData();
// getValue가 살아 있는 한 bigArray(약 8MB)는 수거되지 않음
필요한 값만 추출해 참조를 끊는 것이 좋습니다.
function processLargeData() {
const bigArray = new Array(1_000_000).fill("데이터");
const result = bigArray[0]; // 필요한 값만 복사
return function getValue() {
return result; // bigArray는 참조 없음 → 수거 가능
};
}
| 개념 | 핵심 |
|---|---|
| 클로저 | 함수 + 선언 시점의 렉시컬 환경 참조 |
[[Environment]] | 함수 객체가 렉시컬 환경을 보관하는 내부 슬롯 |
| 상태 유지 | 외부 함수 종료 후에도 내부 함수가 변수를 참조 |
| 캡슐화 | 반환 객체에 포함되지 않은 변수는 외부에서 접근 불가 |
| 반복문 함정 | var는 공유 스코프, let은 반복마다 독립 스코프 생성 |
클로저가 동작하는 이유는 함수 객체가 자신이 만들어진 렉시컬 환경에 대한 참조를 [[Environment]] 슬롯에 보관하기 때문입니다. 외부 함수가 종료된 뒤에도 내부 함수가 변수를 기억하는 것은 이 참조가 끊어지지 않기 때문이며, 상태 유지와 캡슐화가 가능한 것도 같은 원리입니다.