{ "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": 27000, "line": 27000 }, "current_section": { "heading": { "line": 27000, "level": 5, "text": "4.4 `time`이 두 필드로 복제된다" }, "start_line": 27000, "end_line": 27011, "text": "##### 4.4 `time`이 두 필드로 복제된다\n\n```java\nInstant occurredAt = time.toInstant();\nreturn new MessageEnvelope<>(\n ..., occurredAt, // producedAt\n Optional.of(occurredAt), // occurredAt\n ...);\n```\n\nCloudEvents에는 `time` 하나뿐이므로 봉투의 두 시각(플랫폼이 봉투를 만든 때 / 사실이 일어난 때)을 구분할 수 없다. 같은 값을 넣는 것은 합리적 선택이지만 **정보 손실이 기록되지 않았다** — 왕복 후 `producedAt`은 원래 값이 아니다. 테스트의 왕복 검증(`roundTripsBackToAnEnvelopeWithoutInventingATombstone`)이 `messageId`·`messageType`·`schemaVersion`·`correlationId`·`tenantContext`·`payload`만 비교하고 `producedAt`은 비교하지 않는다. fixture에서 `producedAt`은 `09:15:01Z`, `occurredAt`은 `09:15:00Z`로 **일부러 다르게** 설정돼 있으므로, 비교했다면 실패했을 것이다.\n" }, "previous_section": { "heading": { "line": 26979, "level": 5, "text": "4.3 `producerFrom`: 무한 URI를 유한 이름으로" }, "start_line": 26979, "end_line": 26999, "text": "##### 4.3 `producerFrom`: 무한 URI를 유한 이름으로\n\n```java\n// :158-163\n *
The last path or scheme-specific segment is used so that a long URI does not become an\n * unbounded producer name, which would leak straight into metric tags.\nprivate static String producerFrom(URI source) {\n String text = source.toString();\n int separator = Math.max(text.lastIndexOf('/'), text.lastIndexOf(':'));\n String candidate =\n separator >= 0 && separator + 1 < text.length() ? text.substring(separator + 1) : text;\n return candidate.isBlank() ? \"unknown\" : candidate;\n}\n```\n\n`ProducerId`가 \"deployment-independent service name, not a host, pod, or connection identity, so that it stays a bounded value safe for metric tags\"라고 선언한 것과 같은 관심사다.\n\n**다만 이 방어는 완전하지 않다.** 마지막 세그먼트가 여전히 120 UTF-8 바이트를 넘거나 제어문자를 담을 수 있다. 그 경우 `ProducerId` 생성자가 `IllegalArgumentException`을 던진다 — §4.5.\n\n`urn:service:order-api` → `order-api`(테스트가 쓰는 형태). `https://a.example/very/long/path/x` → `x`.\n" }, "next_section": { "heading": { "line": 27012, "level": 5, "text": "4.5 왕복에서 소실되는 것" }, "start_line": 27012, "end_line": 27027, "text": "##### 4.5 왕복에서 소실되는 것\n\n`fromCloudEvent`가 항상 비우는 필드가 다섯이다.\n\n| 필드 | 결과 |\n|---|---|\n| `partitionKey` | `Optional.empty()` |\n| `orderingKey` | `Optional.empty()` |\n| `traceContext` | `TraceContext.none()` |\n| `headers` | `MessageHeaders.empty()` |\n| `producedAt` | `occurredAt`으로 덮임 |\n\n**`traceContext`의 소실이 가장 무겁다.** `messaging-core-api`의 `TraceContext` javadoc이 그 필드를 봉투에 둔 이유를 적는다 — \"Keeping them on the envelope rather than only in headers means a trace survives an Outbox round trip through the database, where broker headers do not exist yet.\" CloudEvents 왕복은 그 보존을 깨뜨린다. CloudEvents는 분산 추적 확장(`traceparent`를 distributed-tracing extension으로)을 정의하는데 이 매퍼는 그것을 읽지도 쓰지도 않는다.\n\n`toCloudEvent`도 `traceContext`·`headers`·`partitionKey`·`orderingKey`를 쓰지 않는다. 즉 소실은 양방향이다.\n" }, "context_range": { "start_line": 26979, "end_line": 27027 }, "context_lines": [ { "line": 26979, "text": "##### 4.3 `producerFrom`: 무한 URI를 유한 이름으로" }, { "line": 26980, "text": "" }, { "line": 26981, "text": "```java" }, { "line": 26982, "text": "// :158-163" }, { "line": 26983, "text": " *
The last path or scheme-specific segment is used so that a long URI does not become an" }, { "line": 26984, "text": " * unbounded producer name, which would leak straight into metric tags." }, { "line": 26985, "text": "private static String producerFrom(URI source) {" }, { "line": 26986, "text": " String text = source.toString();" }, { "line": 26987, "text": " int separator = Math.max(text.lastIndexOf('/'), text.lastIndexOf(':'));" }, { "line": 26988, "text": " String candidate =" }, { "line": 26989, "text": " separator >= 0 && separator + 1 < text.length() ? text.substring(separator + 1) : text;" }, { "line": 26990, "text": " return candidate.isBlank() ? \"unknown\" : candidate;" }, { "line": 26991, "text": "}" }, { "line": 26992, "text": "```" }, { "line": 26993, "text": "" }, { "line": 26994, "text": "`ProducerId`가 \"deployment-independent service name, not a host, pod, or connection identity, so that it stays a bounded value safe for metric tags\"라고 선언한 것과 같은 관심사다." }, { "line": 26995, "text": "" }, { "line": 26996, "text": "**다만 이 방어는 완전하지 않다.** 마지막 세그먼트가 여전히 120 UTF-8 바이트를 넘거나 제어문자를 담을 수 있다. 그 경우 `ProducerId` 생성자가 `IllegalArgumentException`을 던진다 — §4.5." }, { "line": 26997, "text": "" }, { "line": 26998, "text": "`urn:service:order-api` → `order-api`(테스트가 쓰는 형태). `https://a.example/very/long/path/x` → `x`." }, { "line": 26999, "text": "" }, { "line": 27000, "text": "##### 4.4 `time`이 두 필드로 복제된다" }, { "line": 27001, "text": "" }, { "line": 27002, "text": "```java" }, { "line": 27003, "text": "Instant occurredAt = time.toInstant();" }, { "line": 27004, "text": "return new MessageEnvelope<>(" }, { "line": 27005, "text": " ..., occurredAt, // producedAt" }, { "line": 27006, "text": " Optional.of(occurredAt), // occurredAt" }, { "line": 27007, "text": " ...);" }, { "line": 27008, "text": "```" }, { "line": 27009, "text": "" }, { "line": 27010, "text": "CloudEvents에는 `time` 하나뿐이므로 봉투의 두 시각(플랫폼이 봉투를 만든 때 / 사실이 일어난 때)을 구분할 수 없다. 같은 값을 넣는 것은 합리적 선택이지만 **정보 손실이 기록되지 않았다** — 왕복 후 `producedAt`은 원래 값이 아니다. 테스트의 왕복 검증(`roundTripsBackToAnEnvelopeWithoutInventingATombstone`)이 `messageId`·`messageType`·`schemaVersion`·`correlationId`·`tenantContext`·`payload`만 비교하고 `producedAt`은 비교하지 않는다. fixture에서 `producedAt`은 `09:15:01Z`, `occurredAt`은 `09:15:00Z`로 **일부러 다르게** 설정돼 있으므로, 비교했다면 실패했을 것이다." }, { "line": 27011, "text": "" }, { "line": 27012, "text": "##### 4.5 왕복에서 소실되는 것" }, { "line": 27013, "text": "" }, { "line": 27014, "text": "`fromCloudEvent`가 항상 비우는 필드가 다섯이다." }, { "line": 27015, "text": "" }, { "line": 27016, "text": "| 필드 | 결과 |" }, { "line": 27017, "text": "|---|---|" }, { "line": 27018, "text": "| `partitionKey` | `Optional.empty()` |" }, { "line": 27019, "text": "| `orderingKey` | `Optional.empty()` |" }, { "line": 27020, "text": "| `traceContext` | `TraceContext.none()` |" }, { "line": 27021, "text": "| `headers` | `MessageHeaders.empty()` |" }, { "line": 27022, "text": "| `producedAt` | `occurredAt`으로 덮임 |" }, { "line": 27023, "text": "" }, { "line": 27024, "text": "**`traceContext`의 소실이 가장 무겁다.** `messaging-core-api`의 `TraceContext` javadoc이 그 필드를 봉투에 둔 이유를 적는다 — \"Keeping them on the envelope rather than only in headers means a trace survives an Outbox round trip through the database, where broker headers do not exist yet.\" CloudEvents 왕복은 그 보존을 깨뜨린다. CloudEvents는 분산 추적 확장(`traceparent`를 distributed-tracing extension으로)을 정의하는데 이 매퍼는 그것을 읽지도 쓰지도 않는다." }, { "line": 27025, "text": "" }, { "line": 27026, "text": "`toCloudEvent`도 `traceContext`·`headers`·`partitionKey`·`orderingKey`를 쓰지 않는다. 즉 소실은 양방향이다." }, { "line": 27027, "text": "" } ], "numbered_context": "26979 | ##### 4.3 `producerFrom`: 무한 URI를 유한 이름으로\n26980 | \n26981 | ```java\n26982 | // :158-163\n26983 | *
The last path or scheme-specific segment is used so that a long URI does not become an\n26984 | * unbounded producer name, which would leak straight into metric tags.\n26985 | private static String producerFrom(URI source) {\n26986 | String text = source.toString();\n26987 | int separator = Math.max(text.lastIndexOf('/'), text.lastIndexOf(':'));\n26988 | String candidate =\n26989 | separator >= 0 && separator + 1 < text.length() ? text.substring(separator + 1) : text;\n26990 | return candidate.isBlank() ? \"unknown\" : candidate;\n26991 | }\n26992 | ```\n26993 | \n26994 | `ProducerId`가 \"deployment-independent service name, not a host, pod, or connection identity, so that it stays a bounded value safe for metric tags\"라고 선언한 것과 같은 관심사다.\n26995 | \n26996 | **다만 이 방어는 완전하지 않다.** 마지막 세그먼트가 여전히 120 UTF-8 바이트를 넘거나 제어문자를 담을 수 있다. 그 경우 `ProducerId` 생성자가 `IllegalArgumentException`을 던진다 — §4.5.\n26997 | \n26998 | `urn:service:order-api` → `order-api`(테스트가 쓰는 형태). `https://a.example/very/long/path/x` → `x`.\n26999 | \n27000 | ##### 4.4 `time`이 두 필드로 복제된다\n27001 | \n27002 | ```java\n27003 | Instant occurredAt = time.toInstant();\n27004 | return new MessageEnvelope<>(\n27005 | ..., occurredAt, // producedAt\n27006 | Optional.of(occurredAt), // occurredAt\n27007 | ...);\n27008 | ```\n27009 | \n27010 | CloudEvents에는 `time` 하나뿐이므로 봉투의 두 시각(플랫폼이 봉투를 만든 때 / 사실이 일어난 때)을 구분할 수 없다. 같은 값을 넣는 것은 합리적 선택이지만 **정보 손실이 기록되지 않았다** — 왕복 후 `producedAt`은 원래 값이 아니다. 테스트의 왕복 검증(`roundTripsBackToAnEnvelopeWithoutInventingATombstone`)이 `messageId`·`messageType`·`schemaVersion`·`correlationId`·`tenantContext`·`payload`만 비교하고 `producedAt`은 비교하지 않는다. fixture에서 `producedAt`은 `09:15:01Z`, `occurredAt`은 `09:15:00Z`로 **일부러 다르게** 설정돼 있으므로, 비교했다면 실패했을 것이다.\n27011 | \n27012 | ##### 4.5 왕복에서 소실되는 것\n27013 | \n27014 | `fromCloudEvent`가 항상 비우는 필드가 다섯이다.\n27015 | \n27016 | | 필드 | 결과 |\n27017 | |---|---|\n27018 | | `partitionKey` | `Optional.empty()` |\n27019 | | `orderingKey` | `Optional.empty()` |\n27020 | | `traceContext` | `TraceContext.none()` |\n27021 | | `headers` | `MessageHeaders.empty()` |\n27022 | | `producedAt` | `occurredAt`으로 덮임 |\n27023 | \n27024 | **`traceContext`의 소실이 가장 무겁다.** `messaging-core-api`의 `TraceContext` javadoc이 그 필드를 봉투에 둔 이유를 적는다 — \"Keeping them on the envelope rather than only in headers means a trace survives an Outbox round trip through the database, where broker headers do not exist yet.\" CloudEvents 왕복은 그 보존을 깨뜨린다. CloudEvents는 분산 추적 확장(`traceparent`를 distributed-tracing extension으로)을 정의하는데 이 매퍼는 그것을 읽지도 쓰지도 않는다.\n27025 | \n27026 | `toCloudEvent`도 `traceContext`·`headers`·`partitionKey`·`orderingKey`를 쓰지 않는다. 즉 소실은 양방향이다.\n27027 | ",
"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