
오래된 클로저(Stale Closure) 문제
React에서 클로저가 오래된 값을 참조해서 생긴 버그와 해결 방법

React에서 클로저가 오래된 값을 참조해서 생긴 버그와 해결 방법
버튼을 눌렀는데 부모 DIV까지 클릭되는 현상. 이벤트는 물방울처럼 위로 올라갑니다(Bubbling). 반대로 내려오는 캡처링(Capturing)도 있죠.

습관적으로 모든 변수에 `useMemo`를 감싸고 있나요? 그게 오히려 성능을 망치고 있습니다. 메모이제이션 비용과 올바른 최적화 타이밍.

React나 Vue 프로젝트를 빌드해서 배포했는데, 홈 화면은 잘 나오지만 새로고침만 하면 'Page Not Found'가 뜨나요? CSR의 원리와 서버 설정(Nginx, Apache, S3)을 통해 이를 해결하는 완벽 가이드.

진짜 집을 부수고 다시 짓는 건 비쌉니다. 설계도(가상돔)에서 미리 그려보고 바뀐 부분만 공사하는 '똑똑한 리모델링'의 기술.

제 서비스에 타이머 기능을 만들고 있었습니다. 사용자가 버튼을 클릭하면 카운트다운이 시작되고, 0이 되면 알림을 보내는 간단한 기능이었죠. setInterval을 써서 1초마다 카운트를 줄이려고 했습니다.
function Timer() {
const [count, setCount] = useState(10);
useEffect(() => {
const timer = setInterval(() => {
setCount(count - 1); // 🔥 문제!
}, 1000);
return () => clearInterval(timer);
}, []);
return <div>{count}</div>;
}
이 코드를 실행하면 어떻게 될까요? 10에서 9로 한 번 줄어들고... 그 다음부터는 계속 9만 보입니다. "왜 안 줄어들지?"라고 생각하면서 코드를 한참 들여다봤습니다.
더 황당했던 건, 버튼 클릭 이벤트에서도 비슷한 문제가 생겼다는 겁니다. 사용자가 "좋아요" 버튼을 연타하면 카운트가 1만 증가하는 버그였어요. 이건 실제 사용자 경험에 직접 영향을 주는 심각한 문제였습니다.
제가 가진 오개념은 이거였습니다: "함수 안에서 state를 쓰면 항상 최신 값을 가져온다"
일반적인 변수라면 당연히 최신 값을 읽잖아요. 그런데 React의 state는 다릅니다. 특히 클로저 안에서는 더욱 그렇습니다.
클로저가 뭔지는 알고 있었습니다. "함수가 자신이 생성될 때의 환경을 기억한다"는 개념이죠. 하지만 이게 React에서 어떤 문제를 일으키는지는 몰랐습니다.
const timer = setInterval(() => {
console.log(count); // 이 count는 언제의 값일까?
setCount(count - 1);
}, 1000);
이 코드에서 setInterval의 콜백 함수는 클로저입니다. 이 함수가 생성될 때의 count 값을 기억합니다. 처음 useEffect가 실행될 때 count는 10이었으니까, 이 클로저는 영원히 10을 기억합니다.
그래서 1초마다 setCount(10 - 1)을 실행하는 겁니다. 매번 9로 설정하니까 9에서 더 이상 안 줄어드는 거죠.
이 문제를 이해한 건 이런 비유를 들었을 때였습니다:
"클로저는 사진을 찍는 것과 같다. 함수가 생성될 때 주변 변수들의 '스냅샷'을 찍어서 보관한다. 나중에 변수가 바뀌어도 클로저는 여전히 옛날 사진을 보고 있다."아! 그래서 "stale closure"(오래된 클로저)라고 부르는 거구나. 클로저가 오래된 값을 참조하고 있으니까요.
React에서 이 문제가 특히 많이 생기는 이유는 함수형 컴포넌트가 매 렌더링마다 새로 실행되기 때문입니다. 매번 새로운 count 값이 생기는데, 이전에 만들어진 클로저는 여전히 옛날 count를 보고 있는 거죠.
해결책은 간단합니다: 함수형 업데이트를 쓰거나, 의존성 배열을 제대로 설정하면 됩니다.
// 해결책 1: 함수형 업데이트
setInterval(() => {
setCount(prevCount => prevCount - 1); // ✅ 항상 최신 값 사용
}, 1000);
// 해결책 2: 의존성 배열에 count 추가
useEffect(() => {
const timer = setInterval(() => {
setCount(count - 1);
}, 1000);
return () => clearInterval(timer);
}, [count]); // ✅ count가 바뀔 때마다 새 타이머 생성
JavaScript의 클로저는 함수가 생성될 때의 렉시컬 환경(Lexical Environment)을 캡처합니다.
function createCounter() {
let count = 0;
return function() {
count++;
console.log(count);
};
}
const counter = createCounter();
counter(); // 1
counter(); // 2
이 예제에서 반환된 함수는 count 변수를 기억합니다. 이게 클로저의 강력한 점이죠.
하지만 React에서는 이게 문제가 됩니다:
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log(count); // 항상 0!
}, 1000);
return () => clearInterval(id);
}, []); // 빈 배열: 한 번만 실행
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
useEffect가 처음 실행될 때 count는 0입니다. setInterval의 콜백은 이 0을 캡처합니다. 나중에 버튼을 클릭해서 count가 1, 2, 3으로 바뀌어도, setInterval의 콜백은 여전히 0을 보고 있습니다.
이벤트 핸들러에서도 같은 문제가 생깁니다:
function LikeButton() {
const [likes, setLikes] = useState(0);
const handleLike = () => {
setTimeout(() => {
setLikes(likes + 1); // 🔥 Stale closure!
}, 3000);
};
return <button onClick={handleLike}>Likes: {likes}</button>;
}
사용자가 버튼을 빠르게 3번 클릭하면 어떻게 될까요?
setTimeout이 likes = 0을 캡처setTimeout이 likes = 0을 캡처 (아직 3초 안 지남)setTimeout이 likes = 0을 캡처setTimeout이 모두 setLikes(0 + 1) 실행likes는 1 (3이 아니라!)해결책:
const handleLike = () => {
setTimeout(() => {
setLikes(prevLikes => prevLikes + 1); // ✅ 함수형 업데이트
}, 3000);
};
때로는 함수형 업데이트를 쓸 수 없는 경우가 있습니다. 예를 들어, 여러 state를 동시에 참조해야 할 때:
function Chat() {
const [messages, setMessages] = useState([]);
const [user, setUser] = useState(null);
useEffect(() => {
const socket = io();
socket.on('message', (msg) => {
// messages와 user를 둘 다 써야 함
if (user && !messages.includes(msg)) {
setMessages([...messages, msg]); // 🔥 Stale!
}
});
}, []); // 의존성 배열이 비어있음
}
이럴 때는 useRef를 씁니다:
function Chat() {
const [messages, setMessages] = useState([]);
const [user, setUser] = useState(null);
const messagesRef = useRef(messages);
const userRef = useRef(user);
// ref를 항상 최신으로 유지
useEffect(() => {
messagesRef.current = messages;
userRef.current = user;
});
useEffect(() => {
const socket = io();
socket.on('message', (msg) => {
// ref를 통해 최신 값 접근
if (userRef.current && !messagesRef.current.includes(msg)) {
setMessages(prev => [...prev, msg]); // ✅
}
});
}, []); // 의존성 배열 비어있어도 OK
}
useRef는 렌더링 간에 값을 유지하면서도, 변경해도 리렌더링을 트리거하지 않습니다. 클로저 문제를 피하는 완벽한 도구죠.
이 패턴을 자주 쓴다면 커스텀 훅으로 만들 수 있습니다:
function useLatest(value) {
const ref = useRef(value);
useEffect(() => {
ref.current = value;
});
return ref;
}
// 사용
function Chat() {
const [messages, setMessages] = useState([]);
const messagesRef = useLatest(messages);
useEffect(() => {
const socket = io();
socket.on('message', (msg) => {
if (!messagesRef.current.includes(msg)) {
setMessages(prev => [...prev, msg]);
}
});
}, []);
}
훨씬 깔끔하죠!
제 타이머 코드를 이렇게 고쳤습니다:
function Timer() {
const [count, setCount] = useState(10);
useEffect(() => {
if (count <= 0) return;
const timer = setInterval(() => {
setCount(prev => prev - 1); // ✅ 함수형 업데이트
}, 1000);
return () => clearInterval(timer);
}, [count]); // count가 0이 되면 타이머 정지
return <div>{count}</div>;
}
이제 완벽하게 동작합니다!
좋아요 버튼도 함수형 업데이트로 고쳤습니다:
function LikeButton({ postId }) {
const [likes, setLikes] = useState(0);
const [isLiking, setIsLiking] = useState(false);
const handleLike = async () => {
if (isLiking) return; // 중복 클릭 방지
setIsLiking(true);
setLikes(prev => prev + 1); // ✅ 즉시 UI 업데이트
try {
await api.likePost(postId);
} catch (error) {
setLikes(prev => prev - 1); // 실패하면 되돌리기
alert('좋아요 실패');
} finally {
setIsLiking(false);
}
};
return (
<button onClick={handleLike} disabled={isLiking}>
❤️ {likes}
</button>
);
}
실시간 채팅 기능에서 useRef를 활용했습니다:
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
const [user, setUser] = useState(null);
const userRef = useLatest(user);
useEffect(() => {
const socket = io(`/rooms/${roomId}`);
socket.on('message', (msg) => {
// 자기 메시지는 이미 화면에 있으니 추가 안 함
if (msg.userId !== userRef.current?.id) {
setMessages(prev => [...prev, msg]);
}
});
return () => socket.disconnect();
}, [roomId]); // user는 의존성에 없어도 OK (ref 사용)
return (
<div>
{messages.map(msg => (
<Message key={msg.id} {...msg} />
))}
</div>
);
}