JavaScript 클로저(Closure) 완벽 이해하기
핵심 요약
이 글에서 확인할 내용
자바스크립트를 공부하다 보면 반드시 마주치는 거대한 산이 하나 있습니다. 바로 클로저(Closure) 입니다. "함수와 그 함수가 선언된 렉시컬 환경의 조합"이라는 MDN의 정의는 너무나도 추상적입니다. 하지만 클로저는 React Hooks의 근간이자, 데이터를 안전하게 은닉(Information Hiding)하고, 함수형 프로그래밍을 가능하게 하는 자바스크립트의 핵심 엔진입니다.
- 1. 렉시컬 스코프(Lexical Scope): 태어난 곳이 운명을 결정한다
- 2. 클로저의 실체: 죽지 않는 변수
- 3. 반복문과 클로저의 고전적 함정
자바스크립트를 공부하다 보면 반드시 마주치는 거대한 산이 하나 있습니다. 바로 클로저(Closure) 입니다. "함수와 그 함수가 선언된 렉시컬 환경의 조합"이라는 MDN의 정의는 너무나도 추상적입니다. 하지만 클로저는 React Hooks의 근간이자, 데이터를 안전하게 은닉(Information Hiding)하고, 함수형 프로그래밍을 가능하게 하는 자바스크립트의 핵심 엔진입니다. 이 글에서는 난해한 이론 대신, 실무 예제와 도식화를 통해 클로저를 완벽하게 파헤쳐 봅니다.
클로저를 어렵게 느끼는 이유는 대부분 이 개념이 눈에 보이지 않기 때문입니다. 우리가 작성하는 것은 함수 선언 몇 줄이지만, 그 함수가 실행되는 순간 자바스크립트 엔진은 우리 눈에 보이지 않는 곳에서 실행 컨텍스트를 만들고, 변수들의 거처를 정하고, 어떤 변수를 힙(heap)에 오래 남겨둘지를 결정합니다. 클로저는 바로 이 보이지 않는 무대 뒤편에서 벌어지는 일입니다. 그래서 우리는 이 글에서 문법이 아니라 엔진이 무엇을 기억하고, 무엇을 놓아주지 않는가라는 관점으로 접근하려고 합니다. 이 관점을 손에 넣으면 "왜 이 변수가 죽지 않지?", "왜 이 값이 갱신되지 않지?" 같은 실무의 미스터리가 대부분 스스로 풀립니다.
1. 렉시컬 스코프(Lexical Scope): 태어난 곳이 운명을 결정한다
클로저를 이해하기 위해 가장 먼저 알아야 할 개념은 렉시컬 스코프(Lexical Scope) 입니다. 다른 말로 '정적 스코프(Static Scope)'라고도 합니다.
이 말의 핵심은 "변수의 유효 범위(Scope)는 함수가 어디서 호출되었는가가 아니라, 어디서 선언(정의)되었는가에 따라 결정된다" 는 것입니다.
var x = 1;
function foo() {
var x = 10;
bar();
}
function bar() {
console.log(x);
}
foo(); // 결과는 1
foo 함수 안에서 bar를 호출했기 때문에 x가 10이 나올 것 같지만(동적 스코프라면), 자바스크립트는 렉시컬 스코프를 따르므로 bar가 선언된 위치인 전역 스코프의 x(1)를 참조합니다. 이 "선언된 위치"를 기억하는 메커니즘이 클로저의 시작입니다.
여기서 '정적(Static)'이라는 단어를 곱씹어 볼 필요가 있습니다. 스코프가 정적이라는 말은, 코드를 실행하기도 전에 소스 코드의 생김새만 보고 각 변수 참조가 어느 스코프를 가리키는지 확정할 수 있다는 뜻입니다. 함수를 어디서 몇 번 호출하든 이 관계는 변하지 않습니다. 반대로 동적 스코프를 채택한 언어(초기 Lisp의 일부 방언, 셸 스크립트 등)에서는 "호출 스택을 거슬러 올라가며 변수를 찾기" 때문에, 같은 함수라도 누가 불렀느냐에 따라 참조하는 변수가 달라집니다. 자바스크립트가 정적 스코프를 택한 덕분에 우리는 코드를 읽는 것만으로 변수의 출처를 추적할 수 있고, 엔진 역시 컴파일 단계에서 변수의 거처를 미리 계산해 최적화할 수 있습니다. 클로저라는 편의는 바로 이 정적 스코프라는 토대 위에서만 성립합니다.
1.1 실행 컨텍스트와 스코프 체인: 클로저의 뼈대
렉시컬 스코프가 '설계도'라면, 그 설계도를 실제로 작동시키는 것은 실행 컨텍스트(Execution Context) 와 렉시컬 환경(Lexical Environment) 입니다. 함수가 호출될 때마다 엔진은 새로운 실행 컨텍스트를 만들고, 그 안에 렉시컬 환경을 배치합니다. 렉시컬 환경은 크게 두 부분으로 이루어집니다. 하나는 이 스코프에서 선언된 변수와 그 값을 담는 환경 레코드(Environment Record) 이고, 다른 하나는 자신을 감싸는 바깥 렉시컬 환경을 가리키는 외부 참조(outer) 입니다.
이 '외부 참조'가 연쇄적으로 이어진 사슬을 우리는 스코프 체인(Scope Chain) 이라고 부릅니다. 어떤 변수를 참조하면 엔진은 현재 환경 레코드를 먼저 뒤지고, 없으면 외부 참조를 따라 한 칸 위로 올라가고, 거기에도 없으면 다시 위로 올라가 전역까지 훑습니다. 앞의 예제에서 bar가 x를 찾을 때 전역의 x(1)에 도달한 이유가 바로 이 체인이 bar가 정의된 위치를 기준으로 엮여 있기 때문입니다. 핵심은 이 체인의 방향이 호출 시점이 아니라 정의 시점에 고정된다는 점입니다.
여기서 클로저의 진짜 정체가 드러납니다. 모든 함수는 생성될 때 자신이 정의된 렉시컬 환경을 내부 슬롯(명세상 [[Environment]])에 몰래 저장해 둡니다. 함수라는 값 안에는 실행할 코드뿐 아니라 "내가 태어난 동네의 주소"가 함께 들어 있는 셈입니다. 그래서 함수를 다른 곳으로 넘기고, 나중에 전혀 다른 맥락에서 호출해도, 그 함수는 언제나 자신이 태어난 동네의 변수를 정확히 찾아갑니다. 클로저란 결국 함수와 그 함수가 붙잡고 있는 렉시컬 환경이 한 몸으로 묶여 다니는 현상을 부르는 이름입니다.
2. 클로저의 실체: 죽지 않는 변수
일반적으로 함수가 실행을 마치고(return) 콜 스택(Call Stack)에서 제거(pop)되면, 그 함수 내부의 로컬 변수들은 가비지 컬렉터(GC)에 의해 메모리에서 삭제되어야 합니다. 이것이 정상적인 생명주기입니다.
하지만 클로저는 다릅니다.
"외부 함수보다 중첩 함수(내부 함수)가 더 오래 유지되는 경우, 중첩 함수는 이미 생명 주기가 다한 외부 함수의 변수를 여전히 참조할 수 있다."
이 현상을 클로저라고 부릅니다.
function makeCounter() {
let num = 0; // 외부 함수의 변수 (자유 변수)
return function () {
num++; // 내부 함수가 외부 변수를 참조 -> 클로저 형성!
return num;
};
}
const counter = makeCounter(); // makeCounter는 실행 후 종료됨
console.log(counter()); // 1
console.log(counter()); // 2
makeCounter는 종료되었지만, 반환된 익명 함수가 num을 잡고 놓아주지 않고 있습니다. 따라서 num 변수는 메모리에서 해제되지 않고 살아남아 상태를 유지합니다.
여기서 짚고 넘어가야 할 용어가 자유 변수(Free Variable) 입니다. 내부 함수 입장에서 num은 자기가 선언한 변수도, 매개변수도 아닙니다. 그런데도 참조하고 있습니다. 이렇게 함수 스스로가 만들지 않았지만 바깥 스코프에서 끌어다 쓰는 변수를 자유 변수라고 부릅니다. 클로저는 정확히 이 자유 변수를 살려두는 장치입니다. 만약 내부 함수가 바깥 변수를 하나도 참조하지 않는다면, 아무리 함수를 반환해도 클로저라고 부를 이유가 없습니다. 붙잡을 대상이 없으니까요.
2.1 클로저는 '값'이 아니라 '변수'를 캡처한다
클로저를 얕게 이해한 사람이 가장 많이 넘어지는 지점이 바로 이것입니다. 클로저는 외부 변수의 그 순간 값을 복사해 스냅샷으로 저장하는 것이 아닙니다. 변수라는 상자, 즉 바인딩(binding) 자체에 대한 살아 있는 참조를 붙잡습니다. 그래서 바깥에서 그 변수의 값이 바뀌면 클로저를 통해 읽는 값도 함께 바뀝니다.
function makeBox() {
let value = 1;
const read = () => value; // value라는 '변수'를 붙잡는다
const write = (v) => { value = v; };
return { read, write };
}
const box = makeBox();
console.log(box.read()); // 1
box.write(42);
console.log(box.read()); // 42 (스냅샷이었다면 여전히 1이어야 한다)
read와 write는 서로 다른 함수지만, 둘 다 makeBox의 같은 value 바인딩을 공유합니다. write가 값을 바꾸면 read가 그 변화를 즉시 봅니다. 이것이 "클로저는 값이 아니라 변수를 캡처한다"는 문장의 실제 의미입니다. 이 성질은 나중에 반복문과 이벤트 핸들러에서 개발자를 함정에 빠뜨리는 원흉이 되기도 하고, 동시에 프라이빗 상태를 여러 메서드가 공유하게 만드는 강력한 무기가 되기도 합니다. 같은 성질이 상황에 따라 축복이 되기도, 저주가 되기도 하는 셈입니다.
3. 반복문과 클로저의 고전적 함정
클로저가 '값이 아니라 변수를 캡처한다'는 성질을 몸으로 체감하게 되는 대표적인 장면이 반복문입니다. 아래는 자바스크립트 면접의 단골 문제이자, 실무에서도 심심찮게 재현되는 버그입니다.
// var 사용 — 의도와 다르게 동작한다
for (var i = 0; i < 3; i++) {
setTimeout(function () {
console.log(i);
}, 100);
}
// 기대: 0, 1, 2 / 실제: 3, 3, 3
var로 선언한 i는 함수 스코프를 가지므로, 반복문 전체를 통틀어 단 하나의 바인딩만 존재합니다. 세 개의 setTimeout 콜백은 모두 그 하나뿐인 i 를 캡처합니다. 콜백이 실제로 실행되는 100밀리초 뒤에는 이미 반복문이 끝나 i가 3이 된 상태이므로, 세 콜백 모두 3을 출력합니다. 각 콜백이 서로 다른 값을 붙잡았다고 생각했지만, 실은 같은 상자를 붙잡고 있었던 것입니다.
해결책은 두 가지입니다. 첫째, let을 쓰는 것입니다. let은 블록 스코프를 가지며, 명세상 반복이 돌 때마다 새로운 바인딩을 만들어 이전 값을 복사해 넣습니다. 따라서 각 반복의 클로저가 서로 다른 i를 붙잡게 되어 0, 1, 2가 정상 출력됩니다.
// let 사용 — 반복마다 새 바인딩이 생긴다
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100); // 0, 1, 2
}
둘째, let이 없던 시절의 고전적 해법은 즉시 실행 함수(IIFE)로 각 반복의 값을 인자로 넘겨 새로운 스코프를 강제로 만드는 것이었습니다. (function (j) { ... })(i)처럼 매개변수 j에 그 순간의 i 값을 복사해 넣으면, 콜백은 매 반복마다 독립된 j를 캡처하게 됩니다. let이 표준이 된 지금은 이 패턴을 새로 작성할 일은 거의 없지만, 오래된 코드베이스를 읽을 때 이 구조의 의도를 알아보는 것은 여전히 중요합니다. 이 함정의 교훈은 명확합니다. 클로저가 무엇을 캡처하는지 헷갈릴 때는 "이 변수의 바인딩이 몇 개인가"를 먼저 세어 보라는 것입니다.
4. 실무 활용 패턴: 왜 쓰는가?
"그냥 전역 변수 쓰면 되잖아요?"라고 반문할 수 있습니다. 하지만 전역 변수는 누구나 수정할 수 있어 위험합니다. 클로저는 상태를 안전하게 유지(Encapsulation) 하는 데 탁월합니다.
4.1 데이터 은닉 (Data Privacy)
위의 makeCounter 예제에서 num 변수에 접근할 수 있는 방법은 오직 counter 함수를 호출하는 것뿐입니다. 외부에서 num = 100처럼 직접 값을 조작하는 것은 불가능합니다. 이것이 바로 Java의 private 키워드와 같은 효과를 냅니다.
이 은닉이 왜 중요한지는 상태의 불변식(invariant) 을 지켜야 하는 상황에서 분명해집니다. 예컨대 잔액은 음수가 될 수 없다, 카운터는 오직 1씩만 증가한다 같은 규칙을 지키려면, 그 값을 아무나 아무 값으로나 덮어쓰지 못하게 막아야 합니다. 상태를 클로저 안에 가둬 두고 검증 로직을 통과한 조작만 허용하면, 외부 코드가 실수로든 악의로든 내부 규칙을 깨뜨릴 여지가 사라집니다. 인증 토큰이나 세션 비밀처럼 노출되면 곤란한 값을 다룰 때도 같은 원리가 적용됩니다. 실제로 패스키·WebAuthn 기반 인증을 다루는 클라이언트 코드에서도, 민감한 챌린지 값이나 임시 키를 전역에 두지 않고 클로저 스코프 안에 묶어 두는 설계가 기본기가 됩니다. 은닉은 단순한 코드 정리가 아니라 보안과 정합성의 문제이기도 합니다.
물론 2020년 이후로는 클래스의 #private 필드라는 문법적 대안이 생겼습니다. 하지만 클래스 인스턴스가 필요 없는 상황, 함수 하나로 상태를 감싸고 싶은 상황에서는 여전히 클로저가 가장 가볍고 직접적인 은닉 수단입니다.
4.2 모듈 패턴(Module Pattern)
클로저를 가장 많이 활용하는 패턴입니다. 즉시 실행 함수(IIFE) 내부에 private 변수와 함수를 선언하고, public API만 객체로 반환합니다. ES 모듈이 보편화되기 전, 전역 네임스페이스 오염을 방지하는 핵심 기법이었습니다.
const counterModule = (function () {
let count = 0; // private
function validate(n) { // private
return Number.isInteger(n) && n >= 0;
}
return { // public API
increment() { count++; return count; },
reset(n) { if (validate(n)) count = n; },
get value() { return count; },
};
})();
바깥에서는 counterModule.increment()나 counterModule.value로만 접근할 수 있고, count와 validate는 존재조차 보이지 않습니다. 이 구조가 던지는 메시지는 명확합니다. "공개할 것과 감출 것을 스코프로 구분하라." 오늘날에는 ES 모듈(import/export)이 파일 단위로 같은 역할을 해 주기 때문에 모듈 패턴을 직접 손으로 쓸 일은 줄었지만, 그 밑바탕에 흐르는 발상 — 파일이라는 스코프 안에 감춰 두고 필요한 것만 내보낸다 — 은 정확히 클로저의 은닉과 같은 뿌리에서 나옵니다.
4.3 커링(Currying)과 부분 적용
함수형 프로그래밍에서 자주 쓰이는 기법으로, 여러 인자를 받는 함수를 하나의 인자만 받는 함수들의 체인으로 만드는 것입니다.
const multiply = (a) => (b) => a * b; // 클로저 활용
const double = multiply(2); // a=2를 기억하는 클로저 생성
console.log(double(5)); // 10
multiply(2)가 반환한 함수는 a=2라는 상태를 클로저로 기억합니다. 이후 double(5), double(10)처럼 몇 번을 호출하든 a는 언제나 2로 고정되어 있습니다. 이 '일부 인자를 미리 고정하고 나머지를 나중에 받는' 방식을 부분 적용(Partial Application) 이라고 부릅니다. 로깅 함수에서 로그 레벨을 미리 박아 둔 logError, logInfo를 만들거나, API 호출 함수에서 기본 URL과 헤더를 미리 채워 둔 전용 클라이언트를 찍어내는 데 유용합니다. 반복되는 인자를 클로저에 가둬 두면 호출부가 짧고 명확해집니다.
4.4 메모이제이션(Memoization)과 팩토리 함수
클로저의 '상태를 기억한다'는 성질은 캐시를 구현하기에 안성맞춤입니다. 메모이제이션은 이미 계산한 결과를 저장해 두었다가 같은 입력이 다시 들어오면 재계산 없이 돌려주는 기법인데, 이 캐시 저장소를 클로저 안에 숨겨 두면 외부에서 오염시킬 수 없는 안전한 캐시가 됩니다.
function memoize(fn) {
const cache = new Map(); // 클로저에 갇힌 캐시
return function (arg) {
if (cache.has(arg)) return cache.get(arg);
const result = fn(arg);
cache.set(arg, result);
return result;
};
}
반환된 함수는 매번 같은 cache를 공유하므로 호출을 거듭할수록 캐시가 쌓입니다. 이때 캐시가 무한정 커지면 그 자체가 메모리 누수가 되므로, 실무에서는 크기 상한이나 만료 정책을 함께 두는 것이 안전합니다. 같은 원리로, 설정을 미리 받아 특정 동작을 하는 함수를 찍어내는 팩토리 함수도 클로저의 전형적인 응용입니다. 설정값을 클로저에 담아 두고 그것을 사용하는 함수를 반환하면, 호출부는 매번 설정을 넘길 필요 없이 완성된 함수를 받아 쓰기만 하면 됩니다.

