Resilience4j: 기본값의 함정

Seoki·2026년 3월 20일

"기본값으로 시작하세요."
Resilience4j를 도입할 때 가장 많이 듣는 말이다.

하지만 기본값은 "권장값"이 아니다.
"당신은 아직 아무것도 결정하지 않았습니다"에 가깝다.


결제 시스템에 서킷브레이커를 달다

당신은 결제 팀에 합류한 지 한 달 된 개발자다.

PG사 장애로 서버가 통째로 멈춘 포스트모템에서 결론이 나왔다.
"Resilience4j 도입하자."
그 후 당신이 담당자가 됐다. 공식 문서를 읽었다. 생각보다 간단했다.

CircuitBreakerConfig.custom()
    .failureRateThreshold(50)
    .build();

테스트도 작성했다. 문제없다. PR 올리고, 코드 리뷰 통과, 배포.

금요일 오후, PG사가 또 죽었다.

서킷브레이커 상태: CLOSED.

10건, 20건, 50건. 실패가 쌓이는데 서킷은 꿈쩍도 하지 않는다.
당신이 단 서킷브레이커는, 처음부터 작동한 적이 없었다.

.failureRateThreshold(50) 이 한 줄 뒤에는 명시하지 않은 기본값들이 숨어 있었다.

서킷브레이커는 그저 지켜만 보고 있었다.

이건 시작일 뿐이다.

서킷이 열렸는데 닫히지 않는다.
멀쩡한 요청이 서킷을 연다.
재시도가 서킷을 죽인다.

전부 겪고 나서야 보였다.
함정들의 모양은 달랐지만, 패턴은 두 가지로 수렴했다.
모든 설정에 같은 두 가지 질문을 던졌더니,
대부분의 함정은 피할 수 있었다.


Part 1: 함정 이야기

이후 등장하는 설정값은 함정을 드러내기 위한 예시이며, 실무 최적값이 아니다.
최적값은 서비스의 트래픽, 인프라, 응답 시간 요구사항에 따라 달라진다는 점을 참고해주시면 좋겠다.

1장: 서킷이 안 열린다

함정: minimumNumberOfCalls 기본값 100

CircuitBreakerConfig.custom()
    .failureRateThreshold(50)
    .build();

이 설정에는 보이지 않는 기본값이 있다.

  • slidingWindowSize = 100
  • minimumNumberOfCalls = 100

서킷브레이커는 minimumNumberOfCalls만큼 호출이 쌓여야 비로소 실패율을 평가하기 시작한다. 기본값이 100이니까, 99건이 전부 실패해도 서킷은 열리지 않는다. 100번째 호출이 들어와야 "아, 실패율이 100%네?" 하고 OPEN으로 전환된다.

결제 시스템에서 100건이 실패하는 동안 서킷이 열리지 않는다.
그건 서킷브레이커가 없는 것과 다름없다.

해결

CircuitBreakerConfig.custom()
    .failureRateThreshold(50)
    .slidingWindowSize(10)
    .minimumNumberOfCalls(5) // 5건만 모이면 즉시 평가
    .build();

5건 실패 → 실패율 100% → 즉시 OPEN. 이제야 서킷브레이커다운 서킷브레이커.

실제 테스트 로그로 확인해보자.

// minimumNumberOfCalls = 100 (기본값) — 10건 실패해도 CLOSED
  ❌ [CB 실패] InternalServerError | 성공=0 실패=1
  ❌ [CB 실패] InternalServerError | 성공=0 실패=2
  ...
  ❌ [CB 실패] InternalServerError | 성공=0 실패=10
── CB 요약 ──────────────────────────
  상태: CLOSED | 실패율: -1.0%          ← 평가 자체가 안 됨
  성공: 0 | 실패: 10 | 차단: 0
─────────────────────────────────────

