스코프는 사라지지 않을 수 있다
이전 글에서 함수의 스코프는 함수 실행이 끝나면 사라진다고 했습니다. 하지만 항상 그렇지는 않습니다.
function makeCounter() {
let count = 0;
return function increment() {
count++;
return count;
};
}
const counter = makeCounter();makeCounter()는 실행이 끝나고 반환합니다. 하지만 반환된 increment 함수가 count를 참조하고 있습니다. 이 경우 count가 속한 스코프는 사라지지 않습니다.
이것이 클로저 (Closure) 입니다.
클로저의 정의
클로저는 함수와 그 함수가 선언된 렉시컬 환경의 조합이다. — MDN
좀 더 실용적으로 말하면:
클로저 = 함수 + 그 함수가 접근할 수 있는 외부 변수
함수가 외부 스코프의 변수를 참조하면, 그 스코프는 함수가 살아있는 한 가비지 컬렉터에 의해 회수되지 않습니다.
클로저 동작 과정
아래 시각화에서 makeCounter가 반환된 후에도 스코프가 유지되는 과정을 단계별로 확인하세요.
핵심:
makeCounter()가 반환된 후에도 count 변수의 스코프가 살아있습니다counter()를 호출할 때마다 같은 count에 접근합니다 — 복사본이 아니라 원본- 스코프 옆의 CLOSURE 표시가 클로저로 유지되는 스코프를 나타냅니다
각 호출은 독립된 클로저를 만든다
같은 함수를 여러 번 호출하면 매번 독립된 스코프 가 생성됩니다.
a와 b는 각각 자신만의 count를 가집니다. 이것이 클로저가 강력한 이유입니다 — 함수 호출마다 독립된 상태 를 만들 수 있습니다.
클로저는 변수를 캡처한다, 값이 아니라
흔한 실수:
function makeCallbacks() {
const callbacks = [];
for (var i = 0; i < 3; i++) {
callbacks.push(function() {
console.log(i);
});
}
return callbacks;
}
const fns = makeCallbacks();
fns[0](); // 3 (0이 아님!)
fns[1](); // 3
fns[2](); // 3세 함수 모두 같은 i 변수 를 클로저로 캡처합니다. i의 현재 값 을 캡처하는 것이 아니라 변수 자체를 캡처하므로, 루프가 끝난 후 i = 3을 세 함수 모두 보게 됩니다.
해결법 — let을 사용하면 반복마다 새 스코프가 생깁니다:
for (let i = 0; i < 3; i++) {
callbacks.push(function() {
console.log(i); // 각각 0, 1, 2
});
}실용 패턴
데이터 은닉
function createUser(name) {
let loginCount = 0;
return {
getName() { return name; },
login() { loginCount++; },
getLoginCount() { return loginCount; },
};
}
const user = createUser("Ray");
user.login();
user.login();
user.getLoginCount(); // 2
// user.loginCount → undefined (외부에서 직접 접근 불가)loginCount는 클로저 안에 감춰져 있어 외부에서 직접 접근하거나 변경할 수 없습니다. 오직 반환된 메서드를 통해서만 상호작용할 수 있습니다.
부분 적용
function multiply(a) {
return function(b) {
return a * b;
};
}
const double = multiply(2);
const triple = multiply(3);
double(5); // 10
triple(5); // 15multiply(2)를 호출하면 a = 2가 클로저에 캡처됩니다. 반환된 함수는 b만 받으면 됩니다. 이 패턴을 커링 (currying) 이라고도 합니다.
메모이제이션
function memoize(fn) {
const cache = new Map();
return function(...args) {
const key = JSON.stringify(args);
if (cache.has(key)) return cache.get(key);
const result = fn(...args);
cache.set(key, result);
return result;
};
}
const expensiveCalc = memoize((n) => {
// 오래 걸리는 계산...
return n * n;
});
expensiveCalc(42); // 계산 실행
expensiveCalc(42); // 캐시에서 즉시 반환cache가 클로저 안에 유지되므로, 반환된 함수가 이전 결과를 기억합니다.
V8에서의 클로저
엔진 시리즈에서 다뤘던 V8의 관점에서 보면:
- 클로저가 캡처하는 변수는 Context 라는 별도의 힙 객체에 저장됩니다
- 캡처되지 않는 변수는 스택에 있다가 함수 반환 시 사라집니다
- V8은 컴파일 시점에 어떤 변수가 클로저에 의해 캡처되는지 분석하여, 필요한 변수만 Context에 할당합니다 — 모든 변수를 유지하지 않습니다
클로저와 메모리
클로저는 스코프를 유지하므로 메모리를 사용합니다. 주의할 점:
- 더 이상 필요 없는 클로저의 참조를
null로 설정하면 GC가 스코프를 회수할 수 있습니다 - 이벤트 리스너에 클로저를 등록했다면
removeEventListener로 정리하세요 - 같은 스코프의 다른 클로저가 큰 변수를 참조하고 있으면, 의도치 않게 메모리가 유지될 수 있습니다
다음 단계
클로저는 스코프를 이해한 뒤에야 자연스럽게 이해됩니다. 다음 글에서는 또 다른 혼란의 원인인 호이스팅 을 실행 컨텍스트의 관점에서 정확하게 다루겠습니다 — var, let, const, function의 호이스팅이 왜 각각 다르게 동작하는지 살펴봅니다.