tw.heo Github

서버 부하를 고려한 API fetch retry 기능

단일 백엔드 서버의 부하를 줄이기 위해 exponential backoff + jitter 기반 API retry 로직을 설계하고 TanStack Query로 구현한 과정을 정리했다.

Apr 1, 2026

#Network

최근 진행 중인 토이프로젝트에선 단일 백엔드 서버로 API 요청과 실시간 동기화(WebSocket) 요청을 한번에 처리하고 있다. 처음부터 서버를 늘리는 게 과도하다고 판단하여 이렇게 설계했지만, 그럼에도 단일 서버인 만큼 부하에 취약한 건 사실이기에 줄일 수 있는 한 부하를 최대한 줄여야 했다.

부하를 줄일 수 있는 방안은 여러가지가 있겠지만 이번 글에서는 “API 요청 실패 시 재요청하는 로직” 에 대해 어떻게 부하를 줄일 수 있을지 고민해보았다.

이전엔 API 요청이 실패하면 “즉시 재요청”하는 방식을 사용했다. 하지만 이 방식은 부하를 줄이진 못한다. 예를 들어 1초에 1개의 요청만 처리할 수 있는 서버가 있다고 가정해보자. 1초에 N개의 클라이언트가 들어오면 한 개만 처리하고 나머지 N - 1개의 요청은 돌려보내야 한다. 이후 모든 클라이언트가 즉시 재요청을 하면 다시 N - 1개 중 하나를 처리하게 되고 나머지를 되돌려보내는데, 이를 반복하면 총 요청 수가 N(N+1)/2, 즉 O(N^2) 수준이 된다. 클라이언트가 많아질수록 요청 수가 급증하게 되어 부하가 늘어난다. 따라서 부하를 줄일 수 있는 다른 재요청 방식이 필요했다.

Exponential backoff

서버 부하를 줄이기 위해 내가 선택한 전략은 exponential backoff 방식이다. exponential backoff 란 각 재시도 후 대기 시간을 두 배로 늘리는 것이다. 예를 들어 base(=늘어나는 시간)를 1000ms라고 하면, 1000, 2000, 4000, 8000, … 으로 재요청까지의 시간이 늘어나며 요청이 분산된다.

exponential backoff

로직은 TanStack Query(@tanstack/react-query: ^5.95.2)를 사용하여 구현했다. QueryProviderretryDelay 속성에 지수 백오프를 추가했다.

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
const RETRY_DELAY_MS = 1000;
const MAX_RETRY_DELAY_MS = 300000;
export function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
retryDelay: (attemptIndex) =>
Math.min(RETRY_DELAY_MS * 2 ** attemptIndex, MAX_RETRY_DELAY_MS),
},
},
}),
);
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
  • 사실 지수 백오프 자체는 TanStack Query에서 기본값으로 동작한다.
  • 다만 나는 프로젝트에 맞게 보완할 예정이기에 직접 구현했다.

그런데 exponential backoff만으로는 충분하지 않다. 모든 클라이언트들이 동시에 요청한 뒤 동일한 인터벌만큼 멈추고 다시 요청하면 결국 다시 몰리기 때문이다. 이것을 Thundering Herd 문제라고 한다.

Jitter

Thundering Herd를 해결할 아이디어는 꽤 간단하다. 모든 클라이언트들마다 약간의 오프셋을 추가하는 것이다. 이것을 jitter라고 하는데, exponential backoff 로 계산된 대기 시간에 무작위 변동을 추가하는 방식이다. 이렇게 되면 모든 클라이언트들이 전부 다른 시간에 요청할 수 있어 요청을 분산할 수 있다.

jitter

jitter 는 여러 종류가 있다.

  • full jitter: 0~최대 지연시간 범위에서 무작위로 선택
  • equal jitter: 기준점에서 랜덤한 값을 뽑아 최소 시간을 보장
  • decorrelated jitter: 이전 지연 시간을 기준으로 다음 지연 시간을 랜덤하게 조정

