{ "schema_version": "1.0", "document": "/home/donghyeon/workspace/chat-gpt-container/document-haness/docs/clean-architecture-backend-template/final/document.md", "document_sha256": "8071fe71b3359d9cf60b95909c26c7b50653ce2f22bbc5fcf6988719bb91236d", "line_count": 47035, "line_number_space": "canonical-source-with-managed-blocks-collapsed", "anchor": { "kind": "line", "value": 37205, "line": 37205 }, "current_section": { "heading": { "line": 37205, "level": 5, "text": "4.1 `ProtobufMessageContract`: 생성 시점에 짝을 증명한다" }, "start_line": 37205, "end_line": 37246, "text": "##### 4.1 `ProtobufMessageContract`: 생성 시점에 짝을 증명한다\n\n이 leaf에서 가장 밀도 높은 결정이다.\n\n```java\n// ProtobufMessageContract.java:10-20\n *
They used to live in two parallel maps. Nothing checked that the two agreed, so a registry\n * that paired {@code OrderCreated.class} with {@code OrderCancelled}'s parser was accepted at\n * construction and produced a {@code ClassCastException} at decode time — on a broker thread, for\n * one message type, in production. Worse, a type present in one map and absent from the other made\n * {@code parsers.get(type)} return null and the decode fail with a {@code NullPointerException}\n * rather than the registry error the operator needed to read.\n *\n *
Binding them in one value makes the mismatch impossible to express, and the constructor proves\n * the pairing by parsing empty input: the parser's default instance must be an instance of the\n * declared class.\n```\n\n증명 방법이 영리하다.\n\n```java\npublic ProtobufMessageContract {\n Message defaultInstance;\n try {\n defaultInstance = parser.parseFrom(new byte[0]);\n } catch (Exception failure) {\n throw new MessagingConfigurationException(\"PROTOBUF_CONTRACT_UNUSABLE\", ..., failure);\n }\n if (!payloadType.isInstance(defaultInstance)) {\n throw new MessagingConfigurationException(\"PROTOBUF_CONTRACT_MISMATCH\", ...);\n }\n}\n```\n\nproto3에서 모든 필드가 wire상 optional이므로 **빈 바이트는 항상 유효한 메시지**다. 그것을 파싱하면 default instance가 나오고 그 클래스가 곧 parser의 산출 타입이다. 별도 리플렉션 없이 짝을 확인한다.\n\n에러 메시지가 실패 지점을 명시한다 — \"a mismatched pairing fails at decode time on a broker thread, not here\". 즉 **여기서 실패하는 것이 목적**임을 메시지가 스스로 말한다.\n\n두 코드가 다르다: `PROTOBUF_CONTRACT_UNUSABLE`(파싱 자체 실패)과 `PROTOBUF_CONTRACT_MISMATCH`(파싱은 되는데 타입이 다름). 카테고리는 둘 다 `CONFIGURATION`이다.\n\n테스트가 이 성질을 붙든다 — `aParserThatDoesNotProduceTheDeclaredClassIsRejectedAtConstruction`, `as(\"the mismatch used to surface as a ClassCastException on a broker thread\")`.\n" }, "previous_section": { "heading": { "line": 37203, "level": 4, "text": "4. 계약·불변식·상태 모델" }, "start_line": 37203, "end_line": 37204, "text": "#### 4. 계약·불변식·상태 모델\n" }, "next_section": { "heading": { "line": 37247, "level": 5, "text": "4.2 인코딩: 크기를 미리 알 수 있다" }, "start_line": 37247, "end_line": 37269, "text": "##### 4.2 인코딩: 크기를 미리 알 수 있다\n\n```java\nBoundedByteSink sink = BoundedByteSink.of(maxBytes, \"PAYLOAD_TOO_LARGE\");\nsink.requireFits(message.getSerializedSize());\ntry {\n message.writeTo(sink);\n}\n```\n\n주석이 이유를 적는다.\n\n```java\n// :77-79\n// Protobuf knows its serialized size exactly before writing a byte, so the limit is checked\n// against that estimate first and enforced again by the sink. `toByteArray` allocated the whole\n// encoding before anything could object.\n```\n\n**세 codec 중 유일하게 사전 거절이 가능한 포맷이다.** `BoundedByteSink.requireFits`가 이 leaf를 위해 존재하고, schema-api의 javadoc이 그것을 명시한다 — \"Protobuf knows its serialized size exactly, so the whole encode can be refused before the first byte is written.\"\n\n그리고 사전 검사가 사후 경계를 대체하지 않는다 — `writeTo(sink)`가 여전히 sink를 통과하므로 이중 방어다. schema-api javadoc: \"this is a cheaper refusal, not a replacement for the bound.\"\n" }, "context_range": { "start_line": 37203, "end_line": 37269 }, "context_lines": [ { "line": 37203, "text": "#### 4. 계약·불변식·상태 모델" }, { "line": 37204, "text": "" }, { "line": 37205, "text": "##### 4.1 `ProtobufMessageContract`: 생성 시점에 짝을 증명한다" }, { "line": 37206, "text": "" }, { "line": 37207, "text": "이 leaf에서 가장 밀도 높은 결정이다." }, { "line": 37208, "text": "" }, { "line": 37209, "text": "```java" }, { "line": 37210, "text": "// ProtobufMessageContract.java:10-20" }, { "line": 37211, "text": " *
They used to live in two parallel maps. Nothing checked that the two agreed, so a registry" }, { "line": 37212, "text": " * that paired {@code OrderCreated.class} with {@code OrderCancelled}'s parser was accepted at" }, { "line": 37213, "text": " * construction and produced a {@code ClassCastException} at decode time — on a broker thread, for" }, { "line": 37214, "text": " * one message type, in production. Worse, a type present in one map and absent from the other made" }, { "line": 37215, "text": " * {@code parsers.get(type)} return null and the decode fail with a {@code NullPointerException}" }, { "line": 37216, "text": " * rather than the registry error the operator needed to read." }, { "line": 37217, "text": " *" }, { "line": 37218, "text": " *
Binding them in one value makes the mismatch impossible to express, and the constructor proves" }, { "line": 37219, "text": " * the pairing by parsing empty input: the parser's default instance must be an instance of the" }, { "line": 37220, "text": " * declared class." }, { "line": 37221, "text": "```" }, { "line": 37222, "text": "" }, { "line": 37223, "text": "증명 방법이 영리하다." }, { "line": 37224, "text": "" }, { "line": 37225, "text": "```java" }, { "line": 37226, "text": "public ProtobufMessageContract {" }, { "line": 37227, "text": " Message defaultInstance;" }, { "line": 37228, "text": " try {" }, { "line": 37229, "text": " defaultInstance = parser.parseFrom(new byte[0]);" }, { "line": 37230, "text": " } catch (Exception failure) {" }, { "line": 37231, "text": " throw new MessagingConfigurationException(\"PROTOBUF_CONTRACT_UNUSABLE\", ..., failure);" }, { "line": 37232, "text": " }" }, { "line": 37233, "text": " if (!payloadType.isInstance(defaultInstance)) {" }, { "line": 37234, "text": " throw new MessagingConfigurationException(\"PROTOBUF_CONTRACT_MISMATCH\", ...);" }, { "line": 37235, "text": " }" }, { "line": 37236, "text": "}" }, { "line": 37237, "text": "```" }, { "line": 37238, "text": "" }, { "line": 37239, "text": "proto3에서 모든 필드가 wire상 optional이므로 **빈 바이트는 항상 유효한 메시지**다. 그것을 파싱하면 default instance가 나오고 그 클래스가 곧 parser의 산출 타입이다. 별도 리플렉션 없이 짝을 확인한다." }, { "line": 37240, "text": "" }, { "line": 37241, "text": "에러 메시지가 실패 지점을 명시한다 — \"a mismatched pairing fails at decode time on a broker thread, not here\". 즉 **여기서 실패하는 것이 목적**임을 메시지가 스스로 말한다." }, { "line": 37242, "text": "" }, { "line": 37243, "text": "두 코드가 다르다: `PROTOBUF_CONTRACT_UNUSABLE`(파싱 자체 실패)과 `PROTOBUF_CONTRACT_MISMATCH`(파싱은 되는데 타입이 다름). 카테고리는 둘 다 `CONFIGURATION`이다." }, { "line": 37244, "text": "" }, { "line": 37245, "text": "테스트가 이 성질을 붙든다 — `aParserThatDoesNotProduceTheDeclaredClassIsRejectedAtConstruction`, `as(\"the mismatch used to surface as a ClassCastException on a broker thread\")`." }, { "line": 37246, "text": "" }, { "line": 37247, "text": "##### 4.2 인코딩: 크기를 미리 알 수 있다" }, { "line": 37248, "text": "" }, { "line": 37249, "text": "```java" }, { "line": 37250, "text": "BoundedByteSink sink = BoundedByteSink.of(maxBytes, \"PAYLOAD_TOO_LARGE\");" }, { "line": 37251, "text": "sink.requireFits(message.getSerializedSize());" }, { "line": 37252, "text": "try {" }, { "line": 37253, "text": " message.writeTo(sink);" }, { "line": 37254, "text": "}" }, { "line": 37255, "text": "```" }, { "line": 37256, "text": "" }, { "line": 37257, "text": "주석이 이유를 적는다." }, { "line": 37258, "text": "" }, { "line": 37259, "text": "```java" }, { "line": 37260, "text": "// :77-79" }, { "line": 37261, "text": "// Protobuf knows its serialized size exactly before writing a byte, so the limit is checked" }, { "line": 37262, "text": "// against that estimate first and enforced again by the sink. `toByteArray` allocated the whole" }, { "line": 37263, "text": "// encoding before anything could object." }, { "line": 37264, "text": "```" }, { "line": 37265, "text": "" }, { "line": 37266, "text": "**세 codec 중 유일하게 사전 거절이 가능한 포맷이다.** `BoundedByteSink.requireFits`가 이 leaf를 위해 존재하고, schema-api의 javadoc이 그것을 명시한다 — \"Protobuf knows its serialized size exactly, so the whole encode can be refused before the first byte is written.\"" }, { "line": 37267, "text": "" }, { "line": 37268, "text": "그리고 사전 검사가 사후 경계를 대체하지 않는다 — `writeTo(sink)`가 여전히 sink를 통과하므로 이중 방어다. schema-api javadoc: \"this is a cheaper refusal, not a replacement for the bound.\"" }, { "line": 37269, "text": "" } ], "numbered_context": "37203 | #### 4. 계약·불변식·상태 모델\n37204 | \n37205 | ##### 4.1 `ProtobufMessageContract`: 생성 시점에 짝을 증명한다\n37206 | \n37207 | 이 leaf에서 가장 밀도 높은 결정이다.\n37208 | \n37209 | ```java\n37210 | // ProtobufMessageContract.java:10-20\n37211 | *
They used to live in two parallel maps. Nothing checked that the two agreed, so a registry\n37212 | * that paired {@code OrderCreated.class} with {@code OrderCancelled}'s parser was accepted at\n37213 | * construction and produced a {@code ClassCastException} at decode time — on a broker thread, for\n37214 | * one message type, in production. Worse, a type present in one map and absent from the other made\n37215 | * {@code parsers.get(type)} return null and the decode fail with a {@code NullPointerException}\n37216 | * rather than the registry error the operator needed to read.\n37217 | *\n37218 | *
Binding them in one value makes the mismatch impossible to express, and the constructor proves\n37219 | * the pairing by parsing empty input: the parser's default instance must be an instance of the\n37220 | * declared class.\n37221 | ```\n37222 | \n37223 | 증명 방법이 영리하다.\n37224 | \n37225 | ```java\n37226 | public ProtobufMessageContract {\n37227 | Message defaultInstance;\n37228 | try {\n37229 | defaultInstance = parser.parseFrom(new byte[0]);\n37230 | } catch (Exception failure) {\n37231 | throw new MessagingConfigurationException(\"PROTOBUF_CONTRACT_UNUSABLE\", ..., failure);\n37232 | }\n37233 | if (!payloadType.isInstance(defaultInstance)) {\n37234 | throw new MessagingConfigurationException(\"PROTOBUF_CONTRACT_MISMATCH\", ...);\n37235 | }\n37236 | }\n37237 | ```\n37238 | \n37239 | proto3에서 모든 필드가 wire상 optional이므로 **빈 바이트는 항상 유효한 메시지**다. 그것을 파싱하면 default instance가 나오고 그 클래스가 곧 parser의 산출 타입이다. 별도 리플렉션 없이 짝을 확인한다.\n37240 | \n37241 | 에러 메시지가 실패 지점을 명시한다 — \"a mismatched pairing fails at decode time on a broker thread, not here\". 즉 **여기서 실패하는 것이 목적**임을 메시지가 스스로 말한다.\n37242 | \n37243 | 두 코드가 다르다: `PROTOBUF_CONTRACT_UNUSABLE`(파싱 자체 실패)과 `PROTOBUF_CONTRACT_MISMATCH`(파싱은 되는데 타입이 다름). 카테고리는 둘 다 `CONFIGURATION`이다.\n37244 | \n37245 | 테스트가 이 성질을 붙든다 — `aParserThatDoesNotProduceTheDeclaredClassIsRejectedAtConstruction`, `as(\"the mismatch used to surface as a ClassCastException on a broker thread\")`.\n37246 | \n37247 | ##### 4.2 인코딩: 크기를 미리 알 수 있다\n37248 | \n37249 | ```java\n37250 | BoundedByteSink sink = BoundedByteSink.of(maxBytes, \"PAYLOAD_TOO_LARGE\");\n37251 | sink.requireFits(message.getSerializedSize());\n37252 | try {\n37253 | message.writeTo(sink);\n37254 | }\n37255 | ```\n37256 | \n37257 | 주석이 이유를 적는다.\n37258 | \n37259 | ```java\n37260 | // :77-79\n37261 | // Protobuf knows its serialized size exactly before writing a byte, so the limit is checked\n37262 | // against that estimate first and enforced again by the sink. `toByteArray` allocated the whole\n37263 | // encoding before anything could object.\n37264 | ```\n37265 | \n37266 | **세 codec 중 유일하게 사전 거절이 가능한 포맷이다.** `BoundedByteSink.requireFits`가 이 leaf를 위해 존재하고, schema-api의 javadoc이 그것을 명시한다 — \"Protobuf knows its serialized size exactly, so the whole encode can be refused before the first byte is written.\"\n37267 | \n37268 | 그리고 사전 검사가 사후 경계를 대체하지 않는다 — `writeTo(sink)`가 여전히 sink를 통과하므로 이중 방어다. schema-api javadoc: \"this is a cheaper refusal, not a replacement for the bound.\"\n37269 | ",
"headings": [
{
"line": 1,
"level": 1,
"text": "clean-architecture-backend-template — 상세 분석 (통합 정본)"
},
{
"line": 40,
"level": 2,
"text": "0. 이 문서를 읽는 법"
},
{
"line": 60,
"level": 2,
"text": "1. Project map — 숫자로 먼저"
},
{
"line": 62,
"level": 3,
"text": "1.1 빌드와 레지스트리"
},
{
"line": 81,
"level": 3,
"text": "1.2 가족별 분모와 출하 여부"
},
{
"line": 94,
"level": 3,
"text": "1.3 leaf별 규모 (main Java 기준 상위)"
},
{
"line": 119,
"level": 3,
"text": "1.4 이 표에서 읽어야 할 것"
},
{
"line": 168,
"level": 2,
"text": "2. Architectural boundaries — 무엇이 경계를 강제하는가"
},
{
"line": 173,
"level": 3,
"text": "2.1 강제 장치 목록"
},
{
"line": 189,
"level": 3,
"text": "2.2 `CleanArchitectureTest`의 규칙 14종"
},
{
"line": 212,
"level": 3,
"text": "2.3 검증된 경계 — 실제로 성립하는 것"
},
{
"line": 266,
"level": 3,
"text": "2.4 경계가 열려 있는 지점"
},
{
"line": 300,
"level": 2,
"text": "3. Representative execution paths"
},
{
"line": 302,
"level": 3,
"text": "3.1 HTTP 요청 — 출하 경로"
},
{
"line": 364,
"level": 3,
"text": "3.2 트랜잭션 — `application-core` 포트에서 PostgreSQL local timeout까지"
},
{
"line": 453,
"level": 3,
"text": "3.3 메시지 발행 — messaging 플랫폼"
},
{
"line": 494,
"level": 3,
"text": "3.4 gRPC — 채택 시점 경로"
},
{
"line": 518,
"level": 3,
"text": "3.5 알림 발송 — 논리적 수락과 provider 불확실성"
},
{
"line": 539,
"level": 2,
"text": "4. Data and state"
},
{
"line": 541,
"level": 3,
"text": "4.1 관계형 — `persistence-jpa` (605 파일 / main 350 / 27,744 LOC)"
},
{
"line": 654,
"level": 3,
"text": "4.2 문서형 — `persistence-mongo` (497 파일 / main 351 / 22,924 LOC)"
},
{
"line": 705,
"level": 3,
"text": "4.3 messaging 신뢰성 저장소 (`19` §7)"
},
{
"line": 757,
"level": 3,
"text": "4.4 fileserver / objectstorage / cache-redis"
},
{
"line": 788,
"level": 2,
"text": "5. Failure and operational behavior"
},
{
"line": 790,
"level": 3,
"text": "5.1 실패 분류 — 세 개의 계층"
},
{
"line": 824,
"level": 3,
"text": "5.2 관측 — 태그를 유한하게, 그리고 그 대가"
},
{
"line": 854,
"level": 3,
"text": "5.3 시작 검증기 — 법칙과 그 예외"
},
{
"line": 903,
"level": 3,
"text": "5.4 admin plane — 가장 잘 조립된 게이트"
},
{
"line": 939,
"level": 3,
"text": "5.5 gRPC 구현 층의 원자성 (`20` §7)"
},
{
"line": 1011,
"level": 2,
"text": "6. Tests and verification coverage"
},
{
"line": 1013,
"level": 3,
"text": "6.1 실행한 것"
},
{
"line": 1025,
"level": 3,
"text": "6.2 실행하지 않은 것과 그 이유"
},
{
"line": 1047,
"level": 3,
"text": "6.3 fail-closed 레인 규약"
},
{
"line": 1071,
"level": 3,
"text": "6.4 완전히 닫힌 게이트 하나 — messaging 인증 체인"
},
{
"line": 1111,
"level": 3,
"text": "6.5 evidence manifest — JPA의 R1/R2 분리"
},
{
"line": 1125,
"level": 3,
"text": "6.6 게이트가 통과하면서 아무것도 증명하지 않는 경우 — 14건"
},
{
"line": 1156,
"level": 2,
"text": "7. 이 저장소에서 반복된 네 가지 형태"
},
{
"line": 1160,
"level": 3,
"text": "7.1 형태 A — 판정하는 코드는 있고, 부르는 코드가 없다"
},
{
"line": 1203,
"level": 3,
"text": "7.2 형태 B — 게이트가 통과하면서 아무것도 증명하지 않는다"
},
{
"line": 1214,
"level": 3,
"text": "7.3 형태 C — 중복 장치에서 조립된 쪽이 약한 쪽이다"
},
{
"line": 1239,
"level": 3,
"text": "7.4 형태 D — 문서 드리프트, 그리고 그 방향"
},
{
"line": 1274,
"level": 3,
"text": "7.5 공시 스펙트럼 — 자기 미완성을 얼마나 말했는가"
},
{
"line": 1289,
"level": 3,
"text": "7.6 학습 전이 — messaging → grpc"
},
{
"line": 1308,
"level": 2,
"text": "8. Confirmed problems"
},
{
"line": 1310,
"level": 3,
"text": "8.1 P1 — 지금 출하되는 아티팩트에서 틀린 동작"
},
{
"line": 1349,
"level": 3,
"text": "8.2 P2 — 명확한 실패 시나리오를 가진 실질적 공백"
},
{
"line": 1392,
"level": 3,
"text": "8.3 심각도가 등급 때문에 낮아진 것"
},
{
"line": 1403,
"level": 2,
"text": "9. Reusable criteria and rules"
},
{
"line": 1452,
"level": 2,
"text": "10. Explicit project decisions"
},
{
"line": 1457,
"level": 3,
"text": "10.1 계약과 경계"
},
{
"line": 1468,
"level": 3,
"text": "10.2 실패와 불확실성"
},
{
"line": 1480,
"level": 3,
"text": "10.3 조립과 활성화"
},
{
"line": 1492,
"level": 3,
"text": "10.4 데이터와 경계값"
},
{
"line": 1506,
"level": 3,
"text": "10.5 증거와 게이트"
},
{
"line": 1523,
"level": 2,
"text": "11. Unresolved questions"
},
{
"line": 1564,
"level": 2,
"text": "12. Evidence index"
},
{
"line": 1581,
"level": 2,
"text": "13. Limits of this analysis"
},
{
"line": 1632,
"level": 2,
"text": "14. 사이클 2 — 18개 리프 재검증과 23개 리프 전수 통독"
},
{
"line": 1634,
"level": 3,
"text": "14.1 18개 리프 재검증"
},
{
"line": 1668,
"level": 3,
"text": "14.2 23개 리프 전수 통독"
},
{
"line": 1747,
"level": 2,
"text": "부록 A. 모듈 문서 지도"
},
{
"line": 1779,
"level": 2,
"text": "부록 B. 자주 쓸 명령"
},
{
"line": 1825,
"level": 2,
"text": "부록 C. 다시 읽는다면 이 순서"
},
{
"line": 1839,
"level": 1,
"text": "제2부 — 모듈 분석 전문"
},
{
"line": 1845,
"level": 2,
"text": "A00. project-overview"
},
{
"line": 1849,
"level": 3,
"text": "Project Overview"
},
{
"line": 1856,
"level": 4,
"text": "분석 기준 revision"
},
{
"line": 1867,
"level": 4,
"text": "최종 커버리지"
},
{
"line": 1884,
"level": 4,
"text": "Build and module map"
},
{
"line": 1939,
"level": 4,
"text": "Dependency direction"
},
{
"line": 1945,
"level": 4,
"text": "Runtime entry points"
},
{
"line": 1951,
"level": 4,
"text": "Persistence / messaging / external systems"
},
{
"line": 1955,
"level": 4,
"text": "Test topology"
},
{
"line": 1960,
"level": 4,
"text": "Configuration and operational surfaces"
},
{
"line": 1964,
"level": 4,
"text": "분석할 bounded scopes (계획 — 실제 문서 배치는 위 \"최종 커버리지\" 참조)"
},
{
"line": 1977,
"level": 4,
"text": "아직 단정하지 않는 것 (분석 시작 시점의 목록)"
},
{
"line": 1993,
"level": 2,
"text": "A01. domain-core"
},
{
"line": 1997,
"level": 3,
"text": "domain-core 상세 분석"
},
{
"line": 2000,
"level": 4,
"text": "SSOT identity — 2026-08-31 재검증"
},
{
"line": 2015,
"level": 4,
"text": "분석 범위와 결론 상태"
},
{
"line": 2026,
"level": 4,
"text": "1. Quantified scope map"
},
{
"line": 2028,
"level": 5,
"text": "Owned source"
},
{
"line": 2042,
"level": 4,
"text": "2. Coverage ledger"
},
{
"line": 2062,
"level": 4,
"text": "3. 이 모듈이 실제로 소유하는 것"
},
{
"line": 2064,
"level": 5,
"text": "관찰: 재사용 가능한 도메인 “내용”보다 도메인 모델링 계약을 소유한다"
},
{
"line": 2073,
"level": 4,
"text": "4. Identifier contract"
},
{
"line": 2075,
"level": 5,
"text": "`ResourceId