서킷브레이커를 도입하고 나서 슬라이딩 윈도우 타입에 대한 설정값들이 헷갈렸다.
COUNT_BASED의 경우 사실 직관적이지만 TIME_BASED의 경우 딱 와닿지 않는 것 같다.
Resilience4j를 기준으로 두 가지 윈도우 타입의 내부 동작 원리를 자세히 살펴보고, 어떤 상황에서 무엇을 골라야 하는지 정리해보려고 한다.
슬라이딩 윈도우란?
서킷브레이커는 세 가지 상태를 가진다.
- CLOSED: 정상 상태 (모든 요청이 통과한다.)
- OPEN: 차단 상태 (요청을 즉시 실패시킨다.)
- HALF_OPEN: 시험 상태 (일부 요청만 허용해서 회복 여부를 판단한다.)
이때 CLOSED 상태에서 OPEN으로 전환할지를 결정하는 핵심 매커니즘이 바로 슬라이딩 윈도우다.
윈도우는 두 가지 일을 한다.
- 기록: 매 호출의 결과(성공/실패/느림)를 저장한다.
- 집계: 윈도우 안의 호출들로 실패율(failureRate)과 느린 호출 비율(slowCallRate)을 계산해서 임계값과 비교한다.
집계는 minimumNumberOfCalls (평가를 시작하기 위한 최소 호출 수)를 충족시킬 때만 시작한다.
예를 들어서 slidingWindowSize: 100, minimumNumberOfCalls: 20인 경우 20번의 호출부터 failureRateThreshold를 계산한다.
슬라이딩 윈도우 타입에는 두 가지 방식이 있다: COUNT_BASED, TIME_BASED.
COUNT_BASED

내부 동작
slidingWindowSize=N으로 설정하면 내부적으로 길이 N의 원형 배열(circular array)이 생성된다.
각 슬롯에는 한 건의 호출 결과(성공/실패, 소요시간, 느림 여부)가 저장된다.
[호출1] [호출2] [호출3] ... [호출N]
↑
index
동작 방식
호출이 들어올 때마다 인덱스가 (index + 1) % N으로 회전하며 슬롯을 덮어쓴다.
N+1번째 호출은 1번째 호출이 있던 자리를 차지하고, 결과적으로 항상 가장 최근 N개의 호출만 메모리에 남는다.
size=5 일 때
호출 1~5: [F][S][S][F][F] index=0 → 5
호출 6: [S][S][S][F][F] 호출1 자리에 호출6 덮어씀
호출 7: [S][F][S][F][F] 호출2 자리에 호출7 덮어씀
실패율은 논리적으로는 윈도우 전체 호출 기준으로 계산되지만, Resilience4j는 매번 전체 배열을 순회하지 않고 aggregate counter를 증감시키는 방식으로 최적화한다.
단점: 시간 개념이 없다
이 방식의 가장 큰 단점은 시간 개념이 전혀 없다는 것이다.
즉 같은 설정값이라도 트래픽 양에 따라 윈도우가 커버하는 시간이 완전히 달라진다.
slidingWindowSize가 100인 경우:
- 초당 1000건의 트래픽 → 0.1초를 커버
- 시간당 10건의 트래픽 → 10시간을 커버
즉, 시간과 상관없이 해당 CircuitBreakerStateMachine은 계속 카운트 집계를 들고 있어야 한다.
트래픽이 일정하고 충분히 많은 서비스나 메모리 사용량을 정확히 예측해야 하는 환경에 적합할 것 같다.
TIME_BASED

