# 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 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 | — |