--- kind: CASE slug: a-circuit-breaker-permit-that-leaks-on-local-rejection title: 로컬 거부 경로에서 회로 브레이커 permission이 반환되지 않는다 topic: http-failure-classification project: clean-architecture-backend-template status: 게시 전 sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 rootTreeNode: case:a-circuit-breaker-permit-that-leaks-on-local-rejection evidenceCapturedOn: 2026-09-02 body: case-a-circuit-breaker-permit-that-leaks-on-local-rejection.body.md assets: - key: a-circuit-breaker-permit-that-leaks-on-local-rejection file: ../../../final/evidence/rendered/a-circuit-breaker-permit-that-leaks-on-local-rejection.svg evidence: - ../../../final/evidence/raw/a-circuit-breaker-permit-that-leaks-on-local-rejection.txt source: - 원본 분석 절은 final/document.md#a11 §22 이다. permission 획득·반환 경로의 전수 추적은 그 분석이 인용하는 원시 증거 170 번 §8.1 에 있다. --- # 로컬 거부 경로에서 회로 브레이커 permission이 반환되지 않는다 회로 브레이커 permission을 얻은 뒤 rate limiter나 bulkhead가 요청을 거부하면 그 permission이 반환되지 않는다. HALF_OPEN 상태에서는 시험 슬롯이 영구히 소비되어, 회복한 업스트림에 대해 회로가 닫히지 않을 수 있다. ## 관계 - **전송 실패의 단계와 범주 — AttemptStage와 FailureCategory** 이 파이프라인이 그 분류를 만들기 전에 지나는 승인 계층이다. ## 문제 요청 하나가 실행되기 전에 세 가드를 차례로 지난다. 회로 브레이커가 permission 을 주고, rate limiter 가 토큰을 주고, bulkhead 가 슬롯을 준다. 뒤의 두 가드가 거부하면 그 경로는 예외를 던지고 끝난다. 그 사이에 이미 받아 둔 회로 permission 을 돌려주는 호출이 없다. ## 결론 경로 확인으로 확정한 결함이다. 회로가 반쯤 열린 상태에서만 발생한다. 업스트림 장애로 회로가 열린다. 대기 후 반쯤 열린 상태로 바뀌고, 트래픽이 돌아온다. 그 순간 평상시 부하에 맞춰 사이징된 로컬 rate limiter 나 bulkhead 가 거부하기 시작하고, 거부마다 시험 슬롯 하나가 사라진다. 거부가 허용된 시험 호출 수만큼 쌓이면 브레이커는 성공도 실패도 못 본 채 그 상태에 머문다. 회복한 업스트림에 대해 회로가 닫히지 않는다. 수정하려면 인터페이스에 permission 반환 연산을 추가하고, rate limiter 와 bulkhead 가 요청을 거부하는 두 경로에서 그 연산을 호출해야 한다. ## 검증 환경 OpenJDK : 21.0.12 Gradle : 9.0.0 Spring Boot : 4.0.8 Resilience4j : 2.2.0 확인 방식 : 파이프라인 진입부의 예외 경로 추적, 인터페이스 연산 전수 확인, 반환 연산 이름 검색 소스 수정 : x ## 재현 조건 1. AttemptResiliencePipeline.execute 의 83행부터 97행까지를 읽고, 세 가드의 거부 경로에서 회로 브레이커 연산이 호출되는지 확인한다. 2. AttemptCircuitBreaker 의 추상 연산을 전수 확인한다. 넷이며 반환 연산이 없다. 3. releasePermission 을 코드베이스에서 검색한다. Java 매치가 0 이다. 4. AttemptResiliencePipelineTest 에서 회로 permission 반환을 단언하는 테스트가 있는지 확인한다. ## 본문 `AttemptResiliencePipeline.execute` 진입부에서 세 가드가 차례로 실행된다. 83행이 회로 permission 을 얻고, 88행이 rate limiter 를, 93행이 bulkhead 를 본다. 83행을 통과한 뒤 88행과 93행이 거부하면 그 두 경로는 예외를 던지고 끝난다. 회로 permission 이 돌아오지 않는다. ## 반환할 연산 자체가 인터페이스에 없다 :::evidence key="a-circuit-breaker-permit-that-leaks-on-local-rejection" alt="코드베이스에서 AttemptResiliencePipeline 의 가드와 반납 지점, AttemptCircuitBreaker 의 추상 연산 전수, releasePermission 검색 결과를 뽑은 출력 26줄. 인터페이스의 연산 넷과 Java 코드 매치 0, 그리고 그 이름이 설계 문서에만 남아 있다는 것이 그 출력에 그대로 보인다." caption="세 가드 · 정상 경로 반납 · AttemptCircuitBreaker 연산 넷 · releasePermission 검색 — 26줄 · exit 0" zoom="true" ::: `AttemptCircuitBreaker` 의 추상 연산은 넷이다 — `tryAcquirePermission` · `onSuccess` · `onError` · `state`. 획득은 있고 반환은 없다. `releasePermission` 은 `src` 아래 Java 파일에서 매치가 0 이다. 저장소 전체로 넓히면 한 곳에 나오는데, 그것은 이 능력의 설계 문서다. 즉 이름이 설계 단계에서는 존재했고 구현에는 들어오지 않았다. 로컬 거부 경로가 부르는 것을 잊은 것이 아니라, 부를 수 있는 연산이 없다. ## 같은 거부 경로에서 rate 토큰은 돌려준다 94행이 이 판정을 뒷받침한다. bulkhead 가 거부하는 경로는 `rateLimiter.onCompleted()` 를 불러 rate 토큰을 명시적으로 반환한다. 저자가 permit 반환을 의식하고 있었다는 증거다. 정상 경로에도 같은 의식이 보인다. `releaseAttemptPermits()` 가 `bulkhead.release()` 와 `rateLimiter.onCompleted()` 를 함께 부른다 — 자료의 120행이 그 두 번째 호출이다. 세 가드 중 둘은 정상 경로에서도 거부 경로에서도 반납되고, 회로만 어느 쪽에서도 반납되지 않는다. ## 이 결함은 HALF_OPEN 에서만 값을 갖는다 Resilience4j 의 `tryAcquirePermission()` 은 반쯤 열린 상태에서 허용된 시험 호출 수 중 하나를 소비한다. 그 슬롯은 `onSuccess` · `onError` · `releasePermission` 중 하나로만 돌아온다. 아무것도 부르지 않으면 슬롯은 영구히 소비된다. 닫힌 상태에서는 permission 이 계수를 소비하지 않으므로 같은 코드가 무해하다. 그래서 이 결함은 코드가 아니라 상태에 걸려 있고, 평상시 테스트로는 드러나지 않는다. ## 조건들이 우연히 겹치지 않는다 시험 슬롯이 열리는 시점은 업스트림이 회복을 시작한 시점이고, 트래픽이 돌아오는 시점도 같다. 평상시 부하에 맞춰 사이징된 로컬 가드는 그 순간에 거부하기 시작한다. 거부마다 슬롯 하나가 사라진다. 허용된 시험 호출 수만큼 거부가 나면 브레이커는 성공도 실패도 관측하지 못한 채 그 상태에 머문다. `maxWaitDurationInHalfOpenState` 기본값이 0 — 무한 대기 — 이므로 시간이 그것을 풀어 주지도 않는다. ## 테스트가 그 공백을 그대로 보여 준다 인접한 두 성질에는 테스트가 있다. - `openCircuitDoesNotConsumeRateOrBulkheadPermit` — 회로가 거부할 때 뒤의 둘을 소비하지 않는다 - `bulkheadRejectionReleasesTheRateLimiterAndIsNotACircuitError` — bulkhead 거부가 rate 를 돌려준다 둘째 테스트가 단언하는 이벤트 순서는 `circuit-enter` · `rate-enter` · `bulkhead-reject` · `rate-exit` 다. 회로를 돌려주는 이벤트가 그 목록에 없다. 88행의 rate limiter 거부 경로에는 테스트가 아예 없다. ## 수정 `AttemptCircuitBreaker` 에 `releasePermission()` 을 더해 Resilience4j 의 같은 이름 연산에 위임하고, `alwaysClosed()` 구현에서는 아무것도 하지 않게 둔다. 그리고 두 로컬 거부 경로에서 그것을 부른다. ## 확인하지 못한 것 시험 슬롯 고갈을 반복 호출로 재현해 보지는 않았다. 회로가 닫히지 않는 상태를 런타임에서 관측한 것은 아니다. Resilience4j 의 상태별 permission 회계와 무한 대기 기본값은 그 라이브러리의 문서화된 동작을 근거로 삼았고, 이 회차에 라이브러리 코드를 실행해 확인하지는 않았다.