화면에는 '요청 시간이 초과됐습니다'라고 떴는데, 잠시 후 서버가 응답한다. 분명 타임아웃을 걸었는데 요청이 아직 일하고 있다니? 기다리기를 그만두는 것과 요청을 중단하는 것은 다르다. Promise.race()를 쓸 때 특히 놓치기 쉬운 차이다.
Promise.race 타임아웃은 왜 fetch를 취소하지 않을까?
느린 /api/report가 120ms 뒤 응답한다고 가정하자. 화면은 20ms만 기다린다.
const request = fetch('/api/report');
const timeout = new Promise((_, reject) => {
setTimeout(() => reject(new Error('20ms 초과')), 20);
});
try {
await Promise.race([request, timeout]);
} catch (error) {
console.log(error.message); // 20ms 초과
}race는 먼저 끝난 Promise의 결과를 돌려준다. 20ms 타이머가 이겼다는 사실만 알려 줄 뿐, 다른 참가자인 request에 취소 명령을 보내지 않는다. 따라서 120ms에 도착한 응답은 화면에서 더는 사용하지 않더라도 원래 fetch 쪽으로 올 수 있다. 서버가 120ms 뒤 응답한다는 수치는 흐름을 보여 주기 위한 예시이지 네트워크의 시간 보장은 아니다.
위 코드는 차이를 보여 주는 예시다. 실제 코드에서 요청이 먼저 끝났다면 남은 타이머도 정리해야 한다.
그림의 위쪽은 '화면의 대기 종료', 아래쪽은 '클라이언트 요청 중단'이다. 타이머를 붙인 것만으로 아래쪽 경로가 되지는 않는다.
AbortController로 fetch 요청을 어떻게 중단할까?
fetch()에 signal을 전달하고 제한시간이 되면 abort()를 호출한다. 응답 본문을 읽는 동안에도 제한시간을 적용하려면 타이머를 본문 처리까지 유지해야 한다.
async function fetchReport(url, timeoutMs = 3000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.text();
} finally {
clearTimeout(timer);
}
}느린 응답이 오기 전에 타이머가 울리면 fetchReport()는 보통 이름이 AbortError인 오류로 끝난다. 다만 AbortError가 언제나 '시간 초과'라는 뜻은 아니다. 다른 곳에서 같은 신호를 중단할 수도 있다. 화면에서 이유를 구분해야 한다면 타임아웃 여부를 별도로 기록하고, HTTP 오류도 앞 글의 response.ok 검사처럼 나눠 처리하자.
finally의 clearTimeout()도 중요하다. 요청이 먼저 끝났다면 남은 타이머를 치워야 한다. 타이머만 취소하는 clearTimeout()과 이미 시작된 fetch를 중단하는 abort()는 서로 다른 역할이다.
같은 느린 응답으로 무엇을 확인하면 될까?
외부 API 없이 로컬 HTTP 서버가 120ms 뒤 done을 보내게 한 뒤 두 요청을 비교했다. 첫 요청은 20ms에 Promise.race가 시간 초과로 끝났지만, 원래 fetch를 다시 기다리자 done 응답이 도착했다. 두 번째 요청은 20ms에 abort()를 호출하자 fetch가 AbortError로 끝났다.
Promise.race → 20ms 제한시간 오류 → 원래 fetch는 120ms 응답 수신
AbortController → 20ms abort() → 클라이언트 fetch는 AbortError여기서 입증한 것은 클라이언트에서 기다리던 작업의 차이다. abort()가 서버에서 이미 시작한 계산이나 저장까지 되돌린다는 뜻은 아니다. 결제·주문처럼 부작용이 있는 요청은 중단 뒤 재시도할 때 중복 처리 가능성도 따로 설계해야 한다. '화면이 안 기다리니 서버도 안 했겠지'라는 기대는 디버깅하기 꽤 비싼 기대다.
핵심 요약
Promise.race()는 제한시간을 표시할 수 있지만 진행 중인 fetch를 취소하지 않는다. 요청 자체의 클라이언트 대기를 중단하려면 AbortController의 signal을 연결하고 시간이 되면 abort()를 호출하자. 응답 본문까지 읽은 뒤 타이머를 정리하고, HTTP 오류·시간 초과·사용자 취소는 서로 다른 사건으로 다룬다.

