[Spring Boot 프로덕션] Day 4: 외부 연동과 복원력 - 실패를 전제로 설계하기
이 글은 AI(Claude)의 도움을 받아 작성하고, 작성자가 검토·편집했습니다.
서론: 네트워크 호출은 성공·실패 둘 중 하나가 아니다
Day 3에서 애플리케이션과 DB 사이의 대기열을 다뤘다. 외부 API는 더 불확실하다. 요청이 상대에게 도착하지 않았을 수도 있고, 처리는 성공했지만 응답만 유실됐을 수도 있다. 따라서 타임아웃은 “실패했다”가 아니라 결과를 아직 모른다는 뜻일 수 있다.
복원력은 실패를 없애는 기술이 아니다. 실패가 전체 시스템으로 번지는 범위를 제한하고, 중복 없이 복구할 수 있게 만드는 설계다.
1. 모든 원격 호출에 시간 예산을 둔다
1
2
3
4
5
6
클라이언트 제한 2초
└─ 우리 API 예산 1.8초
├─ 인증 100ms
├─ DB 300ms
├─ 결제 API 800ms
└─ 응답 여유 600ms
연결 수립과 응답 읽기는 서로 다른 대기다. 둘 다 상한이 필요하다.
1
2
3
4
5
6
7
8
9
10
11
@Bean
RestClient paymentRestClient(RestClient.Builder builder) {
return builder
.baseUrl("https://payments.internal")
.defaultStatusHandler(
HttpStatusCode::is5xxServerError,
(request, response) -> {
throw new PaymentUnavailableException();
})
.build();
}
위 클라이언트에는 실제 운영에서 사용하는 하위 HTTP 라이브러리 수준의 연결·읽기 타임아웃을 함께 설정한다. 상위 요청의 남은 시간보다 내부 호출 타임아웃이 길어서는 안 된다.
2. 재시도는 조건이 맞을 때만 한다
재시도는 일시 장애를 숨겨주지만, 잘못 쓰면 장애 중인 서버에 트래픽을 더하는 증폭기가 된다.
1
2
3
4
5
6
7
8
재시도 후보:
연결 실패, 일부 5xx, 429, 짧은 네트워크 흔들림
재시도하지 않을 것:
입력 오류인 4xx, 인증 실패, 명백한 비즈니스 거절
필수 조건:
호출이 멱등하거나 멱등성 키로 중복을 막을 수 있어야 함
1
2
3
고정 간격: 100ms, 100ms, 100ms
지수 백오프: 100ms, 200ms, 400ms
지터 포함: 87ms, 231ms, 356ms
여러 인스턴스가 동시에 같은 간격으로 재시도하면 상대가 회복하는 순간 다시 몰려든다. 지수 백오프와 무작위 지터로 재시도 시점을 흩뜨리고, 횟수와 전체 시간에 상한을 둔다.
3. 서킷 브레이커로 실패를 빠르게 격리하기
상대 시스템이 계속 실패하는데 매 요청마다 타임아웃까지 기다릴 이유는 없다.
1
2
3
4
5
6
7
CLOSED: 정상 호출, 실패율 측정
↓ 임계 초과
OPEN: 원격 호출 없이 즉시 실패 또는 폴백
↓ 대기 시간 경과
HALF_OPEN: 소수 시험 호출
├─ 성공 → CLOSED
└─ 실패 → OPEN
서킷 브레이커는 상대 시스템을 보호하는 동시에 우리 요청 스레드와 커넥션 풀도 보호한다. 다만 OPEN 상태의 폴백이 오래된 가격이나 잘못된 권한처럼 더 위험한 결과를 만들지 않는지 업무적으로 판단해야 한다. 안전한 폴백이 없으면 명확히 실패하는 것이 낫다.
4. Bulkhead: 자원을 기능별로 나누기
한 외부 시스템의 지연이 공용 스레드 풀을 모두 차지하면 다른 기능까지 멈춘다. 선박의 격벽(bulkhead)처럼 동시 실행 자원을 분리한다.
1
2
3
4
5
결제 호출: 최대 동시 20개
배송 조회: 최대 동시 10개
알림 전송: 비동기 워커 5개
→ 배송 장애가 결제 처리 자원을 고갈시키지 않음
격리는 스레드 풀뿐 아니라 세마포어, 커넥션 풀, 큐, 인스턴스 분리로도 구현한다. 핵심은 한 의존성의 대기가 전체 자원을 독점하지 못하게 하는 것이다.
5. 멱등성 키로 “한 번만”의 환상을 다루기
결제 요청 후 응답이 유실되면 클라이언트는 재시도한다. 서버가 매번 새 결제로 처리하면 이중 결제가 발생한다.
1
2
POST /payments
Idempotency-Key: order-20260820-1234
1
2
3
4
5
서버 처리:
1. 멱등성 키를 유일 제약과 함께 저장
2. 처음 본 키면 결제를 처리하고 결과 기록
3. 이미 처리한 키면 저장된 같은 결과 반환
4. 처리 중인 키면 상태 조회 또는 재시도 가능 응답
1
2
CREATE UNIQUE INDEX uk_payment_idempotency
ON payment_request(idempotency_key);
메모리 캐시만으로 중복을 막으면 재시작·다중 인스턴스·캐시 만료 때 깨진다. 비즈니스에 중요한 멱등성은 공유 영속 저장소의 유일성으로 보장한다.
6. 동기 호출과 비동기 메시지를 구분하기
사용자가 즉시 결과를 알아야 하지 않는 작업은 메시지로 분리할 수 있다.
1
2
3
4
5
6
7
동기 호출:
요청자가 지금 결과를 알아야 함
예: 로그인, 결제 승인 결과
비동기 메시지:
나중에 처리해도 됨, 트래픽 완충이 중요
예: 이메일, 통계 집계, 검색 인덱싱
1
2
3
4
5
6
7
@KafkaListener(topics = "order-placed")
@Transactional
public void handle(OrderPlaced event) {
// 유일 제약이 있는 INSERT. 이미 처리한 ID면 0을 반환한다.
if (processedEventRepository.insertIfAbsent(event.eventId()) == 0) return;
orderProjection.apply(event); // 처리 표식과 같은 DB 트랜잭션에서 반영
}
브로커는 같은 메시지를 다시 전달할 수 있다고 가정한다. 소비자는 이벤트 ID, 업무 키, 상태 전이의 조건부 UPDATE 등으로 중복 처리에 안전해야 한다. 외부 알림처럼 로컬 트랜잭션에 묶을 수 없는 효과는 이벤트 ID를 멱등성 키로 함께 전달해 수신 측에서도 중복을 막는다.
7. 실패 메시지를 운영 가능한 상태로 남기기
무한 재시도는 독성 메시지 하나로 파티션을 멈추게 한다.
1
2
3
4
5
처리 실패
→ 제한된 횟수만 백오프 재시도
→ 계속 실패하면 DLQ(Dead Letter Queue) 이동
→ 원인·페이로드·시도 횟수·마지막 예외 기록
→ 수정 후 안전하게 재처리
DLQ는 쓰레기통이 아니라 운영 대기열이다. 쌓이는 양에 알림이 있어야 하고, 개인정보 마스킹·보존 기간·재처리 도구가 함께 있어야 한다.
8. 복원력 패턴의 적용 순서
1
2
3
4
5
6
1. 타임아웃: 무한 대기를 끊는다.
2. 멱등성: 재실행해도 안전하게 만든다.
3. 제한된 재시도: 일시 실패를 흡수한다.
4. 서킷 브레이커: 지속 장애를 빠르게 격리한다.
5. Bulkhead: 자원 고갈의 전파를 막는다.
6. 메트릭·알림: 패턴이 실제로 작동하는지 본다.
순서를 건너뛰고 재시도부터 넣으면 중복과 부하가 커진다. 복원력 패턴은 각각의 라이브러리 옵션이 아니라 함께 맞물리는 실패 정책이다.
9. Day 4 체크리스트
- 모든 외부 호출에 상위 요청보다 짧은 시간 예산을 배분했다.
- 일시 오류만, 멱등성이 보장될 때 제한적으로 재시도했다.
- 서킷 브레이커와 Bulkhead로 지속 장애와 자원 고갈을 격리했다.
- 영속 멱등성 키와 중복 소비 방어로 재실행을 안전하게 만들었다.
- DLQ를 알림·원인 추적·재처리가 가능한 운영 대기열로 설계했다.
다음 편 예고
경계를 세우고 실패를 격리했어도 실제 배포에서 이를 증명하고 관찰할 수 있어야 한다. 마지막 Day 5에서는 테스트·관측성·배포 — 테스트 피라미드, Actuator, 로그·메트릭·트레이스, 헬스 프로브와 안전한 릴리스로 시리즈를 완성한다.