장애가 난 대상에 대한 호출을 일정 기간 아예 차단해 연쇄 장애를 막는 패턴. 전기 회로의 두꺼비집에서 이름을 따 왔다.
무엇을 막는가 — 연쇄 장애
결제 서비스가 느려짐 (응답 30초)
↓
주문 서비스의 스레드들이 결제 응답을 기다리며 쌓임
↓
주문 서비스의 스레드 풀 고갈 → 주문 서비스도 응답 불가
↓
주문을 호출하던 API 게이트웨이도 막힘
↓
서비스 전체 마비 ← 원인은 결제 하나였는데
한 곳의 장애가 전체로 번지는 것을 막는 것이 목적이다.
세 가지 상태
다이어그램 로딩 중…
| 상태 | 동작 |
|---|---|
| CLOSED (정상) | 요청을 그대로 통과시키며 실패율을 집계 |
| OPEN (차단) | 호출 자체를 하지 않고 즉시 실패하거나 폴백 반환 |
| HALF_OPEN (반개방) | 소수의 시험 호출만 보내 회복됐는지 확인 |
왜 즉시 실패가 나은가 — 직관과 반대되는 부분
"차단하면 그 기능을 못 쓰는 것 아닌가?" 맞다. 하지만
차단 안 함 : 30초 기다렸다가 실패 + 그동안 스레드 점유 + 전체로 번짐
- 차단함 — : 1ms 만에 실패 + 스레드 즉시 반납 + 다른 기능은 정상
어차피 실패할 호출이라면 빨리 실패하는 것이 자원을 지킨다. 이것을 fail-fast라 한다.
또 회복을 돕는다 — 죽어 가는 서비스에 요청을 계속 퍼부으면 회복할 여유조차 없다. 차단은 상대에게 숨 쉴 틈을 준다.
설정 값
CircuitBreakerConfig.custom()
.slidingWindowSize(100) // 최근 100건 기준
.failureRateThreshold(50) // 실패율 50% 넘으면 OPEN
.waitDurationInOpenState(ofSeconds(30)) // 30초 후 HALF_OPEN
.permittedNumberOfCallsInHalfOpenState(10)
.slowCallRateThreshold(50) // 느린 호출도 실패로 간주 ★
.slowCallDurationThreshold(ofSeconds(2))
.build();
★ 느린 호출을 실패로 세는 것이 중요하다. 에러를 안 내고 느리기만 한 서비스가 실제로는 더 위험하다 — 스레드를 붙잡아 두기 때문이다.
임계값 설정 감각
- 너무 민감하면 일시적 스파이크에 불필요하게 차단
- 너무 둔감하면 이미 번진 뒤에 차단 → 의미 없음
- 보통 실패율 50%, 최소 호출 수 20건 이상에서 시작해 조정
폴백 설계 — 차단했을 때 무엇을 줄 것인가
- 추천 목록 실패 — → 인기 상품 목록 (품질 저하, 서비스는 유지)
- 캐시 조회 실패 — → DB 직접 조회
- 결제 실패 — → 폴백 없음. 명확히 실패시켜야 한다 ★
★ 모든 것에 폴백이 있어야 하는 것은 아니다. 결제를 "성공한 척"하면 훨씬 큰 사고다. 부가 기능은 degrade, 핵심 기능은 fail이 원칙이다.
함께 쓰는 패턴
| 패턴 | 역할 |
|---|---|
| 타임아웃 | 서킷의 전제. 타임아웃이 없으면 실패를 감지조차 못 한다 |
| 재시도 + 백오프 | 일시적 실패 흡수. 단, 서킷이 OPEN이면 재시도하지 않는다 |
| 벌크헤드 | 자원 자체를 격리해 전파를 물리적으로 차단 |
재시도와 서킷을 함께 쓸 때 순서가 중요하다 — 재시도가 서킷 안쪽에 있으면 실패 1건이 3건으로 부풀려져 서킷이 과도하게 열린다. 보통 서킷 → 재시도 순으로 감싼다.