Files
clean-architecture-backend-…/src/adapter/outbound/messaging/README.md
T

11 KiB

adapter:outbound:messaging — 설계 결정 참조

메시징(broker publish + outbox) 아웃바운드 어댑터 모듈. 패키지 루트: dev.caskeleton.adapter.outbound.messaging. :adapter:outbound:support 에 의존해 공유 correlation / fail-open 의존성 로깅을 일반 publisher에서 재사용한다. outbox relay 실패는 이 모듈이 별도의 typed report adapter로 구조화한다.

허용/금지 의존 정책은 src/config/architecture/modules.json과 이 모듈의 CLAUDE.md가 소유한다. 이 문서는 코드 주석에서 덜어낸 설계 결정의 근거를 모아둔 참조용 기록이다.

모듈 개요

application-core 포트(MessagePublisher / OutboxMessagePublishPort) 뒤에 두는 선택형 연동 어댑터다. @ConditionalOnProperty 로 게이팅되고 기본 비활성이며, 비활성 바인딩은 Disabled* 구현으로 fail-fast 한다(Layer 3). 무거운 broker SDK 는 의도적으로 classpath 에 최소화하고, 실제 broker client(KafkaSender)는 포킹 프로젝트가 채우는 seam 이다.

두 포트를 하나의 활성 broker 에 조립

MessagingConfig 는 두 messaging 포트를 단일 활성 MessageBroker 위에 조립한다 — broker 추가는 새 broker 구현 파일 추가만으로 끝나고 이 config 는 바뀌지 않는다.

broker 선택 검증

app.messaging.broker 가 설정됐는데 MessageBroker 빈이 없으면 startup 을 명시적 메시지로 실패시킨다(조용한 no-op 아님). settings 와 활성 빈의 brokerId() 불일치도 startup 실패다.

비활성 sentinel 두 개를 분리한 이유

DisabledMessagePublisherDisabledOutboxMessagePublisher 는 별도 클래스다 — 한 클래스가 두 포트를 모두 구현하면 getBean(MessagePublisher.class) 가 모호해진다.

OutboxEnvelopeJson — 손수 짠 JSON

이 모듈은 jackson-databind 를 classpath 에 두지 않아(스켈레톤을 가볍게 유지) outbox envelope 직렬화는 의존성 없는 손수 짠 JSON 이다.

MessagePublisher vs OutboxMessagePublishPort

MessagePublisher 는 fail-open 어댑터-로컬 발행기로, 발행 실패를 correlationId 와 함께 로깅하고 삼켜(→ :adapter:outbound:supportFailOpenDependencyLogger) outbox/retry 로 위임하므로 core 5xx 가 되지 않는다. 내구성 있는 전달이 필요하면 OutboxMessagePublishPort 를 쓴다. OutboxMessagePublishAdapter는 envelope mapping + broker send만 수행하며 runtime 예외를 그대로 전파하고 checked 예외는 cause를 보존해 감싼다. 성공 DEBUG나 실패 WARN을 남기지 않는다. 반환 타입을 void 로 둬 broker SDK 타입이 어댑터 밖으로 새지 않는다(B7).

Legacy R0 characterization

현재 KafkaSender.send(...)MessageBroker.send(...)의 정상 void 반환은 호출이 예외 없이 끝났다는 뜻일 뿐 broker acknowledgement 증거가 아니다. relay는 이 정상 반환 뒤 legacy PUBLISHED를 기록한다. sender 예외는 fail-closed outbox 경로에서 전파되어 relay의 FAILED/DEAD 전이를 유발한다.

broker 설정이 blank면 두 포트는 각각 DisabledMessagePublisherDisabledOutboxMessagePublisher에 바인딩된다. broker를 선택했지만 project-supplied sender가 없거나 선택 ID와 활성 MessageBroker.brokerId()가 다르면 startup이 실패한다.

