클로저(Closure)는 자바스크립트의 핵심 개념 중 하나로, 함수가 선언될 때의 환경(스코프)을 기억하는 함수를 말합니다.
🧩 1. 기본 개념
자바스크립트에서는 함수가 선언될 때의 스코프(lexical scope) 를 기억합니다.
이때, 내부 함수(inner function) 가 자신을 둘러싼 외부 함수(outer function) 의 변수에 접근할 수 있을 때,
그 관계를 유지한 채로 외부 함수가 종료된 이후에도 접근 가능한 구조 — 이것이 바로 클로저입니다.
📘 2. 예시 코드로 이해하기
function makeCounter() {
let count = 0; // 외부 함수의 지역 변수
return function() { // 내부 함수 (클로저)
count++;
return count;
};
}
const counter = makeCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3
👉 무슨 일이 일어나고 있을까?
- makeCounter()가 실행되면 지역 변수 count가 생성됩니다.
- 내부 함수가 return되며, 이 함수는 count에 접근할 수 있는 권한을 가진 채 밖으로 나갑니다.
- makeCounter()의 실행은 끝났지만, 내부 함수가 count를 참조 중이기 때문에 GC(가비지 컬렉터) 가 그 변수를 제거하지 않습니다.
- 그 결과, counter()를 호출할 때마다 count 값이 유지되고 증가합니다.
🔍 3. 클로저의 특징 요약
| 항목 | 설명 |
| 정의 | 함수가 선언될 당시의 스코프를 기억하여, 외부 함수의 변수에 접근할 수 있는 내부 함수 |
| 형태 | 함수 안의 함수 |
| 생성 시점 | 함수가 선언될 때(정의 시점) |
| 소멸 시점 | 외부 함수가 끝나도 내부 함수가 참조 중이면 메모리에서 유지 |
| 주요 효과 | 데이터 은닉, 상태 유지, 모듈화 가능 |
🧱 4. 클로저의 대표적인 활용 예시
✅ (1) 데이터 은닉 / 캡슐화
function createAccount() {
let balance = 0;
return {
deposit(amount) {
balance += amount;
},
getBalance() {
return balance;
}
};
}
const account = createAccount();
account.deposit(1000);
console.log(account.getBalance()); // 1000
→ 외부에서는 balance를 직접 수정할 수 없고, 오직 deposit() / getBalance()로만 접근 가능.
✅ (2) 이벤트 핸들러에서 상태 유지
function createClickCounter(button) {
let count = 0;
button.addEventListener('click', function() {
count++;
console.log(`Clicked ${count} times`);
});
}
→ 각 버튼마다 클릭 횟수를 “기억”하는 클로저를 가짐.
✅ (3) React에서의 예시 (useState와 비슷한 원리)
function useCounter() {
let count = 0;
function increment() {
count++;
console.log(count);
}
return [() => count, increment];
}
const [getCount, increment] = useCounter();
increment(); // 1
increment(); // 2
console.log(getCount()); // 2
⚠️ 5. 주의할 점
| 문제 | 설명 |
| 메모리 누수 | 클로저가 너무 많은 변수를 오래 참조하면 메모리 점유가 계속 유지될 수 있음 |
| 의도치 않은 값 공유 | 반복문 안에서 var를 사용하면 모든 클로저가 같은 변수를 참조하게 됨 |
| 해결법 | let 사용 또는 즉시실행함수(IIFE)로 스코프 분리 |
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 1000); // 3 3 3
}
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 1000); // 0 1 2
}
⚛️ 6. React / TypeScript 실전 예제
React에서는 클로저가 매우 자주 사용됩니다.
특히 useEffect, useState, 이벤트 핸들러, 비동기 로직에서요.
✅ 예제 1: useEffect와 클로저
export default function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
console.log("count:", count); // ⚠️ 클로저 발생
}, 1000);
return () => clearInterval(interval);
}, []);
return (
<button onClick={() => setCount(c => c + 1)}>
Count: {count}
</button>
);
}
🧠 여기서 무슨 일이 일어날까?
- useEffect는 mount 시점에 한 번만 실행됩니다 ([]).
- 따라서 setInterval 안의 함수는 초기 렌더링 당시의 count를 기억하는 클로저를 만듭니다.
- 이후 버튼을 눌러도 count가 증가해도,
console.log("count:", count)는 0만 계속 찍습니다!
이건 바로 클로저의 “환경을 기억하는 성질” 때문이에요.
✅ 해결 방법
현재의 count 값이 필요하다면 함수형 업데이트나 ref를 이용해야 합니다.
useEffect(() => {
const interval = setInterval(() => {
setCount(c => {
console.log("count:", c);
return c + 1;
});
}, 1000);
return () => clearInterval(interval);
}, []);
이렇게 하면 setCount의 인자 (c)가 매번 최신 상태를 전달받기 때문에 클로저 문제를 피할 수 있습니다.
✅ 예제 2: 이벤트 핸들러에서 상태 유지
import { useState } from "react";
export default function ToggleButton() {
const [isOn, setIsOn] = useState(false);
function handleClick() {
// handleClick이 선언될 때의 스코프를 기억함 (클로저)
console.log("현재 상태:", isOn);
setIsOn(!isOn);
}
return <button onClick={handleClick}>{isOn ? "ON" : "OFF"}</button>;
}
📌 동작 설명
- handleClick 함수는 렌더링될 때마다 새롭게 만들어집니다.
- 하지만 이벤트 핸들러 내부의 isOn은 그 함수가 생성된 당시의 state 값을 기억합니다.
- React는 매 렌더마다 새로운 클로저를 만들기 때문에,
이벤트가 트리거될 때 현재 렌더의 상태값을 참조하게 됩니다.
즉, React는 클로저를 매 렌더마다 새로 만들어서 “이전 상태 유지” 대신 “현재 상태 반영”을 합니다.
✅ 예제 3: 비동기 요청에서 클로저 주의하기
useEffect(() => {
let isMounted = true;
async function fetchData() {
const res = await fetch("/api/data");
const data = await res.json();
if (isMounted) {
setData(data);
}
}
fetchData();
return () => {
isMounted = false;
};
}, []);
⚠️ 왜 이렇게 하냐면:
- fetchData가 실행되는 동안 컴포넌트가 언마운트되면 setData()를 호출하면 에러가 납니다.
- isMounted 변수가 “현재 컴포넌트가 살아 있는지”를 클로저로 기억하고,
cleanup에서 false로 바꿔주는 패턴이에요.
⚛️ 7. 클로저, 그리고 useCallback / useMemo / 비동기 버그의 진짜 이유
🧩 1️⃣ 클로저와 React의 관계
React 컴포넌트는 렌더링될 때마다 함수가 새로 호출됩니다.
즉, 컴포넌트 내부의 모든 변수, 함수, state 참조가 매 렌더마다 새롭게 만들어집니다.
→ 따라서 “이전 렌더의 변수나 state를 참조하는 클로저”가 발생할 수 있어요.
React의 모든 훅은 사실상 “클로저 위에서 작동하는 구조”입니다.
⚙️ 2️⃣ useCallback / useMemo에서 클로저가 중요한 이유
💡 (1) useCallback은 함수를 “메모이제이션”한다
const handleClick = useCallback(() => {
console.log(count);
}, []);
❌ 문제:
- [] 의존성 배열이 비어 있으면 count의 초기값만 기억한 클로저가 유지됩니다.
- 즉, 이후 count가 바뀌어도 handleClick은 옛날 count를 콘솔에 찍습니다.
✅ 해결:
의존성 배열에 count를 넣어야, React가 새로운 count를 클로저에 캡처한 새 함수를 만들어줍니다.
const handleClick = useCallback(() => {
console.log(count);
}, [count]);
즉,
- useCallback은 클로저의 “스냅샷”을 저장하는 훅,
- deps는 “이 클로저를 언제 새로 만들어야 하는가”를 React에게 알려주는 장치예요.
⚙️ (2) useMemo도 똑같이 클로저를 기억한다
const doubled = useMemo(() => {
console.log('memoized');
return count * 2;
}, []);
- []로 두면 처음 count만 기억하고, 이후 변경돼도 다시 계산하지 않아요.
- [count]를 넣으면 새 count로 다시 계산합니다.
👉 useMemo의 콜백도 결국 클로저입니다.
React는 “의존성 배열을 기준으로 클로저를 새로 만들지 말지”를 결정하죠.
⚠️ 3️⃣ stale closure(오래된 클로저) 문제
🧠 개념
Stale Closure란, 오래된 변수 값을 기억하는 클로저 때문에
최신 state나 props를 읽지 못하는 버그를 말합니다.
🧨 예제: setTimeout 안에서 발생하는 stale closure
function Timer() {
const [count, setCount] = useState(0);
const start = () => {
setTimeout(() => {
console.log("Count:", count); // ❌ 항상 오래된 count!
}, 2000);
};
return (
<>
<p>{count}</p>
<button onClick={() => setCount(c => c + 1)}>+1</button>
<button onClick={start}>Start</button>
</>
);
}
🧩 원인:
- start 함수는 렌더링 시점의 count를 기억하는 클로저를 만듭니다.
- 2초 후 콜백 실행 시점엔 이미 여러 번 렌더링이 일어났을 수 있지만,
setTimeout 내부 클로저는 여전히 **“start 버튼을 눌렀던 순간의 count”**만 가지고 있습니다.
🧱 4️⃣ stale closure 방지 패턴 정리
✅ (1) 최신 값을 가져오려면 함수형 업데이트 사용
setCount(c => c + 1);
→ 이 패턴은 React가 내부적으로 최신 state를 전달해주기 때문에,
클로저에 오래된 state가 남아 있어도 안전합니다.
✅ (2) useRef로 최신 값을 추적하기
function Timer() {
const [count, setCount] = useState(0);
const countRef = useRef(count);
useEffect(() => {
countRef.current = count; // 항상 최신값으로 갱신
}, [count]);
const start = () => {
setTimeout(() => {
console.log("Count:", countRef.current); // ✅ 항상 최신값
}, 2000);
};
return (
<>
<p>{count}</p>
<button onClick={() => setCount(c => c + 1)}>+1</button>
<button onClick={start}>Start</button>
</>
);
}
👉 useRef는 렌더링과 무관하게 변수를 지속적으로 유지하므로, stale closure를 방지할 수 있습니다.
✅ (3) useEvent (React 19+ or useCallbackRef 패턴)
React 19부터 공식적으로 useEvent 훅이 추가됩니다.
이는 “항상 최신 클로저를 보장”하는 훅입니다.
import { useEvent } from "react";
function Timer() {
const [count, setCount] = useState(0);
const onTimeout = useEvent(() => {
console.log("Count:", count); // ✅ 항상 최신 count
});
const start = () => setTimeout(onTimeout, 2000);
return <button onClick={() => setCount(c => c + 1)}>+1</button>;
}
⚡️ useEvent는 렌더링 시점을 새로 캡처하지 않고, 내부적으로 최신 클로저를 유지합니다.
✅ (4) 커스텀 훅으로 안전한 비동기 로직 관리하기
function useSafeAsync(callback: () => void, deps: any[]) {
const callbackRef = useRef(callback);
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
return useCallback((...args: any[]) => {
callbackRef.current(...args);
}, []);
}
사용 예시:
const safeLog = useSafeAsync(() => console.log(count), [count]);
→ 이렇게 하면, 오래된 클로저가 아닌 항상 최신 상태를 참조하는 함수를 반환합니다.
🧠 요약 정리
클로저는 단순히 “함수 안의 함수”가 아니라,
함수가 선언될 당시의 스코프를 기억하는 함수예요.
이를 통해 함수 외부에서 접근할 수 없는 변수를 안전하게 유지하고,
필요할 때마다 그 값을 참조하거나 수정할 수 있죠.
즉, 클로저는
- 상태를 기억하는 함수,
- 데이터를 은닉하는 도구,
- 그리고 React의 useCallback / useMemo 같은 훅에서 핵심적으로 작동하는 메커니즘이에요.
하지만 너무 많은 변수를 참조하면 메모리 누수가 생길 수 있으니,
의도를 명확히 하고 꼭 필요한 경우에만 사용하는 습관이 중요합니다.
결국,
클로저를 이해한다는 건 자바스크립트의 “함수”와 “스코프”를 진짜로 이해한다는 뜻이에요.
'매일메일' 카테고리의 다른 글
| 실행 컨텍스트(Execution Context) 완벽 이해 (0) | 2025.10.18 |
|---|---|
| 브라우저 렌더링 최적화: Reflow와 Repaint 완벽 이해 (0) | 2025.10.18 |