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,78 @@
---
kind: CASE
slug: grpc-advanced-bootstrap-f05
title: 예외가 들고 있는 능력이 transient 라 역직렬화 뒤 사라진다
topic: schema-and-data-contracts
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:grpc-advanced-bootstrap-f05
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-advanced-bootstrap-f05
file: ../../../final/evidence/rendered/grpc-advanced-bootstrap-f05.svg
evidence:
- ../../../final/evidence/raw/grpc-advanced-bootstrap-f05.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-advanced-bootstrap.md#L312 이다.
module: grpc-advanced-bootstrap
priority: P3
---
# 예외가 들고 있는 능력이 transient 라 역직렬화 뒤 사라진다
transient 는 보통 직렬화 가능하지 않은 필드를 담은 Serializable 클래스에 대한 정적 분석 경고를 끄려고 붙인다. 그런데 열거형은 언제나 직렬화 가능하다 — 여기서 transient 가 막을 문제가 애초에 없다.
## 문제
transient 는 보통 직렬화 가능하지 않은 필드를 담은 Serializable 클래스에 대한 정적 분석 경고를 끄려고 붙인다.
그런데 열거형은 언제나 직렬화 가능하다 — 여기서 transient 가 막을 문제가 애초에 없다.
## 결론
대가는 있다.
예외가 직렬화를 거쳐 오면 capability() 가 null 이다.
메시지 문자열은 살아남으므로 사람이 읽는 데는 지장이 없고, 그래서 눈에 띄지 않는다.
이 예외를 던지는 require 자체가 리프 밖에서 불리지 않으므로(§12.1) 오늘 도달하지 않는다.
transient 를 지우는 것이 수정 전부다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : Serializable 참조 8건 검색과 예외 필드의 transient 선언 및 열거형 직렬화 성질 확인
소스 수정 : x
## 재현 조건
원문은 analysis/grpc/grpc-advanced-bootstrap.md#L312 에 있다.
## 본문
<!-- body:start -->
`transient` 는 보통 직렬화 가능하지 않은 필드를 담은 `Serializable` 클래스에 대한 정적 분석 경고를 끄려고 붙인다. 그런데 열거형은 언제나 직렬화 가능하다 — 여기서 `transient` 가 막을 문제가 애초에 없다.
## Serializable 참조 위치
:::evidence key="grpc-advanced-bootstrap-f05" alt="코드베이스에서 Serializable 를 검색한 출력 8줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="Serializable 코드베이스 검색 — 8줄 · exit 0" zoom="true"
:::
## 대가는 있다
예외가 직렬화를 거쳐 오면 `capability()``null` 이다. 메시지 문자열은 살아남으므로 사람이 읽는 데는 지장이 없고, 그래서 눈에 띄지 않는다. 이 예외를 던지는 `require` 자체가 리프 밖에서 불리지 않으므로(§12.1) 오늘 도달하지 않는다.
## 수정
`transient` 를 지우는 것이 전부다.
## 확인하지 못한 것
실제로 직렬화·역직렬화해 능력이 사라지는 것을 관측하지 않았다. 열거형이 언제나 직렬화 가능하다는 것과 transient 선언의 대조로 판정했다.
<!-- body:end -->
@@ -0,0 +1,97 @@
---
kind: CASE
slug: grpc-advanced-edition-f01
title: 비교 픽스처에 비교 대상이 없다
topic: schema-and-data-contracts
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:grpc-advanced-edition-f01
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-advanced-edition-f01
file: ../../../final/evidence/rendered/grpc-advanced-edition-f01.svg
- key: grpc-advanced-edition-f01-diagram
file: ../../../final/assets/diagrams/grpc-advanced-edition-f01.svg
evidence:
- ../../../final/evidence/raw/grpc-advanced-edition-f01.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-advanced-edition.md#L180 이다.
module: grpc-advanced-edition
priority: P2
---
# 비교 픽스처에 비교 대상이 없다
compatibility.proto 의 주석이 존재 이유를 적는다. 그 쌍둥이가 저장소에 없다.
## 문제
compatibility.proto 의 주석이 존재 이유를 적는다.
그 쌍둥이가 저장소에 없다.
## 결론
한 곳뿐이다.
같은 필드와 번호를 proto3 로 선언한 파일이 없으므로 비교가 성립하지 않는다.
그리고 두 번째 전제도 없다.
이 저장소에는 protobuf 플러그인이 어디에도 없다 — grpc-proto-contract 와 adapter-inbound-grpc 의 build.gradle 이 그 사실을 주석으로 명시한다.
그러므로 편집 파일도 proto3 파일도 컴파일되지 않고, 유선 바이트와 JSON 을 비교할 산출물 자체가 만들어지지 않는다.
결과적으로 GrpcEditionCompatibilityReport 는 사람이 손으로 채우는 기록이 된다.
승격 게이트가 그것을 읽어 판정하므로, 게이트의 입력이 측정이 아니라 선언이다.
Advanced 가족이라 오늘의 배포에는 영향이 없다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : GrpcEditionCompatibilityReport 참조 7건 검색과 픽스처 디렉터리의 파일 목록 확인
소스 수정 : x
## 재현 조건
원문은 analysis/grpc/grpc-advanced-edition.md#L180 에 있다.
## 본문
<!-- body:start -->
`compatibility.proto` 의 주석이 존재 이유를 적는다. 그 쌍둥이가 저장소에 없다.
## 비교에 필요한 것
:::evidence key="grpc-advanced-edition-f01-diagram" alt="edition 픽스처만 저장소 안에 놓이고 proto3 짝 픽스처와 컴파일 산출물이 바깥에 빗금으로 놓인다" caption="비교에 필요한 것" zoom="false"
:::
같은 필드와 번호를 proto3 로 선언한 파일이 없으므로 비교가 성립하지 않는다.
## GrpcEditionCompatibilityReport 참조 위치
:::evidence key="grpc-advanced-edition-f01" alt="코드베이스에서 GrpcEditionCompatibilityReport 를 검색한 출력 7줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GrpcEditionCompatibilityReport 코드베이스 검색 — 7줄 · exit 0" zoom="true"
:::
## 두 번째 전제도 없다
이 저장소에는 protobuf 플러그인이 어디에도 없다 — `grpc-proto-contract``adapter-inbound-grpc` 의 build.gradle 이 그 사실을 주석으로 명시한다. 그러므로 편집 파일도 proto3 파일도 컴파일되지 않고, 유선 바이트와 JSON 을 비교할 산출물 자체가 만들어지지 않는다.
## 게이트의 입력이 측정이 아니라 선언이다
`GrpcEditionCompatibilityReport` 는 사람이 손으로 채우는 기록이 되고, 승격 게이트가 그것을 읽어 판정한다. Advanced 가족이라 오늘의 배포에는 영향이 없다.
## 기록하는 이유
이 리프의 목적이 "공개 서비스가 옮겨 가기 전에 그 실패를 찾는 것" 이고, 그 실패를 찾을 장치가 픽스처 하나만 있고 짝이 없다. `compatibility_proto3.proto` 를 같은 디렉터리에 두어 필드·번호·JSON 이름을 맞추고, 두 파일을 컴파일해 산출물을 비교하는 레인을 만든다. 그 레인이 생기기 전까지는 이 보고서가 측정이 아니라 선언이라는 것을 자바독에 적는 편이 낫다.
## 확인하지 못한 것
protoc 을 돌려 이 편집 파일이 실제로 컴파일되는지 확인하지 않았다. 저장소에 protobuf 플러그인이 없다.
<!-- body:end -->
@@ -0,0 +1,84 @@
---
kind: CASE
slug: grpc-codegen-f03
title: 픽스처의 메서드 경로가 서비스 × 메서드 교차곱이다
topic: schema-and-data-contracts
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:grpc-codegen-f03
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-codegen-f03
file: ../../../final/evidence/rendered/grpc-codegen-f03.svg
evidence:
- ../../../final/evidence/raw/grpc-codegen-f03.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-codegen.md#L239 이다.
module: grpc-codegen
priority: P3
---
# 픽스처의 메서드 경로가 서비스 × 메서드 교차곱이다
stub.<name>( 호출 하나가 그 파일이 import 한 모든 서비스에 대해 메서드 경로를 만든다. javadoc 의 규칙 서술은 단수형이다 — "a method is a stub.<name>( call, mapped to <service>/<UpperCamelName>".
## 문제
stub.<name>( 호출 하나가 그 파일이 import 한 모든 서비스에 대해 메서드 경로를 만든다.
javadoc 의 규칙 서술은 단수형이다 — "a method is a stub.<name>( call, mapped to <service>/<UpperCamelName>".
## 결론
서비스가 둘 이상일 때 어느 서비스인지는 소스 텍스트만으로 알 수 없고, 코드는 전부에 붙이는 쪽을 골랐다.
결과는 존재하지 않는 메서드 경로를 요구하는 픽스처다.
서비스 둘과 메서드 셋이면 요구 경로가 여섯 개가 되고, 그중 셋은 어떤 후보 스키마에도 없으므로 breaksAgainst 가 항상 METHOD_PATH 파괴를 보고한다.
그러면 GrpcSchemaArtifactPublisher.evaluate 가 모든 발행을 거부한다.
커밋된 픽스처는 서비스가 하나(DocumentServiceGrpc)라 지금은 정확하다.
두 번째 소비자 픽스처를 추가하는 순간 성립한다.
수정은 호출자 변수의 선언 타입을 함께 읽어 메서드를 서비스에 귀속시키거나, 서비스가 둘 이상인 픽스처를 거부하는 것이다.
후자는 지금 형태의 근사를 명시적으로 만든다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : GrpcSchemaArtifactPublisher 참조 19건 검색과 메서드 경로 생성 규칙 대조
소스 수정 : x
## 재현 조건
원문은 analysis/grpc/grpc-codegen.md#L239 에 있다.
## 본문
<!-- body:start -->
`stub.<name>(` 호출 하나가 그 파일이 import 한 **모든** 서비스에 대해 메서드 경로를 만든다. javadoc 의 규칙 서술은 단수형이다 — "a method is a `stub.<name>(` call, mapped to `<service>/<UpperCamelName>`".
## GrpcSchemaArtifactPublisher 참조 위치
:::evidence key="grpc-codegen-f03" alt="코드베이스에서 GrpcSchemaArtifactPublisher 를 검색한 출력 19줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GrpcSchemaArtifactPublisher 코드베이스 검색 — 19줄 · exit 0" zoom="true"
:::
## 소스 텍스트만으로는 알 수 없어 전부에 붙였다
서비스가 둘 이상일 때 어느 서비스인지는 소스 텍스트만으로 알 수 없고, 코드는 전부에 붙이는 쪽을 골랐다. 결과는 존재하지 않는 메서드 경로를 요구하는 픽스처다 — 서비스 둘과 메서드 셋이면 요구 경로가 여섯 개가 되고, 그중 셋은 어떤 후보 스키마에도 없으므로 `breaksAgainst` 가 항상 `METHOD_PATH` 파괴를 보고한다. 그러면 `GrpcSchemaArtifactPublisher.evaluate` 가 모든 발행을 거부한다.
## 지금 픽스처는 서비스가 하나라 정확하다
`DocumentServiceGrpc` 하나다. 두 번째 소비자 픽스처를 추가하는 순간 성립한다. 수정은 호출자 변수의 선언 타입을 함께 읽어 메서드를 서비스에 귀속시키거나, 서비스가 둘 이상인 픽스처를 거부하는 것이다.
## 확인하지 못한 것
서비스가 둘 이상인 픽스처를 만들어 교차곱을 재현하지 않았다. 유도 코드로 판정했다.
<!-- body:end -->
@@ -0,0 +1,86 @@
---
kind: CASE
slug: grpc-core-api-f06
title: 직렬화 가능하다고 선언한 예외가 자기 내용을 직렬화하지 않는다
topic: schema-and-data-contracts
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:grpc-core-api-f06
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-core-api-f06
file: ../../../final/evidence/rendered/grpc-core-api-f06.svg
evidence:
- ../../../final/evidence/raw/grpc-core-api-f06.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-core-api.md#L241 이다.
module: grpc-core-api
priority: P3
---
# 직렬화 가능하다고 선언한 예외가 자기 내용을 직렬화하지 않는다
serialVersionUID 는 이 타입이 직렬화된다는 선언이고, transient 는 유일한 필드가 그 직렬화에서 빠진다는 선언이다. 둘이 함께 있으면 역직렬화된 예외는 context == null 이고, 공개 메서드 둘 중 하나(requiresReconciliation())가 NPE 를 던진다.
## 문제
serialVersionUID 는 이 타입이 직렬화된다는 선언이고, transient 는 유일한 필드가 그 직렬화에서 빠진다는 선언이다.
둘이 함께 있으면 역직렬화된 예외는 context == null 이고, 공개 메서드 둘 중 하나(requiresReconciliation())가 NPE 를 던진다.
## 결론
transient 자체는 강제된 선택이다 — GrpcFailureContext 가 Serializable 을 구현하지 않으므로 필드를 남기면 예외가 직렬화되지 않는다.
기록하는 이유는 이 리프의 서술 규율과 대비되기 때문이다.
다른 자리에서는 부재마다 이유가 붙어 있다("There is no factory that takes raw metadata, and that absence is the design").
여기에는 transient 의 이유도, 역직렬화 뒤의 계약도 적혀 있지 않다.
도달성은 낮다.
gRPC 예외가 자바 직렬화를 지나는 경로는 이 저장소에 없다.
수정은 셋 중 하나다 — GrpcFailureContext 와 그 구성 요소를 Serializable 로 만들거나, serialVersionUID 를 지워 직렬화를 지원하지 않음을 명시하거나, context() 와 requiresReconciliation() 이 null 문맥을 다루도록 하고 그 이유를 적는 것.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : GrpcFailureContext 참조 34건 검색과 예외 필드의 transient·serialVersionUID 선언 확인
소스 수정 : x
## 재현 조건
원문은 analysis/grpc/grpc-core-api.md#L241 에 있다.
## 본문
<!-- body:start -->
`serialVersionUID` 는 이 타입이 직렬화된다는 선언이고, `transient` 는 유일한 필드가 그 직렬화에서 빠진다는 선언이다. 둘이 함께 있으면 역직렬화된 예외는 `context == null` 이고, 공개 메서드 둘 중 하나(`requiresReconciliation()`)가 NPE 를 던진다.
## GrpcFailureContext 참조 위치
:::evidence key="grpc-core-api-f06" alt="코드베이스에서 GrpcFailureContext 를 검색한 출력 34줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GrpcFailureContext 코드베이스 검색 — 34줄 · exit 0" zoom="true"
:::
## transient 자체는 강제된 선택이다
`GrpcFailureContext``Serializable` 을 구현하지 않으므로 필드를 남기면 예외가 직렬화되지 않는다.
## 이 리프의 서술 규율과 대비된다
다른 자리에서는 부재마다 이유가 붙어 있다("There is no factory that takes raw metadata, and that absence is the design"). 여기에는 `transient` 의 이유도, 역직렬화 뒤의 계약도 적혀 있지 않다.
## 도달성은 낮다
gRPC 예외가 자바 직렬화를 지나는 경로는 이 저장소에 없다. 수정은 셋 중 하나다 — `GrpcFailureContext` 와 그 구성 요소를 `Serializable` 로 만들거나, `serialVersionUID` 를 지워 직렬화를 지원하지 않음을 명시하거나, 두 메서드가 null 문맥을 다루도록 하고 그 이유를 적는 것.
## 확인하지 못한 것
실제로 직렬화·역직렬화해 NPE 를 재현하지 않았다. 두 선언이 함께 있다는 것과 공개 메서드의 필드 접근으로 판정했다.
<!-- body:end -->
@@ -0,0 +1,95 @@
---
kind: CASE
slug: grpc-policy-f03
title: 직렬 스트림 기록기의 가장 오래된 것 버리기가 잘못된 메시지의 바이트를 뺀다
topic: schema-and-data-contracts
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:grpc-policy-f03
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-policy-f03
file: ../../../final/evidence/rendered/grpc-policy-f03.svg
- key: grpc-policy-f03-diagram
file: ../../../final/assets/diagrams/grpc-policy-f03.svg
evidence:
- ../../../final/evidence/raw/grpc-policy-f03.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-policy.md#L249 이다.
module: grpc-policy
priority: P2
---
# 직렬 스트림 기록기의 가장 오래된 것 버리기가 잘못된 메시지의 바이트를 뺀다
버려지는 것은 꺼낸 봉투인데 빼는 값은 새 메시지의 크기다. 봉투는 크기를 성분으로 담지 않으므로 이 지점에서 버려지는 크기를 알 방법이 없다.
## 문제
버려지는 것은 꺼낸 봉투인데 빼는 값은 새 메시지의 크기다.
봉투는 크기를 성분으로 담지 않으므로 이 지점에서 버려지는 크기를 알 방법이 없다.
## 결론
계산을 따라가면 이렇다.
한 번의 DROP_OLDEST 마다 queuedBytes 는 nextBytes 만큼 빠졌다가 enqueue 에서 같은 값만큼 다시 더해진다 — 순변화 0.
그런데 큐의 실제 내용은 nextBytes - droppedBytes 만큼 바뀐다.
그 차이가 매 낙차마다 쌓인다.
방향은 둘 다 틀렸다.
들어오는 메시지가 버려지는 것보다 크면 추적값이 실제보다 낮아져 바이트 경계가 늦게 발화한다(메모리).
반대면 실제보다 높아져 경계가 이르게 발화한다(불필요한 종료·낙차).
누적 바이트는 흐름 제어 정책의 판정 입력이고, 바이트 경계의 존재 이유가 javadoc 에 있다 — 개수 경계만 있으면 메모리 한도를 가장 큰 메시지가 정한다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : 큐에서 빼는 대상과 계수기에서 차감하는 값의 대조, 봉투 성분 확인
소스 수정 : x
## 재현 조건
원문은 analysis/grpc/grpc-policy.md#L249 에 있다.
## 본문
<!-- body:start -->
버려지는 것은 꺼낸 봉투인데 빼는 값은 새 메시지의 크기다. 봉투는 크기를 성분으로 담지 않으므로 이 지점에서 버려지는 크기를 알 방법이 없다.
## 두 자리가 가리키는 대상
:::evidence key="grpc-policy-f03-diagram" alt="큐에서 빠지는 것 쪽에 가장 오래된 봉투와 그 봉투의 바이트가 놓이고 차감되는 값 쪽에 새 메시지와 그 바이트가 놓인다" caption="두 자리가 가리키는 대상" zoom="false"
:::
## 계산을 따라가면
한 번의 DROP_OLDEST 마다 `queuedBytes``nextBytes` 만큼 빠졌다가 `enqueue` 에서 같은 값만큼 다시 더해진다 — **순변화 0**. 그런데 큐의 실제 내용은 `nextBytes - droppedBytes` 만큼 바뀐다. 그 차이가 매 낙차마다 쌓인다.
## 빼는 값과 버리는 대상
:::evidence key="grpc-policy-f03" alt="분석 문서 analysis/grpc/grpc-policy.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/grpc/grpc-policy.md 발췌 — 15줄" zoom="true"
:::
## 방향은 둘 다 틀렸다
들어오는 메시지가 버려지는 것보다 크면 추적값이 실제보다 **낮아져** 바이트 경계가 늦게 발화한다(메모리). 반대면 실제보다 **높아져** 경계가 이르게 발화한다(불필요한 종료·낙차). 누적 바이트는 흐름 제어 정책의 판정 입력이고, 바이트 경계의 존재 이유가 javadoc 에 있다 — 개수 경계만 있으면 메모리 한도를 가장 큰 메시지가 정한다.
## flush 가 오차를 끊는다
`flush()` 가 큐를 비우면서 `queuedBytes = 0L` 로 되돌리므로 오차가 flush 를 건너 누적되지는 않는다. 그래서 이것은 영구 드리프트가 아니라 한 flush 주기 안의 폭주 구간에서 바이트 경계를 잘못 판정하는 결함이다. 낙차가 일어나는 상황이 곧 소비자가 못 따라가는 상황이고, 그때 flush 간격이 가장 길어진다.
## 확인하지 못한 것
실제 스트림으로 버리기를 유발해 바이트 오차를 관측하지 않았다. 봉투가 크기를 성분으로 담지 않는다는 것으로 판정했다.
<!-- body:end -->
@@ -0,0 +1,78 @@
---
kind: CASE
slug: grpc-proto-contract-f01
title: reserved 2 to 5; 범위가 개별 숫자로만 수집되어 RESERVED_HISTORY 오탐이 된다
topic: schema-and-data-contracts
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:grpc-proto-contract-f01
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-proto-contract-f01
file: ../../../final/evidence/rendered/grpc-proto-contract-f01.svg
evidence:
- ../../../final/evidence/raw/grpc-proto-contract-f01.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-proto-contract.md#L175 이다.
module: grpc-proto-contract
priority: P3
---
# reserved 2 to 5; 범위가 개별 숫자로만 수집되어 RESERVED_HISTORY 오탐이 된다
reserved 2 to 5; 는 그룹이 "2 to 5" 이고 수집되는 것은 {2, 5} 다. 3·4 는 들어가지 않는다.
## 문제
reserved 2 to 5; 는 그룹이 "2 to 5" 이고 수집되는 것은 {2, 5} 다.
3·4 는 들어가지 않는다.
## 결론
reserved 9 to max; 는 {9} 만 남는다.
그러면 삭제 이력이 3 을 담고 스키마가 reserved 2 to 5; 로 정확히 예약했는데도 RESERVED_HISTORY 위반이 보고된다.
범위 예약은 표준 문법이고 여러 필드를 한 번에 지울 때 쓰는 형태이므로 도달 가능하다.
수정은 to 를 인식해 범위를 펼치는 것이다.
max 는 상한 상수로 다루거나 그 메시지에 대해 검사를 통과시킨다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : reserved 범위 표기의 정규식 그룹과 수집 코드가 남기는 값의 대조
소스 수정 : x
## 재현 조건
원문은 analysis/grpc/grpc-proto-contract.md#L175 에 있다.
## 본문
<!-- body:start -->
`reserved 2 to 5;` 는 그룹이 `"2 to 5"` 이고 수집되는 것은 `{2, 5}` 다. `3`·`4` 는 들어가지 않는다. `reserved 9 to max;``{9}` 만 남는다.
## 범위 표기가 수집되는 방식
:::evidence key="grpc-proto-contract-f01" alt="분석 문서 analysis/grpc/grpc-proto-contract.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/grpc/grpc-proto-contract.md 발췌 — 15줄" zoom="true"
:::
## 정확히 예약한 스키마가 위반으로 보고된다
삭제 이력이 `3` 을 담고 스키마가 `reserved 2 to 5;` 로 예약했는데도 `RESERVED_HISTORY` 위반이 나온다. 범위 예약은 표준 문법이고 여러 필드를 한 번에 지울 때 쓰는 형태이므로 도달 가능하다.
## 수정
`to` 를 인식해 범위를 펼치는 것이다. `max` 는 상한 상수로 다루거나 그 메시지에 대해 검사를 통과시킨다.
## 확인하지 못한 것
범위 문법의 오탐을 실행으로 재현하지 않았다. 정규식과 수집 코드로 판정했다.
<!-- body:end -->
@@ -0,0 +1,78 @@
---
kind: CASE
slug: grpc-proto-contract-f03
title: 커밋 스키마 게이트가 파일 목록을 하드코딩한다
topic: schema-and-data-contracts
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:grpc-proto-contract-f03
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-proto-contract-f03
file: ../../../final/evidence/rendered/grpc-proto-contract-f03.svg
evidence:
- ../../../final/evidence/raw/grpc-proto-contract-f03.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-proto-contract.md#L203 이다.
module: grpc-proto-contract
priority: P3
---
# 커밋 스키마 게이트가 파일 목록을 하드코딩한다
리소스 디렉터리를 훑지 않는다. 이 리프에 세 번째 .proto 를 추가하면 이 테스트를 함께 고치기 전까지 판정되지 않고, 빌드는 초록으로 남는다.
## 문제
리소스 디렉터리를 훑지 않는다.
이 리프에 세 번째 .proto 를 추가하면 이 테스트를 함께 고치기 전까지 판정되지 않고, 빌드는 초록으로 남는다.
## 결론
같은 저장소가 다른 곳에서 이 형태를 이미 경계했다 — 빠뜨림이 통과가 되는 게이트다.
수정은 proto/** 아래 .proto 를 전부 열거해 돌리는 것이다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : 게이트가 판정하는 파일 목록과 리소스 디렉터리의 .proto 목록 대조
소스 수정 : x
## 재현 조건
원문은 analysis/grpc/grpc-proto-contract.md#L203 에 있다.
## 본문
<!-- body:start -->
게이트가 파일 목록을 하드코딩한다.
```java
List<String> files = List.of(
"proto/hyeonworks/grpc/common/v1/error.proto",
"proto/hyeonworks/grpc/common/v1/stream.proto");
```
## 게이트가 하드코딩한 목록
:::evidence key="grpc-proto-contract-f03" alt="분석 문서 analysis/grpc/grpc-proto-contract.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/grpc/grpc-proto-contract.md 발췌 — 15줄" zoom="true"
:::
## 세 번째 파일은 판정되지 않는다
리소스 디렉터리를 훑지 않으므로, 이 리프에 세 번째 `.proto` 를 추가하면 이 테스트를 함께 고치기 전까지 판정되지 않고 빌드는 초록으로 남는다.
## 같은 저장소가 이미 경계한 형태다
빠뜨림이 통과가 되는 게이트다. 수정은 `proto/**` 아래 `.proto` 를 전부 열거해 돌리는 것이다.
## 확인하지 못한 것
세 번째 .proto 를 추가해 게이트가 침묵하는 것을 재현하지 않았다. 목록이 하드코딩이고 디렉터리를 훑지 않는다는 것으로 판정했다.
<!-- body:end -->
@@ -0,0 +1,80 @@
---
kind: CASE
slug: grpc-proto-contract-f04
title: 열거형 안의 reserved 는 수집되지 않는다
topic: schema-and-data-contracts
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:grpc-proto-contract-f04
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-proto-contract-f04
file: ../../../final/evidence/rendered/grpc-proto-contract-f04.svg
evidence:
- ../../../final/evidence/raw/grpc-proto-contract-f04.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-proto-contract.md#L265 이다.
module: grpc-proto-contract
priority: P3
---
# 열거형 안의 reserved 는 수집되지 않는다
scan 은 스코프 종류로 갈라진다. reserved 수집은 scanMessageMember 안에만 있다.
## 문제
scan 은 스코프 종류로 갈라진다.
reserved 수집은 scanMessageMember 안에만 있다.
## 결론
proto3 는 열거형에도 reserved 2, 15; 와 reserved "OLD_VALUE"; 를 허용하고, 열거형 값을 지울 때 번호를 예약하는 것은 필드와 같은 이유로 필요하다 — 예약하지 않고 재사용하면 옛 클라이언트가 보낸 정수가 다른 뜻으로 해석된다.
지금 SchemaHistory 에 열거형 이름으로 삭제 이력을 넣으면, 스키마가 정확히 예약했더라도 scan.reservedNumbers 에 그 이름이 없으므로 RESERVED_HISTORY 오탐이 난다.
§17.1 의 범위 문법 문제와 같은 방향(fail-closed)이고 같은 자리에서 고칠 수 있다.
reserved 수집을 스코프 종류와 무관하게 먼저 시도한 뒤 나머지 판정을 갈래로 보낸다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : SchemaHistory 참조 10건 검색과 scan 의 스코프 분기별 reserved 수집 위치 확인
소스 수정 : x
## 재현 조건
원문은 analysis/grpc/grpc-proto-contract.md#L265 에 있다.
## 본문
<!-- body:start -->
`scan` 은 스코프 종류로 갈라진다. `reserved` 수집은 `scanMessageMember` 안에만 있다.
## SchemaHistory 참조 위치
:::evidence key="grpc-proto-contract-f04" alt="코드베이스에서 SchemaHistory 를 검색한 출력 10줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="SchemaHistory 코드베이스 검색 — 10줄 · exit 0" zoom="true"
:::
## proto3 는 열거형에도 reserved 를 허용한다
`reserved 2, 15;``reserved "OLD_VALUE";` 다. 열거형 값을 지울 때 번호를 예약하는 것은 필드와 같은 이유로 필요하다 — 예약하지 않고 재사용하면 옛 클라이언트가 보낸 정수가 다른 뜻으로 해석된다.
## 그래서 오탐이 난다
`SchemaHistory` 에 열거형 이름으로 삭제 이력을 넣으면, 스키마가 정확히 예약했더라도 `scan.reservedNumbers` 에 그 이름이 없으므로 `RESERVED_HISTORY` 오탐이 난다.
## 같은 자리에서 고칠 수 있다
§17.1 의 범위 문법 문제와 같은 방향(fail-closed)이다. `reserved` 수집을 스코프 종류와 무관하게 먼저 시도한 뒤 나머지 판정을 갈래로 보낸다.
## 확인하지 못한 것
열거형 reserved 오탐을 실행으로 재현하지 않았다. 스코프 분기 코드로 판정했다. 블록 주석 안의 선언이 스캔되는지도 확인하지 않았다.
<!-- body:end -->
@@ -0,0 +1,73 @@
---
kind: CASE
slug: messaging-observability-f04
title: 감사 sink 인터페이스가 사용처에서 다시 선언된다
topic: schema-and-data-contracts
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-observability-f04
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-observability-f04
file: ../../../final/evidence/rendered/messaging-observability-f04.svg
evidence:
- ../../../final/evidence/raw/messaging-observability-f04.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-observability.md#L699 이다.
module: messaging-observability
priority: P3
---
# 감사 sink 인터페이스가 사용처에서 다시 선언된다
MessagingAuditSink.record(MessagingAuditEvent)와 같은 시그니처를 RedriveService:208이 자기 중첩 인터페이스로 선언한다. messaging-admin-runtime은 messaging-observability에 의존할 수 있다(registry 확인).
## 관계
- **타입이 문서화한 불변식은 타입이 강제한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 문제
MessagingAuditSink.record(MessagingAuditEvent)와 같은 시그니처를 RedriveService:208이 자기 중첩 인터페이스로 선언한다.
messaging-admin-runtime은 messaging-observability에 의존할 수 있다(registry 확인).
## 결론
MessagingAuditSink.inMemory()가 제공하는 구현을 admin-runtime이 쓸 수 없다.
그리고 감사 sink의 계약(분리된 보존·접근·무결성 요구)이 문서화된 곳과 실제로 구현되는 곳이 다르다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : 두 인터페이스의 시그니처 대조와 registry 의 의존 허용 여부 확인
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-observability.md#L699 에 있다.
## 본문
<!-- body:start -->
`MessagingAuditSink.record(MessagingAuditEvent)`와 같은 시그니처를 `RedriveService:208`이 자기 중첩 인터페이스로 선언한다. `messaging-admin-runtime``messaging-observability`에 의존할 수 있다(registry 확인).
## 같은 시그니처가 선언된 두 곳
:::evidence key="messaging-observability-f04" alt="분석 문서 analysis/messaging/messaging-observability.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/messaging/messaging-observability.md 발췌 — 15줄" zoom="true"
:::
## 두 결과
`MessagingAuditSink.inMemory()`가 제공하는 구현을 admin-runtime이 쓸 수 없다. 그리고 감사 sink의 계약(분리된 보존·접근·무결성 요구)이 문서화된 곳과 실제로 구현되는 곳이 다르다.
## 확인하지 못한 것
레닥션이 빠진 감사 이벤트가 실제로 기록되는 것을 관측하지 않았다. 시그니처 대조와 의존 선언으로 판정했다.
<!-- body:end -->
@@ -0,0 +1,87 @@
---
kind: CASE
slug: messaging-runtime-core-f03
title: 선언된 content type과 실제 인코딩이 조용히 갈라질 수 있다
topic: schema-and-data-contracts
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-runtime-core-f03
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-runtime-core-f03
file: ../../../final/evidence/rendered/messaging-runtime-core-f03.svg
evidence:
- ../../../final/evidence/raw/messaging-runtime-core-f03.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-runtime-core.md#L727 이다.
module: messaging-runtime-core
priority: P3
---
# 선언된 content type과 실제 인코딩이 조용히 갈라질 수 있다
encode가 codecs.find(message.contentType()).orElseGet(codecs::defaultCodec)으로 폴백한다. 출하 registry에는 JSON codec 하나만 등록된다.
## 관계
- **만들어 두고 흘리지 않는 진단값은 진단이 아니다**
같은 분석 리프에서 끌어낸 규칙이다.
- **증가한다고 문서화한 값이 리터럴이면 그 사실을 적는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **안정 코드는 운영자의 행동이 갈리는 지점마다 나눈다**
같은 분석 리프에서 끌어낸 규칙이다.
- **`CompletionStage`를 반환하는 메서드는 동기적으로 던지지 않는다**
같은 분석 리프에서 끌어낸 규칙이다.
## 문제
encode가 codecs.find(message.contentType()).orElseGet(codecs::defaultCodec)으로 폴백한다.
출하 registry에는 JSON codec 하나만 등록된다.
## 결론
봉투가 application/avro를 선언해도 JSON으로 인코딩되고, EncodedMessage의 content type은 codec이 정하므로 application/json이 된다.
실패하지 않고 다른 포맷으로 성공한다.
소비 측이 봉투의 원래 선언을 믿고 디코더를 고르면 어긋난다.
DestinationProfile.schema().codec()이 목적지의 codec을 선언하는데 그 값과 대조하는 코드가 이 경로에 없다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : encode 의 폴백 코드와 출하 registry 에 등록되는 codec 목록 대조
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-runtime-core.md#L727 에 있다.
## 본문
<!-- body:start -->
`encode``codecs.find(message.contentType()).orElseGet(codecs::defaultCodec)`으로 폴백한다. 출하 registry에는 JSON codec 하나만 등록된다.
## encode 의 폴백과 출하 registry
:::evidence key="messaging-runtime-core-f03" alt="분석 문서 analysis/messaging/messaging-runtime-core.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/messaging/messaging-runtime-core.md 발췌 — 15줄" zoom="true"
:::
## 실패하지 않고 다른 포맷으로 성공한다
봉투가 `application/avro`를 선언해도 JSON으로 인코딩되고, `EncodedMessage`의 content type은 codec이 정하므로 `application/json`이 된다.
## 소비 측이 원래 선언을 믿으면 어긋난다
`DestinationProfile.schema().codec()`이 목적지의 codec을 선언하는데 그 값과 대조하는 코드가 이 경로에 없다.
## 확인하지 못한 것
실제 브로커로 선언과 다른 포맷이 나가는 것을 관측하지 않았다. 폴백 코드와 등록 목록의 대조로 판정했다.
<!-- body:end -->
@@ -0,0 +1,86 @@
---
kind: CASE
slug: messaging-schema-avro-f02
title: 진화 판단이 두 곳에 있고 형태가 반대다
topic: schema-and-data-contracts
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-schema-avro-f02
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-schema-avro-f02
file: ../../../final/evidence/rendered/messaging-schema-avro-f02.svg
- key: messaging-schema-avro-f02-diagram
file: ../../../final/assets/diagrams/messaging-schema-avro-f02.svg
evidence:
- ../../../final/evidence/raw/messaging-schema-avro-f02.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-schema-avro.md#L533 이다.
module: messaging-schema-avro
priority: P2
---
# 진화 판단이 두 곳에 있고 형태가 반대다
isTransitive는 SchemaCompatibilityValidator(public static)와 이 leaf(private static)에 글자까지 같은 복사본이 있다. 방향 판정은 전자가 허용목록, 후자가 거부목록이다.
## 관계
- **모드 enum을 분기 조건으로 쓰면 각 분기에 테스트를 둔다**
같은 분석 리프에서 끌어낸 규칙이다.
- **컬렉션 순서가 계약이면 양쪽에서 같은 방향으로 적는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **안정 코드는 판단 단위로 정하고 구현 단위로 정하지 않는다**
같은 분석 리프에서 끌어낸 규칙이다.
## 문제
isTransitive는 SchemaCompatibilityValidator(public static)와 이 leaf(private static)에 글자까지 같은 복사본이 있다.
방향 판정은 전자가 허용목록, 후자가 거부목록이다.
## 결론
오늘 7개 모드에서 결과는 같지만 형태가 반대이므로 SchemaCompatibility에 값이 추가되는 순간 갈라진다 — 허용목록은 "검사 안 함", 거부목록은 "양방향 검사".
그리고 이 중복은 schema-api의 javadoc이 명시적으로 막으려던 것이다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : SchemaCompatibilityValidator 참조 11건 검색과 두 구현의 판정 방향(허용목록·거부목록) 대조
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-schema-avro.md#L533 에 있다.
## 본문
<!-- body:start -->
`isTransitive``SchemaCompatibilityValidator`(public static)와 이 leaf(private static)에 글자까지 같은 복사본이 있다.
## 판정이 갈리는 자리
:::evidence key="messaging-schema-avro-f02-diagram" alt="포트 판정 쪽에 isTransitive 복사본과 허용목록 방향이 놓이고 게이트 판정 쪽에 같은 복사본과 거부목록 방향이 빗금으로 놓인다" caption="판정이 갈리는 자리" zoom="false"
:::
방향 판정은 전자가 허용목록, 후자가 거부목록이다.
## SchemaCompatibilityValidator 참조 위치
:::evidence key="messaging-schema-avro-f02" alt="코드베이스에서 SchemaCompatibilityValidator 를 검색한 출력 11줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="SchemaCompatibilityValidator 코드베이스 검색 — 11줄 · exit 0" zoom="true"
:::
## 값이 추가되는 순간 갈라진다
오늘 7개 모드에서 결과는 같지만 형태가 반대이므로 `SchemaCompatibility`에 값이 추가되면 허용목록은 "검사 안 함", 거부목록은 "양방향 검사"가 된다. 그리고 이 중복은 schema-api의 javadoc이 명시적으로 막으려던 것이다.
## 확인하지 못한 것
실제 Avro 스키마 진화 사례에서 두 판정이 갈리는지 확인하지 않았다. 테스트는 defaulted 필드 추가·미추가 두 경우만 본다.
<!-- body:end -->
@@ -0,0 +1,88 @@
---
kind: CASE
slug: messaging-schema-json-f01
title: 포맷 중립 payload 정책이, 자기 상수를 두고 JSON codec의 상수를 참조한다
topic: schema-and-data-contracts
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-schema-json-f01
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-schema-json-f01
file: ../../../final/evidence/rendered/messaging-schema-json-f01.svg
- key: messaging-schema-json-f01-diagram
file: ../../../final/assets/diagrams/messaging-schema-json-f01.svg
evidence:
- ../../../final/evidence/raw/messaging-schema-json-f01.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-schema-json.md#L463 이다.
module: messaging-schema-json
priority: P2
---
# 포맷 중립 payload 정책이, 자기 상수를 두고 JSON codec의 상수를 참조한다
MessagingCoreAutoConfiguration:410-413이 new PayloadPolicy(JacksonMessageCodec.DEFAULT_MAX_BYTES, JacksonMessageCodec.DEFAULT_MAX_BYTES / 2)를 만든다. 그런데 PayloadPolicy 자신이 같은 값의 public 상수 PayloadPolicy.DEFAULT_MAX_BYTES(messaging-policy/PayloadPolicy.java:17)를 갖고 있다.
## 관계
- **실패 코드는 운영자의 다음 행동이 갈리는 지점마다 나눈다**
같은 분석 리프에서 끌어낸 규칙이다.
## 문제
MessagingCoreAutoConfiguration:410-413이 new PayloadPolicy(JacksonMessageCodec.DEFAULT_MAX_BYTES, JacksonMessageCodec.DEFAULT_MAX_BYTES / 2)를 만든다.
그런데 PayloadPolicy 자신이 같은 값의 public 상수 PayloadPolicy.DEFAULT_MAX_BYTES(messaging-policy/PayloadPolicy.java:17)를 갖고 있다.
## 결론
MessagingAdmissionController는 목적지의 codec이 무엇이든 지나는 관문이다.
그 상한이 한 포맷 클래스의 상수에서 나오면 두 가지가 깨진다.
(1) @ConditionalOnMissingBean이 허용하는 대로 애플리케이션이 자기 MessageCodecRegistry를 내놓아 JSON codec을 대체해도, 정책은 여전히 JSON codec의 값을 읽는다.
(2) 다섯 곳의 리터럴 중 하나만 바뀌면 조용히 갈라지고, RawBytesMessageCodec javadoc이 이미 "shared with the Stable codecs"라고 사실과 다르게 부르고 있다.
정책 소유자가 이미 존재하는데 배선이 그것을 지나쳤다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : PayloadPolicy 참조 25건 검색과 정책이 읽는 상수의 소유 클래스 확인
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-schema-json.md#L463 에 있다.
## 본문
<!-- body:start -->
`MessagingCoreAutoConfiguration:410-413``new PayloadPolicy(JacksonMessageCodec.DEFAULT_MAX_BYTES, JacksonMessageCodec.DEFAULT_MAX_BYTES / 2)`를 만든다. 그런데 `PayloadPolicy` 자신이 같은 값의 public 상수 `PayloadPolicy.DEFAULT_MAX_BYTES`(`messaging-policy/PayloadPolicy.java:17`)를 갖고 있다.
## 참조가 뒤집힌 자리
:::evidence key="messaging-schema-json-f01-diagram" alt="JacksonMessageCodec 상수만 정책이 읽는 상수 안에 놓이고 PayloadPolicy 자기 상수가 바깥에 빗금으로 놓인다" caption="참조가 뒤집힌 자리" zoom="false"
:::
`MessagingAdmissionController`는 목적지의 codec이 무엇이든 지나는 관문이다.
## PayloadPolicy 참조 위치
:::evidence key="messaging-schema-json-f01" alt="코드베이스에서 PayloadPolicy 를 검색한 출력 25줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="PayloadPolicy 코드베이스 검색 — 25줄 · exit 0" zoom="true"
:::
## 한 포맷 클래스의 상수에서 나오면 둘이 깨진다
(1) `@ConditionalOnMissingBean`이 허용하는 대로 애플리케이션이 자기 `MessageCodecRegistry`를 내놓아 JSON codec을 대체해도, 정책은 여전히 JSON codec의 값을 읽는다. (2) 다섯 곳의 리터럴 중 하나만 바뀌면 조용히 갈라지고, `RawBytesMessageCodec` javadoc이 이미 "shared with the Stable codecs"라고 사실과 다르게 부르고 있다. 정책 소유자가 이미 존재하는데 배선이 그것을 지나쳤다.
## 확인하지 못한 것
실제 배포에서 MessageContracts bean 이 채워지는지 확인하지 못했다. 그 답은 starter 리프가 소유한다.
<!-- body:end -->
@@ -0,0 +1,77 @@
---
kind: CASE
slug: messaging-schema-protobuf-f04
title: registry 조회 로직이 세 codec에 복제돼 있다
topic: schema-and-data-contracts
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-schema-protobuf-f04
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-schema-protobuf-f04
file: ../../../final/evidence/rendered/messaging-schema-protobuf-f04.svg
evidence:
- ../../../final/evidence/raw/messaging-schema-protobuf-f04.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-schema-protobuf.md#L554 이다.
module: messaging-schema-protobuf
priority: P3
---
# registry 조회 로직이 세 codec에 복제돼 있다
requireRegistered(JSON/Protobuf)와 schemaFor(Avro)가 같은 3단 판단을 각자 구현한다. JSON과 Protobuf는 registeredVersions 헬퍼까지 사실상 동일하다.
## 관계
- **검증되지 않는 스키마 파일은 문서임을 파일 안에 적는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **신뢰할 수 없는 입력 쪽 경계를 먼저 테스트한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 문제
requireRegistered(JSON/Protobuf)와 schemaFor(Avro)가 같은 3단 판단을 각자 구현한다.
JSON과 Protobuf는 registeredVersions 헬퍼까지 사실상 동일하다.
## 결론
판단은 MessageContractKey의 성질이지 포맷의 성질이 아니다.
그리고 실제로 갈라졌다 — Avro만 AVRO_ 접두 코드를 쓰고 등록 버전 목록을 메시지에 넣지 않는다.
messaging-schema-api가 흡수할 수 있는 형태다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : MessageContractKey 참조 34건 검색과 세 codec 의 조회 메서드 본문 대조
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-schema-protobuf.md#L554 에 있다.
## 본문
<!-- body:start -->
`requireRegistered`(JSON/Protobuf)와 `schemaFor`(Avro)가 같은 3단 판단을 각자 구현한다. JSON과 Protobuf는 `registeredVersions` 헬퍼까지 사실상 동일하다.
## MessageContractKey 참조 위치
:::evidence key="messaging-schema-protobuf-f04" alt="코드베이스에서 MessageContractKey 를 검색한 출력 34줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MessageContractKey 코드베이스 검색 — 34줄 · exit 0" zoom="true"
:::
## 판단은 키의 성질이지 포맷의 성질이 아니다
그리고 실제로 갈라졌다 — Avro만 `AVRO_` 접두 코드를 쓰고 등록 버전 목록을 메시지에 넣지 않는다. `messaging-schema-api`가 흡수할 수 있는 형태다.
## 확인하지 못한 것
파생 프로젝트가 이 codec 을 쓰는지 확인할 수 없었다. 세 구현의 코드 대조로만 판정했다.
<!-- body:end -->