// minimumNumberOfCalls = 5 — 5건 실패로 즉시 OPEN
  ❌ [CB 실패] InternalServerError | 성공=0 실패=1
  ❌ [CB 실패] InternalServerError | 성공=0 실패=2
  ❌ [CB 실패] InternalServerError | 성공=0 실패=3
  ❌ [CB 실패] InternalServerError | 성공=0 실패=4
  ❌ [CB 실패] InternalServerError | 성공=0 실패=5
  📈 [CB 실패율 초과] failureRate=100.0%
  ⚡ [CB 상태 전이] CLOSED → OPEN
── CB 요약 ──────────────────────────
  상태: OPEN | 실패율: 100.0%           ← 즉시 열림
  성공: 0 | 실패: 5 | 차단: 0
─────────────────────────────────────

위 로그는 resilience4j-labCircuitBreakerTrapTest에서 Mock PG 서버에 실제 HTTP 요청을 보낸 결과다. 실패율 -1.0%은 Resilience4j가 "아직 평가할 데이터가 부족하다"는 의미로 반환하는 값이다. 10건이 실패해도 minimumNumberOfCalls(100)을 못 채우면, 서킷브레이커는 실패율 계산 자체를 하지 않는다.

minimumNumberOfCalls 기본값 100은 "100건이 실패해도 지켜보겠다"는 뜻이다.

원칙 1: 경계를 그어라 - 서킷브레이커에게 "언제부터 장애로 볼 것인지" 경계를 알려주지 않으면, 기본값이라는 엉뚱한 기준이 적용된다.


2장: 열렸는데 안 닫힌다

PG사 장애가 복구됐다. 5분이 지났다. 10분이 지났다.

서킷 상태: OPEN. 아직도 OPEN이다.

waitDurationInOpenState를 1초로 설정했는데? 1초 지나면 HALF_OPEN으로 가서 복구를 시도해야 하는 거 아닌가?

함정: automaticTransitionFromOpenToHalfOpenEnabled 기본값 false

기본값이 false라는 건, OPEN 상태에서 누군가 호출해줘야 HALF_OPEN으로 전이된다는 뜻이다. OPEN 상태에서 waitDurationInOpenState가 지나기 전까지는 모든 호출이 CallNotPermittedException으로 즉시 차단된다. waitDuration이 지난 후 들어온 요청이 HALF_OPEN 전이를 트리거한다.

결과: waitDuration이 지나도 아무도 호출하지 않으면, 서킷은 OPEN에 영원히 갇힌다.

실제로는 요청이 계속 들어오니까 언젠가는 전이되긴 한다. 하지만 그 타이밍이 waitDurationInOpenState 직후가 아니라, 다음 요청이 들어오는 시점이다. 트래픽이 적은 새벽이라면? PG는 10분 전에 복구됐는데 서킷은 30분째 OPEN일 수 있다.

해결

CircuitBreakerConfig.custom()
    .waitDurationInOpenState(Duration.ofSeconds(5))
        .automaticTransitionFromOpenToHalfOpenEnabled(true) // 타이머로 자동 전이
    .build();

"복구됐는데 왜 아직도 차단이지?"의 범인은 이 기본값이다.

원칙 2: 가시성을 제어하라 - 서킷브레이커가 외부 상태(PG 복구)를 스스로 감지할 수 있게 해야 한다. 가시성이 없으면 변화에 반응할 수 없다.


3장: 멀쩡한 요청이 서킷을 열다

이번에는 PG사에 장애가 없다. 그런데 서킷이 OPEN이 됐다.

로그를 봤다. 실패 10건... 전부 403 Forbidden. 카드사 거절, 한도 초과, 분실 카드.

이건 PG 장애가 아니다. 고객이 잘못된 카드로 결제를 시도한 것이다. 비즈니스 에러다.

함정: ignoreExceptions 미설정

서킷브레이커는 기본적으로 모든 예외를 실패로 집계한다. HttpServerErrorException(500)이든 HttpClientErrorException(403)이든 가리지 않는다.

