함수가 선언된 환경의 변수를 기억하는 메커니즘. 상태를 캡슐화하는 가장 근본적인 방법이다. 그런데 “왜” 이런 게 필요한가부터 따져보자.
클로저가 없으면 무슨 문제가 생기나?
상태를 함수 호출 사이에 유지하고 싶다고 하자. 클로저가 없다면 그 상태를 둘 곳은 전역밖에 없다.
// 클로저 없는 세계: 상태를 함수 밖(전역)에 둘 수밖에 없다
let count = 0;
function increment() {
return ++count;
}
increment(); // 1
increment(); // 2
count가 전역에 노출돼 있다. 누구나 count = 9999로 덮어쓸 수 있고, 다른 파일에서 같은 이름의 변수를 선언하면 충돌한다. 카운터가 두 개 필요하면? 변수를 또 만들어야 한다. 전역 네임스페이스가 점점 오염된다.
문제의 본질은 이거다. 함수는 호출이 끝나면 지역 변수가 사라진다. 그래서 “끝난 뒤에도 살아남는 상태”를 두려면 함수 바깥에 둬야 했다. 클로저는 이 강제를 깬다. 상태를 함수 안에 가둔 채로 살려둔다.
function counter() {
let count = 0;
return () => ++count;
}
const inc = counter();
inc(); // 1
inc(); // 2
count는 전역에 없다. counter의 지역 변수다. 그런데도 inc()를 호출할 때마다 값이 유지된다. 외부에서 직접 접근할 방법도 없다.
함수 호출이 끝났는데 변수가 왜 살아있나?
핵심은 렉시컬 스코프(lexical scope) 다. 자바스크립트에서 함수가 어떤 변수에 접근할 수 있는지는 그 함수가 “호출된 위치”가 아니라 “선언된 위치” 로 결정된다.
counter 안에서 선언된 화살표 함수는, 자기가 태어난 자리의 스코프 — 즉 count가 있는 스코프 — 를 기억한다. 이 “기억된 스코프”가 클로저다.
동작 원리를 한 단계 더 들어가면 스코프 체인이다. 함수가 변수를 찾을 때 자기 지역 스코프부터 바깥으로 거슬러 올라간다.
function counter() {
let count = 0; // 이 스코프가
return () => ++count; // 반환된 함수에 붙어서 따라 나간다
}
const inc = counter(); // counter 실행은 끝났다
inc(); // 그런데도 count에 접근 가능
보통 counter()가 끝나면 count는 회수돼야 한다. 하지만 반환된 함수가 count를 참조하고 있으므로, 자바스크립트 엔진은 그 변수를 살려둔다. 함수 호출이 끝나도 스코프가 사라지지 않는 것이다. inc가 살아있는 한 count도 산다.
counter()를 또 호출하면 완전히 새로운 count가 생긴다. 각 클로저는 독립된 상태를 가진다.
const a = counter();
const b = counter();
a(); // 1
a(); // 2
b(); // 1 ← a와 b는 서로 다른 count
실전에서 클로저는 어디에 쓰나?
모듈 패턴 / private 캡슐화. 클래스의 private 필드가 없던 시절부터 클로저로 은닉을 구현했다. 공개하고 싶은 것만 반환하고 나머지는 스코프에 가둔다.
function createAccount(initial) {
let balance = initial; // 외부에서 직접 못 건드린다
return {
deposit: (amount) => { balance += amount; return balance; },
getBalance: () => balance,
};
}
const account = createAccount(100);
account.deposit(50); // 150
account.getBalance(); // 150
account.balance; // undefined — 직접 접근 불가
balance를 음수로 만들거나 멋대로 덮어쓸 방법이 없다. 객체가 노출한 메서드를 통해서만 바꿀 수 있다.
디바운스 / 스로틀. “마지막 호출 이후 N밀리초가 지나야 실행” 같은 동작은 호출 사이에 타이머 ID를 기억해야 한다. 그 상태를 클로저에 담는다.
function debounce(fn, delay) {
let timer; // 호출 사이에 유지되는 상태
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
const onSearch = debounce((q) => console.log("검색:", q), 300);
// 빠르게 여러 번 호출해도 마지막 한 번만 실행된다
이벤트 핸들러 / 콜백의 상태 유지. 핸들러가 등록 시점의 값을 기억해야 할 때 클로저가 그 값을 잡아둔다. React의 useState도 본질적으로 컴포넌트 함수가 다시 실행돼도 이전 상태를 기억하게 만드는, 클로저로 상태를 가두는 발상의 연장선이다.
for + var 루프에서 클로저가 다 같은 값을 가리킨다?
클로저를 처음 쓸 때 가장 많이 밟는 함정이다.
// 의도: 0, 1, 2를 출력하는 함수 3개
const fns = [];
for (var i = 0; i < 3; i++) {
fns.push(() => console.log(i));
}
fns[0](); // 3 ← 기대: 0
fns[1](); // 3 ← 기대: 1
fns[2](); // 3 ← 기대: 2
왜 전부 3인가? var는 함수 스코프라 루프 전체가 하나의 i 를 공유한다. 세 클로저가 모두 같은 i를 가리키고, 루프가 끝난 뒤 i는 3이 되어 있다. 클로저는 “값”이 아니라 “변수”를 기억한다는 점이 여기서 드러난다.
해결책 1 — let. let은 블록 스코프라 반복마다 새로운 i가 생긴다. 각 클로저가 자기만의 i를 잡는다.
const fns = [];
for (let i = 0; i < 3; i++) {
fns.push(() => console.log(i));
}
fns[0](); // 0
fns[1](); // 1
fns[2](); // 2
해결책 2 — IIFE. let을 못 쓰는 환경이라면 즉시실행함수로 매 반복마다 새 스코프를 만들어 값을 복사해 가둔다.
const fns = [];
for (var i = 0; i < 3; i++) {
(function (captured) {
fns.push(() => console.log(captured));
})(i); // 현재 i 값을 captured로 복사
}
fns[0](); // 0
fns[1](); // 1
fns[2](); // 2
클로저의 대가는 없나?
공짜가 아니다. 클로저가 변수를 참조하는 한 그 변수는 가비지 컬렉션 대상이 되지 못한다. 함수 호출이 끝나도 메모리가 회수되지 않고 클로저가 살아있는 동안 계속 잡혀 있다.
대부분은 문제가 되지 않지만, 특히 DOM 참조를 클로저가 잡고 있을 때 누수가 된다.
function attach() {
const huge = new Array(1000000).fill("x"); // 큰 데이터
const el = document.getElementById("btn");
el.addEventListener("click", () => {
console.log(huge.length); // huge를 참조 → 핸들러가 사는 한 huge도 산다
});
}
핸들러가 huge를 참조하므로, 이벤트 리스너를 제거하기 전까지 huge(메가바이트 단위 배열)가 메모리에 남는다. DOM 엘리먼트를 화면에서 지워도 클로저가 그 엘리먼트를 잡고 있으면 엘리먼트도 회수되지 않는다.
대응은 단순하다. 더 이상 필요 없는 핸들러는 removeEventListener로 떼고, 클로저가 꼭 필요한 만큼만 참조하게 설계한다. 클로저는 “상태를 살려두는” 도구이므로, 살려둘 필요가 없어진 시점을 명확히 해두는 게 중요하다.