--- kind: CASE slug: outbox-chain-behind-an-unsatisfiable-condition title: outbox 가 둘이고, 출하되는 것은 messaging 플랫폼 쪽이 아니다 topic: assembly-ownership project: clean-architecture-backend-template status: 게시 전 sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 rootTreeNode: case:outbox-chain-behind-an-unsatisfiable-condition evidenceCapturedOn: 2026-09-01 body: case-outbox-chain-behind-an-unsatisfiable-condition.body.md assets: - key: outbox-chain-behind-an-unsatisfiable-condition file: ../../../final/evidence/rendered/outbox-chain-behind-an-unsatisfiable-condition.svg evidence: - ../../../final/evidence/raw/outbox-chain-behind-an-unsatisfiable-condition.txt - ../../../final/evidence/raw/tl-outbox-unsatisfiable-condition.txt source: - 원본 분석 절은 final/document.md#4-3 · analysis/19 §7.1 이다. --- # outbox 가 둘이고, 출하되는 것은 messaging 플랫폼 쪽이 아니다 messaging 플랫폼의 outbox 사슬은 조건이 참이 될 수 없어 조립되지 않는다. 원인은 조건 결함이 아니라 이 저장소가 outbox 를 두 번 구현했고 다른 쪽을 출하하기 때문이다. ## 관계 - **@Bean이 있다는 것은 조립 증거가 아니다** 두 스택 중 하나만 컨텍스트에 들어간다는 것이 이 규칙의 사례다. - **@ConditionalOnBean은 조건이 만족될 수 있는지까지 확인해야 한다** 조건이 참이 될 수 없다는 관측이 이 규칙으로 이어진다. - **조립 결함을 판정하려면 조립하는 쪽을 먼저 읽어야 한다** 조립하는 쪽을 읽지 않으면 중복을 조건 결함으로 오진한다. - **high-water mark가 본 위치를 뜻해서 재전달된 변경이 영구히 사라졌다** 같은 신뢰성 계열에서 조용한 실패가 나타난 다른 사례다. ## 문제 messaging 스타터의 MessagingReliabilityAutoConfiguration 은 outbox 와 inbox 운영 빈을 조건부로 등록한다. 81행 : OutboxRelay 는 OutboxRepository 와 OutboxEnvelopeFactory 빈이 있을 때만 106행 : OutboxRelayWorker 는 OutboxRelay 가 있을 때만 123행 : MessagingOutboxRelayLifecycle 은 OutboxRelayWorker 가 있을 때만 138행 : OutboxCleanupJob 은 OutboxRepository 가 있을 때만 167행 : InboxCleanupJob 은 InboxRepository 가 있을 때만 사슬의 뿌리는 OutboxRepository 와 OutboxEnvelopeFactory 다. 프로덕션 코드 어디에도 그 두 타입의 빈을 만드는 곳이 없다. 따라서 사슬 전체가 조립되지 않고, outbox 와 inbox 리프의 main 파일 19개 2,818 LOC 가 조용히 비어 있다. 여기까지가 앞선 분석의 판정이었고 그것은 사실이다. 다시 잰 이유는 스타터의 클래스 javadoc 이 이 조건들을 결함이 아니라 계약으로 서술하기 때문이다. 플랫폼은 그 저장소를 제공할 수 없다고 명시한다. 애플리케이션 자신의 트랜잭션에서 애플리케이션 자신의 데이터소스에 쓰기 때문이다. 그렇다면 남는 질문은 하나다. 이 저장소의 출하 애플리케이션은 그 계약을 이행하는가. ## 결론 이행하지 않는다. 대신 자기 outbox 를 갖고 있다. 스택 A 는 배선되어 출하된다. 포트 : application-core 의 outbox 패키지. OutboxStorePort 와 OutboxAppendPort 와 OutboxMessagePublishPort 와 PublishPendingOutboxEventsUseCase 를 포함해 15개 파일 구현 : persistence-jpa 의 OutboxStoreAdapter. @Repository 가 붙어 있어 컴포넌트 스캔으로 컨텍스트에 들어간다 구동 : app-bootstrap 의 OutboxConfig 가 PublishPendingOutboxEventsUseCase 를 직접 생성하고, OutboxRelayScheduler 가 @Scheduled 로 5초 간격 폴링한다 스택 B 는 어둡다. 포트 : messaging-reliability-api 의 OutboxRepository 구현 : JdbcOutboxRepository 2,276 LOC. 스프링 스테레오타입이 없다 조립 : MessagingReliabilityAutoConfiguration 81행의 조건 뒤 측정으로 확정한 것은 이렇다. main 코드에서 JdbcOutboxRepository 나 JdbcInboxRepository 나 OutboxEnvelopeFactory 를 생성하는 곳이 0 이고, app-bootstrap 에서 관련 빈을 만드는 곳도 0 이다. OutboxEnvelopeFactory 를 @Bean 으로 만드는 곳은 스타터의 테스트 하나뿐이다. 이 차이가 중요한 이유는 수정 방향이 반대이기 때문이다. 조건이 만족되지 않는다고 읽으면 app-bootstrap 에 빈을 등록하는 수정이 된다. outbox 가 둘이라고 읽으면 어느 쪽이 정본인지 먼저 정해야 하는 문제가 된다. 후자가 맞다. 두 스택은 저장 모델도 다르고 발행 경로도 다르다. 둘을 동시에 켜면 같은 업무 이벤트가 두 테이블에 적히거나 두 번 발행될 수 있다. 스타터의 javadoc 은 relay 를 자기가 구동하는 이유를 이렇게 적는다. 아무도 relay 를 돌리지 않는 것은 성공을 보고한 업무 트랜잭션 뒤에서 테이블이 차오르는 상황이라는 것이다. 그 경고는 스택 B 의 테이블에는 해당하지 않는다. 그 테이블에 쓰는 코드가 배선되어 있지 않으므로 채워질 일이 없다. 스택 B 는 위험한 것이 아니라 존재하지 않는다. ## 검증 환경 OpenJDK : 21.0.12 Gradle : 9.0.0 Spring Boot : 4.0.8 확인 방식 : 정적 도달성 전수 확인. 애플리케이션을 부팅하지 않았다 소스 수정 : x ## 재현 조건 원문은 final/evidence/raw/335-two-outboxes-one-wired.txt 에 있다. 1. MessagingReliabilityAutoConfiguration 의 조건 사슬과 클래스 javadoc 을 읽는다. 2. main 소스 전체에서 JdbcOutboxRepository 와 JdbcInboxRepository 와 OutboxEnvelopeFactory 를 생성하는 코드를 찾는다. 일치 0 이다. 3. app-bootstrap 의 main 에서 outbox 또는 inbox 저장소 빈을 만드는 곳을 찾는다. 일치 0 이다. 4. application-core 의 outbox 패키지와 persistence-jpa 의 OutboxStoreAdapter 스테레오타입, app-bootstrap 의 OutboxConfig 와 OutboxRelayScheduler 를 읽어 다른 스택이 배선되어 있음을 확인한다. 앞선 사이클의 증거 파일 tl-outbox-unsatisfiable-condition.txt 는 일부 구획에 셸 변수가 전개되지 않은 채 저장돼 있다. 그 구획의 수치는 이번에 다시 쟀다. ## 본문 이 저장소에는 서로를 모르는 outbox 구현이 둘 있다. ## 스택 A — 배선되어 출하된다 `application-core/.../outbox/` 의 포트와 `persistence-jpa` 의 `@Repository OutboxStoreAdapter`, 그리고 `app-bootstrap` 의 `OutboxConfig` 가 `@Scheduled` 로 구동하는 `PublishPendingOutboxEventsUseCase` 다. ## OutboxConfig 참조 위치 :::evidence key="outbox-chain-behind-an-unsatisfiable-condition" alt="코드베이스에서 OutboxConfig 를 검색한 출력 3줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="OutboxConfig 코드베이스 검색 — 3줄 · exit 0" zoom="true" ::: ## 스택 B — 조건이 프로덕션에서 참이 되지 않는다 `messaging-reliability-api` 의 `OutboxRepository` 와 `JdbcOutboxRepository`(2,276 LOC)와 `OutboxRelay` 계열이며, `MessagingReliabilityAutoConfiguration:81` 의 `@ConditionalOnBean({OutboxRepository.class, OutboxEnvelopeFactory.class})` 뒤에 있다. `JdbcOutboxRepository` 에는 스프링 스테레오타입이 없고 그것을 만드는 `@Bean` 도 main 에 없다. ## 조건 결함이 아니라 정본이 정해지지 않은 중복이다 스타터의 javadoc 은 이 조건들을 결함이 아니라 계약으로 서술하고 저장소·팩토리 제공을 애플리케이션 책임으로 둔다. 두 스택은 저장 모델과 발행 경로가 다르므로 동시에 켜면 같은 이벤트가 두 번 적히거나 두 번 발행될 수 있다. ## 확인하지 못한 것 애플리케이션을 부팅해 조건 평가 리포트로 확인하지 않았다. 부팅 한 번이면 두 스택 중 어느 것이 컨텍스트에 들어가는지 직접 관측된다. 두 스택을 동시에 켰을 때 실제로 이중 기록이 일어나는지 재현하지 않았다. 저장 모델이 다르다는 것은 코드로 확인했으나 그 결과를 관측한 것은 아니다. ConditionEvaluationReport로 미충족 사유를 확인하지 않았다