카드사 거절 5건 → 실패율 100% → OPEN. PG는 정상인데 서킷이 열렸다. 오진(false positive).

해결

CircuitBreakerConfig.custom()
    .failureRateThreshold(50)
    .minimumNumberOfCalls(5)
    .recordExceptions(
        HttpServerErrorException.class, // 500: PG 서버 에러 -> 실패
        ResourceAccessException.class // 타임아웃, 연결 실패 -> 실패
)
    .ignoreExceptions(
        CardDeclinedException.class,        // 카드사 거절 (분실, 도난)
        InsufficientBalanceException.class  // 잔액 부족, 한도 초과
)
    .build();

4xx는 PG가 정상적으로 "이 카드 안 됩니다"라고 응답한 것이다.

서킷브레이커에게 "뭐가 진짜 장애인지" 알려주지 않으면, 정상을 장애로 오진한다.

원칙 1: 경계를 그어라 - 이것이 경계 설정의 핵심이다. 500과 403은 둘다 예외지만, 의미가 완전히 다르다. 경계를 안 그으면 가벼운 감기 환자가 응급실을 마비시킨다.


4장: 보이지 않는 자동 보정

1장의 교훈을 배웠다. minimumNumberOfCalls를 적절히 설정하자.

CircuitBreakerConfig.custom()
    .slidingWindowSize(5)
    .minimumNumberOfCalls(10) // 10건 모여야 평가
    .failureRateThreshold(50)
    .build();

잠깐. slidingWindowSize가 5인데 minimumNumberOfCalls가 10?

윈도우에 5건밖에 못 담는데, 10건이 모여야 평가한다고?

함정: 조용한 자동 보정

Resilience4j는 이 모순을 경고 없이 자동으로 해결한다. minimumNumberOfCallsslidingWindowSize와 같은 값(5)으로 조용히 보정한다.

당신은 "10건 모여야 평가 시작"이라고 생각했지만, 실제로는 5건만에 평가가 시작된다. 의도보다 두 배 빨리 서킷이 열린다.

로그도 없다. 에러도 없다. 그냥 조용히 바뀐다.

해결

// 규칙: slidingWindowSize >= minimumNumberOfCalls
CircuitBreakerConfig.custom()
    .slidingWindowSize(10)
    .minimumNumberOfCalls(10)
    .failureRateThreshold(50)
    .build();

slidingWindowSize < minimumNumberOfCalls이면, Resilience4j는 아무 말 없이 당신의 의도를 무시한다.

원칙 1: 경계를 그어라 - 경계를 그을 때 숫자들 사이의 관계도 경계다. 윈도우와 최소 호출 수의 관계를 모르면, 프레임워크가 당신 대신 경계를 정한다.


5장: 느린 게 장애인데, 모르고 있었다

PG 응답 시간이 평소 200ms에서 3초로 치솟았다. 완전한 장애는 아니다. 응답은 온다. 200 OK. 하지만 3초.

서킷 상태: CLOSED. 실패가 0건이니까.

3초짜리 응답이 쌓이면서 스레드 풀이 서서히 고갈되고 있다. 서킷브레이커는 이걸 모른다. 예외가 아니니까.

함정: slowCallDurationThreshold 기본값 60초

서킷브레이커에는 slowCallRateThreshold라는 기능이 있다. 느린 호출 비율이 일정 수준을 넘으면 서킷을 여는 기능이다.

하지만 "느리다"의 기준인 slowCallDurationThreshold의 기본값은 60초다. 3초 응답? 60초 기준으로는 빠른 편이다. 감지 안 된다.

여기에 더 치명적인 함정이 있다. slowCallDurationThreshold를 5초로 설정했는데, readTimeout이 3초라면?

