검색창에 "react"를 타이핑했는데 결과가 "reac"에 대한 거였다.
2024년 9월, 3명이서 운영하던 사내 어드민 툴에 검색 자동완성을 붙이고 있었다. 타이핑할 때마다 API를 쏘고, 결과를 드롭다운에 보여주는 평범한 기능이었다. 로컬에서 테스트하니까 잘 됐다. 스테이징에 올렸다. PM이 5분 만에 슬랙에 스크린샷을 올렸다. "react"를 치면 "reac" 검색 결과가 뜬다고. 가끔은 "rea"에 대한 결과가 나올 때도 있었다.
그날 저녁에 원인을 찾았다. useEffect 클린업 함수를 안 썼다.
그 검색창 코드
문제의 코드는 이렇게 생겼다:
function SearchAutocomplete() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<string[]>([]);
useEffect(() => {
if (!query) {
setResults([]);
return;
}
fetch(`/api/search?q=${query}`)
.then(res => res.json())
.then(data => setResults(data.items));
}, [query]);
return (
<div>
<input
value={query}
onChange={e=> setQuery(e.target.value)}
placeholder="검색어를 입력하세요"
/>
<ul>
{results.map(item => (
<li key={item}>{item}</li>
))}
</ul>
</div>
);
}뭐가 문제인지 바로 보이는 사람도 있겠지만, 나는 한참 걸렸다.
"react"를 타이핑하면 useEffect가 5번 실행된다. "r", "re", "rea", "reac", "react". 각각에 대해 fetch가 나간다. 5개의 HTTP 요청이 거의 동시에 날아간다.
네트워크는 순서를 보장하지 않는다. "reac"에 대한 응답이 50ms 걸리고 "react"에 대한 응답이 200ms 걸리면? "react" 결과가 먼저 setResults에 들어가고, 그 위에 "reac" 결과가 덮어쓴다. 유저가 보는 건 "reac"에 대한 결과다.
이게 race condition이다.
클린업 함수가 하는 일
React 문서를 보면 클린업 함수에 대해 "이전 effect를 정리한다"고 설명한다. 맞는 말인데, 이걸 처음 봤을 때 나는 별로 와닿지 않았다. "정리"가 뭔데?
코드로 보면 확실하다:
useEffect(() => {
// 이 함수는 query가 바뀔 때마다 실행된다
const controller = new AbortController();
if (!query) {
setResults([]);
return;
}
fetch(`/api/search?q=${query}`, { signal: controller.signal })
.then(res => res.json())
.then(data => setResults(data.items))
.catch(err => {
if (err.name !== 'AbortError') throw err;
});
// 이 함수는 다음 effect가 실행되기 "직전에" 실행된다
return () => {
controller.abort();
};
}, [query]);query가 "reac"에서 "react"로 바뀌면:
- "reac"에 대한 클린업 함수가 실행된다 →
controller.abort()로 이전 요청을 취소한다 - "react"에 대한 새 effect가 실행된다 → 새
AbortController로 새 요청을 보낸다
이전 요청은 취소됐으니까 응답이 늦게 와도 setResults를 호출하지 않는다. race condition이 사라진다.
AbortController 없이도 할 수 있다
AbortController가 가장 깔끔한 방법이지만, boolean flag로도 같은 효과를 낼 수 있다. 실제로 Dan Abramov가 예전에 보여준 패턴이기도 하다:
useEffect(() => {
let cancelled = false;
fetch(`/api/search?q=${query}`)
.then(res => res.json())
.then(data => {
if (!cancelled) {
setResults(data.items);
}
});
return () => {
cancelled = true;
};
}, [query]);이 방법은 요청 자체를 취소하지는 않는다. 네트워크 요청은 그대로 날아간다. 다만 응답이 돌아왔을 때 cancelled가 true면 무시한다. 네트워크 비용은 절약 못 하지만, UI 버그는 막을 수 있다.
차이를 정리하면 이렇다:
| AbortController | boolean flag | |
|---|---|---|
| 요청 취소 | 네트워크 레벨에서 취소 | 응답만 무시 |
| 네트워크 비용 | 절약 | 낭비 |
| 구현 복잡도 | AbortError 처리 필요 | 단순 |
프로덕션에서는 AbortController를 쓰는 게 맞다. 유저가 빠르게 타이핑하면 요청이 수십 개 쌓일 수 있으니까.
두 번째 버그: 언마운트된 컴포넌트에 setState
검색창 버그를 고치고 2주쯤 지나서 비슷한 문제가 또 터졌다. 이번엔 콘솔에 이런 워닝이 찍혔다:
Warning: Can't perform a React state update on an unmounted component.
React 18 이후로는 이 워닝이 사라졌지만, 문제 자체는 여전히 존재한다. 코드는 이랬다:
function UserProfile({ userId }: { userId: string }) {
const [profile, setProfile] = useState<Profile | null>(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setProfile(data));
}, [userId]);
if (!profile) return <Skeleton />;
return <div>{profile.name}</div>;
}유저 목록에서 프로필 카드를 클릭하면 모달로 UserProfile이 뜬다. 문제는 API 응답이 오기 전에 모달을 닫았을 때 생긴다. 컴포넌트가 언마운트됐는데 setProfile이 호출된다. 유저 입장에서는 모달을 닫았을 뿐인데, 내부적으로는 이미 사라진 컴포넌트의 상태를 갱신하려는 유령 같은 코드가 돌고 있는 셈이다.
React 18에서는 워닝이 안 뜨지만 그렇다고 안전한 건 아니다. 불필요한 상태 업데이트가 일어나고, 복잡한 앱에서는 이런 것들이 쌓이면서 예상치 못한 사이드 이펙트를 만든다. 우리 팀에서는 이 유저 프로필 모달이 특정 조건에서 열렸다 닫혔다를 반복하면 전체 페이지가 느려지는 현상이 있었는데, 프로파일러를 돌려보니 언마운트된 컴포넌트에서 계속 불필요한 리렌더링이 트리거되고 있었다.
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(res => res.json())
.then(data => setProfile(data))
.catch(err => {
if (err.name !== 'AbortError') throw err;
});
return () => controller.abort();
}, [userId]);같은 패턴이다. AbortController로 이전 요청을 취소한다.
세 번째: setInterval 메모리 누수
이건 내가 직접 겪은 건 아니고, 2025년 1월에 코드 리뷰하다가 잡은 건데, 꽤 교과서적인 사례라 공유한다.
대시보드에 실시간 알림 카운터가 있었다:
function NotificationBadge() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(async () => {
const res = await fetch('/api/notifications/count');
const data = await res.json();
setCount(data.count);
}, 5000);
// 클린업 없음
}, []);
return <span className="badge">{count}</span>;
}setInterval을 걸어놓고 클린업에서 clearInterval을 안 했다. 이 컴포넌트가 조건부로 렌더링되는 곳에 있었는데, 탭을 왔다갔다 할 때마다 인터벌이 하나씩 추가됐다. 10번 왔다갔다하면 5초마다 10개의 API 요청이 동시에 나간다.
useEffect(() => {
const id = setInterval(async () => {
const res = await fetch('/api/notifications/count');
const data = await res.json();
setCount(data.count);
}, 5000);
return () => clearInterval(id);
}, []);한 줄이다. return () => clearInterval(id); 이 한 줄을 빼먹어서 서버에 불필요한 부하가 갔다. 개발 환경에서는 탭을 안 옮기니까 모른다. 프로덕션에서 유저들이 앱을 몇 시간씩 켜놓으면 터진다. 이 버그는 DevTools의 Network 탭을 열어보기 전까지 아무도 눈치 못 챘다. 코드 리뷰에서 잡은 건 운이 좋았던 거고, 만약 그냥 넘어갔으면 백엔드 팀에서 "알림 API에 이상한 트래픽 패턴이 있다"는 알람을 받고 나서야 발견했을 거다.
멘탈 모델: effect는 "동기화"다
여기까지 읽으면 "클린업 함수 중요하구나, 항상 써야지" 정도로 정리될 수 있는데, 한 가지만 더 생각해보자.
Dan Abramov가 A Complete Guide to useEffect에서 한 말이 있다. "effect는 lifecycle이 아니라 synchronization이다." 나는 이 문장을 처음 읽었을 때 무슨 뜻인지 몰랐다. 지금은 안다.
componentDidMount에서 뭔가를 시작하고 componentWillUnmount에서 정리하는 건 "마운트/언마운트"라는 lifecycle에 맞춘 사고방식이다. useEffect는 다르다. "이 값이 바뀔 때마다 외부 세계와 동기화한다"는 사고방식이다.
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]);이 코드를 lifecycle으로 읽으면: "마운트될 때 연결하고, 언마운트될 때 해제한다."
동기화로 읽으면: "roomId가 바뀔 때마다, 이전 방에서 나가고(disconnect) 새 방에 들어간다(connect). 컴포넌트가 사라지면 마지막 방에서 나간다."
두 번째 읽기가 더 정확하다. roomId가 "lobby"에서 "game-1"로 바뀌면:
- 클린업: "lobby"에서
disconnect - 새 effect: "game-1"에
connect
lifecycle으로 생각하면 "왜 언마운트도 안 됐는데 disconnect가 호출되지?"라는 혼란이 온다. 동기화로 생각하면 당연하다. 이전 상태의 동기화를 풀고, 새 상태에 맞게 다시 동기화하는 거니까.
내가 이 사고방식 전환을 체감한 건, 실시간 채팅 기능을 만들 때였다. WebSocket 연결을 useEffect로 관리하고 있었는데, 채팅방을 바꿀 때마다 이전 방의 메시지가 잠깐 보이는 문제가 있었다. lifecycle으로 생각하니까 "마운트 시 연결, 언마운트 시 해제"만 신경 쓰고 있었는데, 실제로는 같은 컴포넌트가 마운트된 상태에서 roomId prop만 바뀌는 거였다. 동기화 관점으로 바꾸니까 "아, 이전 방 구독 해제하고 새 방 구독해야지"가 바로 나왔다.
Strict Mode에서 effect가 두 번 실행되는 이유
React 18의 Strict Mode에서는 개발 환경에서 effect가 마운트 → 언마운트 → 다시 마운트 순서로 실행된다. 처음 이걸 봤을 때 "뭐 이런 미친 동작이 다 있지?"라고 생각했다.
이유가 있다. 클린업이 제대로 구현돼 있으면, 마운트-언마운트-마운트를 해도 결과가 같아야 한다. 채팅방에 들어갔다가 나갔다가 다시 들어가도 정상 동작해야 한다. 그게 안 되면 클린업에 문제가 있다는 뜻이다.
Strict Mode는 그걸 미리 잡아주는 거다. "너 클린업 안 했지?"를 개발 단계에서 터뜨려주는 것.
// 이 코드는 Strict Mode에서 문제가 드러난다
useEffect(() => {
window.addEventListener('resize', handleResize);
// 클린업 없음 → 리스너가 2개 등록됨
}, []);
// 이 코드는 Strict Mode에서도 문제없다
useEffect(() => {
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);첫 번째 코드는 Strict Mode에서 handleResize가 두 번 불린다. resize 이벤트 한 번에 핸들러가 두 개 붙어 있으니까. 프로덕션에서는 Strict Mode가 꺼지니까 한 번만 붙지만, 그래도 페이지 이동 후 돌아오면 또 하나 추가된다.
언제 클린업이 필요한가
모든 useEffect에 클린업이 필요한 건 아니다. 기준은 간단하다:
effect에서 뭔가를 "시작"하면, 클린업에서 "중지"해야 한다.
fetch시작 →abort로 중지setInterval시작 →clearInterval로 중지addEventListener시작 →removeEventListener로 중지- WebSocket
connect→disconnect subscribe→unsubscribe
반대로, 뭔가를 "시작"하지 않는 effect는 클린업이 필요 없다:
// DOM을 읽기만 한다 → 클린업 불필요
useEffect(() => {
const height = ref.current?.offsetHeight;
setHeight(height ?? 0);
}, []);
// analytics 이벤트를 보내기만 한다 → 클린업 불필요
useEffect(() => {
analytics.track('page_view', { page: 'dashboard' });
}, []);이벤트를 보내는 건 fire-and-forget이다. 취소할 필요도 없고, 취소할 수도 없다.
맨 처음 검색창, 다시 보기
글을 쓰면서 맨 처음 검색창 코드를 다시 떠올려봤다. 그때는 race condition이라는 개념 자체를 몰랐다. 비동기 요청을 보내면 보낸 순서대로 돌아온다고 은연중에 가정하고 있었다. 네트워크가 순서를 보장하지 않는다는 걸 머리로는 알았는데, 내 코드에서 그게 버그가 될 거라고는 생각을 못 했다.
지금은 useEffect 안에서 fetch를 쓸 때 AbortController를 거의 자동으로 넣는다. 아니, 사실 요즘은 useEffect에서 직접 fetch를 하는 일 자체가 많지 않다. TanStack Query나 SWR 같은 라이브러리가 이런 문제를 다 처리해주니까. 그래도 이 멘탈 모델을 이해하고 있는 것과 모르는 것은 다르다. 라이브러리가 내부적으로 뭘 해주는지 알아야 뭔가 이상할 때 원인을 찾을 수 있다.
가끔 주니어 개발자분들이 "useEffect가 어렵다"고 하는데, 어려운 게 맞다. lifecycle 관점에서 보면 어렵다. 근데 "이 값이 바뀔 때마다 외부 세계와 동기화하고, 이전 동기화는 해제한다"로 바꿔 생각하면, 클린업 함수가 왜 거기 있어야 하는지가 자연스럽게 따라온다. 적어도 나한테는 이 프레임이 전환점이었다.
아직도 가끔 클린업을 빼먹는다. 근데 예전처럼 "왜 이게 필요하지?"라고 고민하지는 않는다. 빼먹었을 때 뭐가 깨지는지 아니까, 짜면서 자연스럽게 손이 간다. 결국 이해가 습관을 만드는 거 같다.
