docs(keycloak-session-store): import the session-storage lab as a new project

The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.

Follows the import procedure in README.md.

  source/     the originating repository verbatim — 78 documents, 28 SVGs,
              8 manifests, plus .source-revision recording the commit
  final/      the SSOT
    document.md   729 lines written from the 29 experiment documents, not
                  concatenated: what was predicted, what was measured, and
                  where the measurement itself was wrong
    evidence/raw    125 outputs, flattened to <experiment>__<file> because
                    the originals collided (01-baseline.txt appeared three
                    times) and the audit only globs the top level
    evidence/meta   one per raw file; command and exitCode are null and the
                    README says why rather than inventing them
    evidence/browser  22 captures
    assets/       three diagrams through techviz
    .techviz/     their VizSpecs

A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.

Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.

verify-pipeline.py passes. audit-records.py reports no issues.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-04 22:51:59 +09:00
co-authored by Claude Opus 5
parent 43bccd08a8
commit b2963105a8
5017 changed files with 372751 additions and 4943 deletions
@@ -0,0 +1,95 @@
---
kind: CASE
slug: grpc-advanced-diagnostics-f03
title: "실환경 증거" 가 두 리프에 반씩 있고 서로 만나지 않는다
topic: contract-domain-and-bounds
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:grpc-advanced-diagnostics-f03
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-advanced-diagnostics-f03
file: ../../../final/evidence/rendered/grpc-advanced-diagnostics-f03.svg
evidence:
- ../../../final/evidence/raw/grpc-advanced-diagnostics-f03.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-advanced-diagnostics.md#L218 이다.
module: grpc-advanced-diagnostics
priority: P3
---
# "실환경 증거" 가 두 리프에 반씩 있고 서로 만나지 않는다
이 리프가 능력별로 무엇이 실환경인지 정의한다. 그리고 grpc-advanced-bootstrap 이 승격 증거로 그것을 요구한다.
## 문제
이 리프가 능력별로 무엇이 실환경인지 정의한다.
그리고 grpc-advanced-bootstrap 이 승격 증거로 그것을 요구한다.
## 결론
GrpcAdvancedPromotionGate.evaluate 는 그 불리언이 거짓이면 "xds has no real environment test" 를 차단 사유로 낸다.
그 불리언을 무엇으로 채워야 하는지는 그쪽에서 답하지 않고, 답하는 코드가 이 리프에 있는데 두 쪽이 서로를 부르지 않는다.
결과: GrpcAdvancedPromotionEvidence.complete(XDS, 7일) 은 realEnvironmentTest = true 를 그냥 넣는다.
xDS 통제 평면이 실제로 있었는지와 무관하다.
이 리프의 javadoc 이 경계한 상태 — "a suite that runs without the infrastructure passes and establishes nothing" — 를 승격 게이트가 그대로 통과시킬 수 있다.
두 리프 모두 배선되지 않았고 승격은 사람이 수행한다.
다만 이 두 조각이 존재하는 이유가 "그 판단을 코드로 적어 두는 것" 이므로, 판단의 절반이 다른 절반을 부르지 않는 것은 그 목적에 어긋난다.
grpc-advanced-edition §17.2 가 같은 가족에서 같은 모양을 기록했다 — 두 승격 게이트가 서로를 부르지 않는다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : GrpcAdvancedPromotionGate 참조 14건 검색과 두 리프의 실환경 증거 정의 대조
소스 수정 : x
## 재현 조건
원문은 analysis/grpc/grpc-advanced-diagnostics.md#L218 에 있다.
## 본문
<!-- body:start -->
이 리프가 능력별로 무엇이 실환경인지 정의한다.
```java
public static Set<Infrastructure> requiredFor(GrpcAdvancedCapability capability) { }
public static List<String> missingInfrastructure(GrpcAdvancedCapability capability, Set<Infrastructure> available) { }
```
그리고 `grpc-advanced-bootstrap` 이 승격 증거로 그것을 요구하는데, 증거는 불리언 하나다.
## GrpcAdvancedPromotionGate 참조 위치
:::evidence key="grpc-advanced-diagnostics-f03" alt="코드베이스에서 GrpcAdvancedPromotionGate 를 검색한 출력 14줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GrpcAdvancedPromotionGate 코드베이스 검색 — 14줄 · exit 0" zoom="true"
:::
## 두 쪽이 서로를 부르지 않는다
`GrpcAdvancedPromotionGate.evaluate` 는 그 불리언이 거짓이면 "xds has no real environment test" 를 차단 사유로 낸다. 그 불리언을 무엇으로 채워야 하는지는 그쪽에서 답하지 않고, 답하는 코드가 이 리프에 있다. 결과적으로 `GrpcAdvancedPromotionEvidence.complete(XDS, 7일)``realEnvironmentTest = true` 를 그냥 넣는다 — xDS 통제 평면이 실제로 있었는지와 무관하게. 이 리프의 javadoc 이 경계한 상태("a suite that runs without the infrastructure passes and establishes nothing")를 승격 게이트가 그대로 통과시킬 수 있다.
## 왜 P3 인가
두 리프 모두 배선되지 않았고 승격은 사람이 수행한다. 다만 이 두 조각이 존재하는 이유가 "그 판단을 코드로 적어 두는 것" 이므로, 판단의 절반이 다른 절반을 부르지 않는 것은 그 목적에 어긋난다. `grpc-advanced-edition` §17.2 가 같은 가족에서 같은 모양을 기록했다.
## 수정
`GrpcAdvancedPromotionEvidence.realEnvironmentTest` 를 불리언 대신 `Set<Infrastructure> availableInfrastructure` 로 바꾸고, 게이트가 `missingInfrastructure(capability, available)` 를 불러 그 결과를 차단 사유에 합친다. 그러면 "실환경 테스트를 했다" 가 선언이 아니라 능력별 목록에 대한 대조가 된다.
## 확인하지 못한 것
두 리프를 실제로 이어 승격 증거가 흐르는지 확인하지 않았다. 각 리프가 선언한 항목의 대조로 판정했다.
<!-- body:end -->
@@ -0,0 +1,76 @@
---
kind: CASE
slug: grpc-advanced-resilience-f02
title: 대체 선택기는 사용자 정의 선택기가 받는 보호를 받지 않는다
topic: contract-domain-and-bounds
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:grpc-advanced-resilience-f02
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-advanced-resilience-f02
file: ../../../final/evidence/rendered/grpc-advanced-resilience-f02.svg
evidence:
- ../../../final/evidence/raw/grpc-advanced-resilience-f02.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-advanced-resilience.md#L155 이다.
module: grpc-advanced-resilience
priority: P3
---
# 대체 선택기는 사용자 정의 선택기가 받는 보호를 받지 않는다
fallback.pick(selectable) 은 감싸이지 않는다. 대체가 던지면 예외가 그대로 올라가고, 널이나 목록 밖 엔드포인트를 돌려주면 그대로 결정이 된다.
## 문제
fallback.pick(selectable) 은 감싸이지 않는다.
대체가 던지면 예외가 그대로 올라가고, 널이나 목록 밖 엔드포인트를 돌려주면 그대로 결정이 된다.
## 결론
기본 생성자는 플랫폼의 라운드 로빈을 대체로 쓰므로 지금은 안전하다.
그러나 두 인자 생성자가 임의의 선택기를 대체로 받고, 그 인자에는 아무 제약이 없다.
이 클래스의 존재 이유가 "선택기 버그가 가용성이 아니라 균형을 저하시키게 하는 것" 인데, 대체 선택기의 버그는 가용성을 저하시킨다.
수정은 대체 호출도 같은 검사를 지나게 하거나(그 결과가 널이거나 목록 밖이면 플랫폼 라운드 로빈으로 한 번 더 떨어진다), 두 인자 생성자를 없애 대체를 플랫폼 것으로 고정하는 것이다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : 사용자 정의 선택기와 대체 선택기의 호출 지점에 걸린 방어 코드 대조
소스 수정 : x
## 재현 조건
원문은 analysis/grpc/grpc-advanced-resilience.md#L155 에 있다.
## 본문
<!-- body:start -->
`fallback.pick(selectable)` 은 감싸이지 않는다. 대체가 던지면 예외가 그대로 올라가고, 널이나 목록 밖 엔드포인트를 돌려주면 그대로 결정이 된다.
## 대체 선택기가 감싸이지 않는다
:::evidence key="grpc-advanced-resilience-f02" alt="분석 문서 analysis/grpc/grpc-advanced-resilience.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/grpc/grpc-advanced-resilience.md 발췌 — 15줄" zoom="true"
:::
## 지금은 안전하다
기본 생성자는 플랫폼의 라운드 로빈을 대체로 쓴다. 그러나 두 인자 생성자가 임의의 선택기를 대체로 받고, 그 인자에는 아무 제약이 없다.
## 클래스의 존재 이유가 뒤집힌다
"선택기 버그가 가용성이 아니라 균형을 저하시키게 하는 것" 인데, 대체 선택기의 버그는 가용성을 저하시킨다. 수정은 대체 호출도 같은 검사를 지나게 하거나(결과가 널이거나 목록 밖이면 플랫폼 라운드 로빈으로 한 번 더 떨어진다), 두 인자 생성자를 없애 대체를 플랫폼 것으로 고정하는 것이다.
## 확인하지 못한 것
대체 선택기가 널이나 목록 밖 엔드포인트를 돌려주는 상황을 실행으로 재현하지 않았다. 두 호출 지점의 감싸기 유무로 판정했다.
<!-- body:end -->
@@ -0,0 +1,84 @@
---
kind: CASE
slug: grpc-advanced-streaming-f01
title: 클래스가 비판한 무제한 증가를 형제 맵이 그대로 한다
topic: contract-domain-and-bounds
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:grpc-advanced-streaming-f01
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-advanced-streaming-f01
file: ../../../final/evidence/rendered/grpc-advanced-streaming-f01.svg
evidence:
- ../../../final/evidence/raw/grpc-advanced-streaming-f01.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-advanced-streaming.md#L125 이다.
module: grpc-advanced-streaming
priority: P3
---
# 클래스가 비판한 무제한 증가를 형제 맵이 그대로 한다
클래스 javadoc 이 집합 방식을 거부한 이유가 무제한 증가다 — "A set grows without bound for the life of a session". 체크포인트는 그 비판을 지킨다.
## 문제
클래스 javadoc 이 집합 방식을 거부한 이유가 무제한 증가다 — "A set grows without bound for the life of a session".
체크포인트는 그 비판을 지킨다.
## 결론
세션당 항목 하나이고 순번만 앞으로 간다.
형제 맵은 지키지 않는다.
제거는 endSession 뿐이고, 그때 그 세션의 접두를 가진 키를 전부 지운다.
그러므로 결과 참조를 기록하는 세션에서는 적용된 메시지 수만큼 항목이 쌓인다.
상한도 만료도 없다.
클래스 javadoc 은 다르게 말한다.
작은 창이 코드에 없다.
체크포인트가 앞으로 가도 그 이전 결과들은 남는다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : 체크포인트와 형제 맵의 제거 경로 유무 대조, 클래스 javadoc 의 거부 근거 확인
소스 수정 : x
## 재현 조건
원문은 analysis/grpc/grpc-advanced-streaming.md#L125 에 있다.
## 본문
<!-- body:start -->
클래스 javadoc 이 집합 방식을 거부한 이유가 무제한 증가다 — "A set grows without bound for the life of a session".
## javadoc 이 집합 방식을 거부한 이유
:::evidence key="grpc-advanced-streaming-f01" alt="분석 문서 analysis/grpc/grpc-advanced-streaming.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/grpc/grpc-advanced-streaming.md 발췌 — 15줄" zoom="true"
:::
## 체크포인트는 그 비판을 지키고 형제 맵은 지키지 않는다
체크포인트는 세션당 항목 하나이고 순번만 앞으로 간다. 형제 맵의 제거는 `endSession` 뿐이고, 그때 그 세션의 접두를 가진 키를 전부 지운다. 그러므로 결과 참조를 기록하는 세션에서는 적용된 메시지 수만큼 항목이 쌓인다 — 상한도 만료도 없다. 클래스 javadoc 은 다르게 말한다: 작은 창이 코드에 없다.
## 실제로 필요한 창은 좁다
판정이 `alreadyApplied(sequence)` 로 재생을 결정하고, 재생 응답에 쓰이는 것은 그 순번의 결과 하나다. 체크포인트보다 한참 뒤처진 순번의 결과가 필요할 상황은 재개 직후의 좁은 구간뿐이다. 수정은 창을 실제로 만드는 것이다 — 세션당 최근 N개만 유지하거나, 체크포인트가 앞으로 갈 때 그보다 오래된 항목을 지운다. 후자가 자바독의 서술과 정확히 같다.
## 확인하지 못한 것
장시간 실행으로 이 맵의 증가를 측정하지 않았다. 제거 경로가 없다는 것으로 판정했다.
<!-- body:end -->
@@ -0,0 +1,78 @@
---
kind: CASE
slug: grpc-advanced-streaming-f02
title: 클라이언트 스트림 정책의 네 상한 중 둘은 읽는 코드가 없다
topic: contract-domain-and-bounds
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:grpc-advanced-streaming-f02
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-advanced-streaming-f02
file: ../../../final/evidence/rendered/grpc-advanced-streaming-f02.svg
evidence:
- ../../../final/evidence/raw/grpc-advanced-streaming-f02.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-advanced-streaming.md#L159 이다.
module: grpc-advanced-streaming
priority: P3
---
# 클라이언트 스트림 정책의 네 상한 중 둘은 읽는 코드가 없다
GrpcClientStreamPolicy javadoc 이 네 상한을 모두 든다. 저장소 전체에서 접근자 호출을 세면 둘이 0 이다.
## 문제
GrpcClientStreamPolicy javadoc 이 네 상한을 모두 든다.
저장소 전체에서 접근자 호출을 세면 둘이 0 이다.
## 결론
Advanced 가족이 미배선이라는 사실과는 별개다 — 이 리프 안에도 그 값을 쓰는 코드가 없다.
수요 상한을 강제하는 GrpcDemandController 는 GrpcManualFlowControlPolicy 를 쓰고, 이 정책을 보지 않는다.
wholeStreamRetryAllowed() 는 항상 거짓을 돌려주는 형태이므로 그 자체가 문서화 장치다.
나머지 둘은 강제 지점이 필요하다.
수정은 상한을 강제하는 지점을 만들거나(수신 경로에 속도·미확인 수 검사), 강제되지 않는 값이 강제되는 것처럼 읽히지 않도록 자바독을 낮추는 것이다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : GrpcClientStreamPolicy 참조 10건 검색으로 네 상한의 접근자 호출 수 집계
소스 수정 : x
## 재현 조건
원문은 analysis/grpc/grpc-advanced-streaming.md#L159 에 있다.
## 본문
<!-- body:start -->
`GrpcClientStreamPolicy` javadoc 이 네 상한을 모두 든다. 저장소 전체에서 접근자 호출을 세면 둘이 0 이다.
## GrpcClientStreamPolicy 참조 위치
:::evidence key="grpc-advanced-streaming-f02" alt="코드베이스에서 GrpcClientStreamPolicy 를 검색한 출력 10줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GrpcClientStreamPolicy 코드베이스 검색 — 10줄 · exit 0" zoom="true"
:::
## Advanced 미배선과는 별개다
이 리프 안에도 그 값을 쓰는 코드가 없다. 수요 상한을 강제하는 `GrpcDemandController``GrpcManualFlowControlPolicy` 를 쓰고, 이 정책을 보지 않는다.
## 넷 중 하나는 그 자체가 문서화 장치다
`wholeStreamRetryAllowed()` 는 항상 거짓을 돌려주는 형태다. 나머지 둘은 강제 지점이 필요하다. 수정은 상한을 강제하는 지점을 만들거나(수신 경로에 속도·미확인 수 검사), 강제되지 않는 값이 강제되는 것처럼 읽히지 않도록 자바독을 낮추는 것이다.
## 확인하지 못한 것
실제 스트림을 열어 두 상한이 무시되는 것을 관측하지 않았다. 배선 경로가 없다.
<!-- body:end -->
@@ -0,0 +1,88 @@
---
kind: CASE
slug: grpc-core-api-f05
title: 메타데이터 예산의 두 성분 중 하나는 강제되지 않고, 나머지 하나는 바이트가 아니라 문자를 센다
topic: contract-domain-and-bounds
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:grpc-core-api-f05
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-core-api-f05
file: ../../../final/evidence/rendered/grpc-core-api-f05.svg
evidence:
- ../../../final/evidence/raw/grpc-core-api-f05.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-core-api.md#L219 이다.
module: grpc-core-api
priority: P3
---
# 메타데이터 예산의 두 성분 중 하나는 강제되지 않고, 나머지 하나는 바이트가 아니라 문자를 센다
GrpcMetadataBudget 은 세 성분을 갖는다 — maxTotalBytes·maxUserDefinedBytes·maxEntries. check(...) 가 보는 것은 뒤의 둘뿐이다.
## 문제
GrpcMetadataBudget 은 세 성분을 갖는다 — maxTotalBytes·maxUserDefinedBytes·maxEntries.
check(...) 가 보는 것은 뒤의 둘뿐이다.
## 결론
저장소 전체에서 이 접근자를 부르는 곳은 compact constructor 의 순서 가드와 테스트 단언 하나뿐이다.
자바독은 그 이유를 설명한다 — 하드 총계를 넘기는 것은 프레임워크가 던지는 전송 거절이고, 여기서 함께 검사하면 "고칠 수 있는 쪽" 과 "고칠 수 없는 쪽" 이 한 자리에서 발견된다는 것.
판단은 옳다.
다만 그 결과로 이 record 는 자기가 쓰지 않는 수를 성분으로 들고 있고, 이름은 그것이 강제된다고 읽힌다.
성분 이름은 ...Bytes 인데 세는 것은 String.length(), 즉 UTF-16 코드 단위다.
키는 [a-z0-9._-] 로 제한되어 ASCII 지만 값에는 문자 집합 제약이 없다.
다중 바이트 문자를 담은 값은 실제 프레임보다 적게 계산된다.
gRPC 의 ASCII 메타데이터 값은 프로토콜 상 인쇄 가능 ASCII 여야 하므로 실무에서는 대개 일치한다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : GrpcMetadataBudget 참조 26건 검색과 check 가 실제로 보는 성분 확인
소스 수정 : x
## 재현 조건
원문은 analysis/grpc/grpc-core-api.md#L219 에 있다.
## 본문
<!-- body:start -->
`GrpcMetadataBudget` 은 세 성분을 갖는다 — `maxTotalBytes`·`maxUserDefinedBytes`·`maxEntries`. `check(...)` 가 보는 것은 뒤의 둘뿐이다. 저장소 전체에서 `maxTotalBytes` 접근자를 부르는 곳은 compact constructor 의 순서 가드와 테스트 단언 하나뿐이다.
## GrpcMetadataBudget 참조 위치
:::evidence key="grpc-core-api-f05" alt="코드베이스에서 GrpcMetadataBudget 를 검색한 출력 26줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GrpcMetadataBudget 코드베이스 검색 — 26줄 · exit 0" zoom="true"
:::
## 그 판단은 옳다
자바독이 이유를 설명한다 — 하드 총계를 넘기는 것은 프레임워크가 던지는 전송 거절이고, 여기서 함께 검사하면 "고칠 수 있는 쪽" 과 "고칠 수 없는 쪽" 이 한 자리에서 발견된다. 다만 그 결과로 이 record 는 자기가 쓰지 않는 수를 성분으로 들고 있고, 이름은 그것이 강제된다고 읽힌다.
## 이름은 Bytes 인데 세는 것은 문자다
`String.length()`, 즉 UTF-16 코드 단위다. 키는 `[a-z0-9._-]` 로 제한되어 ASCII 지만 값에는 문자 집합 제약이 없으므로, 다중 바이트 문자를 담은 값은 실제 프레임보다 적게 계산된다.
## 실무에서는 대개 일치한다
gRPC 의 ASCII 메타데이터 값은 프로토콜 상 인쇄 가능 ASCII 여야 한다. 다만 그 제약을 이 클래스가 검사하지 않으므로, 일치는 보장이 아니라 관행이다. 수정은 둘 다 작다 — `value.getBytes(StandardCharsets.US_ASCII).length` 로 세거나 값의 문자 집합을 `GrpcMetadataKey.Kind.ASCII` 에 맞춰 검증하고, `maxTotalBytes` 는 성분에서 빼고 javadoc 의 서술로 남긴다.
## 확인하지 못한 것
다국어 헤더 값으로 문자 수와 바이트 수의 차이를 실행으로 관측하지 않았다. 계산 대상의 코드 형태로 판정했다.
<!-- body:end -->
@@ -0,0 +1,80 @@
---
kind: CASE
slug: grpc-discovery-f01
title: 프로파일이 스트림 재접속 예산을 선언하는데 그것이 함의하는 DNS 갱신 주기를 정하지 않는다
topic: contract-domain-and-bounds
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:grpc-discovery-f01
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-discovery-f01
file: ../../../final/evidence/rendered/grpc-discovery-f01.svg
evidence:
- ../../../final/evidence/raw/grpc-discovery-f01.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-discovery.md#L152 이다.
module: grpc-discovery
priority: P3
---
# 프로파일이 스트림 재접속 예산을 선언하는데 그것이 함의하는 DNS 갱신 주기를 정하지 않는다
GrpcKubernetesProfile 은 세 시간 값을 다룬다. 검증기는 앞의 둘만 비교한다 — 배수 유예가 재접속 예산보다 짧으면 위반.
## 문제
GrpcKubernetesProfile 은 세 시간 값을 다룬다.
검증기는 앞의 둘만 비교한다 — 배수 유예가 재접속 예산보다 짧으면 위반.
## 결론
셋째는 비교 대상에 없다.
그래서 headlessStreaming()(재접속 예산 5초, 배수 유예 30초)에서 갱신 주기는 여전히 30초다.
롤아웃으로 스트림이 끊긴 클라이언트가 5초 예산 안에 재접속하려 할 때, 그 클라이언트의 DNS 캐시는 최대 30초 동안 사라진 파드 주소를 들고 있을 수 있다.
그 실패가 GrpcResolverProfile 자신의 javadoc 이 서술한 것이다 — "A channel that resolved once at startup keeps sending to addresses that stopped existing an hour ago; the calls fail with UNAVAILABLE and the deployment looks unhealthy long after it finished." 수정은 갱신 주기를 재접속 예산에서 파생시키거나(예: 예산 이하), 검증기에 세 값의 순서 규칙을 추가하는 것이다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : GrpcKubernetesProfile 참조 26건 검색과 검증기가 비교하는 시간 값 쌍 확인
소스 수정 : x
## 재현 조건
원문은 analysis/grpc/grpc-discovery.md#L152 에 있다.
## 본문
<!-- body:start -->
`GrpcKubernetesProfile` 은 세 시간 값을 다룬다. 검증기는 앞의 둘만 비교한다 — 배수 유예가 재접속 예산보다 짧으면 위반.
## GrpcKubernetesProfile 참조 위치
:::evidence key="grpc-discovery-f01" alt="코드베이스에서 GrpcKubernetesProfile 를 검색한 출력 26줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GrpcKubernetesProfile 코드베이스 검색 — 26줄 · exit 0" zoom="true"
:::
## 셋째는 비교 대상에 없다
그래서 `headlessStreaming()`(재접속 예산 5초, 배수 유예 30초)에서 갱신 주기는 여전히 30초다.
## 그 조합이 만드는 상황
롤아웃으로 스트림이 끊긴 클라이언트가 5초 예산 안에 재접속하려 할 때, 그 클라이언트의 DNS 캐시는 최대 30초 동안 사라진 파드 주소를 들고 있을 수 있다. 그 실패가 `GrpcResolverProfile` 자신의 javadoc 이 서술한 것이다 — "A channel that resolved once at startup keeps sending to addresses that stopped existing an hour ago; the calls fail with `UNAVAILABLE` and the deployment looks unhealthy long after it finished."
## 수정
갱신 주기를 재접속 예산에서 파생시키거나(예: 예산 이하), 검증기에 세 값의 순서 규칙을 추가하는 것이다.
## 확인하지 못한 것
실제 DNS 리졸버로 헤드리스 레코드를 조회해 갱신 주기와 재접속 예산의 관계를 관측하지 않았다. 이 리프는 주소 수를 입력으로 받는다.
<!-- body:end -->
@@ -0,0 +1,82 @@
---
kind: CASE
slug: grpc-operation-ledger-jpa-f01
title: 낙관적 잠금 컬럼이 없어 전이 가드가 메모리 안에만 있다
topic: contract-domain-and-bounds
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:grpc-operation-ledger-jpa-f01
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-operation-ledger-jpa-f01
file: ../../../final/evidence/rendered/grpc-operation-ledger-jpa-f01.svg
evidence:
- ../../../final/evidence/raw/grpc-operation-ledger-jpa-f01.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-operation-ledger-jpa.md#L184 이다.
module: grpc-operation-ledger-jpa
priority: P3
---
# 낙관적 잠금 컬럼이 없어 전이 가드가 메모리 안에만 있다
requireInProgress() 가 두 번째 종결 전이를 막는다. 그 가드는 한 영속성 컨텍스트 안의 인스턴스 상태에만 적용된다.
## 문제
requireInProgress() 가 두 번째 종결 전이를 막는다.
그 가드는 한 영속성 컨텍스트 안의 인스턴스 상태에만 적용된다.
## 결론
엔티티에 @Version 이 없으므로 두 트랜잭션이 같은 행을 읽어 각각 전이하면 나중 쓰기가 앞의 것을 덮는다.
DB 의 세 CHECK 제약은 행의 모양을 지키지 지 전이 순서를 지키지 않는다.
COMMITTED 행이 다른 결과 참조로 갱신되는 것을 막는 제약이 없다.
청구가 배타적이라는 설계 전제 아래서는 도달성이 낮다.
다만 §17.1 을 고치면 이 전제가 실제로 성립하는지가 함께 확인되어야 한다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : 엔티티의 잠금 컬럼 선언 유무와 requireInProgress 가드가 적용되는 범위 확인
소스 수정 : x
## 재현 조건
원문은 analysis/grpc/grpc-operation-ledger-jpa.md#L184 에 있다.
## 본문
<!-- body:start -->
`requireInProgress()` 가 두 번째 종결 전이를 막는다. 그 가드는 한 영속성 컨텍스트 안의 인스턴스 상태에만 적용된다.
## requireInProgress() 가 적용되는 범위
:::evidence key="grpc-operation-ledger-jpa-f01" alt="분석 문서 analysis/grpc/grpc-operation-ledger-jpa.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/grpc/grpc-operation-ledger-jpa.md 발췌 — 15줄" zoom="true"
:::
## @Version 이 없다
엔티티에 낙관적 잠금 컬럼이 없으므로 두 트랜잭션이 같은 행을 읽어 각각 전이하면 나중 쓰기가 앞의 것을 덮는다.
## DB 제약은 모양을 지키지 순서를 지키지 않는다
세 CHECK 제약은 행의 모양을 지킨다. `COMMITTED` 행이 다른 결과 참조로 갱신되는 것을 막는 제약이 없다.
## 청구가 배타적이라는 전제 아래서는 도달성이 낮다
다만 §17.1 을 고치면 이 전제가 실제로 성립하는지가 함께 확인되어야 한다.
## 확인하지 못한 것
실제 데이터베이스로 청구를 두 번 돌려 재현하지 않았다. 동시 청구를 실제 커넥션 둘로도 재현하지 않았다.
<!-- body:end -->
@@ -0,0 +1,74 @@
---
kind: CASE
slug: messaging-core-api-f05
title: WireSafeText의 규칙이 leaf 경계에서 멈춘다
topic: contract-domain-and-bounds
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-core-api-f05
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-core-api-f05
file: ../../../final/evidence/rendered/messaging-core-api-f05.svg
evidence:
- ../../../final/evidence/raw/messaging-core-api-f05.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-core-api.md#L880 이다.
module: messaging-core-api
priority: P3
---
# WireSafeText의 규칙이 leaf 경계에서 멈춘다
WireSafeText의 leaf 밖 참조 0. 제어문자·인코딩 경계를 각자 검사하는 곳이 저장소에 최소 15개.
## 문제
WireSafeText의 leaf 밖 참조 0.
제어문자·인코딩 경계를 각자 검사하는 곳이 저장소에 최소 15개.
## 결론
javadoc이 "Each copy of this check ...
was one more place for the rule to drift"라고 적었고 그 통합을 leaf 안에서만 했다.
저장소 수준에서는 같은 drift가 그대로 남아 있다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : WireSafeText 참조 11건 검색과 같은 검사를 각자 구현한 지점 집계
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-core-api.md#L880 에 있다.
## 본문
<!-- body:start -->
`WireSafeText`의 leaf 밖 참조가 0이다. 제어문자·인코딩 경계를 각자 검사하는 곳이 저장소에 최소 15개 있다.
## WireSafeText 참조 위치
:::evidence key="messaging-core-api-f05" alt="코드베이스에서 WireSafeText 를 검색한 출력 11줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="WireSafeText 코드베이스 검색 — 11줄 · exit 0" zoom="true"
:::
## javadoc 이 통합을 이유로 들었다
"Each copy of this check ... was one more place for the rule to drift" 라고 적었고, 그 통합을 leaf 안에서만 했다. 저장소 수준에서는 같은 drift가 그대로 남아 있다.
## 후보
규칙을 공유 위치(`shared-contract`)로 올리거나, leaf 경계를 이유로 중복을 명시적으로 수용한다고 적는다. 저장소 전역 판단이므로 cross-scope 소유이고 여기서는 관측만 기록한다.
## 확인하지 못한 것
각 지점의 검사가 실제로 이 규칙과 어긋나는 입력을 통과시키는지 확인하지 않았다. 참조 경계와 구현 지점 수로 판정했다.
<!-- body:end -->
@@ -0,0 +1,75 @@
---
kind: CASE
slug: messaging-observability-f07
title: extract가 손상된 추적 헤더에 분류되지 않은 예외를 던진다
topic: contract-domain-and-bounds
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-observability-f07
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-observability-f07
file: ../../../final/evidence/rendered/messaging-observability-f07.svg
evidence:
- ../../../final/evidence/raw/messaging-observability-f07.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-observability.md#L726 이다.
module: messaging-observability
priority: P3
---
# extract가 손상된 추적 헤더에 분류되지 않은 예외를 던진다
MessagingTracer.extract가 new TraceContext(...)를 부르고, 그 생성자는 W3C 문법·바이트 상한·all-zero를 검사해 IllegalArgumentException을 던진다. extract는 잡지 않는다.
## 관계
- **타입이 문서화한 불변식은 타입이 강제한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 문제
MessagingTracer.extract가 new TraceContext(...)를 부르고, 그 생성자는 W3C 문법·바이트 상한·all-zero를 검사해 IllegalArgumentException을 던진다.
extract는 잡지 않는다.
## 결론
다른 시스템이 보낸 메시지의 헤더는 신뢰할 수 없는 입력이다.
손상된 traceparent 하나가 MessagingException이 아닌 예외로 소비 경로를 끊는다 — 추적이 없어야 할 자리에서 메시지 처리가 실패한다.
messaging-cloudevents의 id 파싱과 같은 형태다(그쪽 §17).
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : MessagingTracer 참조 4건 검색과 생성자가 던지는 조건 및 extract 의 catch 유무 확인
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-observability.md#L726 에 있다.
## 본문
<!-- body:start -->
`MessagingTracer.extract``new TraceContext(...)`를 부르고, 그 생성자는 W3C 문법·바이트 상한·all-zero를 검사해 `IllegalArgumentException`을 던진다. `extract`는 잡지 않는다.
## MessagingTracer 참조 위치
:::evidence key="messaging-observability-f07" alt="코드베이스에서 MessagingTracer 를 검색한 출력 4줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MessagingTracer 코드베이스 검색 — 4줄 · exit 0" zoom="true"
:::
## 신뢰할 수 없는 입력이다
다른 시스템이 보낸 메시지의 헤더다. 손상된 `traceparent` 하나가 `MessagingException`이 아닌 예외로 소비 경로를 끊는다 — 추적이 없어야 할 자리에서 메시지 처리가 실패한다. `messaging-cloudevents`의 id 파싱과 같은 형태다(그쪽 §17).
## 확인하지 못한 것
손상된 traceparent 가 실제 배포에서 얼마나 자주 오는지 확인하지 않았다. 생성자의 검사와 extract 의 예외 처리 부재로 판정했다.
<!-- body:end -->
@@ -0,0 +1,77 @@
---
kind: CASE
slug: messaging-reliability-api-f05
title: inbox 보존 규칙이 문서로만 있다
topic: contract-domain-and-bounds
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-reliability-api-f05
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-reliability-api-f05
file: ../../../final/evidence/rendered/messaging-reliability-api-f05.svg
evidence:
- ../../../final/evidence/raw/messaging-reliability-api-f05.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-reliability-api.md#L725 이다.
module: messaging-reliability-api
priority: P3
---
# inbox 보존 규칙이 문서로만 있다
InboxRepository.purgeProcessedBefore javadoc이 "Retention must outlive the broker's maximum redelivery window, otherwise a late redelivery arrives after its inbox row was pruned and is processed a second time"라고 한다. 그 비교를 하는 코드가 이 leaf에도 messaging-policy의 프로파일 검증기에도 없다.
## 관계
- **계약만 담는 leaf도 계약의 거절 조건은 자기 레인에서 검증한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **record의 `equals`를 좁히면 이유를 적는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **호출 컨텍스트가 계약이면 그 컨텍스트를 검증할 수단을 함께 정한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 문제
InboxRepository.purgeProcessedBefore javadoc이 "Retention must outlive the broker's maximum redelivery window, otherwise a late redelivery arrives after its inbox row was pruned and is processed a second time"라고 한다.
그 비교를 하는 코드가 이 leaf에도 messaging-policy의 프로파일 검증기에도 없다.
## 결론
위반의 결과가 부작용의 이중 실행이다 — Inbox가 존재하는 이유 그 자체가 무효화된다.
그리고 위반이 조용하다: 짧은 보존은 정상 동작처럼 보이고 늦은 재전달이 올 때만 드러난다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : InboxRepository 참조 20건 검색과 보존·재전달 창을 비교하는 코드의 존재 여부 확인
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-reliability-api.md#L725 에 있다.
## 본문
<!-- body:start -->
`InboxRepository.purgeProcessedBefore` javadoc이 "Retention must outlive the broker's maximum redelivery window, otherwise a late redelivery arrives after its inbox row was pruned and is processed a second time"라고 한다. 그 비교를 하는 코드가 이 leaf에도 `messaging-policy`의 프로파일 검증기에도 없다.
## InboxRepository 참조 위치
:::evidence key="messaging-reliability-api-f05" alt="코드베이스에서 InboxRepository 를 검색한 출력 20줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="InboxRepository 코드베이스 검색 — 20줄 · exit 0" zoom="true"
:::
## 위반의 결과가 부작용의 이중 실행이다
Inbox가 존재하는 이유 그 자체가 무효화된다. 그리고 위반이 조용하다 — 짧은 보존은 정상 동작처럼 보이고 늦은 재전달이 올 때만 드러난다.
## 확인하지 못한 것
inbox 보존 기간이 실제 배포에서 브로커 재전달 창보다 긴지 확인할 수 없었다. 비교하는 코드가 없다.
<!-- body:end -->
@@ -0,0 +1,77 @@
---
kind: CASE
slug: messaging-reliability-api-f08
title: 포트가 bounded/unbounded purge 두 오버로드를 나란히 노출하고, 호출자가 무제한 쪽을 고른다
topic: contract-domain-and-bounds
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-reliability-api-f08
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-reliability-api-f08
file: ../../../final/evidence/rendered/messaging-reliability-api-f08.svg
evidence:
- ../../../final/evidence/raw/messaging-reliability-api-f08.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-reliability-api.md#L752 이다.
module: messaging-reliability-api
priority: P3
---
# 포트가 bounded/unbounded purge 두 오버로드를 나란히 노출하고, 호출자가 무제한 쪽을 고른다
InboxRepository와 OutboxRepository가 각각 purge*Before(Instant)와 purge*Before(Instant, int)를 선언한다. 후자에 호출 지점이 0이고 두 cleanup job이 전자를 부른다.
## 관계
- **계약만 담는 leaf도 계약의 거절 조건은 자기 레인에서 검증한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **record의 `equals`를 좁히면 이유를 적는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **호출 컨텍스트가 계약이면 그 컨텍스트를 검증할 수단을 함께 정한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 문제
InboxRepository와 OutboxRepository가 각각 purge*Before(Instant)와 purge*Before(Instant, int)를 선언한다.
후자에 호출 지점이 0이고 두 cleanup job이 전자를 부른다.
## 결론
§12.1(a)의 두 세대 전이와 같은 형태다 — 한 인터페이스가 안전한 형태와 그렇지 않은 형태를 나란히 두고, @Deprecated도 이름 차이도 없으며, 호출자가 짧은 쪽을 골랐다.
두 경우 모두 포트의 형태가 오용을 가능하게 했다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : InboxRepository 참조 20건 검색과 두 오버로드의 호출 지점 집계
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-reliability-api.md#L752 에 있다.
## 본문
<!-- body:start -->
`InboxRepository``OutboxRepository`가 각각 `purge*Before(Instant)``purge*Before(Instant, int)`를 선언한다. 후자에 호출 지점이 0이고 두 cleanup job이 전자를 부른다.
## InboxRepository 참조 위치
:::evidence key="messaging-reliability-api-f08" alt="코드베이스에서 InboxRepository 를 검색한 출력 20줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="InboxRepository 코드베이스 검색 — 20줄 · exit 0" zoom="true"
:::
## §12.1(a)의 두 세대 전이와 같은 형태다
한 인터페이스가 안전한 형태와 그렇지 않은 형태를 나란히 두고, `@Deprecated`도 이름 차이도 없으며, 호출자가 짧은 쪽을 골랐다. 두 경우 모두 포트의 형태가 오용을 가능하게 했다.
## 확인하지 못한 것
무제한 오버로드가 실제 백로그에서 무엇을 잠그는지 측정하지 않았다. 판정은 구현 리프의 SSOT 가 소유하고 여기서는 포트 형태의 기여만 남겼다.
<!-- body:end -->
@@ -0,0 +1,50 @@
---
kind: QUESTION
slug: messaging-claim-check-f05
title: 보존 sweep이 없다
topic: contract-domain-and-bounds
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: open-question:messaging-claim-check-f05
questionStatus: OPEN
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
source:
- analysis/messaging/messaging-claim-check.md#L543
---
# 보존 sweep이 없다
실패한 발행이 남긴 claim check 객체를 회수할 주체가 저장소 안에 없다. 저장소 자체의 lifecycle 정책이 그 자리를 대신할 수 있지만, 정책 값과 그 lifecycle 이 연결돼 있지 않다.
## 관계
- **leaf의 각 public 클래스는 자기 레인에 테스트를 갖는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **같은 튜닝 값이 두 계층에 있으면 어느 쪽이 이기는지 정한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 사실
ClaimCheckStore.delete 가 선언돼 있고 이 leaf 에서 호출되지 않는다. git grep -n 'delete(' -- src/messaging/messaging-claim-check 가 인터페이스 선언만 돌려준다.
ClaimCheckPublisher javadoc 이 "the retention sweep reclaims it" 이라고 그 sweep 의 존재를 전제한다.
저장소 lifecycle(예: S3 object expiration)이 대신할 수 있으나 ClaimCheckPolicy.retention 이 그것과 연결되지 않는다.
## 미지수
회수 책임을 애플리케이션이 질 것인가 저장소 lifecycle 에 맡길 것인가. 이 판정이 ClaimCheckStore 구현 계획에 걸려 있다.
## 선택지
sweep 작업을 만든다
retention 값이 실제 삭제 시점을 정하고, 정책이 하나의 주인을 갖는다.
저장소 lifecycle 에 위임한다
위임한다는 사실을 javadoc 에 명시해야 retention 값이 무엇을 뜻하는지 읽힌다.
## 다음 검증
delete 호출자 검색으로 현재 상태는 확정된다. 남은 것은 구현 계획의 결정이고, 그것은 저장소 안의 사실로 닫히지 않는다.
@@ -0,0 +1,52 @@
---
kind: REFERENCE
slug: messaging-inbox-jdbc-postgresql-f05
title: 컬럼 폭은 애플리케이션 검증과 짝을 이룬다
topic: contract-domain-and-bounds
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:messaging-inbox-jdbc-postgresql-f05
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
source:
- analysis/messaging/messaging-inbox-jdbc-postgresql.md#L684
---
# 컬럼 폭은 애플리케이션 검증과 짝을 이룬다
## 관계
- **bounded purge가 구현돼 있고 호출되지 않아, cleanup이 스스로 막겠다고 한 장애를 일으킨다**
이 규칙의 근거는 같은 분석 리프의 판정이 소유한다.
- **속성을 이름으로 주장하는 테스트가 그 속성을 보일 수 없는 fake 위에서 통과한다**
이 규칙의 근거는 같은 분석 리프의 판정이 소유한다.
- **SQL 실패가 재시도 불가로 분류된다**
이 규칙의 근거는 같은 분석 리프의 판정이 소유한다.
## 목적
DB 가 먼저 거절하면 그 실패는 설정 오류가 아니라 인프라 오류의 모양으로 도착한다. 여기서는 긴 consumerId 가 SQLException 이 되고, 그것이 INBOX_RESERVE_FAILED · CONFIGURATION · retryable=false 로 분류돼 결국 설정 실수가 메시지 파킹으로 나타난다.
## 규칙
1. 컬럼에 폭이 선언돼 있으면 애플리케이션 층에도 같은 상한을 둔다
migration 이 consumer_id VARCHAR(160) 을 선언한다.
2. 지금 무엇을 검증하는지 확인한다
IdempotentConsumer · TransactionalInboxHandler · JdbcInboxRepository 셋 다 공백만 거절하고 길이를 보지 않는다.
3. 값 객체가 있으면 그 생성자가 상한을 갖는다
messaging-core-api 의 값 객체들이 바이트 상한을 생성자에서 강제하는 형태가 그쪽 §4.5 에 있다. consumerId 는 아직 값 객체가 아니다.
## 적용 조건
스키마가 폭을 정하고 그 값이 애플리케이션에서 문자열로 다뤄지는 모든 컬럼.
## 예외
폭이 실질적으로 도달 불가능할 만큼 넉넉한 경우는 예외로 둘 수 있다. 다만 그 판단이 어디에도 적혀 있지 않으면 예외가 아니라 미검증이다.
## 예시
V2__messaging_inbox.sql:10 의 컬럼 선언과 세 클래스의 검증 코드. 확인 방법은 161자 consumerId 로 reserve 를 부르는 것이다.
@@ -0,0 +1,52 @@
---
kind: REFERENCE
slug: messaging-inbox-jdbc-postgresql-f06
title: 같은 안전 규칙은 한 공식과 한 강제 시점을 갖는다
topic: contract-domain-and-bounds
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:messaging-inbox-jdbc-postgresql-f06
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
source:
- analysis/messaging/messaging-inbox-jdbc-postgresql.md#L693
---
# 같은 안전 규칙은 한 공식과 한 강제 시점을 갖는다
## 관계
- **bounded purge가 구현돼 있고 호출되지 않아, cleanup이 스스로 막겠다고 한 장애를 일으킨다**
이 규칙의 근거는 같은 분석 리프의 판정이 소유한다.
- **속성을 이름으로 주장하는 테스트가 그 속성을 보일 수 없는 fake 위에서 통과한다**
이 규칙의 근거는 같은 분석 리프의 판정이 소유한다.
- **SQL 실패가 재시도 불가로 분류된다**
이 규칙의 근거는 같은 분석 리프의 판정이 소유한다.
## 목적
같은 종류의 시간 관계가 곱셈과 덧셈으로 다르게 표현되고 강제 시점까지 다르면, 어느 배포가 그 규칙의 보호를 받는지 코드에서 읽을 수 없게 된다.
## 규칙
1. 같은 규칙이 몇 곳에 있는지 센다
보존 규칙은 세 곳에 있다. InboxRepository javadoc 은 강제하지 않고, 이 leaf 의 InboxRetentionPolicy 는 × 2.0 이며, messaging-claim-check 의 ClaimCheckPolicy 는 brokerRetention + maxRedeliveryWindow 다.
2. 공식이 같은지 본다
곱셈과 덧셈은 같은 안전 여유를 표현하지 않는다.
3. 언제 강제되는지 본다
InboxRetentionPolicy.validate() 는 InboxCleanupJob 을 만들 때만 불린다. cleanup 을 배선하지 않은 배포는 보존 검사를 아예 받지 않는다. ClaimCheckPolicy 는 항상 강제한다.
## 적용 조건
보존 기간·재전달 창·타임아웃처럼 시간 관계를 안전 여유로 표현하는 정책이 여러 leaf 에 흩어져 있는 자리.
## 예외
leaf 마다 다른 여유가 필요한 경우는 공식이 아니라 계수가 달라야 한다. 지금은 계수가 아니라 연산 자체가 다르고, 그 차이의 이유가 어느 쪽에도 적혀 있지 않다.
## 예시
세 위치의 공식과 강제 시점. 확인 방법은 그 셋을 나란히 놓고 대조하는 것이다.