3초에 타임아웃이 터져서 ResourceAccessException이 발생한다. slowCall로 기록될 틈도 없이 예외가 먼저 나온다. 서킷브레이커는 이걸 slowCall이 아니라 failure로 집계한다.

  • slowCallDurationThreshold > readTimeout → slowCall 감지 불가
  • 의도: 느린 호출 감지
  • 현실: 타임아웃 에러로 잡힘

해결

// 규칙: slowCallDurationThreshold < readTimeout
// readTimeout이 5초라면:
CircuitBreakerConfig.custom()
    .slowCallDurationThreshold(Duration.ofSeconds(1))
        .slowCallRateThreshold(50)
    .build();

slowCall의 핵심은 "예외는 아닌데 느린 것"을 잡아내는 것이다. 예외가 먼저 터지면 의미가 없다.

slowCallDurationThreshold는 반드시 readTimeout보다 작아야 한다. 아니면 없는 기능이다.

원칙 1: 경계를 그어라 - "느림"과 "장애"의 경계. 이 경계가 다른 설정(readTimeout)과 맞물려야 의미가 있다. 경계는 혼자 존재하지 않는다.


6장: HALF_OPEN의 늪

5장의 장애가 계속된다. PG 응답이 5초. 서킷이 드디어 OPEN으로 전이됐다. waitDurationInOpenState 경과 후 HALF_OPEN으로 전환.

HALF_OPEN에서는 permittedNumberOfCallsInHalfOpenState(기본값 10)만큼 요청을 보내서 PG가 살아났는지 확인한다.

그런데 요청 하나가 5초짜리 응답을 기다리고 있다. 슬롯 하나를 점유한 채로. 그 사이 나머지 요청은 대기한다.

5초 후 응답이 왔다. 성공. 하지만 그 다음 요청도 5초. 그 다음도 5초.

HALF_OPEN 상태에서 10건 x 5초 = 50초 동안 갇혀있다.

아니, 더 나쁜 경우가 있다.

함정: maxWaitDurationInHalfOpenState 기본값 0 (= 무한 대기)

기본값 0은 "제한 없음"이다. HALF_OPEN 상태에서 permittedNumberOfCallsInHalfOpenState만큼의 결과가 모두 돌아올 때까지 무한정 기다린다.

PG가 응답을 10초씩 주고 있다면? HALF_OPEN에서 영원히 빠져나오지 못한다. OPEN으로도, CLOSED로도 갈 수 없다.

해결

CircuitBreakerConfig.custom()
    .permittedNumberOfCallsInHalfOpenState(3)
    .maxWaitDurationInHalfOpenState(Duration.ofSeconds(3)) // 이 시나리오에서는 3초면 충분. 값은 서비스 상황에 맞게 — 핵심은 0(무한)을 피하는 것
        .build();

3초 안에 3건의 결과가 안 모이면? 포기하고 OPEN으로 돌아간다. 다음 waitDurationInOpenState 후에 다시 시도한다.

maxWaitDurationInHalfOpenState 기본값 0은 "HALF_OPEN에 영원히 갇힐 수 있다"는 뜻이다.

원칙 1: 경계를 그어라 - "얼마나 기다릴 것인가"도 경계다. 무한 대기는 경계가 없다는 뜻이고, 경계가 없으면 시스템은 정지한다.


7장: 재시도가 서킷을 죽이다

서킷브레이커가 안정됐다. 이제 재시도를 추가하자. 일시적 에러는 재시도하면 성공하는 경우가 많으니까.

두 개를 조합한다. 순서를 정해야 한다.

"Retry로 감싸고, 그 안에 CircuitBreaker를 넣자."

Supplier<Map> decorated = Decorators.ofSupplier(() -> paymentClient.confirm(...))
        .withCircuitBreaker(cb)
    .withRetry(retry)           // Retry가 바깥
    .decorate();

이 순서가 모든 것을 망친다.

함정: Retry(바깥) → CircuitBreaker(안쪽)

고객 2명이면 6건 실패. minimumNumberOfCalls를 5로 설정했는데 이미 초과. 서킷 OPEN.

