웹 서비스를 개발하다 보면, 네트워크 요청은 항상 성공한다고 가정할 수 없다.
서버가 일시적으로 응답하지 않을 수도 있고, 사용자의 네트워크 환경이 불안정할 수도 있고, 요청이 예상보다 오래 걸리는 경우도 있다.
처음에는 단순히 fetch() 만 사용하면 충분하다고 생각했지만, 공통 Fetch 유틸리티를 구현하면서 ‘실패하는 요청을 어떻게 처리할 것인가’가 서비스의 안정성과 사용자 경험에 큰 영향을 준다는 점을 알게 되었다.
이번 글에서는 안정적인 네트워크 요청을 만들기 위해 고려했던 Timeout, AbortController, Retry Strategy에 대해 정리해보려고 한다.
프론트에서의 재시도 전략은 네트워크 일시 장애나 서버 과부하 시 사용자 경험을 해치지 않고 요청을 복구하는 패턴이다.
참고 자료
fetch() 를 처음 사용할 때 놓치는 부분 중 하나가 바로 Timeout이다.
await fetch("/api/users");
이 코드를 보면 일정 시간이 지나면 자동으로 요청이 종료될 것처럼 느껴질 수 있지만, 실제로는 그렇지 않다.
Fetch API는 자체적으로 Timeout 기능을 제공하지 않는다.
서버가 응답하지 않더라도 브라우저는 응답이 올 때까지 계속 기다린다.
즉, Promise는 pending 상태로 남아 있고, 사용자는 로딩 화면만 계속 보게 될 수도 있다.
서비스를 운영하는 입장에서는 이러한 상황을 그대로 둘 수 없다.
실제로 프로젝트 개발 중 공동 기록 작성 후 저장 API 요청을 보내는데 서버로부터 응답이 오지 않아 10초 이상 기다렸다 끊어버린 경험이 있다.
일정 시간이 지나도 응답이 오지 않는다면 요청을 종료하고, 사용자에게 적절한 안내를 제공하는 것이 더 나은 사용자 경험을 제공할 것이다.
그래서 Timeout 처리가 필요하다.
참고 자료
Fetch API에는 Timeout 옵션은 없지만, 요청을 직접 취소할 수 있는 기능은 제공한다.
이때 사용하는 것이 AbortController 이다.
AbortController 는 비동기 작업을 중단하기 위한 Web API이다.
Fetch API는 이 객체가 제공하는 signal 을 전달받아 요청을 관리한다.
const controller = new AbortController();
fetch("/api/users", {
signal: controller.signal,
});
그리고 일정 시간이 지나면 abort() 를 호출해 요청을 종료할 수 있다.
setTimeout(() => {
controller.abort();
}, 5000);
즉, Timeout 기능을 직접 구현하는 방식이다.
AbortController 와 함께 자주 등장하는 객체가 AbortSignal 이다.
처음에는 두 객체의 차이가 헷갈렸지만, 역할은 생각보다 단순하다.
Fetch는 signal 을 계속 관찰하다가 abort() 가 호출되면 요청을 즉시 종료하고 AbortError 를 발생시킨다.
fetch 시작
│
▼
AbortSignal 등록
│
▼
controller.abort()
│
▼
AbortSignal 상태 변경
│
▼
fetch 종료 (AbortError 발생)
따라서 try...catch 를 사용할 경우에는 일반적인 네트워크 에러뿐 아니라 AbortError 도 함께 처리하는 것이 좋다.
여기서 하나 더 생각해봐야 하는게 있다.
타임 아웃 시간을 선택하는 것이다.
시간 제한을 너무 높게 설정하면 클라이언트가 대기하는 시간 동안에도 리소스가 계속 사용되기 때문에 유용성이 떨어진다.
시간 제한을 너무 낮게 설정하면 두 가지 위험이 대두된다.
Timeout을 구현하고 나면 자연스럽게 드는 생각이 있다.
“실패했으니 다시 요청하면 되는 것 아닌가?”
Timeout뿐만 아니라, 의도치않게 실패된 것인지 의도된 실패인지 분별할 수 없을 때 요청을 재시도하는 것이다.
하지만 모든 요청을 무조건 retry 하는 것은 위험할 수 있다.
네트워크 오류나 일시적인 서버 장애처럼 잠깐의 문제라면 retry가 효과적일 수 있다.
반면, 사용자의 요청 자체가 이미 정상적으로 처리되었는데 응답만 받지 못한 상황이라면 이야기가 달라진다.
예를 들어 결제 API를 생각해보자
결제 요청
↓
서버는 결제 완료
↓
응답 전 네트워크 끊김
↓
클라이언트는 실패라고 판단
↓
Retry
↓
결제가 두 번 수행될 수도 있다.
즉, retry는 “실패했으니 다시 보내자”가 아니라 왜 실패했는지를 먼저 판단해야 하는 전략이다.
일반적으로 retry는 일시적인 오류에 대해서만 수행하는 것이 좋다.
대표적인 예시는 다음과 같다.
이러한 경우는 잠시 후 다시 요청하면 정상적으로 처리될 가능성이 높다.
반대로 인증 오류(401), 권한 오류(403), 잘못된 요청(400)처럼 클라이언트가 수정하지 않는 이상 결과가 바뀌지 않는 오류는 retry해도 의미가 없다.
흔히 GET 은 retry가 가능하고 POST 는 위험하다고 이야기한다.
물론 일반적으로는 맞는 이야기지만, 실제로는 HTTP 메소드보다는 멱등성이 더 중요한 기준이 된다.
멱등성
동일한 요청을 여러 번 수행하더라도 결과가 동일하게 유지되는 성질
예를 들어 PUT /users/1 요청은 여러 번 호출하더라도 최종 상태는 동일하다.
따라서 retry가 비교적 안전하다.
반면 POST /payments 처럼 새로운 데이터를 생성하는 요청은 호출할 때마다 새로운 결제가 발생할 수 있기 때문에 retry가 위험하다.
즉, retry 여부는 HTTP 메소드만 보고 결정하기보다 해당 API가 멱등성을 보장하는지를 먼저 확인해야 한다.
참고 자료
주요 재시도 전략은 다음과 같다.
1. 즉시 재시도
실패 즉시 딜레이 없이 재시도한다.
순간적인 네트워크 깜빡임이 원인일 때 유용하고, 사용자는 대기 시간 없이 원활한 이용이 가능하다.
2. 고정 간격 재시도
실패시 매번 정해진 고정 시간을 기다린 후 재시도한다.
단순한 간헐적 오류에 대응할 때 적합하지만, 서버 장애 시 짧은 시간에 요청이 몰릴 위험이 있다.
3. 지수 백오프(Exponential Backoff) 및 지터(Jitter)
재시도를 거듭할 수록 대기 시간이 지수 함수적으로 증가하며(2초, 4초, 8초..), 여기에 무작위 값(Jitter)을 추가해 요청이 동시에 서버로 쏠리는 현상을 방지한다.
서버 점검이나 트래픽 급증 시 시스템을 보호하는 가장 안정적인 전략이다.
고정 간격과 지수 백오프 모두 일정 시간을 두고 재시도를 한다는 점은 동일하다.
하지만 어차피 동시에 요청이 몰린다면 똑같은 시간 간격으로 모든 재시도가 동일하게 몰릴 것이기에 일정 시간 대기 재요청도 한계가 있다.
그래서 이를 개선하기 위한 방법이 Jitter를 도입하는 것이다.
Jitter
데이터 전송이나 신호 처리시, 시간 간격이 일정하지 않고 미세하게 흔들리거나 변동되는 현상을 말한다.
네트워크 분야에서는 패킷이 목적지에 도착하는 지연 시간(latency)이 불안정하게 요동치는 현상을 말한다.
이 개념을 retry에 적용하면, API를 요청하는 클라이언트 간의 동일한 재시도 시간 간격에 무작위성을 추가하여 서로 요청하는 시간대를 분산시킬 수 있다.
1번과 같은 실패 직후 바로 다시 요청하는 것은 좋은 방법이 아니다.
예를 들어 서버 장애가 발생한 상황에서 수천 명의 사용자가 동시에 Retry를 수행한다면, 이미 과부하 상태인 서버에 더 많은 요청을 보내게 된다.
이러한 문제를 방지하기 위해 대부분의 서비스는 3번 지수 백오프와 지터를 함께 사용한다.
AWS에서도 retry 전략으로 지수 백오프와 지터를 함께 사용하는 방식을 권장하고 있다.
아래는 AWS 문서에서 가져온 내용
- 재시도 시점 결정.
일반적으로 우리는 부작용을 수반하는 API가 멱등성을 제공하지 않는 한 안전하지 않다고 여기고 있습니다.
이것은 재시도 빈도와 상관없이 부작용이 한번만 발생하게 합니다.
읽기 전용 API는 대개 멱등적이지만 리소스 생성 API는 그렇지 않을 수 있습니다.
중복된 부작용을 방지하기 위해서는 바람직한 API 설계와 함께 클라이언트 구현 시 여러 가지 사항을 주의해야 합니다.- 어떤 장애 발생 시 재시도할 가치가 있는지 파악하기.
HTTP는 클라이언트와 서버 오류 간에 분명한 구분을 제공합니다.
클라이언트 오류는 나중에도 성공하지 못할 것이기 때문에 동일한 요청으로 재시도해서는 안 되지만, 서버 오류는 후속 시도에서 성공할 수 있습니다.
불행히도 시스템의 최종 일관성은 이 경계를 모호하게 만듭니다.
한 순간의 클라이언트 오류는 상태가 전파될 경우 다음 순간에 성공으로 바뀔 수 있습니다.
참고 자료
재시도 대상은 ‘응답을 못 받은 경우’만 이다.
가장 먼저 정한 기준은 fetch 자체가 실패한 경우만 재시도 대상으로 삼는다는 것이다.
try {
const response = await fetch(url, { ...fetchOptions, signal: controller?.signal });
// ... 응답을 정상적으로 받았다면(4xx/5xx 포함) 그대로 반환, 재시도하지 않음
return { ...data, headers: response.headers };
} catch (error) {
// AbortError(타임아웃)는 재시도하지 않고 즉시 반환
if (err.name === 'AbortError') {
return { success: false, data: null, error: { code: 'TIMEOUT', ... } };
}
// JSON 파싱 에러도 재시도하지 않음 — 서버 응답 자체의 문제일 가능성
if (err.message.includes('JSON') || ...) { ... }
// 여기까지 걸러지고 남은 것 — 연결 실패, DNS 오류 등 순수 네트워크 예외만 재시도
if (attempt >= maxRetries) { return { ...NETWORK_ERROR }; }
await delay(waitTime);
return fetchWithRetry(url, fetchOptions, attempt + 1, ...);
}
서버가 응답을 주는 순간 재시도 루프에서는 완전히 손을 뗀다.
처음엔 5xx도 재시도해야 하지 않나 고민했지만, 우리 서비스 규모에서는 fetch 자체가 실패하는 케이스(연결 끊김, DNS 실패)와 타임아웃만 우선 다루고, 서버가 명시적으로 에러를 반환한 경우는 재시도 없이 사용자에게 즉시 알리는 쪽을 택했다.
타임아웃(AbortError)을 재시도 대상에서 뺀 것도 같은 맥락이다.
이미 응답이 오래 걸리고 있다는 신호인데, 거기에 재시도까지 얹으면 사용자를 더 오래 기다리게 만들 뿐이다.
앞서 정리했듯 HTTP 메서드보다 멱등성이 기준이 되어야 한다.
이걸 실제 코드에 반영했다.
// 네트워크 예외 재시도는 HTTP 스펙상 멱등이 보장되는 메서드(GET/PUT/DELETE)에만 기본 적용한다.
// POST(생성)와 PATCH(부분 수정, 멱등 보장 안 됨)는 요청이 서버에
// 도달해 처리된 뒤 응답만 유실됐을 수 있어, 자동 재시도가 중복 생성/중복 처리로
// 이어질 수 있다 — 필요하면 호출부에서 options.maxRetries로 명시적으로 오버라이드한다.
const method = (fetchOptions.method ?? 'GET').toUpperCase();
const isIdempotentMethod = method === 'GET' || method === 'PUT' || method === 'DELETE';
const resolvedMaxRetries = maxRetries ?? (isIdempotentMethod ? 3 : 0);
maxRetries 를 호출부에서 명시적으로 넘기면 이 기본값을 오버라이드할 수 있게 열어뒀는데, 이건 특정 POST 엔드포인트가 실제로는 멱등하다는걸 확인했을 때를 위한 탈출구라고 보면 된다.
// 재시도 전 대기: 지수 백오프(최대 1 -> 2 -> 4초)에 Full Jitter 적용.
// 같은 장애로 여러 클라이언트가 동시에 재시도할 때 정확히 같은 타이밍에
// 몰려 서버 회복을 방해하지 않도록, 0~상한 사이에서 무작위로 대기 시간을 뽑는다.
const maxWaitTime = retryDelay * 2 ** attempt;
const waitTime = Math.random() * maxWaitTime;
await delay(waitTime);
AWS 문서에서 소개하는 여러 Jitter 방식 중 가장 단순하고 실측상 성능이 가장 좋다고 언급된 Full Jitter(0~상한 사이 균등 분포)를 골랐다.
상한 자체는 기존 지수 백오프 그대로 유지하고, 실제 대기 시간만 그 안에서 매번 랜덤하게 뽑는 구조다.
토큰 만료로 인한 401 처리는 위의 재시도 로직과 분리했다.
if (!data.success && response.status === 401 && isRefreshableAuthError(errorCode) && ...) {
const newToken = await refreshAccessToken();
// ...
return fetchWithRetry(
url,
{ ...fetchOptions, headers: finalHeaders },
attempt, // ← 증가시키지 않는다
maxRetries,
...
);
}
이 재시도는 attempt 를 그대로 넘겨서 네트워크 예외 재시도의 횟수를 소모하지 않고, 대기 시간도 거치지 않는다.
근거는 멱등성이 아니라 401을 받았다는건 서버가 아직 이 요청의 실제 처리를 시작조차 안 했다는 것이다.
인증 가드가 비즈니스 로직 실행 전에 요청을 막기 때문에, 메서드가 POST든 뭐든 토큰을 갱신한 뒤 같은 요청을 다시 보내도 중복 처리 위험이 없다.
즉, 이 재시도가 안전한 이유는 멱등성과는 다른 축의 이야기다.
AWS의 결론을 그대로 가져와봤다.
분산 시스템에서 일시적인 장애 또는 원격 상호 작용의 지연은 불가피합니다.
시간 제한은 시스템이 과도하게 오래 중단되지 않게 하고, 재시도는 이러한 장애를 거를 수 있게 해 주며, 백오프 및 지터는 활용도를 향상시키고 시스템의 정체를 줄여 줍니다.우리는 재시도를 조심스럽게 다루는 것이 중요하다는 것을 배웠습니다.
재시도는 종속 시스템의 부하를 증폭시킬 수 있습니다.
시스템에 대한 호출이 시간 제한을 초과하고 시스템에서 오버로드가 발생한 경우 재시도는 문제를 개선하는 것이 아니라 더욱 악화시킬 수 있습니다.
우리는 종속성이 양호한 경우에만 재시도를 수행하여 문제가 증폭되는 것을 방지하고 있습니다.
재시도는 가용성 향상을 지원하지 않기 때문에 우리는 재시도를 중단하고 있습니다.
처음 재시도 정책을 도입한 이유는 유저 입장에서의 생각한 불편함 때문이었다.
버튼을 클릭하거나 폼을 제출하는 등의 요청을 보냈는데, 네트워크와 같은 문제로 인해 요청이 실패해버리면 직접 재요청해야 하는 불편함이 생긴다.
이를 어떻게 개선할 수 있을까 고민하다 알게된게 재시도 정책이었다.
그리고 프로젝트를 진행하면서 직접 겪었던 기록 저장 API의 무한 대기로 인해 AbortController로 Timeout까지 적용하게 되었다.
사용자 경험을 개선하기 위해 도입했던 것들이지만, 실제 시스템에 적용하면서 시스템의 안정성과 안전 장치의 중요성을 체감할 수 있었다.
이렇게 구현하고 나중에 어떤 기업의 과제 테스트를 진행했었는데, 거기서 '에러 발생시 재시도 로직을 적절히 구현하라'는 요구사항을 마주했다.
그걸 보고, 좋은 경험 했구나..! 생각이 들었다.