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의 마법 같은 동작 원리가 보이고, 코드를 훨씬 더 우아하고 안전하게 설계할 수 있는 시야가 열릴 것입니다.