재시도 폭주 — 지수 백오프·지터·재시도 예산으로 막기
DB 가 몇 초 느려졌을 뿐인데 서비스가 무너지고, 원인이 사라진 뒤에도 회복이 안 돼요. 「실패하면 세 번 재시도」라는 상식적인 설정이 어떻게 장애를 키우는지 보여 드려요.
DB 가 잠깐 느려졌을 뿐인데 서비스가 완전히 무너졌어요. 트래픽은 평소와 같았고, 각 서비스는 "실패하면 세 번 재시도"라는 지극히 상식적인 설정만 갖고 있었어요.
이상한 건 회복이 안 된다는 점이에요. 원인이었던 DB 지연은 몇 초 만에 정상으로 돌아왔는데, 서비스는 계속 죽어 있어요.
서버를 재시작해도 뜨자마자 다시 죽어요. 쌓여 있던 요청과 재시도가 한꺼번에 몰리거든요. 이쯤 되면 원인과 증상이 완전히 분리된 상태입니다.
원인을 없앴는데도 장애가 계속되는 상태예요. 이 시점에는 이미 원인과 증상이 분리돼 있고, 회복 로직 자체가 새로운 원인이 돼 있습니다.
왜 그런가
재시도는 계층마다 곱해져요.
요청 하나가 게이트웨이, API, DB 클라이언트 세 계층을 지난다고 해 볼게요. 각 계층이 세 번씩 재시도하면 3 × 3 × 3 = 27 번이에요. 네 번씩이면 64 번이고요. 맨 아래 DB 는 이미 힘든 상태인데 27 배의 요청을 받습니다.
이게 회복 로직이 스스로에게 가하는 공격이에요.
그리고 되먹임 고리가 만들어져요. DB 가 느려지면 → 타임아웃이 나고 → 재시도가 늘고 → 부하가 커져서 DB 가 더 느려지고 → 다시 타임아웃이 늘어요. 원인이 사라져도 이 고리는 스스로 돌아가요. 그래서 회복이 안 되는 거예요.
재시작해도 안 되는 이유도 여기 있어요. 뜨자마자 쌓여 있던 요청과 재시도가 한꺼번에 몰려서 다시 무너져요.
또 하나 놓치기 쉬운 게 있어요. 재시도는 실패한 요청만 늘리는 게 아니라 성공할 뻔한 요청도 죽여요. 재시도 트래픽이 자원을 차지하니, 원래 잘 처리됐을 요청까지 대기하다 타임아웃이 나요. 유효 처리량이 감소하는 구간에 들어가는 거예요.
앞서 본 낙관적 잠금의 재시도 폭주와 같은 구조예요. 실패한 작업이 자원을 쓰고 버려지는 것.
코드로 보면
곱셈을 끊고 예산을 둔다:
GW(3회) → API(3회) → DB 클라이언트(3회)
3 × 3 × 3 = 27 회
· 재시도는 한 계층에서만
GW: 재시도 없음 (실패를 그대로 전파)
API: 3회 + 지수 백오프 + full jitter
DB 클라이언트: 재시도 없음
· 그리고 총량에 상한을 둔다
retry_budget = 정상 트래픽의 10~20%
토큰 버킷으로 관리. 넘으면 재시도하지 않고 즉시 실패
· 백오프에 지터가 없으면 같은 시각에 다시 몰린다
sleep(2^n)
sleep(random(0, 2^n)) -- full jitter✕ 문제 지점 · ✓ 해법 · 그대로 돌려 보기 전에 자기 환경에 맞게 고쳐 주세요.
실전에서는
처방의 핵심은 셋이에요.
첫째, 곱셈을 끊는다. 재시도는 한 계층에서만 해요. 어느 계층인지는 정책 결정이에요. 대개 사용자와 가까운 쪽보다 실패를 가장 잘 판단할 수 있는 쪽이 낫습니다. 그리고 아래층이 재시도한다면 위층은 안 해야 해요. 이걸 팀 간 계약으로 문서화해 두지 않으면 각자 "안전하게" 재시도를 넣으면서 곱셈이 생겨요.
둘째, 예산을 둔다. 구글 SRE 의 처방이에요. 재시도 총량에 상한을 두고, 넘으면 재시도하지 않고 즉시 실패시켜요. 프로세스당 분당 몇 회 같은 식으로요. 이게 "일부 요청이 떨어지는 용량 문제"와 "전면 연쇄 장애"를 가르는 차이라고 말합니다.
셋째, 지터를 섞는다. 지수 백오프만 쓰면 실패한 클라이언트들이 같은 간격으로 재시도해서 같은 시각에 다시 몰려요. 무작위를 섞어 흩어야 해요. 절반만 무작위로 하는 것보다 전체를 무작위 범위에서 뽑는 방식이 더 잘 흩어집니다.
여기에 하나 더 있어요. 재시도하면 안 되는 요청을 구분해야 해요. 멱등하지 않은 요청을 재시도하면 중복이 생겨요. 결제나 주문 생성이 대표적이에요. 앞서 본 멱등키가 여기서 필요해집니다.
그리고 무엇이 재시도할 만한 실패인지도 구분해야 해요. 연결 거부나 일시적 타임아웃은 재시도할 만하지만, 잘못된 요청이나 권한 오류는 몇 번을 해도 실패해요. 재시도할 수 없는 실패에 재시도하는 건 순수한 낭비입니다.
처방
- 재시도를 한 계층으로 몰고 나머지는 없애세요. 어느 계층인지 정하고 문서로 남기세요.
- 재시도 예산을 두세요. 정상 트래픽의 10~20% 를 넘으면 재시도를 멈추고 즉시 실패시키세요.
- 지수 백오프에 지터를 반드시 섞으세요. 없으면 재시도가 동기화되어 파도가 됩니다.
- 멱등하지 않은 요청은 재시도하지 마세요. 재시도하려면 멱등키를 먼저 설계하세요.
- 재시도할 만한 실패인지 판별하세요. 4xx 계열은 대개 재시도해도 소용없습니다.
- 서킷 브레이커와 함께 쓰세요. 계속 실패하면 아예 시도를 멈추는 장치가 있어야 되먹임 고리가 끊깁니다.
- 재시도 비율을 지표로 보세요. 이 값이 올라가는 게 장애의 가장 빠른 신호일 때가 많아요.
- 부하 테스트에 장애 주입을 넣으세요. 의존성이 느려졌을 때 재시도가 어떻게 증폭되는지는 그렇게만 확인됩니다.
- 타임아웃을 위로 갈수록 길게 잡으세요. 위층이 더 짧으면 아래층에 버려진 작업이 계속 쌓입니다.
지금 확인해 보세요
증폭이 일어나는지 본다:
· 계층별 요청 수를 비교한다 — 아래로 갈수록 커지면 증폭 중
sum(rate(gw_requests_total[1m]))
sum(rate(api_requests_total[1m]))
sum(rate(db_queries_total[1m]))
정상: 아래로 갈수록 비슷하거나 줄어든다
증폭: 아래로 갈수록 커진다
· 재시도 비율 — 장애의 가장 빠른 신호
sum(rate(http_retries_total[1m]))
/ sum(rate(http_requests_total[1m]))
· 안 보이는 재시도 찾기 — 코드가 아니라 설정을 본다
HTTP 클라이언트 · 클라우드 SDK · DB 드라이버
로드 밸런서 · 서비스 메시 사이드카
각각의 기본값을 실제로 확인한다
· 타임아웃이 위로 갈수록 긴지
위층이 더 짧으면 아래층에 버려진 작업이 쌓인다핵심 한 줄재시도는 한 층에서만. 그리고 예산을 정해라.