Effect 경쟁 조건 — 오래된 응답이 나중에 도착할 때
useEffect에서 데이터 페칭 시 발생하는 경쟁 조건(race condition)의 원리, ignore 플래그 패턴으로 오래된 응답 무시하기, AbortController로 요청 자체 취소하기, 그리고 로딩/에러 상태 처리를 다룹니다.
지난 글에서 Effect가 필요 없는 상황들을 살펴봤다. 이번에는 Effect가 실제로 필요한 가장 흔한 경우인 데이터 페칭에서 발생하는 경쟁 조건(race condition)을 다룬다. 빠르게 클릭하거나 값이 빠르게 바뀌는 환경에서 반드시 마주치는 문제다.
경쟁 조건이란
같은 Effect가 여러 번 실행될 때, 먼저 시작한 요청이 나중에 완료되면 오래된 데이터로 화면이 덮어씌워지는 문제다.
// 문제가 있는 코드
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(data => {
setUser(data); // userId가 바뀐 후 이전 응답이 도착하면?
});
}, [userId]);
}
userId가 1 → 2로 빠르게 바뀌면:
- userId=1 요청 시작
- userId=2 요청 시작
- userId=2 응답 도착 →
setUser(user2)화면에 userId=2 표시 - userId=1 응답이 늦게 도착 →
setUser(user1)화면에 userId=1 데이터 덮어씀
userId=2를 보고 있는데 userId=1의 데이터가 표시된다.
해결책 1: ignore 플래그 패턴
가장 범용적인 해결책이다. cleanup 함수에서 ignore = true로 바꿔 이전 Effect의 콜백을 무력화한다.
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
let ignore = false; // 이 Effect 실행 인스턴스의 플래그
setIsLoading(true);
fetchUser(userId)
.then(data => {
if (!ignore) { // cleanup이 실행됐다면 무시
setUser(data);
setIsLoading(false);
}
})
.catch(err => {
if (!ignore) {
setError(err.message);
setIsLoading(false);
}
});
return () => {
ignore = true; // userId 바뀌면 이 플래그를 true로
};
}, [userId]);
if (isLoading) return <Spinner />;
return <Profile user={user} />;
}
ignore는 각 Effect 실행마다 새로 만들어지는 로컬 변수다. cleanup이 실행되면(userId가 바뀌면) 해당 실행의 ignore가 true로 바뀌어, 이미 시작된 fetch가 완료되어도 setUser를 호출하지 않는다.
해결책 2: AbortController
AbortController를 써서 이전 요청 자체를 취소할 수 있다.
useEffect(() => {
const controller = new AbortController();
fetch(`/api/user/${userId}`, {
signal: controller.signal
})
.then(res => {
if (!res.ok) throw new Error('요청 실패');
return res.json();
})
.then(data => setUser(data))
.catch(err => {
if (err.name === 'AbortError') return; // 취소는 에러 아님
setError(err.message);
});
return () => controller.abort(); // cleanup: 이전 요청 취소
}, [userId]);
AbortController는 브라우저의 fetch API와 직접 연동된다. cleanup에서 controller.abort()를 호출하면 진행 중인 네트워크 요청 자체가 취소되고, promise가 AbortError로 reject된다.
ignore 패턴 vs AbortController:
ignore: 구현 간단, 어떤 비동기 함수에도 사용 가능, 요청은 계속 진행됨 (응답만 무시)AbortController: 네트워크 요청 자체를 취소해 불필요한 대역폭 절약,fetch전용
로딩, 에러, 성공 상태 올바르게 관리
경쟁 조건을 막으면서 로딩/에러/성공 상태를 함께 관리하는 완성된 패턴이다.
function useUser(userId) {
const [state, setState] = useState({
data: null,
isLoading: true,
error: null,
});
useEffect(() => {
if (!userId) return; // userId 없으면 실행 안 함
let ignore = false;
setState({ data: null, isLoading: true, error: null });
fetchUser(userId)
.then(data => {
if (!ignore) setState({ data, isLoading: false, error: null });
})
.catch(err => {
if (!ignore) setState({ data: null, isLoading: false, error: err.message });
});
return () => { ignore = true; };
}, [userId]);
return state;
}
setState를 한 번에 객체로 업데이트해서 로딩 시작 시 data와 error를 동시에 초기화한다. 이렇게 하면 이전 사용자의 데이터가 새 사용자 로딩 중에 잠깐 보이는 문제를 방지한다.
빠른 입력과 디바운스
검색어처럼 빠르게 바뀌는 값에 fetch를 연결할 때는 디바운스와 함께 쓰는 것이 좋다.
function SearchResults({ query }) {
const [results, setResults] = useState([]);
useEffect(() => {
if (!query.trim()) {
setResults([]);
return;
}
let ignore = false;
const timerId = setTimeout(() => {
// 300ms 후에야 fetch 시작 (디바운스)
fetchSearch(query).then(data => {
if (!ignore) setResults(data);
});
}, 300);
return () => {
clearTimeout(timerId); // 타이머 취소 (cleanup)
ignore = true; // 이미 시작된 fetch 무시
};
}, [query]);
return <ul>{results.map(r => <li key={r.id}>{r.name}</li>)}</ul>;
}
cleanup에서 타이머와 ignore 플래그를 함께 처리한다. query가 빠르게 바뀌면 타이머가 계속 취소/재생성되어 마지막 입력 후 300ms 뒤에만 요청이 나간다.
지난 글: Effect가 필요 없는 상황들
다음 글: AbortController로 fetch 요청 취소하기
읽어주셔서 감사합니다. 😊