서버부하를 줄이는 데에는 full jitter가 가장 유용하기에 이것을 선택했다. 다만 확률적으로는 낮지만 아무래도 랜덤값에서 뽑는 방식이기에 즉시 재요청처럼 동작할 가능성이 있다. 만약 즉시 재요청을 하게 되면 네트워크 장애 시 복구할 시간이 부족하기 때문에 최소 지연 시간을 보장하는 MIN_WAIT_FLOOR_MS를 추가했다.

"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
const INITIAL_BACKOFF_MS = 1000;
const MAX_RETRY_DELAY_MS = 30000;
const MIN_WAIT_FLOOR_MS = 1000;
export function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
retryDelay: (attemptIndex) =>
Math.random() *
Math.min(
INITIAL_BACKOFF_MS * 2 ** attemptIndex,
MAX_RETRY_DELAY_MS,
) +
MIN_WAIT_FLOOR_MS,
},
},
}),
);
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}

이 값은 1000ms로 설정하였으며 TCP의 initRTO에 근거했다. TCP는 클라이언트가 SYN을 보내면 서버가 SYN-ACK를 보내는 방식으로 동작한다. 만약 클라이언트가 SYN을 보냈는데도 불구하고 응답이 없으면 연결이 제대로 되지 않은 것으로 판단하고 다시 SYN을 보내는데, 이때 SYN을 다시 보내기까지 기다리는 시간을 RTO(Retransmission Timeout)라고 한다. RFC 6298 표준에 따르면 initRTO 값은 1초(1000ms)로 설정하도록 권고하고 있다.

Until a round-trip time (RTT) measurement has been made… the sender MUST set RTO ← 1 second.

TCP 요청이 실패하고 있을 때(SYN 유실) 애플리케이션 레벨에서 너무 빠르게 재시도하면 커널은 새로운 임시 포트와 소켓 자원을 계속 할당하려 시도하게 되는데, 이는 클라이언트 기기의 자원 낭비뿐만 아니라, 네트워크에 부담이 된다. 물론 이미 연결된 상태에서의 TCP RTO(Dynamic RTO)는 네트워크 환경에 따라 200ms 수준까지 낮아질 수 있다. 하지만 애플리케이션 계층에서는 커널의 실시간 RTO 수치를 정확히 알 수 없으며, 에러가 발생한 시점은 연결 자체가 유실되어 다시 initRTO가 적용될 가능성이 높은 상황이다. 따라서 가장 보수적인 표준인 1000ms를 하한선으로 설정했다.

Retry를 하면 안되는 경우

물론 retry를 하면 안되는 요청들도 존재한다.

  • 401(미인증), 403(권한없음), 404(없음)은 재시도 금지
  • POST, PATCH 멱등성 보장 불가: GET, PUT, DELETE 요청과 달리 POST, PATCH는 멱등성을 보장하지 않기 때문에 서버에 두 번 write 할 가능성이 있음
  • 네트워크 자체가 끊긴 경우: 재요청을 해도 애초에 send 자체가 불가
  • 페이지 활성화 여부: 탭으로 비활성화된 경우엔 굳이 재요청을 할 필요 없음

다만, TanStack Query 에선 HTTP status 에 따른 분기를 제외한 나머지 경우를 내부적으로 처리해주고 있어 별도 설정은 하지 않아도 된다. 따라서 status에 따른 처리만 해주었다.

"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { isAxiosError } from "axios";
import { useState } from "react";
const INITIAL_BACKOFF_MS = 1000;
const MAX_RETRY_DELAY_MS = 30000;
const MIN_WAIT_FLOOR_MS = 1000;
export function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
retry: (failureCount, error) => {
if (failureCount > 3) {
return false;
}
if (isAxiosError(error)) {
const status = error?.response?.status;
if (status && [401, 403, 404].includes(status)) {
return false;
}
}
return true;
},
retryDelay: (attemptIndex) =>
Math.random() *
Math.min(
INITIAL_BACKOFF_MS * 2 ** attemptIndex,
MAX_RETRY_DELAY_MS,
) +
MIN_WAIT_FLOOR_MS,
},
},
}),
);
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}

참고