"API 호출이 실패했습니다. 0.1초 뒤에 다시 해보고, 또 실패하면 0.1초 뒤에 다시 해봤는데... 결국 서버가 트래픽 폭주로 완전히 뻗어버렸습니다."
일시적인 네트워크 오류(Transient Fault)는 클라우드 환경에서 필연적입니다. 그래서 우리는 **재시도(Retry)**를 합니다. 하지만 실패한 요청 수천 개가 **똑같은 시간에 동시에 재시도를 감행(Thundering Herd)**하면, 간신히 살아나려던 서버를 다시 짓밟는 꼴이 됩니다.
이를 막기 위한 두 가지 재시도 전략을 비교해 봅니다.

1. 방식 1: 고정 간격 재시도 (Fixed Backoff) - "꾸준함의 미학"
가장 단순한 방식입니다. 실패하면 **항상 정해진 시간(예: 1초)**만큼 기다렸다가 다시 시도합니다.
특징
- 일정함: 1번째 재시도도 1초 대기, 5번째 재시도도 1초 대기.
- 구현 용이성: 단순한 루프나 타이머로 구현 가능합니다.
코드 예시 (Spring Retry)
@Service
public class PaymentService {
// 최대 3번 시도, 매번 1초(1000ms) 간격으로 대기
@Retryable(
maxAttempts = 3,
backoff = @Backoff(delay = 1000)
)
public void pay() {
// 네트워크 불안정으로 가끔 실패하는 로직
externalBankApi.call();
}
}
치명적 문제점
서버가 과부하로 인해 응답을 못 하는 상황이라고 가정해 봅시다.
- 사용자 A, B, C가 동시에 요청 실패.
- 셋 다 정확히 1초 뒤에 동시에 재요청.
- 서버는 또다시 순간적인 부하를 견디지 못하고 실패.
- (무한 반복)
2. 방식 2: 지수 백오프 (Exponential Backoff) - "배려의 기술"
실패할 때마다 대기 시간을 지수적으로 늘리는($2^n$) 방식입니다. 여기에 **무작위성(Jitter)**을 더하는 것이 표준입니다.
특징
- 점진적 증가: 처음엔 1초, 그다음엔 2초, 4초, 8초... 뒤에 시도합니다.
- Jitter(지터): 정확히 2초가 아니라 2초 + 랜덤(0~100ms) 시간에 시도하여 요청을 분산시킵니다.
- 서버 보호: 서버가 복구될 시간을 충분히 벌어줍니다.
코드 예시 (Resilience4j / Java)
// 순수 자바 로직 예시 (개념 설명용)
public void callWithExponentialBackoff() {
int maxRetries = 5;
long waitTime = 1000; // 초기 대기 1초
for (int i = 0; i < maxRetries; i++) {
try {
externalApi.call();
return; // 성공 시 탈출
} catch (Exception e) {
// 지수 증가 (Multiplier: 2)
waitTime *= 2;
// Jitter 추가 (요청 분산을 위해 랜덤 시간 더함)
long jitter = (long) (Math.random() * 100);
System.out.println((waitTime + jitter) + "ms 뒤에 다시 시도합니다.");
Thread.sleep(waitTime + jitter);
}
}
throw new RuntimeException("결국 실패");
}
Spring Retry / Resilience4j 설정
resilience4j:
retry:
instances:
backendA:
maxRetryAttempts: 3
waitDuration: 500ms
enableExponentialBackoff: true # 지수 백오프 활성화
exponentialBackoffMultiplier: 2 # 2배씩 증가
장단점
- 장점: 요청이 분산되므로 서버 부하를 줄이고 시스템 안정성을 높입니다. 클라우드 네이티브 앱의 표준입니다.
- 단점: 사용자가 느끼는 응답 지연 시간(Latency)이 길어질 수 있습니다. (마지막 재시도는 10초 뒤일 수도 있음)
3. 실무 비교: 언제 무엇을 쓰는가?
| 구분 | Fixed Backoff (고정 간격) | Exponential Backoff (지수 간격) |
| 대기 시간 | 1초 -> 1초 -> 1초 | 1초 -> 2초 -> 4초 -> 8초 |
| 구현 난이도 | 낮음 | 중간 (알고리즘 또는 라이브러리 필요) |
| 서버 부하 | 높음 (동시 재시도 충돌 위험) | 낮음 (충분한 회복 시간 제공) |
| 사용자 경험 | 실패 여부를 빨리 알 수 있음 | 실패 확정까지 오래 걸림 |
| 추천 상황 | 사내 내부망, 충돌 가능성이 낮은 DB 조회 | 외부 API 호출, 클라우드 환경, 모바일 앱 |
결론
"단순히 DB 커넥션이 잠깐 튀었다" 정도라면 **고정 간격(Fixed)**으로 2~3번 빠르게 재시도하는 것이 낫습니다. 사용자를 오래 기다리게 할 필요가 없으니까요.
하지만 **"외부 결제 API를 호출하거나, 마이크로서비스 간 통신"**을 한다면 반드시 **지수 백오프(Exponential Backoff) + 지터(Jitter)**를 사용해야 합니다. 이것이 대규모 장애 상황에서 내 서비스와 상대방 서비스를 모두 살리는 **"우아한 재시도"**의 정석입니다.
주의: 재시도를 무한정 하면 안 됩니다. 일정 횟수 이상 실패하면 **서킷 브레이커(Circuit Breaker)**를 열어서 아예 요청을 차단해야 합니다. (재시도 패턴과 서킷 브레이커는 단짝 친구입니다.)
'프로그래밍 > 디자인패턴' 카테고리의 다른 글
| 벌크헤드 패턴(Bulkhead Pattern) 완벽 정리 (0) | 2025.12.07 |
|---|---|
| 서비스 디스커버리 패턴(Service Discovery) 완벽 정리 (0) | 2025.12.07 |
| 헬스 체크 패턴(Health Check Pattern) 완벽 정리 (0) | 2025.12.07 |
| 처리율 제한 패턴(Rate Limiter Pattern) 완벽 정리 (0) | 2025.12.07 |
| 트랜잭셔널 아웃박스 패턴(Transactional Outbox Pattern) 완벽 정리 (0) | 2025.12.07 |