Files
document-haness/docs/clean-architecture-backend-template/tech-log-studio/notification-and-delivery/case/case-a13-f002-authentication-failed-resumehealthy.md
T
DongHyeonkaandClaude Fable 5.1 b25357c48a docs(clean-architecture-backend-template): fold analysis into final and re-select one topic
- analysis/·source-index·state.json 을 final/document.md 제2부·제3부로 접었다. SSOT 는 하나다
- 파일럿 — commit-ambiguity-as-a-result 를 새 기준으로 재선별. 후보 14 → 글감 5
  (PROMOTE 5 · MERGE_INTO 3 · KEEP_IN_SSOT 4 · 보류 2). 기록 5건을 다시 썼고 그림 1개를
  techviz 로 만들었다
- 재선별이 잡은 것: 제1부 §6.2·§11.1 이 자기 §13.2 와 어긋나 있었다(레인을 안 돌렸다 vs
  돌렸다) — 정정. 이미 답이 나와 있던 Question 을 HEAD 재실행 질문으로 다시 세웠다.
  Concept 이 인용한 코드가 SSOT 에 없어 뺐다
- candidateScope·sourceRepository 기록. 나머지 43개 주제는 재선별 대기(PENDING 905)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:39:20 +09:00

17 KiB

kind, slug, title, topic, project, status, sourceRevision, rootTreeNode, evidenceCapturedOn, body, assets, evidence, source
kind slug title topic project status sourceRevision rootTreeNode evidenceCapturedOn body assets evidence source
CASE a13-f002-authentication-failed-resumehealthy 두 호출이 상태와 함께 경보를 끈다 notification-and-delivery clean-architecture-backend-template 게시 전 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 case:a13-f002-authentication-failed-resumehealthy 2026-09-02 case-a13-f002-authentication-failed-resumehealthy.body.md
key file
a13-f002-authentication-failed-resumehealthy ../../../final/evidence/rendered/a13-f002-authentication-failed-resumehealthy.svg
key file
a13-f002-authentication-failed-resumehealthy-sequence ../../../final/evidence/rendered/a13-f002-authentication-failed-resumehealthy-sequence.svg
../../../final/evidence/raw/a13-f002-authentication-failed-resumehealthy.txt
../../../final/evidence/raw/a13-f002-authentication-failed-resumehealthy-sequence.txt
원본 분석 절은 final/document.md#a13#L320 이다. 등급은 P2 이다. 정상 복귀 전이의 자바독 인용, 배수 표시가 현재 상태를 읽지 않는다는 지적, 두 전이가 관리자 포트에 함께 노출되어 있다는 대조, 그래서 인증 실패를 배수로 지운 뒤 정상 복귀를 부르는 두 단계 시퀀스가 보장을 우회한다는 판정, 테스트가 세 전이를 각각 새 런타임에서 확인한다는 관찰, 그리고 전이별로 현재 값을 읽는지 정리한 표가 그 절에 있다. 같은 문서의 21.2 절이 이 우회가 헬스 신호도 함께 끈다는 파급을 기록으로 남긴다.
이 기록이 더한 것은 셋이다. 네 단계를 실제로 불러 각 단계의 반환값과 상태와 사유를 받았고, 같은 실행에서 헬스 판정을 나란히 찍어 21.2 절이 서술한 파급이 어느 단계에서 일어나는지 보였다. 비활성 경로는 상태 기계가 같지만 헬스 지시등이 내려간 채로 있다는 차이를 확인했다. 그리고 사라지는 사유 코드를 읽는 운영 표면이 없다는 것 — 남는 것은 설명이지 기록이 아니다.
성능 저하로 3 단계가 막힌다는 것은 `degradingAFailedRuntimeIsRefused` 가 이미 단언한다. 이 기록이 더한 것은 그 뒤의 정상 요청까지 거짓이라는 4" 단계다.

두 호출이 상태와 함께 경보를 끈다

