JavaScript 클로저(Closure) 완벽 이해하기

자바스크립트를 공부하다 보면 반드시 마주치는 거대한 산이 하나 있습니다.바로 ** 클로저(Closure) ** 입니다. "함수와 그 함수가 선언된 렉시컬 환경의 조합"이라는 MDN의 정의는 너무나도 추상적입니다.하지만 클로저는 React Hooks의 근간이자, 데이터를 안전하게 은닉(Information Hiding)하고, 함수형 프로그래밍을 가능하게 하는 자바스크립트의 핵심 엔진입니다.이 글에서는 난해한 이론 대신, 실무 예제와 도식화를 통해 클로저를 약 3,000자 분량으로 완벽하게 파헤쳐 봅니다.
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)를 참조합니다. 이 "선언된 위치"를 기억하는 메커니즘이 클로저의 시작입니다.
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 변수는 메모리에서 해제되지 않고 살아남아 상태를 유지합니다.
3. 실무 활용 패턴: 왜 쓰는가?
"그냥 전역 변수 쓰면 되잖아요?"라고 반문할 수 있습니다. 하지만 전역 변수는 누구나 수정할 수 있어 위험합니다. 클로저는 **상태 안전하게 유지(Encapsulation)**하는 데 탁월합니다.
3.1 데이터 은닉 (Data Privacy)
위의 makeCounter 예제에서 num 변수에 접근할 수 있는 방법은 오직 counter 함수를 호출하는 것뿐입니다. 외부에서 num = 100처럼 직접 값을 조작하는 것은 불가능합니다. 이것이 바로 Java의 private 키워드와 같은 효과를 냅니다.
3.2 커링 (Currying)
함수형 프로그래밍에서 자주 쓰이는 기법으로, 여러 인자를 받는 함수를 하나의 인자만 받는 함수들의 체인으로 만드는 것입니다.
const multiply = (a) => (b) => a * b; // 클로저 활용
const double = multiply(2); // a=2를 기억하는 클로저 생성
console.log(double(5)); // 10
4. React와 클로저 (Stale Closure)
우리가 매일 쓰는 React Hooks의 useState, useEffect는 전부 클로저 덩어리입니다. 컴포넌트 함수가 매번 다시 실행되어도 상태 state 값이 초기화되지 않고 유지되는 비결이 바로 클로저입니다.
하지만 이 특성 때문에 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)를 사용해야 합니다.
5. 주의사항: 메모리 누수?
클로저는 "참조되고 있는 메모리는 해제하지 않는다"는 GC의 원칙을 이용합니다. 따라서 불필요하게 사용하면 메모리 낭비(Memory Leak)가 발생할 수 있습니다. 하지만 모던 JS 엔진(V8 등)은 최적화가 잘 되어 있어, 더 이상 참조되지 않는 클로저의 변수는 꽤 똑똑하게 정리해 줍니다. 따라서 "클로저는 메모리 누수의 주범이니 피해야 한다"는 것은 옛말이며, 필요할 때 적절히 사용하는 것이 훨씬 중요합니다.
마치며
클로저는 자바스크립트 개발자를 주니어와 시니어로 나누는 기준이 되기도 합니다. 단순히 면접용 암기가 아니라, **"변수의 수명을 내가 통제한다"**는 관점에서 클로저를 바라보세요. React의 마법 같은 동작 원리가 보이고, 코드를 훨씬 더 우아하고 안전하게 설계할 수 있는 시야가 열릴 것입니다.
6. 실전 응용: 클로저를 활용한 디자인 패턴
6.1 모듈 패턴(Module Pattern)
클로저를 가장 많이 활용하는 패턴입니다. 즉시 실행 함수(IIFE) 내부에 private 변수와 함수를 선언하고, public API만 객체로 반환합니다. ES 모듈이 보편화되기 전, 전역 네임스페이스 오염을 방지하는 핵심 기법이었습니다.
6.2 커링(Currying)과 부분 적용
클로저는 함수형 프로그래밍의 핵심인 커링을 가능하게 합니다. 여러 인자를 받는 함수를 하나의 인자만 받는 함수들의 체인으로 변환하는 것입니다.
6.3 이벤트 핸들러에서의 클로저
이벤트 핸들러를 등록할 때 외부 변수를 참조하는 것도 클로저입니다. 리스트의 각 아이템에 인덱스를 포함한 클릭 핸들러를 붙이는 것이 전형적인 예시이며, var 대신 let을 사용하거나 클로저로 즉시 실행 함수를 감싸는 것이 해결책입니다.
X. 깊게 파헤치는 렉시컬 스코프 최적화와 메모리 누수 방지 (Deep Dive)
클로저의 마법 뒤에는 무거운 대가가 따를 수 있습니다. 바로 자바스크립트 엔진(V8) 내부의 렉시컬 환경(Lexical Environment) 기록 장부가 가비지 컬렉터(Garbage Collector)에 의해 영원히 회수되지 못하는 현상, "메모리 누수(Memory Leak)"입니다.
1. 닫힌 스코프(Closed Scope)가 발생시키는 숨겨진 참조 에러
클로저를 활용해 상태(State)를 은닉할 때 가장 흔히 겪는 문제는 브라우저가 화면을 파괴(Unmount)하거나 이벤트 생명주기가 끝났는데도 해당 클로저가 거대한 DOM 요소나 무거운 배열 객체를 여전히 '붙잡고 있는' 상황입니다.
function attachEvent(hugeData) {
document.getElementById('btn').addEventListener('click', function handler() {
console.log(hugeData.id);
});
}
이 경우, hugeData라는 수십 메가바이트의 배열 덩어리는 버튼이 삭제되지 않는 한 영원히 메모리를 떠다닙니다. 이러한 스코프 유실 방지를 위해, 우리는 컴포넌트 생명주기가 종료되는 시점(예: React의 useEffect cleanup 함수)에서 반드시 내부 이벤트 핸들러를 removeEventListener()로 탈착하거나 강제로 null을 할당하여 체인을 끊어내는 습관을 지녀야 합니다.
2. V8 엔진 관점에서의 컴파일러 최적화
모던 JS 엔진은 단순히 코드를 해석하는 것을 넘어 JIT(Just-in-Time) 컴파일을 무자비하게 수행합니다. V8은 클로저를 감시하다가, 만일 내부 함수가 바깥 스코프의 변수 중에서 '일부'만 참조한다면 전체 스코프 환경을 남겨두지 않고, 오직 참조하는 그 변환값만 똑 떼어내서 '마이크로 클로저 객체'로 축소 저장하는 이른바 "Escape Analysis"를 거칩니다.
결국 자바스크립트는 표면적으로는 단순한 스크립트 언어처럼 보이지만, 그 밑바닥은 C++ 수준의 극한의 메모리 최적화를 이뤄내기 위해 발버둥 치는 거대한 컴파일러 과학의 산물입니다. 클로저의 진정한 이해 없이는 결코 하이퍼포먼스 엔터프라이즈 환경의 프론트엔드 장인이 될 수 없습니다.
X. 깊게 파헤치는 렉시컬 스코프 최적화와 메모리 누수 방지 (Deep Dive)
클로저의 마법 뒤에는 무거운 대가가 따를 수 있습니다. 바로 자바스크립트 엔진(V8) 내부의 렉시컬 환경(Lexical Environment) 기록 장부가 가비지 컬렉터(Garbage Collector)에 의해 영원히 회수되지 못하는 현상, "메모리 누수(Memory Leak)"입니다.
1. 닫힌 스코프(Closed Scope)가 발생시키는 숨겨진 참조 에러
클로저를 활용해 상태(State)를 은닉할 때 가장 흔히 겪는 문제는 브라우저가 화면을 파괴(Unmount)하거나 이벤트 생명주기가 끝났는데도 해당 클로저가 거대한 DOM 요소나 무거운 배열 객체를 여전히 '붙잡고 있는' 상황입니다.
function attachEvent(hugeData) {
document.getElementById('btn').addEventListener('click', function handler() {
console.log(hugeData.id);
});
}
이 경우, hugeData라는 수십 메가바이트의 배열 덩어리는 버튼이 삭제되지 않는 한 영원히 메모리를 떠다닙니다. 이러한 스코프 유실 방지를 위해, 우리는 컴포넌트 생명주기가 종료되는 시점(예: React의 useEffect cleanup 함수)에서 반드시 내부 이벤트 핸들러를 removeEventListener()로 탈착하거나 강제로 null을 할당하여 체인을 끊어내는 습관을 지녀야 합니다.
2. V8 엔진 관점에서의 컴파일러 최적화
모던 JS 엔진은 단순히 코드를 해석하는 것을 넘어 JIT(Just-in-Time) 컴파일을 무자비하게 수행합니다. V8은 클로저를 감시하다가, 만일 내부 함수가 바깥 스코프의 변수 중에서 '일부'만 참조한다면 전체 스코프 환경을 남겨두지 않고, 오직 참조하는 그 변환값만 똑 떼어내서 '마이크로 클로저 객체'로 축소 저장하는 이른바 "Escape Analysis"를 거칩니다.
결국 자바스크립트는 표면적으로는 단순한 스크립트 언어처럼 보이지만, 그 밑바닥은 C++ 수준의 극한의 메모리 최적화를 이뤄내기 위해 발버둥 치는 거대한 컴파일러 과학의 산물입니다. 클로저의 진정한 이해 없이는 결코 하이퍼포먼스 엔터프라이즈 환경의 프론트엔드 장인이 될 수 없습니다.
X. 깊게 파헤치는 렉시컬 스코프 최적화와 메모리 누수 방지 (Deep Dive)
클로저의 마법 뒤에는 무거운 대가가 따를 수 있습니다. 바로 자바스크립트 엔진(V8) 내부의 렉시컬 환경(Lexical Environment) 기록 장부가 가비지 컬렉터(Garbage Collector)에 의해 영원히 회수되지 못하는 현상, "메모리 누수(Memory Leak)"입니다.
1. 닫힌 스코프(Closed Scope)가 발생시키는 숨겨진 참조 에러
클로저를 활용해 상태(State)를 은닉할 때 가장 흔히 겪는 문제는 브라우저가 화면을 파괴(Unmount)하거나 이벤트 생명주기가 끝났는데도 해당 클로저가 거대한 DOM 요소나 무거운 배열 객체를 여전히 '붙잡고 있는' 상황입니다.
function attachEvent(hugeData) {
document.getElementById('btn').addEventListener('click', function handler() {
console.log(hugeData.id);
});
}
이 경우, hugeData라는 수십 메가바이트의 배열 덩어리는 버튼이 삭제되지 않는 한 영원히 메모리를 떠다닙니다. 이러한 스코프 유실 방지를 위해, 우리는 컴포넌트 생명주기가 종료되는 시점(예: React의 useEffect cleanup 함수)에서 반드시 내부 이벤트 핸들러를 removeEventListener()로 탈착하거나 강제로 null을 할당하여 체인을 끊어내는 습관을 지녀야 합니다.
2. V8 엔진 관점에서의 컴파일러 최적화
모던 JS 엔진은 단순히 코드를 해석하는 것을 넘어 JIT(Just-in-Time) 컴파일을 무자비하게 수행합니다. V8은 클로저를 감시하다가, 만일 내부 함수가 바깥 스코프의 변수 중에서 '일부'만 참조한다면 전체 스코프 환경을 남겨두지 않고, 오직 참조하는 그 변환값만 똑 떼어내서 '마이크로 클로저 객체'로 축소 저장하는 이른바 "Escape Analysis"를 거칩니다.
결국 자바스크립트는 표면적으로는 단순한 스크립트 언어처럼 보이지만, 그 밑바닥은 C++ 수준의 극한의 메모리 최적화를 이뤄내기 위해 발버둥 치는 거대한 컴파일러 과학의 산물입니다. 클로저의 진정한 이해 없이는 결코 하이퍼포먼스 엔터프라이즈 환경의 프론트엔드 장인이 될 수 없습니다.
X. 깊게 파헤치는 렉시컬 스코프 최적화와 메모리 누수 방지 (Deep Dive)
클로저의 마법 뒤에는 무거운 대가가 따를 수 있습니다. 바로 자바스크립트 엔진(V8) 내부의 렉시컬 환경(Lexical Environment) 기록 장부가 가비지 컬렉터(Garbage Collector)에 의해 영원히 회수되지 못하는 현상, "메모리 누수(Memory Leak)"입니다.
1. 닫힌 스코프(Closed Scope)가 발생시키는 숨겨진 참조 에러
클로저를 활용해 상태(State)를 은닉할 때 가장 흔히 겪는 문제는 브라우저가 화면을 파괴(Unmount)하거나 이벤트 생명주기가 끝났는데도 해당 클로저가 거대한 DOM 요소나 무거운 배열 객체를 여전히 '붙잡고 있는' 상황입니다.
function attachEvent(hugeData) {
docume