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>
39 KiB
messaging-cloudevents 완전 해부
상태: COMPLETE 기준 revision:
21234e38cdb9a926cbc92bb97a2aee2e4a7d2916분석 범위:src/messaging/messaging-cloudeventsSSOT owner:messaging-cloudeventsintegration/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이 한정한다.
// CloudEventMapper.java:11-13
* <p>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.
의존성 선언에 이 저장소에서 가장 자세한 근거 주석이 붙어 있다.
// 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<EncodedMessage>
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 두 가지 명시적 매핑 결정
// DefaultCloudEventMapper.java:31-35
* <p>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를 유한 이름으로
// :158-163
* <p>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이 두 필드로 복제된다
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에서 가장 중요한 계약
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다.
의도는 문서화돼 있다. 테스트에 주석이 있다.
// 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를 거절한다"는 것은 알고 내린 결정이다. 그러나 두 가지가 그 결정과 별개다.
- 비UUID id는 명세 위반이 아니다. v4 거절은 정책 선택이지만,
A234-1234-1234거절은 CloudEvents 상호운용성 자체를 포기하는 것이다. 그리고 그 경우는 어디에도 언급되지 않았다. - 실패가 플랫폼 어휘 밖이다. 이 매퍼의 다른 모든 검증 실패는
MessageValidationException(→FailureDescriptor,PERMANENT_BUSINESS, 안정 코드)이다. id 실패만 rawIllegalArgumentException이다. 분류도, 코드도, retryable 판정도 없다. DLQ 라우팅과 대시보드 집계가 이 실패를 못 본다.
§17에서 다룬다.
4.7 schemaversion 확장이 필수다
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 계약
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 전용인 이유 —
CloudEventMapperjavadoc occurredAt없는 이벤트를 거절하는 이유 —DefaultCloudEventMapperjavadoc- 빈 data를 tombstone으로 만들지 않는 이유 — 같은 javadoc
- producer 이름을 마지막 세그먼트로 자르는 이유 —
producerFromjavadoc - 확장 이름이 봉투 필드명과 다른 이유 —
CloudEventExtensionsjavadoc 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 결과: 명세 예시 idA234-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 실패만 rawIllegalArgumentException이라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의TraceContextjavadoc이 그 필드를 봉투에 둔 이유를 "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의SchemaRegistryport가 구현 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 | — |