정상 복귀 전이가 인증 실패 상태를 거부한다고 자바독이 적는다. 같은 원자 참조를 쓰는 일곱 전이 중 여섯이 관리자 포트에 있고, 그중 둘은 현재 값을 매개변수로 받고도 읽지 않는다. 그 둘 중 하나를 거친 뒤 정상을 요청하면 상태가 통과하고, 헬스 지시등도 그 자리에서 함께 올라온다.

관계

  • nonce replay 경계가 결과를 읽고 버린다 두 사례 모두 경계가 판정은 하는데 그 결과가 다음 단계로 가지 않는다.
  • 그 속성을 보일 수 없는 대역 위에서 통과한 테스트는 증인이 아니다 이 조합만 런타임 셋을 따로 만들어 확인한다.
  • 아직 쓰이지 않은 연산에도 같은 불변식이 성립하게 만든 순서 형제 전이가 같은 불변식을 지키는지 묻는 규칙이다.

문제

제공자 런타임의 정상 복귀 전이가 자신이 지키는 성질을 자바독에 명시한다.

인증 실패는 여전히 거부한다. 제공자를 정상이라 선언한다고 해서 제공자가 받아들일 자격증명이 생기지는 않으므로, 첫 시도에서 실패할 런타임을 건네는 대신 호출자에게 그 사실을 알린다.

결론

여섯 중 셋은 현재 상태를 읽는다. 정상 복귀와 성능 저하와 스로틀이 조건을 건다.

나머지 셋은 읽지 않고 덮어쓴다. 인증 실패 표시는 그것이 하는 일이라 set 으로 쓰고, 배수 표시와 비활성 표시 둘만 원자 갱신의 람다로 현재 값을 받아 놓고 쓰지 않는다.

실행해서 확인했다. 인증 실패 상태에서 정상을 요청하면 거짓이 돌아오고 상태와 사유가 유지된다. 배수를 요청하면 참이 돌아오고 상태가 배수가 되며, 사유가 배수로 바뀌면서 원래 사유가 사라진다. 다시 정상을 요청하면 참이 돌아오고 상태가 정상이 된다.

같은 자리에서 헬스 지시등이 올라온다. 배수가 보고기의 비정상 목록에 들어 있지 않아서다. 3 단계에서 이미 정상으로 돌아오고, 자격증명은 그대로 거부된 그것이다. 보고기 자신의 자바독이 강조하는 조건이 자격증명 거부이고, 그것이 프로세스 수준 헬스에서는 보이지 않으므로 운영자를 호출해야 하는 조건이라고 적는다. 그 호출이 꺼진다.

비활성으로 가도 상태 기계는 같다. 다만 보고기는 비활성을 비정상으로 보므로 그 경로에서는 지시등이 내려간 채로 있다. 성능 저하로 가면 상태부터 다르다. 성능 저하 경로는 정상일 때만 상태를 바꾸므로 인증 실패 상태에서 거짓이 돌아오고, 이어지는 정상 요청도 거짓이다.

포트가 여섯을 그대로 넘기는 것은 문제가 아니다. 받는 쪽 둘에 조건이 없는 것이 문제다.

이 순서를 밟으려면 제공자 제어 권한이 필요하고, 성공한 두 걸음은 각각 감사 행을 남긴다. 거부된 2 단계는 응용 서비스에서 예외로 바뀌어 운영자에게 도착한다. 막히지는 않지만 흔적 없이 되는 일도 아니다.

상태 자체는 자가 복구된다. 첫 시도가 실패하면 다시 인증 실패로 돌아간다.

그 사이에 시도 하나가 제공자를 향해 나간다. 그리고 클래스 자바독이 막으려 한 창이 매 재설정마다 다시 열린다. 다만 실제로 새는 시도는 첫 실패가 상태를 되돌리기 전까지 동시에 떠 있던 발송 수만큼이고, 그 뒤의 발송은 시도 획득이 먼저 막는다.