5. 이벤트 핸들러에서의 클로저
이벤트 핸들러를 등록할 때 외부 변수를 참조하는 것도 클로저입니다. 리스트의 각 아이템에 인덱스를 포함한 클릭 핸들러를 붙이는 것이 전형적인 예시이며, 3장에서 본 반복문 함정이 그대로 재현되는 무대이기도 합니다. var로 인덱스를 돌리며 핸들러를 붙이면 모든 핸들러가 마지막 인덱스를 가리키고, let을 쓰거나 반복 변수를 인자로 넘겨 감싸면 각 핸들러가 올바른 인덱스를 붙잡습니다.
핸들러 클로저에서 실무적으로 더 중요한 것은 무엇을 붙잡고 있는가입니다. 핸들러가 무거운 데이터 구조나 거대한 DOM 노드를 클로저로 참조하고 있으면, 그 핸들러가 살아 있는 동안 참조된 대상 전체가 메모리에 함께 머뭅니다. 이 문제는 6장에서 메모리 누수와 함께 자세히 다룹니다. 여기서 기억할 것은, 이벤트 핸들러는 '나중에, 어쩌면 아주 오래 뒤에' 실행되는 클로저이므로, 그 함수가 붙잡는 스코프를 최소화하는 습관이 곧 메모리 위생이 된다는 점입니다.
6. React와 클로저 (Stale Closure)
우리가 매일 쓰는 React Hooks의 useState, useEffect는 전부 클로저 덩어리입니다. 컴포넌트 함수가 매번 다시 실행되어도 상태 state 값이 초기화되지 않고 유지되는 비결이 바로 클로저입니다.
조금 더 정확히 말하면, React는 렌더링할 때마다 컴포넌트 함수를 처음부터 다시 실행합니다. 그때마다 그 렌더링 시점의 props와 state 값을 담은 새로운 스코프가 만들어지고, 그 스코프 안에서 정의된 이벤트 핸들러나 effect 콜백은 그 렌더링의 값을 캡처한 클로저가 됩니다. 즉 매 렌더링마다 서로 다른 값을 기억하는, 매번 새로 태어나는 클로저 뭉치가 만들어지는 셈입니다. 이 모델을 이해하면 React의 많은 동작이 자연스럽게 설명됩니다.
하지만 이 특성 때문에 Stale Closure(상한 클로저) 문제가 발생하기도 합니다.
function App() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
console.log(count); // 항상 0만 출력됨!
}, 1000);
return () => clearInterval(timer);
}, []); // 의존성 배열이 비어있음
// ...
}
useEffect의 콜백 함수는 첫 렌더링 시점의 count(0)를 기억하는 클로저입니다. 렉시컬 환경이 갱신되지 않으므로, 아무리 count가 증가해도 이 콜백 안에서는 영원히 0입니다. 이를 해결하려면 의존성 배열에 count를 넣거나, 함수형 업데이트 setCount(prev => prev + 1)를 사용해야 합니다.
이 문제의 본질은 결국 2장에서 본 "클로저는 변수를 캡처한다"의 React 버전입니다. 다만 여기서는 렌더링마다 변수 자체가 새로 만들어지기 때문에, 오래된 렌더링의 클로저가 오래된 변수를 붙잡고 있으면 새 값을 볼 방법이 없는 것입니다. 의존성 배열은 "이 값이 바뀌면 effect를 새 클로저로 다시 만들어라"는 지시이고, 함수형 업데이트는 "현재 값을 캡처하지 말고, 갱신 시점의 최신 값을 인자로 받아라"는 우회로입니다. useRef를 써서 항상 최신 값을 담은 상자를 참조하게 만드는 것도 자주 쓰는 처방입니다. 어떤 처방을 쓰든, 뿌리에는 언제나 "이 콜백이 어느 렌더링의 클로저인가"라는 질문이 놓여 있습니다.
이런 함수형 업데이트와 stale closure의 긴장 관계는 최근 논의되는 세밀한 반응성(fine-grained reactivity)과 Signals 모델과 대비해 보면 더 선명해집니다. Signals는 값 자체가 구독자에게 변화를 알리는 구조라, "오래된 클로저가 오래된 값을 붙잡는" 문제를 설계 차원에서 다르게 다룹니다. 클로저 기반 모델의 함정을 몸으로 겪어 본 사람일수록 이 대비의 의미를 깊이 이해하게 됩니다.
7. 메모리 누수와 가비지 컬렉션
클로저는 "참조되고 있는 메모리는 해제하지 않는다"는 GC의 원칙을 이용합니다. 따라서 불필요하게 사용하면 메모리 낭비(Memory Leak)가 발생할 수 있습니다. 하지만 모던 JS 엔진(V8 등)은 최적화가 잘 되어 있어, 더 이상 참조되지 않는 클로저의 변수는 꽤 똑똑하게 정리해 줍니다. 따라서 "클로저는 메모리 누수의 주범이니 피해야 한다"는 것은 옛말이며, 필요할 때 적절히 사용하는 것이 훨씬 중요합니다.
그럼에도 클로저가 메모리를 붙잡는 방식은 정확히 알고 있어야 합니다. 자바스크립트의 GC는 도달 가능성(reachability) 을 기준으로 동작합니다. 루트(전역 객체, 현재 실행 중인 함수의 스코프 등)에서 참조를 타고 도달할 수 있는 값은 살려 두고, 어디서도 도달할 수 없게 된 값만 회수합니다. 클로저가 어떤 변수를 붙잡고 있고 그 클로저 자체가 어딘가(예: 이벤트 리스너 목록, 타이머, 전역 배열)에서 참조되고 있다면, 그 변수는 계속 '도달 가능'한 상태로 남아 회수되지 않습니다. 이것이 클로저발 메모리 누수의 기본 메커니즘입니다.
7.1 닫힌 스코프(Closed Scope)가 발생시키는 숨겨진 참조
클로저를 활용해 상태(State)를 은닉할 때 가장 흔히 겪는 문제는 브라우저가 화면을 파괴(Unmount)하거나 이벤트 생명주기가 끝났는데도 해당 클로저가 거대한 DOM 요소나 무거운 배열 객체를 여전히 '붙잡고 있는' 상황입니다.
function attachEvent(hugeData) {
document.getElementById('btn').addEventListener('click', function handler() {
console.log(hugeData.id);
});
}
이 경우, hugeData라는 수십 메가바이트의 배열 덩어리는 버튼이 삭제되지 않는 한 영원히 메모리를 떠다닙니다. 흥미로운 점은, 핸들러가 실제로 사용하는 것은 hugeData.id라는 작은 값 하나뿐인데도, 클로저가 붙잡는 것은 id가 아니라 hugeData라는 변수 전체라는 사실입니다. 필요한 것만 미리 꺼내 두는 것만으로도 이런 참조를 크게 줄일 수 있습니다. 예컨대 핸들러 밖에서 const id = hugeData.id;로 필요한 값만 지역 변수에 뽑아 두면, 핸들러는 작은 id만 캡처하고 거대한 hugeData는 GC 대상이 됩니다.
이러한 스코프 유실 방지를 위해, 우리는 컴포넌트 생명주기가 종료되는 시점(예: React의 useEffect cleanup 함수)에서 반드시 내부 이벤트 핸들러를 removeEventListener()로 탈착하거나, 더 이상 필요 없는 참조를 명시적으로 끊어내는 습관을 지녀야 합니다. 타이머(setInterval)를 clearInterval로 해제하지 않는 것, 구독을 해지하지 않는 것, 이미 사라진 컴포넌트의 콜백을 외부 이벤트 버스에 남겨 두는 것 — 이 모두가 클로저를 매개로 한 전형적인 누수 경로입니다. 이런 자원 수명 관리의 감각은 비단 프론트엔드만의 이야기가 아닙니다. 대용량 데이터를 다룰 때 AsyncIterator와 Web Streams로 백프레셔·취소·누수를 통제하는 방식이나, 백엔드에서 PostgreSQL 커넥션 풀을 적정 크기로 유지하며 반납을 강제하는 원칙 모두, "붙잡은 자원은 반드시 제때 놓아준다"는 같은 규율을 공유합니다. 클로저를 통제한다는 것은 결국 자원의 수명을 통제한다는 뜻입니다.
7.2 V8 엔진 관점에서의 변수 캡처 최적화
모던 JS 엔진은 단순히 코드를 해석하는 것을 넘어 JIT(Just-in-Time) 컴파일을 수행합니다. 여기서 흔한 오해 하나를 바로잡을 필요가 있습니다. V8은 클로저를 만들 때 바깥 함수의 모든 변수를 통째로 짊어지지 않습니다. 파싱 단계의 스코프 분석에서, 내부 함수가 실제로 참조하는 변수만 골라내 힙에 있는 Context 객체에 배치하고(이를 컨텍스트 할당, context allocation이라 합니다), 아무도 캡처하지 않는 순수 지역 변수는 스택이나 레지스터에 두었다가 함수가 끝나면 그대로 사라지게 합니다.
즉, function outer() { let a = big(); let b = 1; return () => b; }에서 내부 함수가 b만 참조한다면, 이론상 a는 클로저에 붙잡히지 않아야 합니다. 다만 실제 엔진의 최적화 수준은 코드 형태(예: eval의 존재, 같은 스코프의 다른 클로저)에 따라 달라질 수 있으므로, 메모리에 민감한 코드라면 앞 절에서처럼 필요한 값만 명시적으로 좁혀 캡처하는 습관이 여전히 가장 확실한 방어책입니다. 여기에 더해 V8의 TurboFan은 객체가 함수 밖으로 새어 나가지 않는다고 판단되면 힙 할당 자체를 생략하는 탈출 분석(Escape Analysis) 같은 최적화도 수행합니다. 결국 자바스크립트는 표면적으로는 단순한 스크립트 언어처럼 보이지만, 그 밑바닥에서는 메모리 사용을 한계까지 줄이려는 컴파일러 엔지니어링이 쉬지 않고 돌아가고 있습니다. 우리가 엔진을 신뢰하되, 그 신뢰가 게으름의 핑계가 되지 않도록 캡처 범위를 스스로 좁히는 균형 감각이 필요합니다.
8. 자주 묻는 질문(FAQ)
Q. 모든 함수가 클로저인가요?
명세를 엄격히 따지면 자바스크립트의 거의 모든 함수는 생성 시점의 렉시컬 환경([[Environment]])을 참조하므로 잠재적으로 클로저입니다. 다만 실무에서 '클로저'라고 부를 때는 보통 바깥 스코프의 자유 변수를 실제로 참조하며, 그 스코프가 원래 수명보다 오래 유지되는 경우를 가리킵니다. 아무 자유 변수도 붙잡지 않는 함수는 클로저라는 이름을 붙일 실익이 없습니다.
Q. 클로저는 성능에 나쁜가요?
클로저 자체가 느린 것은 아닙니다. 변수 참조가 스코프 체인을 몇 단계 타고 올라가는 비용이 아주 미미하게 있지만, 현대 엔진에서는 대부분 무시할 수준으로 최적화됩니다. 진짜 문제는 성능이 아니라 메모리 수명입니다. 붙잡을 필요 없는 큰 객체를 오래 붙잡는 설계가 문제이지, 클로저를 쓴다는 사실 자체가 문제가 아닙니다.
Q. 클래스의 #private 필드가 있으면 클로저 은닉은 이제 필요 없나요?
용도가 다릅니다. #private는 인스턴스 단위의 은닉에 적합하고, 클로저는 인스턴스 없이 함수 하나로 상태를 감싸거나, 여러 함수가 하나의 숨은 상태를 공유하게 만들 때 유용합니다. 둘은 대체 관계라기보다 상황에 따라 골라 쓰는 도구입니다. 참고로 날짜·시간처럼 불변 값을 다루는 Temporal API로의 전환 같은 최신 흐름도, 결국 "상태를 어떻게 안전하게 가두고 노출할 것인가"라는 같은 질문의 다른 얼굴입니다.
Q. Stale closure는 React에서만 생기나요?
아닙니다. setTimeout, setInterval, 이벤트 핸들러, 프로미스 콜백처럼 정의 시점과 실행 시점이 벌어지는 모든 비동기 코드에서 발생할 수 있습니다. React는 렌더링마다 스코프를 새로 만들기 때문에 유독 자주 드러날 뿐, 원리는 순수 자바스크립트에서도 똑같습니다.
마치며
클로저는 자바스크립트 개발자를 주니어와 시니어로 나누는 기준이 되기도 합니다. 단순히 면접용 암기가 아니라, "변수의 수명을 내가 통제한다" 는 관점에서 클로저를 바라보세요. React 훅이 왜 그렇게 동작하는지가 보이고, 코드를 훨씬 더 우아하고 안전하게 설계할 수 있는 시야가 열릴 것입니다.
정리하면 클로저를 다루는 실무 감각은 세 가지 질문으로 압축됩니다. 첫째, "이 함수는 무엇을 붙잡고 있는가" — 캡처하는 자유 변수를 의식하는 것. 둘째, "그 변수의 바인딩은 몇 개인가" — 반복문과 렌더링에서 값이 갱신되지 않는 함정을 피하는 것. 셋째, "이 클로저는 언제 놓아주는가" — 리스너·타이머·구독을 제때 해제해 메모리 수명을 통제하는 것. 이 세 질문을 코드 리뷰의 습관으로 만들면, 클로저는 더 이상 넘어야 할 산이 아니라 우리 손에 익은 도구가 됩니다.
이 주제와 연결되는 글