# messaging-cloudevents 완전 해부 > 상태: COMPLETE > 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916` > 분석 범위: `src/messaging/messaging-cloudevents` > SSOT owner: `messaging-cloudevents` > integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY) --- ## 0. SSOT identity / 커버리지와 숫자 지도 - registered leaf id: `messaging-cloudevents` - canonical state `analysisFile`: `analysis/messaging/messaging-cloudevents.md` - source path: `src/messaging/messaging-cloudevents` - registry `allowed_dependencies`: `["messaging-core-api", "messaging-schema-api"]` - registry `runtime_memberships`: **`["app-bootstrap"]`** ### 숫자 | 항목 | 수 | |---|---:| | production Java 파일 | 3 | | production LOC | 228 | | 패키지 | 1 (`dev.caskeleton.messaging.cloudevents`) | | test 파일 | 1 | | test 메서드(실행 확인) | 7 | | 외부 의존성 | 2 (`cloudevents-api:4.0.1` **api**, `cloudevents-core:4.0.1` implementation) | 세 타입: `CloudEventMapper`(인터페이스), `DefaultCloudEventMapper`(구현), `CloudEventExtensions`(확장 속성 이름 4개). ### Coverage ledger | scope/file group | count | disposition | reason | |---|---:|---|---| | `.../cloudevents/CloudEventMapper.java` | 1 | `FULL_READ` | 33줄 전문 | | `.../cloudevents/DefaultCloudEventMapper.java` | 1 | `FULL_READ` | 171줄 전문 | | `.../cloudevents/CloudEventExtensions.java` | 1 | `FULL_READ` | 24줄 전문 | | `src/test/java/**` | 1 | `FULL_READ` | 162줄 전문 | | `build.gradle` | 1 | `FULL_READ` | 주석 포함 17줄 | | `gradle.lockfile` | 1 | `FULL_READ` | cloudevents 좌표 2건 확인 | | `build/**` | — | `EXCLUDED` | 빌드 산출물 | `UNCLASSIFIED` 0. --- ## 1. 모듈의 정체와 경계 플랫폼 봉투와 CloudEvents 1.0.2 사이의 양방향 매퍼. 적용 범위를 인터페이스 javadoc이 한정한다. ```java // CloudEventMapper.java:11-13 *

Offered for domain and integration events only. Commands and work items are not forced through * CloudEvents: they are internal contracts where the interoperability the specification buys does * not pay for the attributes it requires. ``` 의존성 선언에 이 저장소에서 가장 자세한 근거 주석이 붙어 있다. ```groovy // api, because CloudEventMapper's public signatures return io.cloudevents.CloudEvent. // // Declared `implementation`, the type appeared in this module's public API while the // dependency was hidden from consumers: an adopter calling the documented method could not // name its return type without adding CloudEvents to their own build, and Gradle gave them no // hint why. A type in a public signature is part of the artifact's contract. api 'io.cloudevents:cloudevents-api:4.0.1' implementation 'io.cloudevents:cloudevents-core:4.0.1' ``` **둘의 scope가 다른 것이 정확하다.** `cloudevents-api`(`CloudEvent`, `CloudEventData`)는 public 시그니처에 나오므로 `api`, `cloudevents-core`(`CloudEventBuilder`, `BytesCloudEventData`)는 구현 안에서만 쓰이므로 `implementation`이다. `src/messaging/CLAUDE.md:40-43`의 게이트가 잡는 구분을 두 좌표로 나눠 지켰다. **이 leaf의 위치가 형제들과 다르다.** `runtime_memberships`가 `["app-bootstrap"]`이다 — 배포 아티팩트가 싣는다. 그런데 소비자가 하나도 없다(§12.1). Avro·Protobuf는 "싣지도 않고 쓰지도 않는다"로 정합하지만, 이 leaf는 **싣고 쓰지 않는다.** --- ## 2. 의존성과 런타임 배선 들어오는 것: `messaging-core-api`(api), `messaging-schema-api`(api), `cloudevents-api:4.0.1`(api), `cloudevents-core:4.0.1`(implementation). 나가는 것: `messaging-spring-boot-starter`의 `allowed_dependencies`에 포함된다. 그래서 `app-bootstrap` → starter → 이 leaf 경로로 런타임 classpath에 오른다. **그러나 어떤 코드도 이 leaf의 타입을 부르지 않는다.** starter의 어느 `@Bean`도 `CloudEventMapper`를 만들지 않고, 어느 클래스도 import하지 않는다(§12.1). bean 없음(Spring 주석 0개). --- ## 3. 패키지/컴포넌트 지도 ``` CloudEventMapper (interface) ├── toCloudEvent(MessageEnvelope, URI) → CloudEvent └── fromCloudEvent(CloudEvent) → MessageEnvelope DefaultCloudEventMapper (구현) ├── toCloudEvent : occurredAt 필수, payload는 이미 인코딩된 것만 ├── fromCloudEvent : time 필수, schemaversion 확장 필수 ├── stringExtension / intExtension └── producerFrom(URI) : 마지막 세그먼트를 producer id로 CloudEventExtensions (상수 4개) correlationid · causationid · schemaversion · tenantcontext ``` `CloudEventExtensions`의 javadoc이 이름이 봉투 필드명과 다른 이유를 적는다 — "CloudEvents requires extension names to be lowercase alphanumeric". --- ## 4. 계약·불변식·상태 모델 ### 4.1 매핑 표 **봉투 → CloudEvent** | 봉투 | CloudEvent | 비고 | |---|---|---| | `messageId.value()` | `id` (String) | UUID 문자열 | | — | `source` | 호출자가 인자로 준다 | | `messageType.value()` | `type` | | | `occurredAt` | `time` | **필수** — 없으면 거절 | | `contentType.value()` | `datacontenttype` | | | `schemaVersion.value()` | 확장 `schemaversion` | 문자열로 | | `correlationId` | 확장 `correlationid` | 있을 때만 | | `causationId` | 확장 `causationid` | 있을 때만 | | `tenantContext.tenantId()` | 확장 `tenantcontext` | 있을 때만 | | `payload`의 `schemaReference.schemaUri` | `dataschema` | 있을 때만 | | `payload` | `data` | `EncodedMessage` 또는 `byte[]`만 | **CloudEvent → 봉투** | CloudEvent | 봉투 | 비고 | |---|---|---| | `id` | `messageId` | `UUID.fromString` → `MessageId`(**UUIDv7 강제**) | | `type` | `messageType` | | | `time` | `producedAt` **및** `occurredAt` | 같은 값이 둘에 들어간다 | | `source` | `producer` | 마지막 세그먼트만 | | 확장 `schemaversion` | `schemaVersion` | **필수** | | 확장 `correlationid` | `correlationId` | | | 확장 `causationid` | `causationId` | `UUID.fromString` → `MessageId` | | 확장 `tenantcontext` | `tenantContext` | | | `datacontenttype` (없으면 `application/json`) | `contentType` | | | `data` (없으면 `new byte[0]`) | `EncodedMessage` | | | — | `partitionKey`, `orderingKey` | 항상 empty | | — | `traceContext` | 항상 `TraceContext.none()` | | — | `headers` | 항상 `MessageHeaders.empty()` | ### 4.2 두 가지 명시적 매핑 결정 ```java // DefaultCloudEventMapper.java:31-35 *

