클로저 심화 — 렉시컬 환경부터 실전 패턴과 메모리 관리까지
makeCounter()가 이미 실행을 끝냈는데, 반환된 함수가count변수에 어떻게 접근할 수 있는 걸까요?
함수가 자신이 생성된 스코프의 변수를 "기억"하는 이유는 자바스크립트 엔진이 함수 객체에 렉시컬 환경 참조를 저장하기 때문입니다.
클로저란
클로저(Closure)는 함수와 그 함수가 선언된 렉시컬 환경(Lexical Environment)의 조합 입니다.
쉽게 말하면, 함수가 자신이 만들어진 스코프의 변수를 나중에도 기억하고 접근할 수 있는 것 입니다.
function outer() {
const message = '안녕하세요';
function inner() {
console.log(message); // outer의 변수에 접근
}
return inner;
}
const greeting = outer(); // outer 실행 종료
greeting(); // '안녕하세요' — outer가 끝났는데도 message에 접근 가능
outer()가 실행 종료되어 콜 스택에서 제거되었지만, 반환된 inner 함수가 message를 참조하고 있으므로 outer의 렉시컬 환경은 가비지 컬렉션되지 않고 유지됩니다.
렉시컬 환경과 클로저의 관계
자바스크립트 엔진이 함수를 생성할 때, 그 함수 객체에 [[Environment]]라는 내부 슬롯 이 생깁니다. 이 슬롯에 함수가 선언된 시점의 렉시컬 환경 참조가 저장됩니다.
function outer() {
let count = 0;
// inner 생성 시 [[Environment]]에 outer의 렉시컬 환경이 저장됨
function inner() {
count++;
return count;
}
return inner;
}
inner가 호출될 때의 스코프 체인을 그려보면 이렇습니다.
inner의 렉시컬 환경
→ outer의 렉시컬 환경 (count: 0) ← [[Environment]]로 참조
→ 전역 렉시컬 환경
이 참조 때문에 outer가 종료되어도 렉시컬 환경이 살아 있고, count에 접근할 수 있습니다.
클로저의 활용 패턴
1. 팩토리 함수
같은 로직이지만 설정이 다른 함수를 생성합니다.
function createMultiplier(factor) {
// factor를 기억하는 클로저
return function (number) {
return number * factor;
};
}
const double = createMultiplier(2);
const triple = createMultiplier(3);
console.log(double(5)); // 10
console.log(triple(5)); // 15
double과 triple은 각각 다른 렉시컬 환경을 참조하므로 factor가 다릅니다.
2. 캡슐화 (정보 은닉)
자바스크립트에는 private 키워드가 없지만(클래스 # 필드 제외), 클로저로 유사한 효과를 낼 수 있습니다.
function createCounter() {
let count = 0; // 외부에서 직접 접근 불가
return {
increment() {
return ++count;
},
decrement() {
return --count;
},
getCount() {
return count;
}
};
}
const counter = createCounter();
console.log(counter.increment()); // 1
console.log(counter.increment()); // 2
console.log(counter.getCount()); // 2
console.log(counter.count); // undefined — 직접 접근 불가
count는 클로저를 통해서만 접근할 수 있으므로, 외부에서 마음대로 변경할 수 없습니다.
3. 커링 (Currying)
여러 인자를 받는 함수를 하나씩 받는 함수의 체인으로 변환합니다.
// 일반 함수
function add(a, b) {
return a + b;
}
// 커링된 함수 — 클로저 활용
function curriedAdd(a) {
return function (b) {
return a + b; // a를 클로저로 기억
};
}
const add5 = curriedAdd(5);
console.log(add5(3)); // 8
console.log(add5(7)); // 12
4. 이벤트 핸들러에서의 상태 유지
function setupButton(buttonId) {
let clickCount = 0;
document.getElementById(buttonId).addEventListener('click', () => {
clickCount++; // 클로저로 clickCount 유지
console.log(`${buttonId} 클릭 횟수: ${clickCount}`);
});
}
setupButton('btn-a');
setupButton('btn-b');
// 각 버튼이 독립적인 clickCount를 가짐
반복문과 클로저 — 스코프 차이가 드러나는 핵심 사례
문제: var와 setTimeout
for (var i = 0; i < 3; i++) {
setTimeout(function () {
console.log(i);
}, 1000);
}
// 출력: 3, 3, 3
var는 함수 스코프이므로 루프 전체에서 하나의 i를 공유 합니다. setTimeout 콜백이 실행되는 시점에는 루프가 이미 끝나서 i는 3입니다.
해결 1: let 사용
for (let i = 0; i < 3; i++) {
setTimeout(function () {
console.log(i);
}, 1000);
}
// 출력: 0, 1, 2
let은 블록 스코프이므로 매 반복마다 새로운 바인딩 이 생깁니다. 각 콜백이 자신만의 i를 캡처합니다.
해결 2: IIFE로 클로저 생성
ES2015 이전에는 이렇게 해결했습니다.
for (var i = 0; i < 3; i++) {
(function (j) {
setTimeout(function () {
console.log(j);
}, 1000);
})(i); // 현재 i를 j로 복사
}
// 출력: 0, 1, 2
IIFE가 호출될 때마다 새로운 스코프가 생기고, j에 현재 i 값이 복사됩니다.
해결 3: setTimeout의 세 번째 인자
for (var i = 0; i < 3; i++) {
setTimeout(function (j) {
console.log(j);
}, 1000, i); // 세 번째 인자가 콜백의 파라미터로 전달됨
}
// 출력: 0, 1, 2
세 가지 해결법은 모두 "각 반복에서 별도의 스코프를 만든다"는 공통 원리를 가집니다.
클로저와 메모리
메모리 누수 주의점
클로저가 참조하는 렉시컬 환경은 가비지 컬렉션되지 않습니다. 불필요한 참조가 남아 있으면 메모리 누수가 발생할 수 있습니다.
function createHeavyClosure() {
// 큰 데이터
const largeData = new Array(1000000).fill('데이터');
return function () {
// largeData를 사용하지 않지만 렉시컬 환경에 있으므로 참조됨
console.log('hello');
};
}
const fn = createHeavyClosure();
// largeData가 해제되지 않음 — 엔진 최적화에 따라 다를 수 있음
모던 자바스크립트 엔진(V8 등)은 실제로 참조하는 변수만 유지 하는 최적화를 수행합니다. 하지만 eval을 사용하거나 디버거가 붙어 있으면 최적화가 무효화될 수 있습니다.
메모리 누수를 방지하는 방법
function setupHandler() {
let cache = loadExpensiveData();
const handler = function (event) {
// cache 사용
process(cache, event);
};
// 핸들러 제거 시 참조도 해제
return {
handler,
cleanup() {
cache = null; // 명시적으로 참조 해제
}
};
}
const { handler, cleanup } = setupHandler();
element.addEventListener('click', handler);
// 나중에 정리
element.removeEventListener('click', handler);
cleanup();
DOM 참조와 클로저
function attachHandler() {
const element = document.getElementById('button');
element.addEventListener('click', function () {
// element를 클로저로 참조 — DOM 노드가 해제되지 않을 수 있음
console.log(element.textContent);
});
}
이벤트 핸들러 내에서 DOM 요소를 참조하면, 해당 요소가 DOM에서 제거되어도 가비지 컬렉션되지 않을 수 있습니다. removeEventListener로 정리하는 습관이 중요합니다.
실무에서 자주 보는 클로저 패턴
디바운스 (Debounce)
function debounce(fn, delay) {
let timerId = null; // 클로저로 타이머 ID 유지
return function (...args) {
clearTimeout(timerId);
timerId = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
// 검색 입력에 디바운스 적용
const debouncedSearch = debounce((query) => {
console.log(`검색: ${query}`);
}, 300);
메모이제이션 (Memoization)
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.apply(this, args);
cache.set(key, result);
return result;
};
}
const expensiveCalc = memoize((n) => {
console.log('계산 중...');
return n * n;
});
expensiveCalc(5); // '계산 중...' → 25
expensiveCalc(5); // 캐시에서 반환 → 25
모듈 패턴
ES 모듈이 없던 시절, 클로저로 모듈을 구현했습니다.
const Module = (function () {
// private 변수와 함수
let privateVar = 0;
function privateMethod() {
return privateVar++;
}
// public API
return {
increment: privateMethod,
getValue() {
return privateVar;
}
};
})();
Module.increment();
console.log(Module.getValue()); // 1
console.log(Module.privateVar); // undefined
주의할 점
의도하지 않은 변수 캡처로 인한 메모리 누수
클로저가 참조하는 렉시컬 환경에 큰 데이터가 포함되어 있으면, 클로저가 살아 있는 한 해당 데이터도 GC 대상이 되지 않습니다. 모던 엔진(V8)은 실제로 참조하는 변수만 유지하는 최적화를 수행하지만, eval이나 디버거가 개입하면 최적화가 무효화될 수 있습니다.
DOM 이벤트 핸들러에서의 순환 참조
이벤트 핸들러 내에서 DOM 요소를 클로저로 참조하면, 해당 요소가 DOM에서 제거되어도 GC되지 않을 수 있습니다. removeEventListener로 핸들러를 해제하는 습관이 중요합니다.
반복문 + var에서 하나의 변수를 공유하는 문제
var는 함수 스코프이므로 루프 전체에서 하나의 변수를 공유합니다. setTimeout 같은 비동기 콜백과 결합하면 의도와 다른 결과가 나옵니다. let으로 선언하거나, IIFE로 각 반복의 값을 캡처하면 해결됩니다.
정리
| 항목 | 설명 |
|---|---|
| 클로저란 | 함수와 그 함수가 선언된 렉시컬 환경의 조합 |
| 동작 원리 | 함수 객체의 [[Environment]] 슬롯이 렉시컬 환경을 참조 |
| 활용 패턴 | 팩토리, 캡슐화, 커링, 디바운스, 메모이제이션, 모듈 패턴 |
| 메모리 주의 | 불필요한 참조가 남으면 GC 대상이 되지 않음 |
| 반복문 해결 | let 사용, IIFE, setTimeout 세 번째 인자 |