검색 인풋에 "react"를 타이핑하면 요청이 다섯 번 나간다. "r", "re", "rea", "reac", "react". 네트워크 상태에 따라 "rea"의 응답이 "react"보다 늦게 도착할 수 있다. 그러면 사용자는 "react"를 검색했는데 "rea"의 결과를 보게 된다.
이게 race condition이다. 그리고 AbortController가 이걸 해결한다.
AbortController 기본
AbortController는 진행 중인 비동기 작업을 취소하는 웹 표준 API다.
const controller = new AbortController();
fetch('/api/search?q=react', {
signal: controller.signal,
});
// 요청을 취소하고 싶을 때
controller.abort();abort()를 호출하면 해당 signal에 연결된 fetch가 AbortError를 던지며 중단된다. 브라우저가 실제로 네트워크 요청을 취소하기 때문에 대역폭도 절약된다.
패턴 1: 검색 자동완성의 race condition 방지
이전 요청을 새 요청이 시작될 때 취소하면 된다.
function useSearch(query: string) {
const [results, setResults] = useState([]);
useEffect(() => {
if (!query) {
setResults([]);
return;
}
const controller = new AbortController();
fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: controller.signal,
})
.then((res) => res.json())
.then(setResults)
.catch((err) => {
if (err.name !== 'AbortError') throw err;
// AbortError는 의도적 취소이므로 무시
});
return () => controller.abort();
}, [query]);
return results;
}useEffect의 cleanup 함수에서 abort()를 호출한다. query가 바뀔 때마다 이전 요청이 취소되고 새 요청만 살아남는다. "rea"의 응답이 "react"보다 늦게 도착하는 문제가 원천적으로 해결된다.
AbortError는 정상적인 취소이므로 catch에서 걸러줘야 한다. 안 그러면 에러 모니터링에 불필요한 노이즈가 쌓인다.
패턴 2: 버튼 중복 클릭 방지
"저장" 버튼을 빠르게 두 번 클릭하면 같은 요청이 두 번 나간다. 데이터가 두 번 생성될 수 있다. 보통 버튼을 disable 시키는데, AbortController로도 처리할 수 있다.
function useMutation<T>(mutationFn: (signal: AbortSignal) => Promise<T>) {
const controllerRef = useRef<AbortController | null>(null);
const [isPending, setIsPending] = useState(false);
const mutate = useCallback(async () => {
// 이전 요청이 진행 중이면 취소
controllerRef.current?.abort();
const controller = new AbortController();
controllerRef.current = controller;
setIsPending(true);
try {
const result = await mutationFn(controller.signal);
return result;
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') return;
throw err;
} finally {
setIsPending(false);
}
}, [mutationFn]);
return { mutate, isPending };
}
// 사용
const { mutate: savePost, isPending } = useMutation((signal) =>
fetch('/api/posts', {
method: 'POST',
body: JSON.stringify(postData),
signal,
})
);두 번 클릭해도 첫 번째 요청이 취소되고 두 번째만 실행된다. 물론 서버 사이드 멱등성 처리도 필요하지만, 클라이언트에서 1차 방어를 하는 셈이다.
패턴 3: 타임아웃 구현
fetch에는 기본 타임아웃이 없다. AbortSignal.timeout()으로 간단하게 구현할 수 있다.
// 5초 안에 응답이 없으면 취소
const response = await fetch('/api/data', {
signal: AbortSignal.timeout(5000),
});직접 구현할 필요 없이 한 줄이다. 수동으로 AbortController와 setTimeout을 조합하는 것보다 깔끔하다.
여러 조건을 조합하고 싶으면 AbortSignal.any()를 쓴다:
const controller = new AbortController();
const response = await fetch('/api/data', {
signal: AbortSignal.any([
controller.signal, // 수동 취소
AbortSignal.timeout(5000), // 5초 타임아웃
]),
});사용자가 취소 버튼을 누르거나, 5초가 지나거나, 둘 중 하나라도 발생하면 요청이 취소된다.
패턴 4: 페이지 이동 시 진행 중인 요청 정리
SPA에서 페이지를 이동하면 이전 페이지의 컴포넌트가 언마운트된다. 그런데 진행 중이던 fetch는 그대로 살아있고, 응답이 도착하면 이미 없는 컴포넌트의 state를 업데이트하려고 한다.
React 18부터는 경고가 사라졌지만, 불필요한 네트워크 요청이 계속 진행되는 건 여전히 낭비다.
function useData(url: string) {
const [data, setData] = useState(null);
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal })
.then((res) => res.json())
.then(setData)
.catch((err) => {
if (err.name !== 'AbortError') throw err;
});
return () => controller.abort(); // 언마운트 시 요청 취소
}, [url]);
return data;
}TanStack Query를 쓰고 있다면 내부적으로 AbortController를 처리해주니까 직접 관리할 필요가 없다. queryFn에 signal이 자동으로 전달된다:
useQuery({
queryKey: ['search', query],
queryFn: ({ signal }) =>
fetch(`/api/search?q=${query}`, { signal }).then((res) => res.json()),
});정리
AbortController는 화려한 기술이 아니다. 하지만 제대로 쓰면 race condition, 중복 요청, 메모리 낭비를 깔끔하게 해결한다. 특히 검색, 자동완성, 무한 스크롤처럼 요청이 빈번한 UI에서는 사실상 필수다.
fetch를 쓸 때 signal을 넘기는 습관만 들여도 대부분의 문제를 예방할 수 있다.