런타임이 상태와 짝지어 들고 있던 원인 코드도 배수 단계에서 사라진다. 다만 그 값을 읽는 운영 표면이 없다. 헬스 엔드포인트는 자격증명을 이름 짓는다는 이유로 사유를 싣지 않는다. 없어지는 것은 설명이지 기록이 아니다.

테스트가 이것을 잡지 못하는 자리는 좁다. 같은 파일이 인증 실패 위에 성능 저하를, 정상 복귀를, 성공 복귀를 한 런타임에서 겹쳐 본다. 겹쳐 보지 않는 조합이 하나 있다. 가드가 없는 두 운영자 전이 다음의 정상 복귀다. 그것을 확인하는 테스트만 런타임 셋을 따로 만든다.

판정은 P2 다.

검증 환경

OpenJDK : 21.0.12 확인 방식 : 일곱 전이의 구현과 관리자 포트 분배 확인, 헬스 보고기 판정 확인, 실행 탐침 소스 수정 : x

재현 조건

  1. 같은 원자 참조를 쓰는 전이를 전수로 세고, 각각이 현재 값을 읽는지 확인한다.
  2. 정상 복귀 전이의 자바독과 구현을 읽는다.
  3. 관리자 포트가 여섯 전이를 어떻게 분배하는지 확인한다.
  4. 헬스 보고기가 비정상으로 보는 상태 목록과 그 클래스 자바독을 읽는다.
  5. 런타임의 사유 필드를 읽는 곳을 저장소 전체에서 세고, 헬스 스냅숏이 사유를 싣는지 본다.
  6. 상태 테스트가 어떤 조합을 한 런타임에서 겹쳐 보는지 확인한다.
  7. 런타임 하나를 인증 실패로 두고 정상·배수·정상을 순서대로 부르며, 각 단계의 반환값과 상태와 사유와 헬스 판정을 함께 기록한다.
  8. 비활성 경로와 성능 저하 경로로 같은 순서를 반복한다.

본문

자바독이 지키겠다고 적은 성질이 하나 있다.

적어 둔 보장

:::evidence key="a13-f002-authentication-failed-resumehealthy" alt="같은 원자 참조를 쓰는 전이 일곱의 선언, 정상 복귀 전이의 자바독과 구현, 현재 값을 받아 놓고 쓰지 않는 둘과 set 으로 쓰는 하나의 구현, 관리자 포트가 여섯을 분배하는 스위치 전체, 헬스 보고기의 클래스 자바독과 비정상으로 보는 상태 목록, 런타임의 사유 필드를 읽는 곳과 헬스 스냅숏의 필드 목록, 그리고 이 보장을 확인하는 test 둘의 본문을 출력한 터미널 기록." caption="같은 참조를 쓰는 전이는 일곱이고 여섯이 관리자 포트에 있다 · markDraining 과 markDisabled 만 current 를 받아 놓고 쓰지 않는다 · 헬스 보고기가 비정상으로 보는 상태는 인증 실패와 비활성 둘 · 사유 필드를 읽는 프로덕션 코드는 없고 스냅숏도 사유를 싣지 않는다 — 109줄 · exit 0" zoom="true" :::