내부 동작
slidingWindowSize=N으로 설정하면 이건 N초를 의미하며, 내부적으로는 초 단위 partial aggregation bucket을 circular array 형태로 유지한다.
각 버킷은 1초 단위로 다음 값들을 집계한다.
- 총 호출 수
- 실패 수
- 느린 호출 수
- 누적 응답 시간
개별 호출은 저장하지 않고, 1초 단위로 카운터만 누적한다 (이게 COUNT_BASED와 가장 큰 차이).
slidingWindowSize=60 일 때 (61개 버킷)
[0초][1초][2초]...[59초]
현재 →
동작 방식
호출이 들어오면 현재 시각 기준 버킷을 찾아 그 버킷의 카운터를 증가시킨다.
1초가 흐르면 가장 오래된 버킷을 0으로 리셋하고 새 버킷으로 사용한다 (N+1 슬롯인 이유).
실패율 계산은 현재 시각 기준 윈도우 안의 모든 버킷 카운터를 합산하고, 윈도우 바깥으로 빠진 버킷은 자동으로 제외한다.
트래픽이 끊기면 자동으로 비워진다
COUNT_BASED와 결정적으로 다른 차이점은 트래픽이 끊기면 자동으로 비워진다는 점이다.
호출이 없으면 시간이 흐를수록 버킷들이 차례로 리셋된다.
N초 동안 호출이 한 건도 없으면 모든 버킷이 0이 되고, minimumNumberOfCalls를 만족하지 못해 평가 자체가 일어나지 않는다.
실패율이 누적되어 그대로 살아있는 COUNT_BASED와 결정적으로 다른 지점이다.
하지만 윈도우가 비워진다고 해서 OPEN이 자동으로 풀리는 건 아니다.
슬라이딩 윈도우는 CLOSED → OPEN 상태 전환에만 개입하기 때문에, 이미 OPEN 상태가 된 서킷브레이커는 waitDurationInOpenState가 지나야 HALF_OPEN으로 바뀌고, permittedNumberOfCallsInHalfOpenState 결과로 CLOSED 전환이 결정된다.
즉 윈도우가 비워진다는 건 상태 변경 카운트가 리셋된다는 의미일 뿐, 서킷의 상태가 변하는 건 아니다.
메모리 효율
COUNT_BASED size=1000 → 최근 1000개 호출에 대한 결과 슬롯 유지
TIME_BASED size=60 → 버킷 61개만 저장 (그 60초간 호출이 100만 건이어도)
트래픽이 시간대별로 다르거나 (야간/새벽 트래픽이 거의 없는 시간대) 있는 환경에 적절한 방법이다.
시나리오로 보는 차이
설정: failureRateThreshold=50%, minimumNumberOfCalls=10
시나리오: 12:00~12:05 사이에 10건의 호출이 모두 실패한 뒤, 12:30까지 호출이 한 건도 없다가 12:30에 새 호출이 들어옴.
COUNT_BASED (size=10)
12:30 시점 윈도우 상태: [F][F][F][F][F][F][F][F][F][F]
실패율: 100%
→ minimumNumberOfCalls(10) 충족, 임계값(50%) 초과
→ 새 호출이 들어오는 순간 OPEN으로 전환
30분 전 장애가 현재 판정에 그대로 영향을 준다.
TIME_BASED (size=60)
12:06 시점: 12:05 이전 버킷들이 모두 윈도우 밖으로 빠짐
12:30 시점 윈도우 상태: 모든 버킷이 0
호출 수: 0건
→ minimumNumberOfCalls(10) 미달
→ 평가 자체가 일어나지 않음, CLOSED 유지
핵심 비교표
| 항목 | COUNT_BASED | TIME_BASED |
|---|---|---|
slidingWindowSize의 의미 |
호출 개수 (N건) | 시간 (N초) |
| 내부 자료구조 | 길이 N의 원형 배열 | 길이 N+1의 1초 단위 버킷 |
| 저장 단위 | 개별 호출 결과 | 1초당 집계값 |
| 메모리 사용량 | 호출 수에 비례 | 윈도우 시간에 비례 (호출 수 무관) |
| 트래픽이 끊기면 | 옛 데이터가 계속 유지됨 | 시간이 흐르면 자동으로 비워짐 |
| 시간 개념 | 없음 | 있음 |
| 적합한 환경 | 트래픽이 일정하고 많음 | 트래픽 변동이 크거나 적음 |
운영 트래픽에 맞춰 동적 변경
운영 환경에서는 시간대별 트래픽 패턴에 따라 CircuitBreaker 설정을 다르게 가져가고 싶어질 때가 있을 것 같다.
예를 들어 저녁 피크 시간에는 짧은 윈도우로 빠르게 장애를 감지하고, 새벽 시간(트래픽이 낮은 시간)에는 너무 민감하게 OPEN 되지 않도록 더 긴 윈도우를 사용하고 싶은 경우
다만 런타임에 윈도우 타입(COUNT_BASED ↔ TIME_BASED) 자체를 변경하는 건 사실상 어렵다. (내부 자료구조가 다르기 때문)
- COUNT_BASED → 최근 N개 호출 기반 circular array
- TIME_BASED → 초 단위 partial aggregation bucket
반면 같은 타입을 유지한 채로 설정값은 운영 중 동적으로 조정할 수 있다.
- slidingWindowSize
- minimumNumberOfCalls
- failureRateThreshold
CircuitBreakerConfig peakConfig =
CircuitBreakerConfig.custom()
.slidingWindowType(SlidingWindowType.TIME_BASED)
.slidingWindowSize(30)
.minimumNumberOfCalls(20)
.failureRateThreshold(50)
.build();
CircuitBreaker newCircuitBreaker =
CircuitBreaker.of("ceph-api", peakConfig);
circuitBreakerRegistry.replace(
"ceph-api",
newCircuitBreaker
);
@Scheduled(cron = "0 0 18 * * *") // 매일 저녁 6시
public void switchToPeakConfig() {
circuitBreakerRegistry.replace("ceph-api", buildPeakCircuitBreaker());
}
@Scheduled(cron = "0 0 2 * * *") // 매일 새벽 2시
public void switchToOffPeakConfig() {
circuitBreakerRegistry.replace("ceph-api", buildOffPeakCircuitBreaker());
}
멀티 인스턴스 환경에서는 각 애플리케이션 인스턴스가 독립적인 CircuitBreaker 상태를 가지기 때문에, 설정 교체 역시 각 인스턴스별로 개별 적용된다.
마무리
정리하자면, 트래픽이 24시간 안정적이라면 COUNT_BASED도 괜찮지만, 시간대별 편차가 있는 서비스라면 거의 무조건 TIME_BASED를 선택하는 게 안전하다.
옛날 장애 흔적이 오늘까지 따라오는 사고를 만들지 않으려면 말이다.
'dev > Spring' 카테고리의 다른 글
| 테스트 더블 정리 (Dummy, Fake, Stub, Spy, Mock) (0) | 2026.05.26 |
|---|---|
| [SpringMVC] 요청 매핑, API 요청 매핑 (0) | 2022.11.23 |
| 스프링 컨테이너와 스프링 빈 (1) | 2022.11.05 |
| [Spring] @ResponseBody 어노테이션 (0) | 2022.06.07 |
| [Spring] Spring의 컨텍스트? (0) | 2021.07.12 |