서킷 브레이커 패턴: 언제 열리고 닫히나, 설정 값은 어떻게 고르나
서킷 브레이커(circuit breaker)는 원격 호출을 감싸 두었다가, 실패가 정해 둔 기준을 넘으면 상대 서비스를 아예 부르지 않고 곧바로 에러나 대체 응답을 돌려주는 장치다. Java 라이브러리 Resilience4j 를 기본값으로 쓰면 최근 100번의 호출 중 절반이 실패할 때 서킷이 열리고, 1분이 지나면 시험 호출로 복구 여부를 가린다. 상태 기계 자체는 단순하다. 실제로 손이 가는 곳은 무엇을 실패로 셀지, 열렸을 때 무엇을 돌려줄지, 서버 여러 대가 상태를 어떻게 함께 볼지 쪽이다.
01 타임아웃만 걸어 두면 멈춘 서비스가 호출한 쪽까지 끌어내린다
원격 호출은 메모리 안의 함수 호출과 다르다. 실패하기도 하고, 성공도 실패도 아닌 채 타임아웃이 끝날 때까지 붙잡혀 있기도 한다. Martin Fowler 는 2014년 bliki 글에서 이렇게 설명한다. 응답하지 않는 서비스 하나에 호출자가 여럿 매달리면 꼭 필요한 자원이 바닥나고, 그 여파가 여러 시스템으로 번진다. 이런 연쇄 장애(cascading failure)를 막는 방법으로 Michael Nygard 가 책 『Release It!』에서 널리 알린 것이 서킷 브레이커다.
Microsoft 의 Azure Architecture Center 문서는 타임아웃이 답이 되지 못하는 이유를 한 걸음 더 짚는다. 타임아웃이 끝나기 전까지는 같은 작업으로 들어온 요청들이 메모리·스레드·DB 커넥션을 쥐고 있다. 그래서 같은 자원을 쓰는, 상관없는 기능까지 함께 실패할 수 있다. 화해 기술 블로그도 같은 상황을 예로 든다. MSA 로 나뉜 User Service 가 Order Service 에서 주문 정보를 받아 온다고 가정하면, Order Service 의 장애가 User Service 를 거쳐 사용자에게 그대로 전해진다.
흔한 첫 대응인 재시도는 여기서 도움이 되지 않는다. Azure 문서는 오류마다 재시도하면 복구 중인 상대에 부하만 더 얹는다고 보고, 두 패턴의 목적을 나눈다. 재시도는 결국 성공하리라 보고 다시 부르는 것이고, 서킷 브레이커는 실패할 가능성이 큰 호출을 아예 하지 않게 하는 것이다. 둘을 함께 쓴다면 재시도가 서킷 브레이커를 거쳐 호출하게 두고, 서킷이 일시적 장애가 아니라고 알리면 재시도를 멈춰야 한다.
이 글은 애플리케이션 코드 안에 넣는 서킷 브레이커를 다룬다. 서비스 메시의 사이드카가 대신 끊어 주는 방식은 범위 밖이다.
02 세 상태는 실패 기준과 대기 시간 두 값으로 넘어간다
서킷 브레이커는 상태 셋을 오가는 상태 기계이고, 이름과 동작은 전기 차단기에서 따왔다.
| 상태 | 들어온 호출 | 다음 상태로 가는 조건 |
|---|---|---|
| 닫힘(Closed) | 상대에게 보내고 결과를 센다 | 실패가 기준에 닿으면 열림 |
| 열림(Open) | 보내지 않고 바로 에러를 낸다 | 대기 시간이 지나면 반열림 |
| 반열림(Half-Open) | 시험 호출만 보낸다 | 시험을 통과하면 닫힘, 실패하면 다시 열림 |
열림에서 반열림으로 가는 대기 시간은 고정값이 아니어도 된다. Azure 문서는 처음엔 몇 초만 열어 두고, 문제가 이어지면 몇 분으로 늘리는 방식을 예로 든다. 화해는 밑이 5인 지수 백오프로 대기 시간을 늘리되 180초에서 멈추게 했다. 같은 Azure 문서는 양 끝의 대가도 적었다. 너무 오래 열어 두면 원인이 사라진 뒤에도 에러를 내고, 너무 빨리 반열림으로 돌리면 상태가 출렁이면서 응답 시간이 나빠진다.
반열림에서 무엇을 통과로 볼지는 구현마다 다르다.
- Fowler 의 예제 코드는 시험 호출 한 번이 성공하면 닫고, 실패하면 대기 시간을 처음부터 다시 잰다.
- Azure 문서는 정해 둔 횟수만큼 연속으로 성공해야 닫고, 그사이 하나라도 실패하면 바로 다시 연다.
- Resilience4j 는
permittedNumberOfCallsInHalfOpenState(기본 10)만큼만 들여보내고, 그 결과의 실패율이 기준 아래여야 닫는다. - Python 라이브러리 pybreaker 는
success_threshold(기본 1)번 성공하면 닫고, 시험 호출이 한 번이라도 실패하면 연다.
Azure 문서는 반열림을 두는 이유로 막 살아난 서비스에 요청이 한꺼번에 몰려 다시 쓰러지는 경우를 든다. 화해는 다른 위험에 초점을 맞췄다. 반열림 동안 무작위로 호출해 실패율을 구하는 방식도 검토했지만, 상대가 살아난 뒤에도 닫힘 전환이 늦어지면 그동안 사용자가 잘못된 정보를 보거나 장애를 겪는다고 판단했다. 그래서 화해 구현은 반열림에서 한 번이라도 성공하면 닫고, 실패율이 반열림용 기준인 30%를 넘으면 다시 연다. 상대 서비스가 갑작스러운 부하에 약하다면 깐깐한 쪽이 낫고, 복구 직후 사용자가 겪는 공백이 더 아프다면 화해의 방식이 맞는다.
03 무엇을 실패로 셀지가 설정의 절반이다
닫힘 상태에서 실패를 세는 방식은 크게 둘이다. 가장 단순한 쪽은 연속 실패 횟수로, Fowler 의 기본 예제와 pybreaker 가 이 방식을 쓴다. pybreaker 소스를 보면 fail_max(기본 5)번 연속으로 실패하면 열리고, 중간에 성공이 한 번 끼면 카운터가 0으로 돌아간다. 시간 창은 따로 없다. Fowler 는 더 정교한 대안으로 실패율(그의 예는 50%)을 기준으로 삼는 방식을 든다.
실패율을 쓰려면 "최근" 이 어디까지인지 정하는 슬라이딩 윈도우가 있어야 한다. Resilience4j 공식 문서는 창을 두 가지로 나눈다.
| 창 종류 | 담는 것 | 메모리 |
|---|---|---|
| COUNT_BASED | 최근 N번 호출의 결과 하나하나(원형 배열) | N에 비례 |
| TIME_BASED | 최근 N초를 1초짜리 버킷으로 나눠 센 실패·느린 호출·전체 호출 수 | 거의 일정 |
화해는 내부 통신에서는 실시간 트래픽을 보는 편이 맞다고 보고 시간 기반 창을 골랐다. 예시 설정은 300초 창에 실패율 기준 80%다. 창과 짝을 이루는 값이 최소 호출 수다. Resilience4j 의 minimumNumberOfCalls 는 창 안에 기록된 호출이 이 수보다 적으면 실패율을 아예 계산하지 않는다. 공식 문서의 예로는, 이 값이 10이면 처음 9번이 모두 실패해도 서킷이 닫혀 있다.
화해도 최소 호출 수를 두었다. 창 안에서 실패가 순간적으로 치솟을 때 서킷이 너무 민감하게 열리지 않도록 지수 백오프와 함께 건 장치다. 예시 값은 호스트 단위 900, API 단위 300이다. 에러 없이 느려지기만 하는 상대를 끊는 기준도 있다. Resilience4j 는 slowCallDurationThreshold 보다 오래 걸린 호출을 느린 호출로 세고, 그 비율이 slowCallRateThreshold 에 닿으면 연다. 기본값은 각각 60초와 100%다. 창 안의 호출이 하나도 빠짐없이 그 시간을 넘겨야 열린다는 뜻이니, 느린 호출로 끊고 싶다면 두 값을 직접 낮춰야 한다.
어떤 예외를 셀지도 정해야 한다. Fowler 는 모든 에러가 차단 사유는 아니라고 짚는다. 일반 로직이 처리하는 정상적인 실패도 있고, 에러 종류마다 기준을 달리 둘 수도 있다(그의 예는 타임아웃 10번, 연결 실패 3번). 반대로 Azure 문서는 실패 응답에 "몇 분 뒤에 다시" 같은 정보가 담겨 있으면 곧바로 열어도 된다고 본다. 예시 아키텍처에서도 Cosmos DB 가 429(요청 과다)를 처음 돌려주는 순간 서킷을 연다.
| 구현 | 실패로 세는 것 | 세지 않는 것 |
|---|---|---|
| Resilience4j | 기본은 모든 예외, recordExceptions 를 정하면 그 목록만 | ignoreExceptions(성공도 실패도 아님) |
| pybreaker | exclude 에 걸리지 않은 모든 예외 | exclude 에 넣은 예외 클래스나 판별 함수 |
| 화해 자체 구현 | 기본으로 모든 Exception 과 HTTP 501~511 | 글에 따로 적지 않음 |
여기에 함정이 둘 있다. Resilience4j 에서 recordExceptions 를 지정하면 목록에 없는 예외는 성공으로 센다. pybreaker 는 exclude 에 걸린 예외를 성공과 같은 경로로 처리하므로, 연속 실패 카운터까지 0으로 돌아간다.
04 라이브러리로 붙이면 감쌀 함수와 실패 기준, 대체 응답만 적으면 된다
Python: pybreaker
pybreaker 는 pip install pybreaker 로 설치하며, README 기준 Python 3.10 이상이 필요하다. 아래는 앞에서 본 User Service → Order Service 호출을 감싼 예다. README 예시는 판별 함수로 500 미만 HTTP 에러를 실패 집계에서 뺀다. 여기서는 상태 코드를 직접 보고 예외 클래스를 나눴다.
import pybreaker
import redis
import requests
class UpstreamError(Exception): ... # 5xx: 실패로 센다
class ClientError(Exception): ... # 4xx: 실패로 세지 않는다
order_breaker = pybreaker.CircuitBreaker(
fail_max=5,
reset_timeout=60, # 초
exclude=[ClientError],
state_storage=pybreaker.CircuitRedisStorage(
pybreaker.STATE_CLOSED, redis.StrictRedis(), namespace="order-api"
),
)@order_breaker
def fetch_orders(user_id):
resp = requests.get("http://order-service/orders",
params={"user": user_id}, timeout=2)
if resp.status_code >= 500:
raise UpstreamError(resp.status_code)
if resp.status_code >= 400:
raise ClientError(resp.status_code)
return resp.json()
def my_orders(user_id):
try:
return fetch_orders(user_id)
except pybreaker.CircuitBreakerError:
return [] # 열림: Order Service 를 부르지 않고 빈 목록timeout=2 는 서킷보다 먼저 일한다. 서버가 2초 동안 아무 바이트도 보내지 않으면 requests 가 Timeout 을 던지고, 서킷은 이를 실패로 센다. 이 값은 응답 전체를 받는 시간의 상한이 아니어서, 조금씩 끊어 오는 느린 응답은 2초를 넘겨도 끊기지 않는다. 연속 실패가 fail_max 에 닿는 그 호출과 열린 동안 들어온 호출은 CircuitBreakerError 로 올라온다. 기준 아래의 실패는 원래 예외가 그대로 올라오니 호출부에서 따로 받아야 한다. CircuitRedisStorage 를 넘기면 상태를 Redis 에 저장한다. README 는 서킷을 여럿 둘 때 namespace 를 서로 다르게 주고, Redis 클라이언트를 decode_responses=True 로 만들지 말라고 안내한다.
Spring Boot: Resilience4j
Spring Boot 3 에서는 resilience4j-spring-boot3 에 Actuator·AOP 스타터를 함께 넣는다. 바꿀 값을 고르려면 기본값부터 알아야 한다. 아래 표는 2026년 11월 공식 문서 기준이다.
| 설정 | 기본값 | 뜻 |
|---|---|---|
failureRateThreshold | 50% | 이 실패율에 닿으면 연다 |
slidingWindowType | COUNT_BASED | 창을 호출 수로 셀지, 시간으로 셀지 |
slidingWindowSize | 100 | 창 크기(호출 수 또는 초) |
minimumNumberOfCalls | 100 | 실패율 계산에 필요한 최소 호출 수 |
slowCallDurationThreshold | 60초 | 이보다 길면 느린 호출 |
slowCallRateThreshold | 100% | 느린 호출 비율이 여기 닿으면 연다 |
waitDurationInOpenState | 60초 | 열림에서 반열림까지 대기 |
permittedNumberOfCallsInHalfOpenState | 10 | 반열림 시험 호출 수 |
automaticTransitionFromOpenToHalfOpenEnabled | false | false 면 대기 뒤 다음 호출이 올 때 반열림으로 바뀐다 |
기본 창은 호출 100번 단위다. 호출이 드문 API 라면 시간 기반 창에 최소 호출 수를 작게 두는 편이 맞는다. 아래 설정은 최근 60초 동안 20번 이상 호출됐을 때만 실패율을 따지고, 서버 오류·타임아웃·I/O 예외만 실패로 센다. 대기 시간은 Spring Boot 의 기간 표기(30s)로 적었다.
resilience4j.circuitbreaker:
configs:
default:
slidingWindowType: TIME_BASED
slidingWindowSize: 60 # 최근 60초
minimumNumberOfCalls: 20
failureRateThreshold: 50
waitDurationInOpenState: 30s
permittedNumberOfCallsInHalfOpenState: 3
registerHealthIndicator: true
recordExceptions:
- org.springframework.web.client.HttpServerErrorException
- java.util.concurrent.TimeoutException
- java.io.IOException
instances:
orderService:
baseConfig: default
management.health.circuitbreakers.enabled: true@CircuitBreaker(name = "orderService", fallbackMethod = "emptyOrders")
public List<Order> myOrders(long userId) {
return orderClient.fetch(userId);
}
// 같은 클래스, 같은 시그니처 + 예외 파라미터 하나
private List<Order> emptyOrders(long userId, CallNotPermittedException e) {
return List.of();
}fallbackMethod 가 가리키는 메서드는 같은 클래스에 있어야 한다. 원래 메서드와 시그니처가 같고, 끝에 예외 파라미터 하나가 더 붙는 형태다. 대체 메서드가 여럿이면 던져진 예외에 가장 가까운 타입을 받는 메서드가 불린다. 상태는 Actuator 로 본다. /actuator/circuitbreakers 는 서킷 이름 목록을, /actuator/circuitbreakerevents 는 최근 이벤트를 보여 준다.
헬스 지표의 상태 표기는 문서와 코드가 다르다. 공식 문서는 닫힘을 UP, 열림을 DOWN, 반열림을 UNKNOWN 으로 적었다. 그런데 2026년 11월 기준 Spring Boot 3 모듈 소스를 보면, allowHealthIndicatorToFail 이 기본값 false 일 때 열림은 CIRCUIT_OPEN, 반열림은 CIRCUIT_HALF_OPEN 이라는 별도 상태로 나온다. 열림을 DOWN 으로 받아 알림에 걸고 싶다면 인스턴스 설정에서 이 값을 true 로 켜야 한다.
05 열렸을 때 돌려줄 값은 기능마다 따로 정한다
서킷이 열리면 호출한 쪽은 기다리지 않고 바로 실패를 받는다. 그 실패에 어떻게 응답할지는 Fowler 의 말대로 여전히 호출한 쪽이 정해야 한다. 그가 드는 선택지는 셋이다. 작업을 그대로 실패시키거나, 결제 승인처럼 큐에 넣어 두고 나중에 처리하거나, 최신은 아니어도 쓸 만한 이전 데이터를 보여 준다. Azure 문서는 기능을 잠시 줄이기, 같은 데이터를 다른 경로로 얻기, 사용자에게 알리고 나중에 다시 시도하게 하기를 더한다. 열린 동안 들어온 요청을 기록해 두었다가 상대가 살아나면 다시 보내는 방법도 같은 문서에 있다.
화해는 예외를 잡아 빈 응답 객체(예시는 쿠폰 목록)를 돌려주는 방식을 보였고, 빈 값을 줄지 더미를 줄지는 서비스 정책으로 정하라고 했다. 실제 장애에서 이 방식이 버틴 기록도 남겼다. ML 서빙 API 서버에서 쓰지 않는 RDS 를 지우면서 헬스체크 로직은 함께 지우지 않아 헬스체크가 실패했고, 그 결과 ECS Task 가 내려갔다. 평균 지연이 1,000ms 를 넘긴 구간은 09:59~10:07이었다.
그 사이 서킷은 열렸다가 반열림을 거쳐 닫혔다. 화해는 대체 응답 덕분에 API 응답 실패율이 0%였다고 밝혔다. 한 회사가 겪은 한 번의 장애에서 나온 수치라는 점은 감안하고 읽는 편이 낫다.
빈 목록은 편하지만, 화면에서는 "주문이 없다" 로 읽힌다. 추천이나 쿠폰처럼 비어 있어도 사용자가 손해 보지 않는 기능이라면 빈 값으로 충분하다. 잔액이나 결제 상태처럼 틀린 값이 더 위험한 기능이라면, 실패를 그대로 알리거나 큐로 미루는 편이 낫다.
운영 쪽에서 챙길 것도 Fowler 가 정리해 두었다. 상태가 바뀔 때마다 로그를 남기고, 상태를 모니터링에 드러내고, 운영자가 손으로 서킷을 열고 닫을 수 있게 해 두라는 것이다. 서킷이 열렸다는 사실 자체를 더 깊은 문제의 조기 경보로 보라는 말도 덧붙인다. Resilience4j 에는 이를 위한 특수 상태가 있다. 늘 거부하는 FORCED_OPEN, 늘 통과시키고 지표를 남기지 않는 DISABLED, 늘 통과시키되 지표는 남기는 METRICS_ONLY 다. pybreaker 는 open()·close()·half_open() 메서드와, 상태 변화를 받는 리스너를 제공한다.
06 서버가 여러 대면 상태를 둘 곳과 나눌 단위를 정해야 한다
Resilience4j 는 서킷 상태를 인스턴스 안의 AtomicReference 에 두고, 레지스트리는 같은 이름을 물으면 같은 인스턴스를 돌려준다. 한 JVM 안에서는 이름이 같은 서킷이 상태를 나눠 쓴다는 뜻이다. 공식 문서는 JVM 끼리 상태를 나누는 방법을 다루지 않는다. 서버 프로세스가 여럿이라면 각자 자기 창에 쌓인 호출만 보고 따로 열고 닫는다고 전제하고 설정하는 편이 맞다.
화해는 이 점을 문제로 봤다. 로컬 메모리는 쓰기 쉽지만, 프로세스·인스턴스마다 상태가 달라져 서킷이 일관되지 않게 움직일 수 있다는 것이다. 그래서 상태를 Redis 로 모았다. 여러 곳에서 동시에 카운트를 올릴 때 생기는 경쟁 조건은 Redis 의 INCR 이 원자적으로 동작한다는 점으로 막았다. Redis 가 단일 장애 지점이 되는 문제는 고가용성 구성으로 대응한다고 적었다.
부하 테스트에서는 적용 전후 TPS 차이가 거의 없었고, 평균 150 TPS 에서 Redis CPU 사용률이 4%를 넘지 않았다. 다만 화해 스스로 로컬 테스트 환경이 스레드 제한 탓에 TPS 가 낮았다고 밝혔다. 트래픽이 훨씬 많은 환경에 이 수치를 그대로 옮기기는 어렵다. 상태를 Redis 에 두면 모든 호출에 Redis 왕복이 붙는다는 점도 함께 따져야 한다.
나눌 단위도 정해야 한다. 화해는 호스트 단위(per_host)와 API 엔드포인트 단위(per_method) 중에서 고르게 했고, 둘 다 설정하면 호스트 단위가 적용된다. Azure 문서는 단위를 너무 크게 잡았을 때의 문제를 샤드로 설명한다. 샤드가 여러 개인 저장소를 서킷 하나로 묶으면 멀쩡한 샤드까지 막히고, 망가진 샤드로는 요청이 계속 간다.
서킷 브레이커가 동시에 나가는 호출 수까지 줄여 주지는 않는다. Resilience4j 문서에 따르면 창 크기가 15여도 닫힘 상태에서는 스레드 20개가 동시에 호출할 수 있고, 동시 호출을 묶으려면 Bulkhead 를 함께 써야 한다. Fowler 가 소개한 Netflix 의 Hystrix 도 서킷 브레이커에 스레드 풀 한도를 묶은 구조였다.
07 작은 팀이라면 타임아웃과 라이브러리 기본값에서 출발한다
먼저 직접 만들지 않는 편이 낫다. Hystrix 저장소는 더 이상 개발하지 않는 유지보수 모드라고 밝혔고, Netflix 는 새 사내 프로젝트에 resilience4j 같은 활발한 프로젝트를 쓰며 다른 곳에도 같은 선택을 권하기 시작했다고 적었다. JVM 이라면 Resilience4j, Python 이라면 pybreaker 로 시작하면 된다. 화해가 직접 만든 데는 사정이 있었다. Python 백엔드라 Java 라이브러리를 쓸 수 없었고, Python 오픈소스는 GitHub 스타 수가 적어 선뜻 도입을 결정하기 어려웠다. 실패율·시간 창·인스턴스 간 공유 상태가 모두 필요하다면 pybreaker 의 연속 실패 방식으로는 모자랄 수 있으니, 그때 직접 구현을 따져 보면 된다. 라이브러리로 시작하면 되돌리기도 쉽다. 데코레이터나 애너테이션 한 줄과 설정 블록을 지우면 원래 호출로 돌아간다.
서킷 브레이커보다 타임아웃이 먼저다. Azure 문서는 외부 서비스의 타임아웃이 길면 서킷이 실패를 알아채기 전에 스레드가 오래 묶인다고 경고한다. 그 사이 다른 인스턴스들도 같은 서비스를 부르며 스레드를 잡는다. 같은 문서는 타임아웃을 짧게 잡되, 대부분의 호출이 성공할 만큼은 길게 두라고 한다.
트래픽이 적은 내부 API 라면 최소 호출 수부터 의심한다. 기본값 100은 창 안에 호출이 100번 차기 전에는 실패율을 계산하지 않는다는 뜻이다. 그래서 호출이 드문 API 는 장애 내내 서킷이 닫혀 있을 수 있다. 임계값을 정하기 전에 METRICS_ONLY 상태로 실제 실패율과 호출량을 먼저 보는 방법도 있다.
붙이기 전에 호출마다 네 가지를 확인한다.
- 이 호출에 타임아웃이 걸려 있고, 그 값이 상대의 평소 응답 시간에 맞나.
- 4xx 응답이나 비즈니스 예외가 실패로 세어지고 있지 않나.
- 창 길이 안에 들어오는 실제 호출 수가 최소 호출 수를 넘나.
- 서킷이 열리면 이 화면이나 API 는 무엇을 돌려주나.
붙이지 않아도 되는 경우도 있다. Azure 문서는 메모리 안 자료 구조 같은 로컬 자원에 접근할 때, 비즈니스 로직의 예외 처리를 대신하려 할 때를 든다. 실패한 메시지를 dead letter queue 로 보내는 메시지·이벤트 기반 구조, 로드 밸런서 헬스체크나 서비스 메시가 복구를 맡는 환경도 여기에 들어간다. 도입을 결정하는 입장이라면 기준은 하나로 충분하다. 서비스 사이에 동기 호출이 있고 그 상대가 멈췄을 때 우리 응답까지 멈춘 적이 있다면 도입할 이유가 있다. 그런 적이 없다면 타임아웃 정비가 먼저다.
08 자주 묻는 질문
4xx 응답도 실패로 세야 하나?
아니오, 대개는 세지 않는다. pybreaker README 예시도 500 미만 HTTP 에러를 exclude 로 빼고, Fowler 도 일반 로직이 처리하는 에러는 차단 사유에서 빼라고 한다. 다만 429 처럼 상대가 과부하를 알리는 응답은 Azure 예시처럼 서킷을 여는 신호로 쓸 수 있다.
서비스 메시가 있으면 코드에는 넣지 않아도 되나?
예, 끊는 일 자체는 메시에 맡길 수 있다. Azure 문서는 인프라가 복구를 맡는 환경을 이 패턴이 맞지 않는 경우로 든다. 다만 서킷이 열렸을 때 사용자에게 무엇을 보여 줄지는 여전히 호출한 쪽 코드가 정해야 한다.