이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- Resilience4j를 Spring 프로젝트에 넣을 때 설정값을 어떤 요구사항에서 끌어와야 하는가?
- TimeLimiter, CircuitBreaker, Bulkhead, Retry, Fallback을 코드 경계에서 어떻게 분리해야 하는가?
- 운영 가능한 설정인지 테스트와 metric으로 어떻게 확인하는가?
실전 예시의 목적
Resilience4j 실전 설계는 annotation을 붙이는 일이 아니다.
요구사항, 설정, 코드 위치, fallback 응답, metric, 테스트가 서로 맞는지 확인하는 작업이다.
예시는 주문 상세 API가 배송 provider를 호출하는 Spring 서비스다.
목표는 배송 provider 장애가 주문 상세 전체 장애로 번지지 않게 하는 것이다.
배송 위치는 5분 정도 오래되어도 되지만, 주문 상태와 결제 상태는 최신이어야 한다.
따라서 Resilience4j 정책은 배송 client 경계에만 둔다.
주문 DB 조회나 결제 상태 검증을 같은 fallback 정책으로 묶지 않는다.
요구사항에서 설정으로
설정값은 샘플을 복사해서 정하지 않는다.
먼저 다음 표를 채운다.
| 항목 | 배송 조회 기준 |
|---|---|
| 사용자 API 목표 | 주문 상세 p95 500ms |
| 배송 조회 예산 | 150ms, 최악 800ms 이내 |
| fallback 가능 여부 | 최근 배송 위치 또는 일시 불가 |
| retry 안전성 | 조회는 가능하지만 주문 응답 안에서는 제한 |
| 격리 대상 | 배송 provider connection과 동시 실행 수 |
| 사용자 표현 | shipping.status=DEGRADED |
| 운영 지표 | timeout, circuit open, bulkhead full, fallback reason |
이 표가 있어야 timeout-duration, slow-call-duration-threshold, max-concurrent-calls를 설명할 수 있다.
숫자가 정확하지 않아도 가정과 검증 방법을 남겨야 한다.
설정 예시
resilience4j:
timelimiter:
instances:
shippingClient:
timeout-duration: 800ms
circuitbreaker:
instances:
shippingClient:
sliding-window-type: count_based
sliding-window-size: 100
minimum-number-of-calls: 30
failure-rate-threshold: 50
slow-call-rate-threshold: 60
slow-call-duration-threshold: 800ms
wait-duration-in-open-state: 30s
permitted-number-of-calls-in-half-open-state: 5
bulkhead:
instances:
shippingClient:
max-concurrent-calls: 30
max-wait-duration: 0max-wait-duration: 0은 bulkhead가 꽉 찼을 때 기다리지 않고 바로 축소 응답으로 전환한다는 뜻이다.
배송 위치처럼 부가 정보라면 대기보다 빠른 부분 응답이 낫다.
결제 승인 같은 명령 경로에는 같은 설정을 그대로 쓰면 안 된다.
조합 책임
각 장치는 실패의 다른 면을 맡는다.
| 장치 | 맡는 책임 | 잘못 쓰면 생기는 문제 |
|---|---|---|
| TimeLimiter | 호출 대기 시간 상한 | 너무 길면 스레드를 오래 잡는다. |
| CircuitBreaker | 반복 실패와 느린 호출 차단 | fallback 계약이 없으면 단순 실패가 된다. |
| Bulkhead | 동시 실행 수 제한 | pool이 작으면 정상 트래픽도 거절한다. |
| Retry | 일시 실패 재시도 | provider 호출량을 증폭한다. |
| Fallback | 사용자에게 축소 상태 표현 | 가짜 성공을 만들 수 있다. |
Retry는 기본값으로 켜지 않는다.
주문 상세 화면 안의 배송 조회는 사용자가 기다리는 경로이므로, retry보다 빠른 degradation이 더 적합할 수 있다.
코드 경계
정책은 보통 외부 의존성 client에 둔다.
Service 전체에 annotation을 붙이면 어떤 provider가 실패했는지, 어떤 fallback이 실행됐는지 흐려진다.
@Component
class ShippingGateway {
@CircuitBreaker(name = "shippingClient", fallbackMethod = "onProviderFailure")
@Bulkhead(name = "shippingClient", fallbackMethod = "onBulkheadFull")
ShippingSnapshot fetch(String orderId) {
return shippingProvider.fetch(orderId);
}
ShippingSnapshot onProviderFailure(String orderId, Throwable cause) {
return ShippingSnapshot.degraded(orderId, "provider_failure");
}
ShippingSnapshot onBulkheadFull(String orderId, BulkheadFullException cause) {
return ShippingSnapshot.degraded(orderId, "bulkhead_full");
}
}fallback reason을 나누면 운영자는 provider 장애와 내부 리소스 보호 동작을 구분할 수 있다.
같은 DEGRADED라도 원인이 timeout인지, circuit open인지, bulkhead full인지에 따라 대응이 다르다.
API 응답으로 드러내기
Service는 fallback을 정상 데이터처럼 감추지 않는다.
{
"orderId": "ord-1001",
"orderStatus": "PAID",
"shipping": {
"status": "DEGRADED",
"reason": "bulkhead_full",
"retryable": true,
"lastKnownAt": "2026-07-01T09:30:00+09:00"
}
}HTTP 200이어도 완전한 성공은 아니다.
클라이언트는 배송 영역만 축소 렌더링하고, 주문 상태를 실패로 표시하지 않아야 한다.
API 문서에는 DEGRADED, UNAVAILABLE, STALE 같은 상태를 명시한다.
Metric 연결
Resilience4j metric은 사용자 영향 지표와 같이 본다.
| 지표 | 해석 |
|---|---|
resilience4j.circuitbreaker.state | breaker 상태 변화 |
not_permitted_calls | open 상태로 차단된 호출 |
slow_call_rate | 느린 성공 비율 |
bulkhead_available_concurrent_calls | 남은 동시 실행 여유 |
bulkhead_rejected_calls | bulkhead full 거절 |
fallback_reason_count | 사용자 축소 경험 원인 |
not_permitted_calls가 늘면 provider 호출을 막고 있는 것이다.
bulkhead_rejected_calls가 늘면 격리된 리소스가 꽉 찬 것이다.
둘 다 HTTP 200 fallback으로 보일 수 있으므로 reason별 집계가 필요하다.
테스트 시나리오
운영 가능한 설정은 실패를 재현해야 확인된다.
1. provider가 5초 지연된다.
2. ShippingGateway timeout 또는 slow call 기록이 증가한다.
3. threshold 이후 circuit이 open된다.
4. open 상태에서는 provider mock 호출 수가 늘지 않는다.
5. bulkhead 동시 실행 수를 넘기면 bulkhead_full fallback이 반환된다.
6. API 응답에는 shipping.status=DEGRADED가 남는다.테스트는 성공 path보다 장애 path가 중요하다.
설정 파일만 있으면 실제 annotation 순서, fallback method signature, metric 노출을 보장하지 못한다.
배포 전 체크
- Actuator metric에서 breaker와 bulkhead 지표가 보이는가?
- fallback reason이 로그와 API 응답에 모두 남는가?
- provider 장애와 bulkhead full alert가 다른가?
- retry가 provider quota를 넘기지 않는가?
- 설정 변경 시 rollback 방법이 있는가?
- 장애 중 운영자가 shipping 기능만 끌 수 있는가?
이 체크는 운영 복잡도를 늘리지만 장애 시 의사결정을 빠르게 만든다.
특히 공통 starter로 Resilience4j를 적용할 때도 서비스별 예외 정책은 문서에 남겨야 한다.
위험 신호
- latency 목표 없이 샘플 설정을 그대로 쓴다.
- annotation을 service 전체에 붙여 실패 의존성이 흐려진다.
- timeout, retry, breaker, bulkhead가 같은 실패를 중복 처리한다.
- fallback method가 모든 예외를 같은 응답으로 감춘다.
- metric은 있지만 사용자 축소 경험과 연결하지 않는다.
- 장애 path 테스트 없이 정상 호출 테스트만 있다.
- 운영자가 설정을 바꿔도 어떤 지표가 회복되어야 하는지 모른다.
확인 질문
확인 질문
- 확인 질문: Resilience4j 설정값은 어디에서 출발해야 하는가?
- 답변: 사용자 latency 목표, provider 지연 분포, fallback 가능 여부, retry 안전성, 격리할 리소스에서 출발해야 한다.
- 확인 질문: 정책을 Service 전체가 아니라 client 경계에 두는 이유는 무엇인가?
- 답변: 어떤 외부 의존성의 실패를 격리하는지 명확히 하고 fallback reason과 metric을 정확히 남기기 위해서다.
- 확인 질문:
not_permitted_calls와bulkhead_rejected_calls를 구분해야 하는 이유는 무엇인가?
- 답변: 전자는 circuit open으로 호출이 차단된 것이고, 후자는 동시 실행 격리 리소스가 가득 찬 것이어서 대응이 다르기 때문이다.