재시도가 서킷브레이커의 실패 카운트를 3배로 부풀렸다.

실제 테스트 로그로 확인해보자.

// Retry(바깥) → CB(안쪽) — 고객 2명이 각 1건씩 요청
  ❌ [CB 실패] InternalServerError | 성공=0 실패=1
  🔄 [RETRY #1] InternalServerError
  ❌ [CB 실패] InternalServerError | 성공=0 실패=2
  🔄 [RETRY #2] InternalServerError
  ❌ [CB 실패] InternalServerError | 성공=0 실패=3    ← 고객 1명인데 CB에 3건
  ❌ [RETRY 최종 실패] attempts=3
  ❌ [CB 실패] InternalServerError | 성공=0 실패=4
  🔄 [RETRY #1] InternalServerError
  ❌ [CB 실패] InternalServerError | 성공=0 실패=5
  🔄 [RETRY #2] InternalServerError
  ❌ [CB 실패] InternalServerError | 성공=0 실패=6    ← 고객 2명인데 CB에 6건
  ❌ [RETRY 최종 실패] attempts=3
── CB 요약 ──────────────────────────
  성공: 0 | 실패: 6                     ← 2건 요청이 6건으로 부풀림
─────────────────────────────────────

// CB(바깥) → Retry(안쪽) — 고객 2명이 각 1건씩 요청
  🔄 [RETRY #1] InternalServerError
  🔄 [RETRY #2] InternalServerError
  ❌ [RETRY 최종 실패] attempts=3
  ❌ [CB 실패] InternalServerError | 성공=0 실패=1    ← 최종 결과만 1건
  🔄 [RETRY #1] InternalServerError
  🔄 [RETRY #2] InternalServerError
  ❌ [RETRY 최종 실패] attempts=3
  ❌ [CB 실패] InternalServerError | 성공=0 실패=2    ← 정확히 2건
── CB 요약 ──────────────────────────
  성공: 0 | 실패: 2                     ← 2건 요청 = 2건 기록
─────────────────────────────────────

같은 2건의 요청인데, 순서만 바꿨을 뿐인데 CB에 기록되는 실패 수가 6건 vs 2건이다.

해결: CircuitBreaker(바깥) → Retry(안쪽)

고객 1건 요청 → 서킷브레이커에 1건 기록. Retry 내부에서 뭐가 됐든 CircuitBreaker는 최종 결과만 본다.

더 좋은 시나리오: Retry가 3번째에 성공하면?

Retry 내부의 실패가 CircuitBreaker를 전혀 오염시키지 않는다.

데코레이터 순서는 CircuitBreaker(바깥) → Retry(안쪽)이다. 반대로 하면 재시도가 서킷을 죽인다.

원칙 2: 가시성을 제어하라 - 서킷브레이커가 Retry의 중간 실패까지 보면 과잉 반응이다. 최종 결과만 보게 해야 정확한 판단을 한다.


8장: 재시도가 서킷 차단을 무력화하다

7장을 고쳤다. CircuitBreaker가 바깥, Retry가 안쪽. 서킷이 OPEN이 되면 CallNotPermittedException이 발생하면서 요청을 차단한다.

그런데 동료가 "안전하게" 하겠다며 이런 설정을 추가했다.

RetryConfig.custom()
    .maxAttempts(3)
    .retryExceptions(
        HttpServerErrorException.class,
        ResourceAccessException.class,
        CallNotPermittedException.class     // ← 이것
)
    .build();

CallNotPermittedException도 재시도하겠다고.

"서킷이 열려서 차단당했으면, 재시도하면 되지 않나? 잠깐 후에 HALF_OPEN 되면 통과할 수도 있잖아."

재앙

PG 서버에는 단 한 건도 가지 않았다. 이미 서킷이 "이 서버 죽었어"라고 판단했는데, Retry가 "그래도 한 번 더 물어보자"를 3번 반복한 것이다.

서킷브레이커가 즉시 차단(fail-fast)으로 지켜주려는 걸, 재시도가 무력화했다.

서킷이 "멈춰"라고 했으면 멈춰야 한다.

해결

RetryConfig.custom()
    .maxAttempts(3)
    .retryExceptions(
        HttpServerErrorException.class,
        ResourceAccessException.class
        // CallNotPermittedException은 절대 넣지 않는다
)
    .build();

CallNotPermittedExceptionretryExceptions에 넣는 순간, 서킷브레이커는 무용지물이 된다.

원칙 1: 경계를 그어라 - CallNotPermittedException은 "장애"가 아니라 "보호 동작"이다. 재시도할 예외와 존중할 신호의 경계를 그어야 한다.


9장: RateLimiter가 서킷을 오염시키다

8장까지 살아남았다. 이제 RateLimiter를 추가하자. 초당 요청 수를 제한해서 PG사에 폭탄을 안 던지겠다는 것이다.

RateLimiter를 CircuitBreaker 안쪽에 넣었다. 요청이 CB를 통과하고, 그 다음에 RateLimiter를 만나는 구조다.

// CircuitBreaker가 RateLimiter를 감싸는 구조
Supplier<Map> decorated = Decorators.ofSupplier(() -> paymentClient.confirm(...))
        .withRateLimiter(rateLimiter)    // 안쪽: 먼저 실행됨
    .withCircuitBreaker(cb)          // 바깥: 결과를 관찰함
    .decorate();

잘 동작하는 것 같다.

그런데 트래픽이 몰리는 시간대에 RateLimiter가 요청을 거절하기 시작했다. RequestNotPermitted 예외. 여기까지는 정상이다. 초당 허용량을 넘었으니까.

문제는 그 다음이다. 서킷브레이커가 OPEN이 됐다. PG는 멀쩡한데.

함정: RateLimiter가 CircuitBreaker 안쪽에 있다

RateLimiter가 RequestNotPermitted를 던진다. 이 예외는 CircuitBreaker를 통과하면서 실패로 집계된다.

RateLimiter 거절은 "PG 장애"가 아니다. "우리가 스스로 트래픽을 조절한 것"이다. 그런데 CircuitBreaker는 이걸 장애로 착각한다.

3장의 비즈니스 에러 오진과 같은 패턴이다.

해결

RateLimiter를 CircuitBreaker 바깥으로 빼면 된다:

// RateLimiter가 CircuitBreaker를 감싸는 구조
Supplier<Map> decorated = Decorators.ofSupplier(() -> paymentClient.confirm(...))
        .withCircuitBreaker(cb)          // 안쪽: 장애만 관찰
    .withRateLimiter(rateLimiter)    // 바깥: 먼저 거절 판단
    .decorate();

RateLimiter가 바깥이면, 거절당한 요청은 CircuitBreaker에 도달하지 않는다. CB 메트릭은 깨끗하게 유지된다.

RateLimiter, Bulkhead 같은 "자체 거절" 컴포넌트는 반드시 CircuitBreaker 바깥에 두어라. 안쪽에 두면 자체 거절이 장애로 오진된다.

원칙 2: 가시성을 제어하라 - RateLimiter의 거절은 CB가 볼 필요 없는 정보다. CB 바깥에 두면 CB의 시야에서 사라진다. 각 컴포넌트가 자기 역할에 필요한 것만 보게 하라.

Part 2: 두 가지 질문

9개의 함정을 관통하는 원칙은 두 가지였다.
경계를 그어라. 그리고 가시성을 제어하라.
설정 앞에서 실제로 도움이 된 건, 이 원칙을 질문으로 바꿨을 때였다.


경계를 그어라

Resilience4j의 모든 컴포넌트는 "무엇이 비정상인지"를 판단해야 한다.
경계를 안 그으면, 기본값이 경계가 된다. 그리고 기본값의 경계는 거의 항상 엉뚱하다.

모든 설정 앞에서 이렇게 질문해본다.

"이것은 장애인가, 아닌가?" 그 다음 경계를 그어보자

질문을 안 던졌을 때질문을 던졌을 때
1장100건까지는 장애가 아닌가?5건이면 충분하다 → minimumNumberOfCalls=5
3장403도 장애인가?비즈니스 응답이다 → ignoreExceptions
4장window=5, minimum=10이 가능한가?불가능하다 → slidingWindowSize >= minimumNumberOfCalls
5장3초 응답은 느린 건가?느리다 → slowCallDurationThreshold < readTimeout
6장HALF_OPEN에서 얼마나 기다릴 건가?무한 대기는 안 된다 → maxWaitDurationInHalfOpenState ≠ 0
8장CallNotPermittedException은 재시도할 에러인가?보호 신호다 → retryExceptions에서 제외

가시성을 제어하라

데코레이터를 조합하면 레이어가 생긴다.
바깥 레이어는 안쪽 레이어의 최종 결과만 본다.

가시성을 잘못 설정하면, 컴포넌트가 볼 필요 없는 것까지 보고 과잉 반응한다.

모든 조합 앞에서 이렇게 질문해본다.

"이 컴포넌트는 뭘 봐야 하는가?" 그 다음 가시성을 제어하자

잘못된 가시성올바른 가시성
2장CB가 PG 복구를 감지 못함automaticTransition=true → 스스로 감지
7장CB가 Retry의 중간 실패까지 봄 → 3배 부풀림CB(바깥) → Retry(안쪽) → 최종 결과만 봄
9장CB가 RateLimiter 거절을 장애로 봄RateLimiter(바깥) → CB(안쪽) → 거절이 시야에서 사라짐

Part 3: 실무 체크리스트

배포 전 점검표

설정기본값위험권장
minimumNumberOfCalls100100건 실패해도 서킷 안 열림서비스 트래픽에 맞게 (5~20)
slidingWindowSize100장애 감지 지연minimumNumberOfCalls 이상
automaticTransitionFromOpenToHalfOpenEnabledfalse요청 없으면 OPEN에 영원히 갇힘true
ignoreExceptions없음4xx 비즈니스 에러가 서킷을 열음비즈니스 에러 등록
slowCallDurationThreshold60초사실상 slowCall 감지 불가readTimeout보다 작게, 정상 응답 시간 기준으로 설정
maxWaitDurationInHalfOpenState0 (무한)HALF_OPEN에서 영원히 갇힘0(무한)만 피하고 서비스에 맞게 설정
데코레이터 순서-Retry가 바깥이면 실패 3배CB(바깥) → Retry(안쪽)
retryExceptions-CallNotPermittedException 포함 시 서킷 무력화CallNotPermittedException을 절대 포함하지 않음
RateLimiter 위치CB 안쪽 (Spring 기본)rate limit 거절이 CB 실패로 오진CB 바깥에 배치

새 설정을 추가할 때

1. "이것은 장애인가, 아닌가?"

  • recordExceptions에 진짜 장애(5xx, 타임아웃)만 있는가?
  • ignoreExceptions에 비즈니스 에러(4xx)가 등록되어 있는가?
  • retryExceptions에 보호 신호(CallNotPermittedException)는 빠져있는가?
  • 임계값이 기본값이 아닌, 서비스에 맞는 값인가?
  • 설정 간의 관계가 맞는가? (slowCallDuration < readTimeout, windowSize >= minimumCalls)

2. "이 컴포넌트는 뭘 봐야 하는가?"

  • CB 안쪽에 "자체 거절" 컴포넌트가 있는가? → CB 바깥으로 이동
  • CB → Retry 순서인가? (CB는 최종 결과만 봐야 한다)
  • 각 레이어가 상위 레이어의 보호 신호를 존중하는가?

"기본값으로 시작하세요."

이 말은 틀리지 않았다.
다만, 빠진 전제가 있었다.
기본값이 무엇을 의미하는지 알고 시작하라는 것.

그래야 비로소 결정은 프레임워크의 것이 아니라, 당신의 것이 된다.


Resilience4j는 설정 하나가 장애 대응의 성패를 가르는 도구다.
그런 도구 앞에서 "그렇다더라"는 근거가 되지 못한다.
직접 테스트하고, 눈으로 확인하고, 그 결과로 설정하는 수밖에 없다.
막연히 상상하는 것과 로그로 확인하는 것은 생각보다 차이가 크다.

그래서 학습 테스트를 만들었다.
한 번 만들어두면 설정값만 바꿔서 언제든 재검증할 수 있고,
팀에 공유하면 "이 설정이 왜 이 값인지"를 코드로 설명할 수 있다.

신뢰할 수 있는 동료와 일하고 싶다면,
내가 먼저 근거를 가진 사람이 되어야 한다고 생각한다.

이 글에서 다룬 모든 함정은 resilience4j-lab의 120+ 학습 테스트로 Before/After를 직접 확인할 수 있다.
자신의 프로젝트에서 PG Mock 서버와 카오스 엔지니어링 환경이 필요하다면 pg-mock-server를 활용해보자.

profile
물이 없어도 노 젓기

2개의 댓글

comment-user-thumbnail
2026년 3월 20일

두 가지 질문 프레임워크인 “경계를 그어라”, “가시성을 제어하라”로 9개 함정을 관통한 구조가 정말 인상 깊었습니다. 특히 각 함정마다 테스트 로그로 Before/After를 보여주셔서, 읽는 입장에서 실제로 이렇게 동작하는구나를 확인할 수 있었던 점이 좋았습니다.

저도 결제 도메인에 Resilience4j를 적용하면서 비슷한 함정들을 많이 겪었는데, 설정 방향이 많이 겹쳐서 반가웠습니다.
특히 3장의 “멀쩡한 요청이 서킷을 연다”는 저도 비슷하게 경험했습니다. 저는 이걸 응답을 받았으면 결과를 안다는 기준으로 정리했는데, PG가 4xx를 반환했다면 그건 장애라기보다 처리 불가를 명확히 응답한 것으로 보고 서킷브레이커 실패로 잡지 않았습니다. 결과적으로 글에서 말씀하신 “경계를 그어라”와 같은 결론에 도달한 셈이었습니다.

7장 데코레이터 순서 이슈도 공감했습니다. 저는 이 부분을 구조적으로 회피하려고, CB와 Retry를 같은 메서드에 두지 않고 분리했습니다.

CB는 Facade 레이어에서 전체 흐름 보호
Retry는 Client의 query() 같은 멱등 조회에만 적용
승인(POST)에는 Retry 미적용

승인 요청은 멱등성이 확실하지 않으면 재시도 자체가 이중 결제 위험이 될 수 있다고 판단했기 때문입니다.

한 가지 궁금한 점은 6장에서 maxWaitDurationInHalfOpenState를 2~5초로 권장하신 부분입니다.
저는 이 값을 60초로 두고 운영했는데, 이유는 PG 응답이 느린 상황에서 HALF_OPEN의 탐색 요청이 실제 고객 결제일 수 있어서, 너무 짧으면 정상 응답이 돌아올 시간을 충분히 주지 못한 채 다시 OPEN으로 복귀할 수 있다고 봤기 때문입니다.

혹시 2~5초처럼 짧게 가져가신 기준이
탐색 요청은 오래 기다리기보다 빠르게 실패시키는 게 낫다는 판단이셨는지
아니면 slow-call-duration-threshold, timeout, half-open permitted calls와 함께 묶어서 보신 것인지
궁금합니다.

좋은 글 잘 읽었습니다.

1개의 답글