Two mapping decisions are deliberate. An event without {@code occurredAt} is rejected rather * than defaulted to the production instant, because {@code time} is read downstream as when the * fact happened, not when the platform got around to serialising it. And an event with no data maps * to an envelope with empty bytes, never to a Kafka null value: a tombstone deletes a key, and * inventing one from an absent CloudEvent payload would turn an empty notification into a deletion. ``` 두 번째는 `messaging-core-api`의 `MessageEnvelope` javadoc과 정확히 짝을 이룬다 — "A null Kafka value is a tombstone, which is a distinct broker-native operation with different retention semantics." 봉투가 payload를 non-null로 강제한 이유가 여기서 실제 매핑 규칙으로 나타난다. ### 4.3 `producerFrom`: 무한 URI를 유한 이름으로 ```java // :158-163 *

The last path or scheme-specific segment is used so that a long URI does not become an * unbounded producer name, which would leak straight into metric tags. private static String producerFrom(URI source) { String text = source.toString(); int separator = Math.max(text.lastIndexOf('/'), text.lastIndexOf(':')); String candidate = separator >= 0 && separator + 1 < text.length() ? text.substring(separator + 1) : text; return candidate.isBlank() ? "unknown" : candidate; } ``` `ProducerId`가 "deployment-independent service name, not a host, pod, or connection identity, so that it stays a bounded value safe for metric tags"라고 선언한 것과 같은 관심사다. **다만 이 방어는 완전하지 않다.** 마지막 세그먼트가 여전히 120 UTF-8 바이트를 넘거나 제어문자를 담을 수 있다. 그 경우 `ProducerId` 생성자가 `IllegalArgumentException`을 던진다 — §4.5. `urn:service:order-api` → `order-api`(테스트가 쓰는 형태). `https://a.example/very/long/path/x` → `x`. ### 4.4 `time`이 두 필드로 복제된다 ```java Instant occurredAt = time.toInstant(); return new MessageEnvelope<>( ..., occurredAt, // producedAt Optional.of(occurredAt), // occurredAt ...); ``` CloudEvents에는 `time` 하나뿐이므로 봉투의 두 시각(플랫폼이 봉투를 만든 때 / 사실이 일어난 때)을 구분할 수 없다. 같은 값을 넣는 것은 합리적 선택이지만 **정보 손실이 기록되지 않았다** — 왕복 후 `producedAt`은 원래 값이 아니다. 테스트의 왕복 검증(`roundTripsBackToAnEnvelopeWithoutInventingATombstone`)이 `messageId`·`messageType`·`schemaVersion`·`correlationId`·`tenantContext`·`payload`만 비교하고 `producedAt`은 비교하지 않는다. fixture에서 `producedAt`은 `09:15:01Z`, `occurredAt`은 `09:15:00Z`로 **일부러 다르게** 설정돼 있으므로, 비교했다면 실패했을 것이다. ### 4.5 왕복에서 소실되는 것 `fromCloudEvent`가 항상 비우는 필드가 다섯이다. | 필드 | 결과 | |---|---| | `partitionKey` | `Optional.empty()` | | `orderingKey` | `Optional.empty()` | | `traceContext` | `TraceContext.none()` | | `headers` | `MessageHeaders.empty()` | | `producedAt` | `occurredAt`으로 덮임 | **`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으로)을 정의하는데 이 매퍼는 그것을 읽지도 쓰지도 않는다. `toCloudEvent`도 `traceContext`·`headers`·`partitionKey`·`orderingKey`를 쓰지 않는다. 즉 소실은 양방향이다. ### 4.6 `id`의 UUIDv7 강제 — 이 leaf에서 가장 중요한 계약 ```java new MessageId(UUID.fromString(event.getId())) ``` CloudEvents 1.0.2는 `id`를 **"Type: String; Constraints: REQUIRED, MUST be a non-empty string"**으로 정의한다. UUID 형식 요구가 없다. `MessageId`(messaging-core-api)는 UUID이면서 **version 7 · variant 2**를 요구한다. 두 계약이 만나는 지점의 실제 동작을 런타임 probe로 확인했다(`evidence/raw/273-cloudevents-inbound-id-probe.txt`). ``` --- spec-conformant opaque string id id = A234-1234-1234 result = REJECTED thrown = java.lang.IllegalArgumentException message = Invalid UUID string: A234-1234-1234 is a MessagingException (carries FailureDescriptor) = false --- UUIDv4 id id = 9c1f1f2e-6a1a-4d3b-8f0e-2b0d5b2f6c11 result = REJECTED thrown = java.lang.IllegalArgumentException message = a message identity is UUIDv7 (time-ordered); this is version 4 is a MessagingException (carries FailureDescriptor) = false --- UUIDv7 id (what this platform mints) result = ACCEPTED ``` `A234-1234-1234`는 CloudEvents 명세 자신의 예시가 쓰는 id다. **의도는 문서화돼 있다.** 테스트에 주석이 있다. ```java // CloudEventMappingTest.java:90-91 // A v7 id: MessageId enforces the version it documents, so a v4 arriving from a foreign // producer is refused here exactly as it would be on the wire. ``` 즉 "외부 producer의 v4를 거절한다"는 것은 알고 내린 결정이다. 그러나 두 가지가 그 결정과 별개다. 1. **비UUID id는 명세 위반이 아니다.** v4 거절은 정책 선택이지만, `A234-1234-1234` 거절은 CloudEvents 상호운용성 자체를 포기하는 것이다. 그리고 그 경우는 어디에도 언급되지 않았다. 2. **실패가 플랫폼 어휘 밖이다.** 이 매퍼의 다른 모든 검증 실패는 `MessageValidationException`(→ `FailureDescriptor`, `PERMANENT_BUSINESS`, 안정 코드)이다. id 실패만 raw `IllegalArgumentException`이다. 분류도, 코드도, retryable 판정도 없다. DLQ 라우팅과 대시보드 집계가 이 실패를 못 본다. §17에서 다룬다. ### 4.7 `schemaversion` 확장이 필수다 ```java private static int intExtension(CloudEvent event, String name) { return stringExtension(event, name) .map(value -> { try { return Integer.valueOf(value); } catch (NumberFormatException e) { throw new MessageValidationException("CLOUDEVENT_SCHEMA_VERSION_INVALID", ...); } }) .orElseThrow(() -> new MessageValidationException("CLOUDEVENT_SCHEMA_VERSION_REQUIRED", "schemaversion extension is required by this profile")); } ``` 에러 메시지가 "**by this profile**"이라고 적어 이것이 명세 요구가 아니라 이 프로파일의 요구임을 밝힌다. 좋은 표현이다 — `id`의 UUIDv7 요구에는 그런 표시가 없다. 이 확장을 쓰지 않는 외부 producer의 CloudEvent는 전부 거절된다. `id`와 합치면 **이 매퍼가 받아들이는 CloudEvent는 사실상 이 플랫폼이 만든 것뿐이다.** ### 4.8 `toCloudEvent`의 payload 계약 ```java if (envelope.payload() instanceof EncodedMessage encoded) { ... } else if (envelope.payload() instanceof byte[] bytes) { builder.withData(BytesCloudEventData.wrap(bytes.clone())); } else { throw new MessageValidationException("CLOUDEVENT_PAYLOAD_NOT_ENCODED", ...); } ``` 이미 인코딩된 것만 받는다 — 매퍼가 codec 역할을 하지 않는다. `byte[]` 분기에서 `clone()`하는 것도 `EncodedMessage.bytes()`가 이미 복사본을 주는 것과 대칭이다. --- ## 5. 주요 실행 경로 **나가는 방향:** `occurredAt` 확인(없으면 거절) → `CloudEventBuilder.v1()`에 id·source·type·time·datacontenttype·schemaversion → 선택 확장 셋 → payload 종류 판정 → `dataschema`(있을 때) → `build()` **들어오는 방향:** `time` 확인(없으면 거절) → `datacontenttype`(기본 `application/json`) → `data`(없으면 빈 배열) → `MessageId`·`MessageType`·`SchemaVersion`·`ProducerId`·확장 셋 → `MessageEnvelope` 조립 --- ## 6. 실패 경로와 복구/번역 | 코드 | 예외 | 방향 | 조건 | |---|---|---|---| | `CLOUDEVENT_TIME_REQUIRED` | `MessageValidationException` | 양방향 | `occurredAt` 없음 / `time` 없음 | | `CLOUDEVENT_PAYLOAD_NOT_ENCODED` | `MessageValidationException` | 나가는 | payload가 `EncodedMessage`도 `byte[]`도 아님 | | `CLOUDEVENT_SCHEMA_VERSION_REQUIRED` | `MessageValidationException` | 들어오는 | 확장 없음 | | `CLOUDEVENT_SCHEMA_VERSION_INVALID` | `MessageValidationException` | 들어오는 | 확장이 정수가 아님 | | **(코드 없음)** | `IllegalArgumentException` | 들어오는 | `id`가 UUID가 아니거나 v7이 아님 | | **(코드 없음)** | `IllegalArgumentException` | 들어오는 | `causationid`가 UUID가 아니거나 v7이 아님 | | **(코드 없음)** | `IllegalArgumentException` | 들어오는 | `type`이 `MessageType` 제약 위반(240바이트·제어문자) | | **(코드 없음)** | `IllegalArgumentException` | 들어오는 | `correlationid`가 160바이트 초과 | | **(코드 없음)** | `IllegalArgumentException` | 들어오는 | `tenantcontext`가 슬러그 패턴 위반 | | **(코드 없음)** | `IllegalArgumentException` | 들어오는 | 유도된 producer 이름이 120바이트 초과 또는 제어문자 | | **(코드 없음)** | `IllegalArgumentException` | 들어오는 | `datacontenttype`이 미디어 타입 문법 위반 | | **(코드 없음)** | `IllegalArgumentException` | 들어오는 | `schemaversion`이 0 이하 | **분류된 실패 넷, 분류되지 않은 실패 여덟.** 매퍼가 직접 던지는 것은 전부 `MessageValidationException`이지만, 값 객체 생성자에 위임한 검증은 전부 raw `IllegalArgumentException`이다. `fromCloudEvent`는 **외부에서 온 데이터**를 다루는 유일한 진입점인데, 그 진입점의 실패 대부분이 플랫폼 실패 어휘 밖이다. `messaging-core-api`의 `FailureDescriptor` 설계 전체가 "예외 클래스로 분기하지 말고 선언된 분류로 판단하라"였다. 이 경로는 그 분류를 만들지 않는다. --- ## 7. 트랜잭션·동시성·수명주기 트랜잭션 없음. `DefaultCloudEventMapper`는 **상태가 없다** — 필드가 `SPEC_CONTENT_TYPE_FALLBACK` 상수 하나뿐이고 모든 메서드가 인자만 쓴다. 스레드 안전하다. 다만 그 사실이 javadoc에 적혀 있지 않다. `CloudEventExtensions`는 상수 홀더이고 private 생성자를 갖는다. `CloudEventBuilder`는 호출마다 새로 만들어진다. --- ## 8. 설정·기능 플래그·환경 차이 설정 없음. | 상수 | 값 | 위치 | |---|---|---| | `SPEC_CONTENT_TYPE_FALLBACK` | `"application/json"` | `DefaultCloudEventMapper.java:39` (private) | | `CloudEventExtensions.CORRELATION_ID` | `"correlationid"` | public | | `CloudEventExtensions.CAUSATION_ID` | `"causationid"` | public | | `CloudEventExtensions.SCHEMA_VERSION` | `"schemaversion"` | public | | `CloudEventExtensions.TENANT_CONTEXT` | `"tenantcontext"` | public | CloudEvents 버전은 `4.0.1`로 고정(lockfile 확인). CloudEvents **명세** 버전은 `CloudEventBuilder.v1()`이 고정한다 — javadoc은 1.0.2를 명시한다. --- ## 9. 퍼시스턴스/외부 시스템 세부 없다. --- ## 10. 테스트 레인과 실제 증명 범위 레인: `./gradlew :messaging:messaging-cloudevents:test`. **BUILD SUCCESSFUL, 7 tests, 0 skipped, 0 failures**. | 테스트 | 증명하는 것 | |---|---| | `mapsLogicalIdentityAndExtensions` | id·type·schemaversion·source·datacontenttype | | `mapsCorrelationAndTenantAsExtensions` | 두 확장 | | `mapsOccurredAtToEventTime` | `occurredAt` → `time` | | `rejectsAnEventEnvelopeWithoutOccurredAt` | 나가는 방향의 `time` 필수 | | `roundTripsBackToAnEnvelopeWithoutInventingATombstone` | 왕복 시 6개 필드 보존 | | `aCloudEventWithNoDataBecomesAnEmptyPayloadNotANullValue` | 빈 data → 빈 바이트(tombstone 아님) | | `rejectsAnUnencodedPayload` | 인코딩되지 않은 payload 거절 | **이 레인의 결정적 한계: 모든 입력이 이 플랫폼이 만든 것이다.** `fromCloudEvent`를 부르는 두 테스트 중 하나는 `mapper.toCloudEvent(original, SOURCE)`의 출력을 되돌리고, 다른 하나는 `MessageId.newId()`로 v7 id를 만들어 CloudEvent를 조립한다. 후자에는 주석이 붙어 있다 — "A v7 id: MessageId enforces the version it documents". 즉 **외부 producer가 만든 CloudEvent를 이 매퍼에 넣는 경로가 한 번도 테스트되지 않았다.** 이 leaf의 존재 이유가 상호운용성인데, 상호운용 방향이 검증 공백이다. §4.6의 probe가 그 공백을 실제로 실행해 본 결과다. **왕복 검증의 선택적 비교.** `roundTripsBackToAnEnvelopeWithoutInventingATombstone`이 `producedAt`·`traceContext`·`headers`·`partitionKey`·`orderingKey`를 비교하지 않는다. fixture는 `producedAt`(`09:15:01Z`)과 `occurredAt`(`09:15:00Z`)을 다르게 두었으므로, 비교했다면 실패했을 것이다. 테스트 이름이 "roundTrips"인데 실제로는 6개 필드의 부분 보존을 확인한다. --- ## 11. 빌드/ArchUnit/CI 강제 지점 | 게이트 | 이 leaf에 대해 | |---|---| | `verifyCleanArchitectureDependencies` | `["messaging-core-api","messaging-schema-api"]` | | `verifyRuntimeModuleMembership` | `["app-bootstrap"]` — 편입이 강제됨 | | vendor `api` 규칙(`src/messaging/CLAUDE.md:40-43`) | `cloudevents-api`는 public 시그니처에 등장 → `api`. `cloudevents-core`는 구현 전용 → `implementation`. **통과** | | ArchUnit | 전용 규칙 없음 | --- ## 12. 실제 사용 여부와 negative-space probes 원시 증거: `evidence/raw/272-schema-family-reachability.txt`, `evidence/raw/273-cloudevents-inbound-id-probe.txt`. ### 12.1 Public surface reachability | 타입 | leaf 밖 참조 | 판정 | |---|---:|---| | `CloudEventMapper` | **0** | 소비자 없음 | | `DefaultCloudEventMapper` | **0** | 소비자 없음 | | `CloudEventExtensions` | **0** | 소비자 없음 | 세 타입 모두 `git grep` exit 1. **형제와 다른 조합이다.** | leaf | 소비자 | starter codec 등록 | `runtime_memberships` | 정합 | |---|:---:|:---:|---|---| | `messaging-schema-json` | 1 | o | `["app-bootstrap"]` | o | | `messaging-schema-avro` | 0 | x | `[]` | o | | `messaging-schema-protobuf` | 0 | x | `[]` | o | | **`messaging-cloudevents`** | **0** | 해당 없음 | **`["app-bootstrap"]`** | **x** | Avro·Protobuf는 "싣지 않고 쓰지 않는다"로 정합한다. 이 leaf는 **싣고 쓰지 않는다.** `messaging-spring-boot-starter`의 `allowed_dependencies`에 들어 있어 배포 아티팩트가 `cloudevents-api`와 `cloudevents-core` 두 jar를 함께 싣는다. 지금 그것이 사고는 아니다 — 아무도 부르지 않으므로 코드가 실행되지 않는다. 비용은 아티팩트 크기와, "이 의존성이 왜 여기 있지?"를 나중에 조사할 사람의 시간이다. ### 12.2 Conditional sibling comparison Spring 주석 0개, bean 없음. **조립 비대칭은 starter 쪽에서 관측된다.** `MessagingCoreAutoConfiguration`이 `JacksonMessageCodec`으로 codec registry를 만드는 `@Bean`을 갖는데, `CloudEventMapper`를 만드는 `@Bean`은 없다. 두 leaf 모두 starter의 의존 목록에 있고 한쪽만 배선된다. 상세는 `messaging-spring-boot-starter` leaf SSOT가 소유한다. ### 12.3 Duplicate mechanism sweep **(a) 다른 CloudEvents 구현이 있는가 — 없다** `git grep -l 'io.cloudevents' -- src`가 이 leaf 밖에서 맞추는 것이 없다. 저장소에 CloudEvents를 다루는 코드는 이 세 파일뿐이다. **(b) 봉투 ↔ 외부 표현 매핑이 다른 곳에도 있는가 — 있다, 그러나 책임이 다르다** `messaging-kafka`의 `KafkaHeaderMapper`/`KafkaDeliveryMapper`, `messaging-rabbit`의 `RabbitDeliveryMapper`가 봉투를 브로커 표현으로 옮긴다. 그러나 그들은 **transport 매핑**이고 이것은 **interchange 포맷 매핑**이다. runtime eligibility가 겹치지 않는다(브로커 매퍼는 항상 실행되고 이것은 명시 호출이 필요하다). 다만 겹치는 관심사가 하나 있다 — `traceContext`. 브로커 매퍼들은 `traceparent`/`tracestate`/`baggage`를 예약 헤더로 실어 나르고(`ReservedHeaders`가 세 이름을 갖는다), 이 매퍼는 그것을 버린다(§4.5). 같은 봉투 필드를 두 경로가 다르게 취급한다. **(c) UUID 파싱** — `UUID.fromString`을 통한 외부 문자열 → 식별자 변환이 이 leaf에서 두 곳(id, causationid)에 있고 둘 다 방어가 없다. 저장소의 다른 곳에서는 대체로 값 객체가 그 방어를 갖는다. ### 12.4 Documentation / measured-count drift | 문서 주장 | 재측정 | 결과 | |---|---|---| | build.gradle 주석: `cloudevents-api`가 public 시그니처에 등장 | `CloudEventMapper`의 두 메서드가 `CloudEvent`를 반환/수취 | **일치** | | build.gradle 주석: `cloudevents-core`는 구현 전용 | `CloudEventBuilder`·`BytesCloudEventData`가 `DefaultCloudEventMapper` 안에서만 | **일치** | | 클래스 javadoc: "CloudEvents 1.0.2 compatible profile" | `id` 제약이 명세보다 엄격(§4.6). `schemaversion` 확장 필수 | **부분 불일치** — 아래 참조 | | `CloudEventMapper` javadoc: domain/integration event 전용 | 코드에 그 구분을 강제하는 것 없음 | **미강제** — 정책 진술이고 게이트가 없다 | | `support-matrix.md:23`: 모든 messaging leaf가 unwired | 이 leaf는 `["app-bootstrap"]` | **불일치** — family drift의 사례(`messaging-core-api` §12.4) | **"compatible profile"의 정확한 의미.** 명세는 `id`를 임의의 비어 있지 않은 문자열로 정의하고, 이 프로파일은 UUIDv7만 받는다. **나가는 방향은 명세를 만족한다**(UUID 문자열은 유효한 id다). **들어오는 방향은 명세 준수 이벤트의 부분집합만 받는다.** javadoc의 "compatible"이 어느 방향을 말하는지 밝히지 않는다. `schemaversion` 에러 메시지는 "required by this profile"이라고 정확히 적는 반면 `id` 제약에는 그런 표시가 없다 — 같은 파일 안에서 표현의 정밀도가 다르다. --- ## 13. Git/설계 문서에서 확인한 변화와 실패 기록 build.gradle 주석이 이전 결함 하나를 보존한다. > Declared `implementation`, the type appeared in this module's public API while the dependency was hidden from consumers: an adopter calling the documented method could not name its return type without adding CloudEvents to their own build, and Gradle gave them no hint why. A type in a public signature is part of the artifact's contract. 이것이 `src/messaging/CLAUDE.md:40-43`의 게이트를 만든 사례군에 속한다 — "source에서 public/protected 시그니처에 등장하는 vendor 라이브러리를 뽑아 그 leaf의 `build.gradle`이 `api`로 선언했는지 대조". 이 leaf는 그 게이트를 두 좌표로 나눠 통과한 모범 사례다. 코드 주석이 남긴 두 매핑 결정(§4.2)도 실패 이력의 성격을 갖는다 — "defaulted to the production instant"와 "inventing a tombstone"은 하지 않기로 한 것들이다. --- ## 14. 런타임·터미널 Evidence | id | 종류 | 파일 | 무엇을 보여주는가 | 한계 | |---|---|---|---|---| | EVD-272 | command | `evidence/raw/272-schema-family-reachability.txt` §D, §E | 세 타입의 소비자 0, membership `["app-bootstrap"]` | 정적 검색 | | **EVD-273** | **runtime probe** | `evidence/raw/273-cloudevents-inbound-id-probe.txt` | 명세 예시 id·UUIDv4·UUIDv7 세 경우의 실제 결과와 예외 타입, `MessagingException` 여부 | 저장소 소스를 수정하지 않은 별도 probe. 세 id 형태만 확인 | | EVD-278 | command | `./gradlew :messaging:messaging-cloudevents:test --rerun-tasks` | BUILD SUCCESSFUL, 7 / 0 / 0 | 외부 producer 입력 없음 | EVD-273의 실행 방법: `:messaging:messaging-cloudevents` test runtimeClasspath에 대해 `/tmp/CeProbe.java`를 컴파일·실행. 저장소 파일은 읽기만 했다. --- ## 15. 명시적 설계 이유와 추론을 구분한 정리 **명시적** - domain/integration event 전용인 이유 — `CloudEventMapper` javadoc - `occurredAt` 없는 이벤트를 거절하는 이유 — `DefaultCloudEventMapper` javadoc - 빈 data를 tombstone으로 만들지 않는 이유 — 같은 javadoc - producer 이름을 마지막 세그먼트로 자르는 이유 — `producerFrom` javadoc - 확장 이름이 봉투 필드명과 다른 이유 — `CloudEventExtensions` javadoc - `cloudevents-api`가 `api`여야 하는 이유 — build.gradle 주석 - v4 id를 거절하는 것이 의도라는 것 — 테스트 주석(`CloudEventMappingTest.java:90-91`) **추론** - 비UUID id 거절이 의도인지 → **미상**. 테스트 주석은 v4만 언급하고 비UUID는 언급하지 않는다. 두 경우는 다른 판단이다. - `traceContext`·`headers`를 버리는 것이 의도인지 → **미상**. 어디에도 언급이 없다. - `producedAt`을 `occurredAt`으로 덮는 것이 의도인지 → **추론**. CloudEvents에 `time`이 하나뿐이라는 제약에서 나온 것으로 보이지만 주석이 없다. - membership이 있고 소비자가 없는 이유 → **미상**. --- ## 16. 확인한 것 / 확인하지 못한 것 **확인한 것** - 세 타입 228줄 전문의 매핑 계약, 양방향 필드 대응표 - 7개 테스트가 통과하고 무엇을 단언하는지, 그리고 무엇을 비교하지 않는지 - 소비자 0인데 `runtime_memberships`가 `["app-bootstrap"]`이라는 비정합 - **명세 예시 id와 UUIDv4가 분류되지 않은 `IllegalArgumentException`으로 거절된다는 것 — 런타임 probe로 실행 확인** - 왕복에서 다섯 필드가 소실된다는 것 - `api`/`implementation` 분리가 정확하다는 것 **확인하지 못한 것** - 실제 외부 CloudEvents producer(예: Knative, Azure Event Grid)의 id 형식 분포. 명세가 제약하지 않으므로 UUID가 아닐 가능성이 높지만 측정하지 않았다. - 이 leaf가 starter 의존 목록에 들어간 시점과 이유. 커밋이 4개뿐이고 전부 대량 커밋이다. - `dataschema`가 실제로 쓰이는지 — `EncodedMessage.schemaReference().schemaUri()`가 채워지는 경로가 이 저장소에 없다(세 codec 모두 `SchemaReference.of(subject, version)`로 URI 없이 만든다). 즉 `dataschema`는 현재 항상 비어 있다. - CloudEvents distributed-tracing extension을 쓸 계획이 있는지. --- ## 17. 손볼 것 ### P2 — 상호운용을 위한 매퍼가 명세 준수 이벤트를 분류되지 않은 예외로 거절한다 - **사실.** `fromCloudEvent`가 `new MessageId(UUID.fromString(event.getId()))`로 id를 파싱한다. CloudEvents 1.0.2는 `id`를 비어 있지 않은 문자열로만 제약한다. 런타임 probe 결과: 명세 예시 id `A234-1234-1234` → `java.lang.IllegalArgumentException: Invalid UUID string`, UUIDv4 → `java.lang.IllegalArgumentException: a message identity is UUIDv7`. **둘 다 `MessagingException`이 아니다.** - **근거.** `evidence/raw/273-cloudevents-inbound-id-probe.txt` (실행 확인). `DefaultCloudEventMapper.java:116`. - **왜 문제인가.** 두 층이다. - **(1) 범위.** v4 거절은 의도이고 테스트 주석이 그렇게 적는다. 그러나 **비UUID 거절은 어디에도 언급되지 않았고** 그것은 다른 판단이다 — v4 거절은 "우리 정책", 비UUID 거절은 "CloudEvents 상호운용 포기"다. 이 leaf의 존재 이유가 상호운용인데 명세 예시조차 받지 못한다. - **(2) 실패 어휘.** 같은 메서드의 다른 검증 실패 넷은 전부 `MessageValidationException`이고 안정 코드(`CLOUDEVENT_TIME_REQUIRED` 등)를 갖는다. id 실패만 raw `IllegalArgumentException`이라 `FailureDescriptor`가 없다 — 카테고리도, 코드도, retryable 판정도 없다. DLQ 라우팅과 대시보드가 이 실패를 분류하지 못한다. 같은 문제가 `causationid`·`type`·`correlationid`·`tenantcontext`·`producer`·`datacontenttype`·`schemaversion` 값 범위에도 있다(§6의 "코드 없음" 여덟 행). - **확인 방법.** `evidence/raw/273`의 probe 재실행. 또는 `MessageId` 생성자와 `UUID.fromString`의 계약 대조. - **후보.** (a) `fromCloudEvent`의 값 객체 생성을 전부 감싸 `MessageValidationException`으로 번역하고 각각 안정 코드를 준다. (b) 비UUID id에 대해 결정한다 — 거절하되 명시적으로 하거나, `id`를 그대로 보존하는 필드를 두거나, 결정론적 UUIDv5/v7으로 유도한다. (c) javadoc의 "compatible profile"이 나가는 방향만 뜻함을 밝힌다. - **다음 단계.** **CASE 후보.** 재현이 실행 evidence로 확정됐고 결론이 leaf 경계 안에서 닫힌다. (b)의 선택은 별도 **DECISION 후보**이며 지금은 근거가 없으므로 `NEEDS_DECISION`이다. ### P2 — 배포 아티팩트가 싣지만 아무도 부르지 않는다 - **사실.** 세 타입의 leaf 밖 참조가 0인데 `runtime_memberships`가 `["app-bootstrap"]`이다. `messaging-spring-boot-starter`의 의존 목록에 있어 `cloudevents-api`·`cloudevents-core` 두 jar가 런타임 classpath에 오른다. starter에 `CloudEventMapper`를 만드는 `@Bean`이 없다. - **근거.** `evidence/raw/272` §D·§E. `MessagingCoreAutoConfiguration` 전수(`CloudEvent` 참조 0). - **왜 문제인가.** 형제 Avro·Protobuf는 소비자 0과 membership `[]`이 일치하는 정합적 incubating 상태다. 이 leaf만 어긋난다. 오늘 실행되는 코드가 없으므로 사고는 아니지만, 아티팩트 크기와 "이 의존성이 왜 있지"의 조사 비용이 남는다. 그리고 `support-matrix.md:23`이 "모든 messaging leaf가 unwired"라고 적고 있어 문서에서도 이 사실을 알 수 없다. - **확인 방법.** `git grep -l -w CloudEventMapper -- src ':!src/messaging/messaging-cloudevents'` → exit 1. registry의 membership 확인. - **후보.** (a) starter에서 `@ConditionalOnClass`/`@ConditionalOnProperty`로 mapper bean을 배선한다. (b) starter 의존에서 빼고 membership을 `[]`로 되돌려 Avro·Protobuf와 같은 상태로 만든다. - **다음 단계.** **CASE 후보.** "장치는 있고 회로가 닫히지 않았다"의 변형 — 여기서는 회로가 닫히지 않았는데 **부품은 배송됐다.** ### P3 — 왕복이 다섯 필드를 버리고, 테스트가 그 필드를 비교하지 않는다 - **사실.** `fromCloudEvent`가 `partitionKey`·`orderingKey`를 empty로, `traceContext`를 `none()`으로, `headers`를 `empty()`로 두고, `producedAt`을 `occurredAt` 값으로 덮는다. 왕복 테스트는 6개 필드만 비교하고 이 다섯은 비교하지 않는다. fixture의 `producedAt`(`09:15:01Z`)과 `occurredAt`(`09:15:00Z`)이 다르므로 비교했다면 실패했을 것이다. - **근거.** `DefaultCloudEventMapper.java:115-131`, `CloudEventMappingTest.java:72-84, 143-161`. - **왜 문제인가.** `traceContext` 소실이 가장 무겁다. `messaging-core-api`의 `TraceContext` javadoc이 그 필드를 봉투에 둔 이유를 "a trace survives an Outbox round trip through the database, where broker headers do not exist yet"이라고 적는다. CloudEvents 왕복이 그 보존을 깨뜨리고, CloudEvents 자신이 정의하는 distributed-tracing extension을 쓰지 않는다. 그리고 테스트 이름이 `roundTrips…`인데 실제로는 부분 보존 확인이다. - **확인 방법.** 왕복 테스트에 `producedAt`·`traceContext` 비교를 추가하면 실패한다. - **후보.** (a) 소실 필드를 javadoc에 명시한다. (b) `traceparent`/`tracestate`/`baggage`를 CloudEvents distributed-tracing extension으로 왕복시킨다. (c) 테스트 이름을 실제 보장에 맞춘다. - **다음 단계.** **REFERENCE 후보**(왕복이라 부르는 테스트는 무엇을 보존하지 않는지도 적는다). ### P3 — `dataschema`가 채워질 경로가 없다 - **사실.** `toCloudEvent`가 `encoded.schemaReference().flatMap(SchemaReference::schemaUri).ifPresent(builder::withDataSchema)`로 `dataschema`를 채운다. 그런데 세 codec(JSON·Avro·Protobuf) 모두 `SchemaReference.of(subject, version)`로 만들고, 그 factory는 `schemaUri`를 `Optional.empty()`로 둔다. - **근거.** `DefaultCloudEventMapper.java:81-86`, `SchemaReference.java:36-38`, 세 codec의 `encode`. - **왜 문제인가.** `dataschema`는 CloudEvents 소비자가 페이로드를 해석하는 데 쓰는 표준 속성이다. 항상 비어 있으므로 이 프로파일이 만드는 CloudEvent는 스키마 위치를 알리지 않는다. `schemaversion` 확장이 그 자리를 대신하지만 그것은 비표준 확장이다. - **확인 방법.** `git grep -n 'new SchemaReference(' -- 'src/messaging/**/*.java'` — 3인자 생성자를 부르는 production 코드가 있는지 확인. - **후보.** schema registry URI를 갖는 배포에서 `SchemaReference`의 3인자 생성자를 쓰게 하거나, `dataschema` 분기가 현재 도달 불가임을 주석으로 남긴다. - **다음 단계.** **OPEN QUESTION 후보.** 판정이 "이 저장소가 외부 schema registry를 쓸 것인가"에 걸리고, 그 질문은 `messaging-schema-api`의 `SchemaRegistry` port가 구현 0인 것과 같은 뿌리다. ### P3 — `CloudEventMapper` javadoc의 범위 제한이 강제되지 않는다 - **사실.** "Offered for domain and integration events only. Commands and work items are not forced through CloudEvents." 코드에 `DestinationKind`를 보는 분기가 없다. - **근거.** `CloudEventMapper.java:11-13`, `DefaultCloudEventMapper` 전문. - **왜 문제인가.** 소비자가 0이므로 지금은 무해하다. 배선되면 `ASYNC_COMMAND`·`WORK_QUEUE` 봉투도 이 매퍼를 통과한다. - **확인 방법.** `git grep -n 'DestinationKind' -- 'src/messaging/messaging-cloudevents/**'` → 매치 없음. - **후보.** 진술을 유지하되 "호출자 책임"임을 명시하거나, `toCloudEvent`가 `DestinationKind`를 받아 검사한다. - **다음 단계.** **REFERENCE 후보**(문서가 범위를 제한하면 그 제한을 누가 강제하는지 같이 적는다). ### 확인된 설계(문제 아님) - `occurredAt` 없는 이벤트를 production 시각으로 기본값 처리하지 않고 거절하는 것 - 빈 data를 tombstone(Kafka null value)으로 만들지 않는 것 — `MessageEnvelope`의 non-null payload 계약과 정확히 짝을 이룸 - producer 이름을 마지막 세그먼트로 잘라 메트릭 카디널리티를 막는 것 - `cloudevents-api`를 `api`로, `cloudevents-core`를 `implementation`으로 나눈 것과 그 근거 주석 - `schemaversion` 에러 메시지가 "by this profile"이라고 밝히는 것 - `byte[]` payload를 `clone()`해서 넘기는 것 - 매퍼가 상태를 갖지 않는 것 --- ## Source anchors | id | kind | path | revision | what it proves | limitations | |---|---|---|---|---|---| | MCE-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps, `runtime_memberships: ["app-bootstrap"]` | 선언 | | MCE-002 | build | `messaging-cloudevents/build.gradle` | same | `api`/`implementation` 분리와 그 근거 | — | | MCE-003 | build | `messaging-cloudevents/gradle.lockfile:33-34` | same | cloudevents 4.0.1 두 좌표 | — | | MCE-004 | code | `.../cloudevents/CloudEventMapper.java` 전문 | same | 계약과 적용 범위 진술 | 범위 미강제(§17) | | MCE-005 | code | `.../cloudevents/DefaultCloudEventMapper.java` 전문 | same | §4 전체 매핑표와 두 명시적 결정 | — | | MCE-006 | code | `.../cloudevents/CloudEventExtensions.java` | same | 확장 이름 4개와 명명 이유 | — | | MCE-007 | test | `CloudEventMappingTest` (7) | same | §10 표 | 외부 producer 입력 없음. 왕복이 5개 필드 미비교 | | MCE-008 | cross-leaf code | `messaging-core-api/.../MessageId.java:20-32` | same | UUIDv7 강제의 출처 | 해당 leaf SSOT가 소유 | | MCE-009 | cross-leaf code | `messaging-core-api/.../TraceContext.java:11-13` | same | 봉투가 trace를 갖는 이유(§17 왕복 소실) | 해당 leaf SSOT가 소유 | | MCE-010 | cross-leaf code | `messaging-schema-api/.../SchemaReference.java:36-38` | same | `of`가 URI를 비움 → `dataschema` 도달 불가 | 해당 leaf SSOT가 소유 | | MCE-011 | external spec | CloudEvents 1.0.2, `id` 속성 정의 | — | `id`는 비어 있지 않은 String이며 형식 제약 없음 | 외부 표준. 저장소 밖 지식으로 명시 분리 | | EVD-272 | command | `evidence/raw/272-schema-family-reachability.txt` | same | 세 타입 소비자 0, membership | 정적 검색 | | EVD-273 | runtime probe | `evidence/raw/273-cloudevents-inbound-id-probe.txt` | same | 세 id 형태의 실제 결과와 예외 타입 | 세 형태만. 저장소 소스 미수정 | | EVD-278 | command | `./gradlew :messaging:messaging-cloudevents:test --rerun-tasks` | same | 7 / 0 / 0 | — |