
검색창에 'Vue'를 쳤는데 'React'가 나왔다 (Race Condition)
비동기 통신의 함정, 경쟁 상태(Race Condition)를 해결하는 과정을 담았습니다. 플래그 변수부터 AbortController까지, 탭 전환 시 발생하는 버그까지 잡는 법.

비동기 통신의 함정, 경쟁 상태(Race Condition)를 해결하는 과정을 담았습니다. 플래그 변수부터 AbortController까지, 탭 전환 시 발생하는 버그까지 잡는 법.
매번 3-Way Handshake 하느라 지쳤나요? 한 번 맺은 인연(TCP 연결)을 소중히 유지하는 법. HTTP 최적화의 기본.

HTTP는 무전기(오버) 방식이지만, 웹소켓은 전화기(여보세요)입니다. 채팅과 주식 차트가 실시간으로 움직이는 기술적 비밀.

IP는 이사 가면 바뀌지만, MAC 주소는 바뀌지 않습니다. 주민등록번호와 집 주소의 차이. 공장 출고 때 찍히는 고유 번호.

학교에서는 OSI 7계층을 배우지만, 실제 인터넷은 TCP/IP 4계층으로 돌아갑니다. 이론과 실제 차이.

제 사이트의 검색창에는 유령이 살고 있었습니다. 사용자가 "React"를 검색하려다가 오타를 내서 지우고 "Vue"를 검색했습니다. 화면에 "Vue" 관련 결과가 잘 뜨나 싶더니... 갑자기 1초 뒤에 "React" 결과로 덮어씌워지는 겁니다!
사용자는 분명 "Vue"를 보고 있었는데, 눈 깜짝할 사이에 내용물이 바뀌어 버렸습니다. 이건 단순한 버그가 아니라, 사용자 경험을 파괴하는 심각한 문제였습니다.
처음엔 "React State가 꼬였나?"라고 생각해서 useState와 useEffect를 샅샅이 뒤졌지만, 로직은 완벽해 보였습니다.
범인은 코드가 아니라 네트워크였습니다. 이 현상을 경쟁 상태(Race Condition)라고 부릅니다.
먼저 보낸 요청(A)이 나중에 도착해서, 최신 결과(B)를 덮어써버린 것입니다. 우리는 "나중에 보낸 게 나중에 오겠지"라고 믿지만, 인터넷 세상에서는 순서가 보장되지 않습니다.
가장 쉬운 해결책은 "유효하지 않은 요청은 무시하는 것"입니다.
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) 문에 막혀서 무시당합니다.
첫 번째 방법은 결과는 막았지만, 네트워크 요청 자체는 계속 진행된다는 단점이 있습니다.
데이터 낭비죠.
브라우저 표준 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)됩니다. 데이터도 아끼고, 확실하게 경쟁 상태를 막을 수 있는 가장 깔끔한 방법입니다.
사용자가 "내 정보" 탭을 눌렀다가(로딩 중), "설정" 탭으로 이동했습니다. 그런데 "내 정보" 로딩이 늦게 끝나서, "설정" 화면 위에 "내 정보"가 덮어씌워지는 경우도 있습니다.
이때도 마찬가지로 탭이 언마운트(Unmount)될 때 요청을 취소해야 합니다.
AbortController 패턴을 Custom Hook(useFetch 등)으로 만들어두면 편합니다.
매 글자마다(R, Re, Rea...) 요청을 보내면 서버가 힘듭니다.
디바운싱(0.3초 대기)을 섞어 쓰면 금상첨화입니다.
하지만 디바운싱만으로는 경쟁 상태를 완벽히 막을 수 없습니다. (0.3초 뒤에 보낸 요청이 0.31초 뒤에 보낸 요청보다 늦게 올 수도 있으니까요).
그래서 디바운싱 + AbortController 조합이 필승 공식입니다.
우리가 수동으로 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는 그걸 무시하고 계속 요청을 진행하기 때문입니다.
프론트엔드 개발자가 흔히 겪는 착각 중 하나가 "코드는 위에서 아래로, 순서대로 실행된다"는 믿음입니다. 하지만 비동기(Async)의 세계는 무질서합니다.
내가 보낸 요청이 언제, 어떤 순서로 돌아올지 아무도 모릅니다. 그 불확실성을 제어하는 것이 프론트엔드 개발자의 실력입니다.
여러분의 검색창은 안녕하신가요? 지금 가서 빠르게 타이핑해보세요. 유령이 살고 있을지도 모릅니다.
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?
The culprit wasn't code logic, but the Network. We call this a Race Condition.
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.
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.
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.
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).
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.
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.
Sometimes, you can't cancel the request (it already reached the server). In this case, the Backend must handle it.
Use an Idempotency Key.
uuid-v4) for the transaction.Idempotency-Key: abc-123.abc-123 was already processed, it returns the cached result without processing again.This is crucial for Payment systems where race conditions lead to double billing.
How do you reproduce a race condition often enough to fix it? It's hard because it depends on network timing.
Simulate Network Delay:await new Promise(r => setTimeout(r, Math.random() * 2000));
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.
If Frontend guards and Backend queues fail, the Database is your final fortress.
SELECT ... FOR UPDATE):
version column to the table.UPDATE tickets SET owner='me', version=2 WHERE id=1 AND version=1;version is already 2, so your query affects 0 rows.For critical financial transactions, I always implement Optimistic Locking as a safety net.
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.
resource_id with TTL (Time To Live).Use libraries like ioredis which have built-in lock support.