publisher 정상 반환 뒤 DB mark가 실패하면 row는 IN_FLIGHT에 남아 timeout reclaim 후 같은 event가 다시 publish될 수 있다. 현재 consumer/inbox가 없으므로 이 duplicate 가능 구간을 중복 안전 전달로 표현하지 않는다. 또한 legacy FIFO gate는 occurred_at의 strict less-than 비교만 사용하므로 동일 aggregate의 timestamp 동률 row는 서로를 gate하지 않는다.

OutboxRelayFailureReport 구조화 ERROR

MessagingConfig는 broker 활성 여부와 무관하게 정확히 하나의 Slf4jOutboxRelayFailureReportAdapter를 등록한다. broker 설정이 blank면 안전한 dependency_name=disabled를 쓴다. 이 adapter는 confirmed FAILED/DEAD report 하나를 SLF4J 2 fluent ERROR 하나로 렌더링한다.

공통 field는 error.code, error.category, dependency_name, dependency_type=messaging, outcome, event_id, event_type, aggregate_id, correlation_id, attempt_count, runbook_link다. retry report만 next_attempt_at을 추가한다. payload/idempotency key/envelope/exception-derived field는 받거나 렌더링하지 않고 cause만 throwable로 붙인다. logging 내부 RuntimeException은 adapter와 use case 양쪽에서 방어하므로 persisted FAILED/DEAD outcome을 바꾸지 않는다.

Closed local binding compiler R1

contract, destination, config package는 명시적으로 주입된 IntegrationEventContractContribution 목록만 사용해 contract catalog와 deployment binding을 로컬에서 컴파일한다. payload record의 exact Class token, 선언 순서, schema resource/hash를 검증하며 scan, Class.forName, assignable discovery, raw JSON/tree discovery를 하지 않는다. catalog/settings/schema digest는 정렬된 semantic identity와 length-prefixed UTF-8/raw SHA-256 bytes로 계산한다.

physical topic과 bootstrap server는 DestinationBindingSettingsCompiledPublicationBinding에만 존재한다. application contract에는 broker, topic, credential 설정이 유출되지 않는다. logical destination과 physical topic은 각각 deployment 안에서 unique다. compiled publication binding은 package-local compiler만 만들 수 있고 active descriptor는 public static compile 경로로만 생성한다. compiled integration-event contract도 catalog compiler만 package-local constructor로 만들 수 있다. partition key v1은 canonical non-null tenant scope, logical destination, aggregate type/id를 strict UTF-8 REPORT + domain-separated length-prefixed SHA-256으로 계산하고 lowercase hex US-ASCII bytes를 반환한다. malformed surrogate는 replacement 문자로 바꾸지 않고 거부한다.

adapter 내부 compiler/card/binding 타입은 direct 또는 generic argument 형태의 public instance response surface로 노출하지 않는다. cross-package adapter composition이 정확한 compiled identity를 확인해야 할 때만 public static bridge를 사용하며, instance accessor는 package-private이고 외부에 필요한 상태는 boolean/String/ core value로만 제공한다. 이는 broker SDK뿐 아니라 adapter 자체의 조립 타입도 B7 반환 경계를 통해 application consumer로 새지 않게 한다.

MessagingCapabilityCardRegistry는 first R2 tuple의 11개 ID와 각 role을 닫힌 Java vocabulary로 제공할 뿐이다. readiness registry의 not-implemented 상태를 승격하거나 R2/ACTIVE_READY를 주장하지 않는다. DISABLED + empty catalog/bindings는 client, thread, scheduler, network, filesystem resource가 정확히 0인 descriptor다. ACTIVE는 empty catalog, missing/duplicate/extra binding, unknown/mismatched card와 ordering/schema/security 완화를 resource 생성 전에 거부한다.

Deterministic local JSON Schema encoder candidate

