목차
- 첫 주: 503 한 번에 모든 게 무너졌다
- 둘째 주: React Query 기본 retry를 다시 봤다
- 셋째 주: Exponential Backoff만으로는 부족했다
- 넷째 주: Jitter를 넣고 알게 된 것
- 한 달 차: 운영 중 터진 의외의 문제
- 두 달 차: Mutation에는 왜 retry를 끄게 됐나
- 마지막 주: 다음 프로젝트라면 이렇게
서비스 결제 페이지에서 react query retry 재시도 전략이 잘못 잡혀 있던 탓에, 503 한 번에 전체 백엔드가 한 시간 가까이 마비됐다. 3개월간 retryDelay 함수를 다시 설계하고 나서야 같은 패턴의 트래픽 스파이크가 와도 서버가 죽지 않는다.
또한, 이 글은 그 3개월의 회고다. 시간 순서대로 뭐가 막혔고, 어떻게 풀었으며, 어디서 다시 막혔는지 적는다. 프론트엔드 5년 차에서 백엔드로 옮긴 지 2년째인 입장에서 보면, 클라이언트 retry는 서버 rate limiting과 동전의 양면 같다. 한쪽만 잘 짜면 결국 다른 쪽이 망가진다.
첫 주: 503 한 번에 모든 게 무너졌다
문제 자체는 단순했다. 결제 승인 API가 외부 PG사 응답 지연으로 503을 뱉었고, React Query는 기본 설정대로 3번 재시도했다. 클라이언트 한 명이면 문제가 없다. 동시 접속자 약 4,200명이 거의 같은 순간 재시도를 돌리자, 백엔드는 한 번에 약 12,000건의 요청을 다시 받았다.
그런데, 그래프를 다시 보면 503이 뜬 직후 1초, 3초, 7초 지점에서 트래픽이 정확히 톱니 모양으로 튀었다. 서버는 이미 한계 상태였고, retry 폭주가 복구를 더 늦췄다. 책에서는 이걸 thundering herd라고 부른다는 걸 그제서야 검색해서 알았다.
당시 ALB 로그를 발췌하면 이런 식이었다.
504 GatewayTimeout target_response_time=29.998
503 ServiceUnavailable target_status_code=- (target_response_time=- 응답 자체 실패)
503 ServiceUnavailable target_status_code=503
target_response_time이 측정 안 되는 503이 시간당 8천 건 가까이 찍혔다. 백엔드 컨테이너는 OOMKilled로 재시작 루프에 빠진 상태였고, 그 사이 들어온 retry가 ALB에서 503으로 돌아갔다. 클라이언트는 503을 받았으니 또 재시도한다. 악순환이다.
물론, TanStack Query 공식 문서(2026년 5월 시점 v5.59 기준)를 다시 읽어보니 기본 retryDelay는 attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000)이었다. 지수 함수긴 한데, 모든 클라이언트가 같은 시점에 재시도한다는 게 핵심 문제였다.
둘째 주: React Query 기본 retry를 다시 봤다
기본 옵션이 어떻게 동작하는지부터 정리했다. v5 기준 retry는 query마다 3회, mutation마다 0회가 기본이다. 함수형으로 넘기면 에러 타입에 따라 다르게 동작시킬 수 있다.
// QueryClient 기본값을 한 번에 잡는다
import { QueryClient } from '@tanstack/react-query'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: (failureCount, error: any) => {
// 4xx는 재시도해도 같은 결과다
if (
error?.response?.status >= 400 &&
error?.response?.status < 500
) {
return false
}
// 5xx와 네트워크 에러만 최대 4회까지
return failureCount < 4
},
retryDelay: attemptIndex =>
Math.min(1000 * 2 ** attemptIndex, 30000),
},
},
})
실제로, 여기까지는 흔한 패턴이다. 4xx에서 재시도를 막는 건 백엔드 시각으로 봐도 당연하다. 인증 실패나 validation 에러는 같은 요청을 또 보낸다고 풀리지 않는다.
이처럼, 핵심은 5xx에서 무엇을 재시도하느냐다. 504 GatewayTimeout과 503 ServiceUnavailable, 502 BadGateway는 일시적 문제라 재시도가 의미 있다. 500 InternalServerError는 코드 버그일 가능성이 커서 재시도해도 같은 결과를 받는 경우가 많다.
반면, 세분화한 버전은 이렇다.
const RETRYABLE_STATUS = new Set([502, 503, 504])
retry: (failureCount, error: any) => {
const status = error?.response?.status
// 네트워크 에러는 status가 없다
if (!status && error?.code === 'ERR_NETWORK') {
return failureCount < 4
}
if (RETRYABLE_STATUS.has(status)) {
return failureCount < 4
}
return false
},
여기까지가 retry 함수의 골자다. 진짜 문제는 retryDelay였다. 위 코드는 1초, 2초, 4초, 8초로 재시도하는데, 1만 명의 클라이언트가 정확히 1초 뒤에 일제히 다시 들어오면 부하 그래프는 다시 톱니 모양으로 솟는다.
셋째 주: Exponential Backoff만으로는 부족했다
반면, 분산 시스템 책 대부분이 이 지점에서 같은 답을 한다. AWS Architecture Blog의 "Exponential Backoff And Jitter" 글이 사실상 표준이다(2015년 발행, 2026년 현재까지 동일 패턴 유지). 핵심은 두 가지다.
특히, 첫째, 재시도 간격을 지수로 늘리는 게 기본이다. 둘째, 거기에 무작위성을 더해서 클라이언트들이 서로 다른 시점에 재시도하도록 만든다.
그래서, 지수 백오프만 적용하면 어떤 모양인지 그려봤다.
| 시도 횟수 | Pure Exponential | Full Jitter 범위 | Equal Jitter 범위 |
|---|---|---|---|
| 1회 | 1,000ms | 0~1,000ms | 500~1,000ms |
| 2회 | 2,000ms | 0~2,000ms | 1,000~2,000ms |
| 3회 | 4,000ms | 0~4,000ms | 2,000~4,000ms |
| 4회 | 8,000ms | 0~8,000ms | 4,000~8,000ms |
| 5회 | 16,000ms | 0~16,000ms | 8,000~16,000ms |
수치만 보면 별 차이 없어 보인다. 클라이언트 수를 곱하면 그림이 달라진다. 1만 명이 같은 순간 503을 받으면 Pure exponential은 정확히 1초 뒤에 1만 건이 몰린다. Full jitter면 0~1,000ms 사이 균등 분포라, 매 ms마다 평균 10건씩 들어온다. 서버 입장에서 이 차이는 절대적이다.
물론, :::tip Full jitter vs Equal jitter: AWS 글에서는 두 방식을 시뮬레이션으로 비교한다. Full jitter는 0~cap 사이 랜덤, Equal jitter는 cap/2 + 0~cap/2 사이 랜덤이다. 결과를 보면 두 방식 모두 thundering herd를 막지만, Full jitter 쪽이 평균 재시도 횟수가 약간 더 적게 나오는 경향이 있다. 실무에서는 Full jitter가 단순해서 채택했다. :::
수학적으로 보면 Full jitter는 분포가 [0, exp]인 균등 분포다. 기댓값은 exp/2이라 평균 대기 시간은 절반으로 줄어든다. 백엔드 큐가 비워질 때까지의 총 시간은 비슷하지만, 큐가 가득 차서 추가 요청이 거부되는 일은 줄어든다.
넷째 주: Jitter를 넣고 알게 된 것
특히, 코드를 이렇게 바꿨다.
// utils/retry.ts
export const exponentialBackoffWithJitter = (
attemptIndex: number,
options: { base?: number; cap?: number } = {}
) => {
const { base = 1000, cap = 30000 } = options
// 지수 함수의 상한선을 구한다
const exponential = Math.min(base * 2 ** attemptIndex, cap)
// 0 ~ exponential 사이 랜덤 (Full jitter)
return Math.floor(Math.random() * exponential)
}
// QueryClient에 적용
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: (failureCount, error: any) => {
const status = error?.response?.status
if (status >= 400 && status < 500) return false
return failureCount < 4
},
retryDelay: exponentialBackoffWithJitter,
},
},
})
Math.random() 한 줄이 단순해 보여도, 이거 하나로 트래픽 그래프 모양이 완전히 바뀌었다. 배포 다음 모니터링에서 톱니가 사라지고 완만한 언덕이 됐다.
또한, 다만 한 가지 함정이 있었다. 첫 시도(failureCount = 0)에서 retryDelay가 0~1,000ms 사이 랜덤이면, 운 나쁘면 5ms 뒤에 재시도가 들어간다. 503이 일시적인 게 아니라 백엔드가 실제로 죽은 상황이면 5ms는 너무 짧다. 그래서 floor를 추가했다.
export const exponentialBackoffWithJitter = (
attemptIndex: number,
options: { base?: number; cap?: number; floor?: number } = {}
) => {
const { base = 1000, cap = 30000, floor = 200 } = options
const exponential = Math.min(base * 2 ** attemptIndex, cap)
const jittered = Math.floor(Math.random() * exponential)
// 너무 빠른 재시도를 막는다
return Math.max(jittered, floor)
}
그러나, floor를 200ms로 두니 시뮬레이션을 다시 돌려도 서버가 살아있었다. 200이라는 숫자가 절대값은 아니다. 백엔드 헬스체크 인터벌이 100ms라 그 두 배 정도면 충분하다고 판단했다.
따라서, 추가로 cap도 조정했다. 30초는 너무 길다. 사용자가 30초 동안 빈 화면을 보면 그냥 새로고침을 한다. 8초로 줄였더니 평균 retry 시간이 14.3초에서 4.2초로 떨어졌다(자체 측정, 1주일 평균).
retryDelay: (attemptIndex) =>
exponentialBackoffWithJitter(attemptIndex, {
base: 1000,
cap: 8000,
floor: 200,
}),
테스트는 vitest로 돌렸다. Math.random()을 모킹하면 결정론적으로 검증할 수 있다.
import { describe, it, expect, vi } from 'vitest'
import { exponentialBackoffWithJitter } from './retry'
describe('exponentialBackoffWithJitter', () => {
it('floor 미만으로 떨어지지 않는다', () => {
vi.spyOn(Math, 'random').mockReturnValue(0)
const delay = exponentialBackoffWithJitter(0, { floor: 200 })
expect(delay).toBe(200)
})
it('cap을 넘지 않는다', () => {
vi.spyOn(Math, 'random').mockReturnValue(0.99)
const delay = exponentialBackoffWithJitter(10, { cap: 8000 })
expect(delay).toBeLessThanOrEqual(8000)
})
})
한 달 차: 운영 중 터진 의외의 문제
코드는 잘 굴러갔다. 한 달쯤 지났을 때 사용자 한 분이 "결제가 멈춘 것 같은데 30초 기다려야 화면이 풀린다"고 신고했다.
그런데, 원인은 cap을 8초로 줄이기 전에 배포된 버전이었다. cap이 30초였을 때 4번째 재시도에서 8초, 5번째에서 16초, 6번째에서 30초까지 기다린다. 합치면 최악의 경우 60초가 넘는다. 사용자는 그동안 빈 화면을 본다.
실제로, cap만 줄이는 걸로는 부족했다. 사용자에게 "지금 재시도 중"이라는 사실을 알려야 했다. React Query의 failureCount를 UI에 노출하기로 했다.
// hooks/useCheckout.ts
import { useQuery } from '@tanstack/react-query'
export function useCheckout(orderId: string) {
return useQuery({
queryKey: ['checkout', orderId],
queryFn: () => fetchCheckout(orderId),
retry: 3,
retryDelay: (attempt) =>
exponentialBackoffWithJitter(attempt, { cap: 8000 }),
})
}
// 컴포넌트
function CheckoutPage({ orderId }: { orderId: string }) {
const { data, isPending, isError, failureCount, error } =
useCheckout(orderId)
if (isPending && failureCount > 0) {
// 재시도 중이라는 사실을 사용자에게 보여준다
return (
<RetryNotice
count={failureCount}
message={`서버 응답 지연으로 재시도 중 (${failureCount}/3)`}
/>
)
}
if (isError) return <ErrorView error={error} />
return <CheckoutContent data={data} />
}
failureCount를 노출하면 사용자가 "재시도 중"이라는 걸 인지한다. 이것만으로 CS 문의가 첫 달 대비 약 40% 줄었다(자체 집계, 1개월 비교).
그러나, 부수 효과로 모니터링도 깔끔해졌다. 어떤 페이지에서 재시도가 자주 일어나는지 백엔드 메트릭과 무관하게 클라이언트에서 측정 가능해졌다. failureCount > 0인 사용자 비율을 시간당 그래프로 그리면, 배포 직후 spike가 보인다. 5xx가 백엔드 메트릭에 잡히기 전에 클라이언트가 먼저 안다.
두 달 차: Mutation에는 왜 retry를 끄게 됐나
게다가, Query는 idempotent라 재시도가 안전하다. Mutation은 다르다. 결제 승인 같은 mutation을 자동 재시도하면 중복 결제가 발생한다.
이건 백엔드로 옮긴 뒤에 가장 빨리 캐치한 부분이다. 프론트만 했을 때는 "그냥 다시 호출하면 되지"라고 생각했다. 백엔드 코드를 짜본 뒤에는 idempotency key가 없는 mutation은 재시도하면 안 된다는 게 본능에 가까워졌다.
// QueryClient 기본값에서 mutation은 retry 0
defaultOptions: {
queries: { /* 위와 동일 */ },
mutations: {
retry: 0,
},
},
특정 mutation만 retry를 켜고 싶으면 idempotency key를 헤더에 실어야 한다. Stripe API의 Idempotent Requests 문서가 이 패턴을 가장 잘 정리한다.
// hooks/useApprovePayment.ts
import { useMutation } from '@tanstack/react-query'
import { useMemo } from 'react'
import { v4 as uuidv4 } from 'uuid'
export function useApprovePayment(orderId: string) {
// 마운트 시점에 한 번만 생성한다
const idempotencyKey = useMemo(
() => `approve-${orderId}-${uuidv4()}`,
[orderId]
)
return useMutation({
mutationFn: async (params: ApproveParams) => {
return approvePayment({
...params,
headers: { 'Idempotency-Key': idempotencyKey },
})
},
retry: (failureCount, error: any) => {
// 네트워크 에러만 1회 재시도
if (error?.code === 'ERR_NETWORK' && failureCount < 1) return true
// 504도 안전하다 (서버는 응답을 못 보냈을 뿐)
if (error?.response?.status === 504 && failureCount < 1) return true
return false
},
retryDelay: 500,
})
}
useMemo로 idempotency key를 마운트 시점에 한 번만 생성하는 게 핵심이다. 매 호출마다 새로 만들면 중복 결제 방지 효과가 사라진다. 서버는 같은 key로 들어온 요청을 한 번만 처리하고, 두 번째 호출에는 첫 번째 응답을 그대로 돌려줘야 한다.
예를 들어, 503이나 502에서 재시도를 끈 점도 시행착오 끝에 정한 거다. 503은 "서버가 받았는지 모른다"는 상태라, 결제 같은 mutation에서는 차라리 사용자에게 다시 누를 수 있는 버튼을 보여주는 게 안전했다. 504만 예외로 둔 이유는, 504는 정의상 "프록시가 백엔드 응답을 못 받음"이라 백엔드는 보통 요청 자체를 받지도 못한 상태이기 때문이다.
마지막 주: 다음 프로젝트라면 이렇게
3개월의 결과로 정착된 react query retry 재시도 전략은 이렇다.
- Query는 retry 4회 + exponential backoff + Full jitter + floor 200ms + cap 8s
- 4xx는 모든 retry에서 제외, 5xx 중 502/503/504만 재시도
- Mutation은 기본 retry 0, idempotency key가 있을 때만 504/네트워크 에러에 한해 1회 재시도
- failureCount를 UI에 노출해 사용자에게 상태 전달
지금 와서 보면 첫 주에 503이 뜨자마자 retry 설정부터 봤어야 했다. 처음엔 백엔드 스케일아웃부터 만졌다. 클라이언트 retry가 백엔드 부하를 증폭시킨다는 감각이 부족했던 거다. 프론트엔드만 하던 시절엔 잘 안 보이는 부분이라 그렇다.
당장 할 수 있는 액션은 세 가지다. 첫째, 지금 운영 중인 React Query 설정에 retryDelay가 명시되어 있는지 확인한다. 없으면 기본값 그대로 쓰는 중이고, jitter가 없는 상태다. 둘째, mutation의 retry가 0인지 확인한다. 기본값이긴 하지만 어디선가 켜뒀을 가능성이 있다. 셋째, failureCount를 로딩 UI에 노출해본다. 코드 수정량 대비 사용자 경험 개선이 가장 크다.
결국, 다음 프로젝트에선 retry 횟수 자체를 메트릭으로 수집할 생각이다. React Query에는 query별 onError 훅이 있어 거기서 카운터를 올리면 된다. 503이 늘어날 때 백엔드 메트릭보다 먼저 알림을 받고 싶다.
그러나, 다만 이 메트릭 수집 부분은 운영 환경 검증이 아직 끝나지 않았다. 4xx와 5xx를 sampling으로 어떻게 분리할지, 카디널리티 폭발은 어떻게 막을지는 retry 재시도 전략 너머의 영역이라 더 지켜봐야 한다.
관련 글
- Redis 캐시 전략 비교 — LRU·LFU·allkeys로 maxmemory 다루기 – 프론트엔드에서 백엔드로 넘어온 지 2년. Redis OOM 에러를 만나고서야 maxmemory-policy를 제대로 본다. LRU와 LFU…
- PostgreSQL 커넥션 풀 설정 회고: PgBouncer 3개월 운영 기록 – PostgreSQL 커넥션 풀 설정으로 PgBouncer를 도입했다가 Transaction 모드에서 prepared statement 충돌…
- Docker Compose 헬스체크 설정 완벽 가이드 — 의존성 순서 해결 – depends_on만 쓰면 DB가 준비되기도 전에 API가 먼저 뜬다. Docker Compose 헬스체크 설정과 service_healt…