1. 유령이 사는 검색창
제 사이트의 검색창에는 유령이 살고 있었습니다. 사용자가 "React"를 검색하려다가 오타를 내서 지우고 "Vue"를 검색했습니다. 화면에 "Vue" 관련 결과가 잘 뜨나 싶더니... 갑자기 1초 뒤에 "React" 결과로 덮어씌워지는 겁니다!
사용자는 분명 "Vue"를 보고 있었는데, 눈 깜짝할 사이에 내용물이 바뀌어 버렸습니다. 이건 단순한 버그가 아니라, 사용자 경험을 파괴하는 심각한 문제였습니다.
처음엔 "React State가 꼬였나?"라고 생각해서 useState와 useEffect를 샅샅이 뒤졌지만, 로직은 완벽해 보였습니다.
2. 범인은 "네트워크의 속도 차이"
범인은 코드가 아니라 네트워크였습니다. 이 현상을 경쟁 상태(Race Condition)라고 부릅니다.
- 사용자가 "React" 입력 -> 요청 A 출발 (서버가 바빠서 3초 걸림)
- 사용자가 "Vue" 입력 -> 요청 B 출발 (서버가 한가해서 0.5초 걸림)
- 요청 B 도착 -> 화면에 "Vue" 결과 표시 (정상)
- 요청 A 도착 -> 화면에 "React" 결과 표시 (비정상!)
먼저 보낸 요청(A)이 나중에 도착해서, 최신 결과(B)를 덮어써버린 것입니다. 우리는 "나중에 보낸 게 나중에 오겠지"라고 믿지만, 인터넷 세상에서는 순서가 보장되지 않습니다.
3. 해결책 1 - 문 닫고 가라 (Cleanup Function)
가장 쉬운 해결책은 "유효하지 않은 요청은 무시하는 것"입니다.
React의 useEffect에는 청소(Cleanup) 기능이 있습니다.
컴포넌트가 다시 렌더링되거나 useEffect가 다시 실행되기 직전에 실행되는 함수죠.
useEffect(() => {
let isCurrent = true; // "이 요청은 유효해"
fetchResults(query).then(data => {
if (isCurrent) { // 유효할 때만 결과 업데이트!
setResults(data);
}
});
// Cleanup: 다음 useEffect가 실행되기 전에 호출됨
return () => {
isCurrent = false; // "야, 이전 요청은 이제 무효야!"
};
}, [query]);
이제 "Vue"를 입력하는 순간, "React" 요청에 대한 isCurrent는 false가 됩니다.
나중에 "React" 응답이 도착해도 if (isCurrent) 문에 막혀서 무시당합니다.
4. 해결책 2 - 아예 취소해버려 (AbortController)
첫 번째 방법은 결과는 막았지만, 네트워크 요청 자체는 계속 진행된다는 단점이 있습니다.
데이터 낭비죠.
브라우저 표준 API인 AbortController를 쓰면 요청 자체를 취소(Cancel)할 수 있습니다.
useEffect(() => {
const controller = new AbortController(); // 취소 버튼 생성
const signal = controller.signal;
fetch(url, { signal }) // 요청에 '취소 신호' 연결
.then(res => res.json())
.then(data => setResults(data))
.catch(err => {
if (err.name === 'AbortError') {
console.log('요청이 취소되었습니다.'); // 에러 처리 안 함
} else {
setError(err);
}
});
return () => {
controller.abort(); // "야, 취소해!"
};
}, [query]);
이제 사용자가 타이핑을 할 때마다 이전 요청은 브라우저 수준에서 즉시 중단(Red Status)됩니다. 데이터도 아끼고, 확실하게 경쟁 상태를 막을 수 있는 가장 깔끔한 방법입니다.
5. 팁 - 또 다른 함정들
탭 전환 시 Race Condition
사용자가 "내 정보" 탭을 눌렀다가(로딩 중), "설정" 탭으로 이동했습니다. 그런데 "내 정보" 로딩이 늦게 끝나서, "설정" 화면 위에 "내 정보"가 덮어씌워지는 경우도 있습니다.
이때도 마찬가지로 탭이 언마운트(Unmount)될 때 요청을 취소해야 합니다.
AbortController 패턴을 Custom Hook(useFetch 등)으로 만들어두면 편합니다.
디바운싱(Debouncing)과 함께 쓰기
매 글자마다(R, Re, Rea...) 요청을 보내면 서버가 힘듭니다.
디바운싱(0.3초 대기)을 섞어 쓰면 금상첨화입니다.
하지만 디바운싱만으로는 경쟁 상태를 완벽히 막을 수 없습니다. (0.3초 뒤에 보낸 요청이 0.31초 뒤에 보낸 요청보다 늦게 올 수도 있으니까요).
그래서 디바운싱 + AbortController 조합이 필승 공식입니다.
6. 라이브러리는 어떻게 해결할까? (React Query)
우리가 수동으로 AbortController를 쓰는 건 귀찮습니다.
TanStack Query (React Query) 같은 라이브러리는 이 기능을 기본으로 제공합니다.
useQuery를 쓸 때 queryKey가 바뀌면(예: ['search', 'Vue'] -> ['search', 'React']),
이전 키(['search', 'Vue'])에 대한 요청은 자동으로 취소(Cancel)됩니다.
(단, fetch 함수에서 signal을 받아서 연결해줘야 합니다.)
const { data } = useQuery({
queryKey: ['search', query],
queryFn: async ({ signal }) => { // signal을 받아서
const res = await fetch(`/api/search?q=${query}`, { signal }); // 연결!
return res.json();
}
});
이걸 몰라서 "React Query 썼는데 왜 Race Condition이 생기죠?"라고 묻는 분들이 많습니다.
signal을 연결하지 않으면, React Query는 취소 신호를 보내지만 fetch는 그걸 무시하고 계속 요청을 진행하기 때문입니다.
7. 마무리 - 비동기는 믿지 마라
프론트엔드 개발자가 흔히 겪는 착각 중 하나가 "코드는 위에서 아래로, 순서대로 실행된다"는 믿음입니다. 하지만 비동기(Async)의 세계는 무질서합니다.
내가 보낸 요청이 언제, 어떤 순서로 돌아올지 아무도 모릅니다. 그 불확실성을 제어하는 것이 프론트엔드 개발자의 실력입니다.
여러분의 검색창은 안녕하신가요? 지금 가서 빠르게 타이핑해보세요. 유령이 살고 있을지도 모릅니다.
I Searched for 'Vue' but Got 'React' Results (Race Condition)
1. The Ghost in the Search Bar
There was a ghost living in my search bar. A user started typing "React", realized a typo, backspaced, and typed "Vue". The screen seemed to show "Vue" results correctly... but 1 second later, it was overwritten by "React" results!
The user was definitely looking at "Vue", but the content swapped in the blink of an eye. This wasn't just a bug; it was a UX disaster.
Usage: useState and useEffect seemed logically perfect. What was wrong?
2. The Culprit: Network Speed Difference
The culprit wasn't code logic, but the Network. We call this a Race Condition.
- User types "React" -> Request A departs (Server busy, takes 3s)
- User types "Vue" -> Request B departs (Server idle, takes 0.5s)
- Request B arrives -> Show "Vue" results (Correct)
- Request A arrives -> Show "React" results (Incorrect!)
The earlier request (A) arrived later and overwrote the latest result (B). We tend to believe "Later requests arrive later," but in the internet world, order is not guaranteed.
3. Solution 1: Close the Door (Cleanup Function)
The easiest solution is "Ignore invalid requests."
React's useEffect has a cleanup function.
It runs right before the component re-renders or useEffect runs again.
useEffect(() => {
let isCurrent = true; // "This request is valid"
fetchResults(query).then(data => {
if (isCurrent) { // Update only if valid!
setResults(data);
}
});
// Cleanup: Called before the next useEffect
return () => {
isCurrent = false; // "Hey, the previous request is now invalid!"
};
}, [query]);
Now, the moment you type "Vue", isCurrent for the "React" request becomes false.
Even if "React" response arrives later, it hits the if (isCurrent) wall and gets ignored.
4. Solution 2: Cancel It Entirely (AbortController)
The first method ignores the result but the network request still consumes bandwidth.
Waste of data.
Using the browser standard API AbortController, you can actually cancel the request.
useEffect(() => {
const controller = new AbortController(); // Create a Cancel Button
const signal = controller.signal;
fetch(url, { signal }) // Attach 'signal' to request
.then(res => res.json())
.then(data => setResults(data))
.catch(err => {
if (err.name === 'AbortError') {
console.log('Request aborted.'); // Don't treat as error
} else {
setError(err);
}
});
return () => {
controller.abort(); // "Hey, cancel it!"
};
}, [query]);
Now, every time user types, the previous request is immediately terminated (Red Status) at the browser level. It saves data and cleanly prevents race conditions.
5. Practical Tips: Other Traps
Tab Switching Race Condition
User clicks "Profile" tab (loading), then clicks "Settings" tab. If "Profile" loads late, it might overwrite the "Settings" screen.
Here too, you must cancel requests when the tab Unmounts.
It's convenient to wrap the AbortController pattern in a Custom Hook (like useFetch).
Combining with Debouncing
Sending a request for every letter (R, Re, Rea...) kills the server.
Mixing Debouncing (wait 0.3s) is perfect.
But Debouncing alone cannot fully prevent race conditions. (A request sent after 0.3s might arrive later than one sent after 0.31s).
So Debouncing + AbortController is the winning formula.
6. Advanced: How Libraries Handle This (React Query)
Writing AbortController boilerplate every time is tedious.
Libraries like TanStack Query (React Query) handle this out of the box.
When queryKey changes (e.g., ['search', 'Vue'] -> ['search', 'React']),
React Query automatically cancels the request for the old key.
(Crucial Note: You MUST pass the signal to your fetch function).
const { data } = useQuery({
queryKey: ['search', query],
queryFn: async ({ signal }) => { // Receive signal
const res = await fetch(`/api/search?q=${query}`, { signal }); // Pass it!
return res.json();
}
});
Many developers ask, "I used React Query, why do I still have race conditions?"
It's usually because they forgot to pass the signal. React Query fires the cancellation gun, but if fetch isn't listening, the bullet hits nothing.
7. Backend Perspective: Idempotency
Sometimes, you can't cancel the request (it already reached the server). In this case, the Backend must handle it.
Use an Idempotency Key.
- Frontend generates a unique ID (
uuid-v4) for the transaction. - Sends it in header:
Idempotency-Key: abc-123. - Backend checks Redis. If
abc-123was already processed, it returns the cached result without processing again.
This is crucial for Payment systems where race conditions lead to double billing.
8. Testing Race Conditions
How do you reproduce a race condition often enough to fix it? It's hard because it depends on network timing.
Simulate Network Delay:
- Chrome DevTools -> Network -> Slow 3G.
- Add artificial delay in your backend:
await new Promise(r => setTimeout(r, Math.random() * 2000)); - Click the button furiously.
If your UI flickers or shows wrong data, you have a race condition. Automated tests (Playwright/Cypress) can also simulate this by intercepting network requests and delaying responses.
9. Database Level Locking (The Nuclear Option)
If Frontend guards and Backend queues fail, the Database is your final fortress.
-
Pessimistic Locking (
SELECT ... FOR UPDATE):- "I am reading this row. Nobody touch it until I'm done."
- Great for high-conflicts (Ticket Booking).
- Risk: Deadlocks if not careful.
-
Optimistic Locking (Version Column):
- Add a
versioncolumn to the table. UPDATE tickets SET owner='me', version=2 WHERE id=1 AND version=1;- If someone else updated it,
versionis already 2, so your query affects 0 rows. - The app sees 0 rows updated and throws an error: "State changed, please retry."
- Add a
For critical financial transactions, I always implement Optimistic Locking as a safety net.
10. Distributed Locking (Redis Redlock)
If you run multiple server instances (e.g., Kubernetes pods), memory locks won't work because state is not shared between servers.
You need a Distributed Lock.
Redlock Algorithm (Redis):
- Server A requests lock
resource_idwith TTL (Time To Live). - Redis grants lock.
- Server B requests same lock -> Fails.
- Server A finishes -> Releases lock.
Use libraries like ioredis which have built-in lock support.