EVD-335  outbox 가 둘이다 — 배선된 것과 어두운 것
측정일 2026-08-31 · revision 21234e38 · workmachine

────────────────────────────────────────────────────────────────────────
1. 무엇을 다시 쟀나
────────────────────────────────────────────────────────────────────────
analysis/19 §7.1 과 root-tree 의 case:outbox-chain-behind-an-unsatisfiable-condition 은
"MessagingReliabilityAutoConfiguration 의 @ConditionalOnBean 사슬이 만족되지 않아 outbox/inbox
19개 main 파일 2,818 LOC 가 조용히 비어 있다"로 판정했다. 그 판정은 사실이다.

다시 잰 이유는 스타터의 클래스 javadoc 이 그것을 결함이 아니라 계약으로 서술하기 때문이다.

  messaging-spring-boot-starter/.../MessagingReliabilityAutoConfiguration.java 클래스 javadoc
    "Every bean here is conditional on the application having supplied the corresponding
     repository. The platform cannot provide those: they write inside the application's own
     transaction, against the application's own datasource, and a default implementation would
     silently write to the wrong place — or to nowhere at all, which is worse because the outbox
     would look healthy while nothing was ever staged."

그렇다면 남는 질문은 하나다. 이 저장소의 출하 애플리케이션은 그 계약을 이행하는가.
starter 리프 SSOT 도 그 질문을 app-bootstrap 재검증으로 넘겨 두었다
(analysis/messaging/messaging-spring-boot-starter.md:815).

────────────────────────────────────────────────────────────────────────
2. 답 — 이행하지 않는다. 대신 자기 outbox 를 갖고 있다
────────────────────────────────────────────────────────────────────────
이 저장소에는 서로를 모르는 outbox 구현이 둘 있다.

[스택 A — 배선되어 출하된다]
  포트      application-core/.../application/outbox/
              OutboxStorePort · OutboxAppendPort · OutboxMessagePublishPort
              PublishPendingOutboxEventsUseCase · OutboxBackoffPolicy · OutboxPayloadPolicy 외 15파일
  구현      adapter/outbound/persistence-jpa/.../persistence/outbox/OutboxStoreAdapter.java:23-24
              @Repository
              public class OutboxStoreAdapter implements OutboxAppendPort, OutboxStorePort
            → 스테레오타입이 붙어 있어 컴포넌트 스캔으로 컨텍스트에 들어간다
  구동      app-bootstrap/.../bootstrap/outbox/OutboxConfig.java
              @ConditionalOnProperty(prefix="ca-skeleton.outbox", name="enabled", havingValue="true")
              OutboxConfig.java:64-65  new PublishPendingOutboxEventsUseCase(...)
            app-bootstrap/.../bootstrap/outbox/OutboxRelayScheduler.java:34
              @Scheduled(fixedDelayString = "${ca-skeleton.outbox.poll-interval:PT5S}")
            부속: OutboxMetrics · OutboxLeaderElectionToken · OutboxSettings ·
                  OutboxRelayBrokerRequirementValidator

[스택 B — 어둡다]
  포트      messaging/messaging-reliability-api/.../reliability/OutboxRepository.java
  구현      messaging/messaging-outbox-jdbc-postgresql/.../JdbcOutboxRepository.java:48
              public final class JdbcOutboxRepository implements OutboxRepository
            → 스프링 스테레오타입 없음
  조립      messaging-spring-boot-starter/.../MessagingReliabilityAutoConfiguration.java:81
              @ConditionalOnBean({OutboxRepository.class, OutboxEnvelopeFactory.class})
  협력자    OutboxRelay · OutboxRelayWorker · MessagingOutboxRelayLifecycle ·
            OutboxCleanupJob · OutboxEnvelopeFactory

────────────────────────────────────────────────────────────────────────
3. 스택 B 가 조립될 수 없음을 확정한 측정
────────────────────────────────────────────────────────────────────────
$ grep -rn "new JdbcOutboxRepository|new JdbcInboxRepository|new OutboxEnvelopeFactory" \
      --include=*.java . | grep "/src/main/"
  (일치 0)

$ grep -rn "Outbox|Inbox" app-bootstrap/src/main --include=*.java | grep -iE "@Bean|new Jdbc|Repository "
  (일치 0)

타입별 구현 수와 main 참조 파일 수:
  OutboxRepository         구현 4 (main 1 = JdbcOutboxRepository, 나머지 3은 test)   main참조 4
  OutboxEnvelopeFactory    구현 0 (final class, 생성자 OutboxEnvelopeFactory(ProducerId))  main참조 2
  InboxRepository          구현 3 (main 1)                                          main참조 4
  OutboxRelay              구현 0                                                   main참조 2
  OutboxRelayWorker        구현 0                                                   main참조 2

OutboxEnvelopeFactory 를 @Bean 으로 만드는 곳은 test 하나뿐이다:
  messaging-spring-boot-starter/src/test/.../MessagingOutboxRelayLifecycleTest.java:85-86

따라서 81행의 두 조건 중 어느 것도 프로덕션에서 참이 되지 않는다.

────────────────────────────────────────────────────────────────────────
4. 교정된 판정
────────────────────────────────────────────────────────────────────────
"만족될 수 없는 조건 뒤에 사슬이 있다"는 서술은 현상으로는 맞지만 원인을 가린다.
정확한 서술은 이것이다.

  이 저장소는 outbox 를 두 번 구현했고, 출하하는 것은 application-core 쪽이다.
  messaging 플랫폼의 outbox(2,818 LOC)는 그 결과로 어둡다.

이 차이가 중요한 이유는 수정 방향이 반대이기 때문이다.
  "조건이 만족되지 않는다"로 읽으면 → app-bootstrap 에 OutboxRepository 빈을 등록하는 수정
  "outbox 가 둘이다"로 읽으면      → 어느 쪽이 정본인지 먼저 결정해야 하는 문제

후자가 맞다. 두 스택은 저장 모델도 다르고(OutboxStorePort vs OutboxRepository),
발행 경로도 다르며(OutboxMessagePublishPort vs OutboxRelay + OutboxEnvelopeFactory),
둘을 동시에 켜면 같은 업무 이벤트가 두 테이블에 적히거나 두 번 발행될 수 있다.

스타터의 javadoc 은 relay 를 자기가 구동하는 이유를 이렇게 적는다 —
"nobody running one is a table that fills up behind a business transaction that reported success."
그 경고는 스택 B 의 테이블에 대해서는 무의미하다. 그 테이블에 적는 코드가 배선되어 있지 않으므로
채워질 일도 없다. 즉 스택 B 는 위험하지 않고, 다만 존재하지 않는다.

────────────────────────────────────────────────────────────────────────
5. 이 측정이 바꾸는 문서
────────────────────────────────────────────────────────────────────────
analysis/19-messaging-platform.md §7.1        — 원인 서술 보강
analysis/messaging/messaging-spring-boot-starter.md:815 — 열린 항목 종결
root-tree.md case:outbox-chain-behind-an-unsatisfiable-condition — 분류 교체

애플리케이션 소스는 수정하지 않았다.