198:   * <p>This is the admin counterpart of {@link #markHealthy()}: it resumes a runtime that an
199:   * operator drained or disabled. It still refuses {@code AUTHENTICATION_FAILED}, because declaring
200:   * a provider healthy does not give it a credential the provider will accept  the caller is told
201:   * so rather than being handed a runtime that will fail on its first attempt.
202:   *
203:   * @return whether the runtime is now healthy
204:   */
205:  public boolean resumeHealthy() {
206:    return health
207:            .updateAndGet(
208:                current ->
209:                    current.state() == ProviderRuntimeState.AUTHENTICATION_FAILED
210:                        ? current
211:                        : new RuntimeHealth(ProviderRuntimeState.HEALTHY, Optional.empty()))

같은 참조를 쓰는 전이는 일곱이고, 그중 여섯이 관리자 포트에 있다. 남는 하나는 성공한 시도가 부르는 markHealthy 다.

현재 값을 받아 놓고 쓰지 않는 둘

221:  public boolean markDraining() {
222:    return health
223:            .updateAndGet(
224:                current ->
225:                    new RuntimeHealth(ProviderRuntimeState.DRAINING, Optional.of("DRAINING")))
226:            .state()
227:        == ProviderRuntimeState.DRAINING;
228:  }

비활성 표시도 같은 조건에서 같은 값을 돌려준다. 인증 실패 표시는 아예 받지 않는다.

127:  public void markAuthenticationFailed(String reasonCode) {
128:    Objects.requireNonNull(reasonCode, "reasonCode");
129:    // One write, so the state and the reason it carries are never observed apart.
130:    health.set(
131:        new RuntimeHealth(ProviderRuntimeState.AUTHENTICATION_FAILED, Optional.of(reasonCode)));
132:  }

여섯 중 읽는 것이 셋, 읽지 않는 것이 셋이다.

여섯이 한 포트에 있다

30:    return switch (desiredState) {
31:      case DISABLED -> runtime.markDisabled();
32:      case DRAINING -> runtime.markDraining();
33:      case HEALTHY -> runtime.resumeHealthy();
34:      case DEGRADED -> runtime.markDegraded(reason);
35:      case THROTTLED -> runtime.markThrottled();
36:      case AUTHENTICATION_FAILED -> {
37:        runtime.markAuthenticationFailed(reason);
38:        yield true;
39:      }

운영자가 요청한 상태가 그대로 메서드 하나로 간다. 인증 실패만 반환값을 만들지 않고 true 를 낸다.

순서대로 불러 보면

:::evidence key="a13-f002-authentication-failed-resumehealthy-sequence" alt="관리자 포트가 부르는 여섯 전이가 현재 상태를 읽는지 하나씩 적은 표, 런타임 하나를 인증 실패로 두고 정상·배수·정상을 순서대로 불러 각 단계의 반환값과 상태와 사유와 헬스 판정을 함께 기록한 결과, 비활성 경로로 같은 순서를 반복한 결과, 그리고 가드가 있는 성능 저하 경로로 같은 순서를 반복한 결과를 출력한 터미널 기록." caption="배수를 거치면 사유가 지워지고 다음 정상 요청이 참으로 돌아온다 · 그 배수 단계에서 헬스 판정이 이미 정상으로 올라온다 · 비활성 경로는 헬스가 내려간 채로 있고, 가드가 있는 성능 저하로는 상태부터 막힌다 — 24줄 · exit 0" zoom="true" :::

[네 단계 시퀀스]
  0. 시작                              반환 -      상태 HEALTHY                사유 (없음)                헬스 true
  1. 제공자가 자격증명 거부                    반환 -      상태 AUTHENTICATION_FAILED  사유 INVALID_CREDENTIAL  헬스 false
  2. 운영자가 HEALTHY 요청                 반환 false  상태 AUTHENTICATION_FAILED  사유 INVALID_CREDENTIAL  헬스 false
  3. 운영자가 DRAINING 요청                반환 true   상태 DRAINING               사유 DRAINING            헬스 true
  4. 운영자가 다시 HEALTHY 요청              반환 true   상태 HEALTHY                사유 (없음)                헬스 true

4 단계에는 읽을 인증 실패가 없다. 3 단계가 그것을 지웠기 때문이다.

경보가 먼저 꺼진다

헬스 열이 3 단계에서 이미 참이다. 보고기가 비정상으로 보는 상태 목록에 배수가 없다.

63:      ProviderRuntimeState state = runtime.get().state();
64:      if (state == ProviderRuntimeState.AUTHENTICATION_FAILED
65:          || state == ProviderRuntimeState.DISABLED) {
66:        healthy = false;
67:      }

같은 클래스의 자바독이 무엇을 지키려 했는지 적는다.

18: * <p>A provider whose credentials were rejected reports unhealthy even though the process is fine:
19: * that is exactly the condition an operator needs paged on, and it is invisible from process-level
20: * health.

3 단계에서 그 호출이 꺼진다. 자격증명은 그대로 거부된 그것이다.

비활성 경로는 다르다.

[DISABLED 로도 같은지]
  3'. 운영자가 DISABLED 요청               반환 true   상태 DISABLED               사유 DISABLED            헬스 false
  4'. 운영자가 HEALTHY 요청                반환 true   상태 HEALTHY                사유 (없음)                헬스 true

상태 기계는 같지만 3' 단계에서는 지시등이 내려간 채로 있다. 두 경로가 같은 것은 상태까지다.

[비교] 가드가 있는 전이로는 통과하지 못한다
  3". 운영자가 DEGRADED 요청               반환 false  상태 AUTHENTICATION_FAILED  사유 INVALID_CREDENTIAL  헬스 false
  4". 운영자가 HEALTHY 요청                반환 false  상태 AUTHENTICATION_FAILED  사유 INVALID_CREDENTIAL  헬스 false

우회를 만드는 것은 관리자 포트가 아니라 가드가 없는 두 전이다.

원인 코드는 누가 읽는가

3 단계에서 INVALID_CREDENTIAL 이 사라진다. 그런데 그 값을 읽는 프로덕션 코드가 없다.

    ProviderRuntime.java:84:   * <p>Prefer this to calling {@link #state()} and {@link #unhealthyReason()} in turn: two reads
    ProviderRuntime.java:97:  public Optional<String> unhealthyReason() {

선언과 자기 자바독뿐이다. 헬스 스냅숏도 사유를 싣지 않는다.

    public record ProviderHealth(
      String profileId, String state, long credentialGeneration, int activeAttempts) {

사라지는 것은 상태와 짝을 이루던 런타임 자신의 설명이고, 원인 코드 자체는 시도 기록에 남는다.

test 가 못 보는 조합

124:  @DisplayName("an operator can resume a drained or disabled runtime but not a failed credential")
125:  void resumeHealthyClearsOperatorStatesOnly() {
126:    ProviderRuntime drained = runtime(1, 1);
127:    drained.markDraining();
128:    assertThat(drained.resumeHealthy()).isTrue();
129:
130:    ProviderRuntime disabled = runtime(1, 1);
131:    disabled.markDisabled();
132:    assertThat(disabled.resumeHealthy()).isTrue();
133:
134:    ProviderRuntime failed = runtime(1, 1);
135:    failed.markAuthenticationFailed("INVALID_CREDENTIAL");
136:    assertThat(failed.resumeHealthy())

런타임 셋을 각각 새로 만든다. 세 객체가 만나지 않으므로 인증 실패한 런타임을 배수로 보내는 순서가 생기지 않는다.

합성을 아예 안 보는 것은 아니다.

143:  @DisplayName("degrading an already failed runtime is refused rather than silently ignored")
144:  void degradingAFailedRuntimeIsRefused() {
145:    ProviderRuntime runtime = runtime(1, 1);
146:    runtime.markAuthenticationFailed("INVALID_CREDENTIAL");

가드가 있는 전이 위에서는 한 런타임에 겹쳐 본다. 겹쳐 보지 않는 것은 가드가 없는 두 전이 쪽이다.

확인하지 못한 것

관리자 응용 서비스를 통과시키지 않았다. 탐침은 런타임을 직접 부르므로 권한 확인과 멱등 재생과 감사 기록, 그리고 거부를 예외로 바꾸는 단계가 빠져 있다.

정상이 된 런타임에 실제 알림을 흘려 시도가 제공자를 향해 나가는지 재현하지 않았다.