실패율이 임계치를 넘으면 아예 호출하지 않고 즉시 실패시키는 장치. 장애 전파를 끊는다.
왜 필요한가
결제 서비스가 느려졌다 (응답 30초)
- 주문 서비스의 스레드들이 거기 묶인다
- 주문 서비스의 스레드 풀이 마른다
- 주문과 무관한 API 까지 멈춘다
- 그 앞단도 멈춘다 (연쇄 장애)
한 서비스의 장애가 시스템 전체로 번진다
어차피 실패할 호출을 계속 시도하는 것이 문제다. 차라리 즉시 실패시키고 폴백을 주는 게 낫다.
세 가지 상태
CLOSED (정상)
호출을 통과시키며 실패율을 집계한다
↓ 실패율이 임계치(예: 50%)를 넘으면
OPEN (차단)
호출하지 않고 즉시 실패시킨다 → 폴백 실행
↓ 일정 시간(wait duration) 후
HALF_OPEN (탐색)
소수의 호출만 통과시켜 회복됐는지 본다
↓ 성공하면 CLOSED · 실패하면 다시 OPEN
@CircuitBreaker(name = "payment", fallbackMethod = "fallback")
@TimeLimiter(name = "payment")
PaymentResult pay(Order o) { return paymentClient.pay(o); }
PaymentResult fallback(Order o, Throwable t) {
return PaymentResult.pending(); // 큐에 넣고 나중에 처리
}
resilience4j.circuitbreaker.instances.payment:
slidingWindowSize: 100
failureRateThreshold: 50 # 50% 실패하면 OPEN
waitDurationInOpenState: 10s
permittedNumberOfCallsInHalfOpenState: 10
minimumNumberOfCalls: 20 # 이만큼 모여야 판단한다 (초기 오탐 방지)
minimumNumberOfCalls가 중요하다 — 없으면 첫 호출 하나가 실패했다고 회로가 열린다.
서킷 브레이커 하나로는 부족하다
- ① 타임아웃 — 가장 먼저. 무한히 기다리지 않는다 (이게 없으면 나머지가 무의미)
- ② 재시도 — 일시적 실패에만. 지수 백오프 + 지터
- ③ 서킷 브레이커 — 반복 실패하면 아예 차단
- ④ 벌크헤드 — 한 의존성의 폭주를 격리 (동시 호출 수 제한)
- ⑤ rate limiter — 과부하 자체를 막는다
재시도에 지터가 필요한 이유
고정 간격으로 재시도하면
- 실패한 클라이언트들이 '동시에' 다시 몰린다 (thundering herd)
- 회복 중인 서비스를 다시 쓰러뜨린다
지수 백오프(1s, 2s, 4s) + 무작위 지터로 시각을 흩뿌린다
데코레이터 순서가 동작을 바꾼다
Retry( CircuitBreaker( TimeLimiter( 호출 ) ) )
- 회로가 열려도 재시도한다 (대개 원하지 않는 동작)
CircuitBreaker( Retry( TimeLimiter( 호출 ) ) )
- 재시도까지 다 실패한 것을 '한 번의 실패' 로 집계한다 (권장)
Resilience4j의 기본 순서를 확인하지 않고 조합하면 의도와 다르게 동작한다.
면접 함정
- ❌ "서킷 브레이커만 있으면 된다" → 타임아웃이 없으면 회로가 열리기 전에 스레드가 먼저 마른다.
- ❌ "재시도하면 성공률이 오른다" → 상대가 과부하면 재시도가 부하를 키운다.