envelope package는 exact final IntegrationPayload record만 받아 frozen v1 field order로 UTF-8 JSON을 쓴다. Map, raw JSON, JsonNode, polymorphic typing, custom serializer, assignable search는 입력 경로에 없다. contract compiler가 선언된 generic type graph를 scalar/enum, Optional<T>/List<T>/exact final nested record로만 재귀 고정하며 Object, interface, raw/wildcard, generic record와 runtime record discovery를 거부한다. writer는 이 compiled graph를 따라서 각 record accessor를 정확히 한 번 읽은 immutable snapshot을 만들고 payload를 정확히 한 번 encode한다. schema validation에 건넨 바로 그 payload bytes를 raw JSON API 없이 envelope suffix에 삽입한다. depth, string character/UTF-8 byte, array, object, number, payload/envelope byte limit는 writer와 parser 양쪽에서 적용한다. output byte limit는 generator가 쓰는 bounded stream에서 allocation 전에 강제하고, list는 size를 먼저 검사한 뒤 bounded iterator로 한 번만 snapshot한다. mutation/concurrent access와 극단적 BigDecimal scale도 큰 배열/String 생성 전에 fail-closed 한다. duplicate key, malformed UTF-8, trailing data, unpaired surrogate와 non-finite number는 replacement나 coercion 없이 거부한다.

LocalJsonSchemaRegistry는 호출자가 명시적으로 제공한 exact bytes와 SHA-256만 startup에서 Draft 2020-12 meta-schema로 검사하고 precompile한다. root $id와 absolute $ref는 lowercase exact urn scheme만 허용하고 $ref는 동일 문서 fragment 또는 제공된 exact URN map으로 닫힌다. HTTP/HTTPS/file/classpath/resource/jar 및 unknown absolute scheme fetch와 YAML loader는 제공하지 않는다. public validation boundary도 UTF-8 bytes만 받는다. format assertion은 활성이다. 이 Task 6 closed subset은 scope 추적의 모호성을 제거하기 위해 root 외 nested $id를 value type과 무관하게 거부하고, $dynamicRef, $dynamicAnchor, deprecated $recursiveRef/$recursiveAnchor, $anchor 키워드를 어느 위치에서도 지원하지 않는다.

Draft 2020-12 authority는 draft/2020-12/schema 1개와 meta/* 8개의 exact checked-in bytes 및 digest로 pin한다. registry startup은 9개 digest, 각 $id, NetworkNT runtime schema tree를 대조하고 하나라도 다르면 fail-closed 한다. Spring Boot executable/fat/nested JAR 배치를 깨뜨리는 CodeSource regular-file/JAR 가정은 하지 않는다. NetworkNT 3.0.2 artifact provenance는 strict Gradle dependency lock과 verifyJsonSchemaRuntimeGraph가 담당한다. 이 검증은 business schema registry나 runtime remote resolution 경로를 넓히지 않는다.

공통 build evidence manifest는 수동 구조 검사만으로 PASS하지 않는다. test/build 전용 MessagingEvidenceManifestSchemaValidator가 exact common schema와 생성된 manifest bytes를 같은 pinned NetworkNT/LocalJsonSchemaRegistry로 Draft 2020-12 검증하며, common schema 자체도 meta-schema 검증을 통과해야 한다. verifyMessagingContracts는 JSON-only qualification과 그 manifest schema validation을 명시적으로 선행해 CLI task 나열 순서와 무관하게 combined manifest가 build/messaging-evidence/contracts-schema/manifest.json의 최종 소유자가 된다.

NetworkNT 3.0.2의 adopted regex 구현은 JDK regex 실행시간을 강제 중단시키는 별도 engine/timeout을 제공하지 않는다. 따라서 현재 후보는 regex 입력 길이와 전체 document 구조를 먼저 제한하고, pathological pattern의 작은 repository corpus를 회귀 테스트한다. 이는 JSON Schema Test Suite/Bowtie 전체 호환이나 hostile regex 시간 상한 증명이 아니며 evidence의 regex-engine-timeout, consumer-compatibility-full-suite unsupported claim으로 남는다. 테스트 runtime은 remote corpus를 내려받지 않는다.

이 Task의 acceptance는 deterministic local wire contract candidate다. encoder/catalog는 기존 MessagingConfig, runtime append, KafkaSender 또는 legacy broker selection에 연결하지 않았다. Kafka ACK, durable outbox R2, external topic attestation은 아직 구현되지 않았고 R0 runtime authority와 동작은 그대로다.