Files
document-haness/docs/clean-architecture-backend-template/analysis/messaging/messaging-inbox-jdbc-postgresql.md
T
DongHyeonkaandClaude Opus 5 b2963105a8 docs(keycloak-session-store): import the session-storage lab as a new project
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>
2026-09-04 22:51:59 +09:00

45 KiB
Raw Blame History

messaging-inbox-jdbc-postgresql 완전 해부

상태: COMPLETE 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/messaging/messaging-inbox-jdbc-postgresql SSOT owner: messaging-inbox-jdbc-postgresql integration/family document: analysis/19-messaging-platform.md (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지와 숫자 지도

  • registered leaf id: messaging-inbox-jdbc-postgresql
  • canonical state analysisFile: analysis/messaging/messaging-inbox-jdbc-postgresql.md
  • source path: src/messaging/messaging-inbox-jdbc-postgresql
  • registry allowed_dependencies: ["messaging-core-api", "messaging-reliability-api"]
  • registry runtime_memberships: ["app-bootstrap"]

숫자

항목
production Java 파일 6
production LOC 542
패키지 1 (dev.caskeleton.messaging.inbox)
migration 1 (V2__messaging_inbox.sql)
test 파일 4
test 메서드(실행 확인) 25
외부 의존성 spring-jdbc, spring-tx(implementation) · testcontainers·postgresql·messaging-testkit(test)

여섯 타입:

타입 역할 leaf 밖 참조
JdbcInboxRepository InboxRepository 구현 0
IdempotentConsumer 예약+부작용을 한 트랜잭션에 1
TransactionalInboxHandler IdempotentMessageHandler 구현 1
InboxCleanupJob 보존 스윕 1
InboxRetentionPolicy 보존 규칙 1
InboxOutcome 처리/중복 결과 0

Coverage ledger

scope/file group count disposition reason
src/main/java/** (6) 6 FULL_READ 전 파일 본문 확인
src/main/resources/db/migration/messaging/V2__messaging_inbox.sql 1 FULL_READ 18줄 전문
src/test/java/** (4) 4 FULL_READ fake 구현·테스트명·단언 확인
build.gradle 1 FULL_READ 주석 포함 17줄
gradle.lockfile 1 STRUCTURAL_ONLY 잠금 파일
build/** EXCLUDED 빌드 산출물

UNCLASSIFIED 0.


1. 모듈의 정체와 경계

messaging-reliability-apiInboxRepository·IdempotentMessageHandler 포트를 PostgreSQL로 구현한다. 이름이 기술을 드러낸다 — docs/messaging/support-matrix.md가 그 개명 이유를 적는다(MSG-023).

메커니즘 전체가 하나의 SQL 문장에 있다.

INSERT INTO messaging_inbox (message_id, consumer_id, processed_at)
VALUES (?, ?, ?)
ON CONFLICT (message_id, consumer_id) DO NOTHING
// JdbcInboxRepository.java:20-23
 * <p>Reservation is an {@code INSERT ... ON CONFLICT DO NOTHING} whose affected-row count is the
 * answer: one means first delivery, zero means already processed. The composite primary key does
 * the work, so there is no read-then-write race  two concurrent deliveries of the same message
 * cannot both see "not processed" and both proceed.

migration이 같은 사실을 반대편에서 적는다.

-- The composite primary key is the deduplication mechanism: reserving a message is an INSERT that
-- either succeeds or violates the key, inside the same transaction as the handler's side effect.
-- Two independent consumers of the same event each get their own row, so one cannot suppress the
-- other.

build.gradle 주석이 테스트 전략을 명시한다.

// Live-database certification. The reliability patterns are claims about transaction
// boundaries and uniqueness constraints, and only a real database can settle them.
testImplementation 'org.testcontainers:testcontainers-postgresql'

그리고 실제로 실행된다InboxPostgresIT 6개가 기본 test 태스크에서 통과한다(§10).


2. 의존성과 런타임 배선

들어오는 것: messaging-core-api(api), messaging-reliability-api(api), spring-jdbc·spring-tx(implementation).

나가는 것: messaging-spring-boot-starter.

배선됨. starter의 MessagingReliabilityAutoConfiguration이 셋을 만든다.

bean 이 leaf의 타입
InboxRetentionPolicy o
InboxCleanupJob o
TransactionalInboxHandler<Object> o (IdempotentConsumer를 받음)

JdbcInboxRepository는 그 목록에 없다 — InboxRepository bean을 누가 만드는지는 starter leaf가 답한다.

Spring 타입을 두 곳에서 쓴다 — DataSourceUtilsTransactionSynchronizationManager. 둘 다 implementation scope이고 public 시그니처에 나오지 않으므로 vendor api 규칙에 맞는다.


3. 패키지/컴포넌트 지도

TransactionalInboxHandler<T>  (IdempotentMessageHandler<T> 구현)
  └── handleOnce(consumerName, delivery, action)
        └── IdempotentConsumer.runOnce(messageId, consumerId, now, sideEffect)
              └── TransactionRunner.inTransaction(...)          ← 호출자가 제공
                    ├── InboxRepository.reserve(...) == false → InboxOutcome.duplicate()
                    └── true → sideEffect.get() → InboxOutcome.processed(...)

JdbcInboxRepository  (InboxRepository 구현)
  ├── reserve(MessageId, String, Instant)   ← requireActiveTransaction 3검사 후 위임
  ├── reserve(Connection, ...)              ← package-private, 실제 INSERT
  ├── isProcessed(...)                      ← 자기 커넥션
  ├── purgeProcessedBefore(Instant, int)    ← LIMIT + FOR UPDATE SKIP LOCKED. 호출자 0 (§12.1)
  └── purgeProcessedBefore(Instant)         ← 무제한 DELETE. 이것이 불린다

InboxCleanupJob(inbox, policy, maxBatches)
  ├── 생성자가 policy.validate()
  └── runOnce(now) → maxBatches회 루프, 매회 무제한 purge

InboxRetentionPolicy(retention, maximumRedeliveryWindow)
  ├── REQUIRED_SAFETY_FACTOR = 2.0
  └── validate() → retention >= window * 2 아니면 INBOX_RETENTION_TOO_SHORT

4. 계약·불변식·상태 모델

4.1 requireActiveTransaction — 세 겹 검사

이 leaf에서 가장 중요한 안전 장치이고 이전 결함이 javadoc에 있다.

// JdbcInboxRepository.java:53-57
 * <p>Package-private. It used to be public and was the only path that actually joined the
 * caller's transaction, while the interface method  the one {@code IdempotentConsumer} calls 
 * opened a raw connection that auto-commits. A reservation that commits on its own while the
 * business side effect rolls back is a message that will never be redelivered and whose work
 * never happened.

두 개의 오버로드가 있었고 호출되는 쪽이 틀린 쪽이었다. 현재는 interface 메서드가 세 가지를 확인한다.

검사 실패 시 메시지의 핵심
isActualTransactionActive() "a reservation that commits alone marks a message processed whose work may still roll back"
!isCurrentTransactionReadOnly() "the current one is read-only"
hasResource(dataSource) "it is bound to another, so the reservation and the side effect would commit independently"

세 번째가 특히 정교하다 — 트랜잭션이 활성이어도 다른 DataSource에 묶여 있으면 거절한다. 멀티 데이터소스 배포에서 실제로 발생하는 형태이고, 그 경우 예약과 부작용이 서로 다른 트랜잭션에 들어간다.

세 검사 전부 같은 코드 INBOX_TRANSACTION_REQUIRED를 쓴다 — 메시지만 다르다.

// requireActiveTransaction javadoc:96-99
 * <p>The reservation and the side effect it guards have to commit or roll back together. Running
 * the reservation on its own connection breaks that on the rollback path only  which is the path
 * nobody exercises before production, and the one where the message is lost for good.

**"the path nobody exercises before production"**가 이 leaf의 테스트 전략을 설명한다 — InboxPostgresIT.aRolledBackTransactionLeavesNoReservationAndNoSideEffect가 정확히 그 경로를 실 DB에서 돈다.

4.2 IdempotentConsumer — 트랜잭션을 열지 않는다

// :12-15
 * <p>The reservation and the side effect must share one transaction. This class does not open that
 * transaction itself  the caller supplies a runner that does  because the boundary belongs to the
 * application's data access layer, and a nested or separate transaction here would silently break
 * the guarantee while still looking correct.

TransactionRunner가 함수형 인터페이스이고 <T> T inTransaction(Supplier<T> work) 하나다. 즉 이 leaf는 Spring @Transactional에 의존하지 않고 경계 제공을 호출자에게 위임한다. JdbcInboxRepository.requireActiveTransaction이 그 위임이 지켜졌는지를 런타임에 확인한다 — 위임과 검증이 짝을 이룬다.

중복이 정상 결과라는 것도 명시돼 있다 — "A duplicate is not an error. It is the expected consequence of at-least-once delivery, so the skip path is a normal outcome rather than an exception."

4.3 TransactionalInboxHandler — 세 가지를 할 수 없다

// :20-23
 * <p>Reservation and effect commit together, in the runner's single transaction. Everything else
 * about this class follows from that: it cannot settle the message (settlement is not
 * transactional), it cannot publish (the publish would survive a rollback), and it cannot catch and
 * swallow the action's exception (the rollback is how the reservation is undone).

세 금지가 messaging-reliability-apiTransactionalMessageAction javadoc이 구현자에게 요구한 것과 대칭이다 — 그쪽은 action에게, 이쪽은 handler에게.

예외 처리가 그 세 번째를 지킨다.

try {
  action.apply(delivery);
} catch (Exception failure) {
  // Wrapped, not swallowed: the transaction runner has to see a throw to roll the
  // reservation back along with the effect.
  throw new ActionFailedException(failure);
}

ActionFailedException이 private RuntimeException이고, 바깥에서 잡아 HandleResult.Retry로 번역한다. checked exception을 트랜잭션 runner를 통과시키기 위한 캐리어다.

중복은 성공으로 보고한다.

private static HandleResult duplicateIsSuccess() {
  // The effect already ran in an earlier delivery. Settling is correct; redelivering is not.
  return HandleResult.success();
}

실패는 TRANSIENT_INFRASTRUCTURE + retryable = true + exceptionType에 원인 클래스 단순명 — FailureDescriptorOptional<String> exceptionType을 실제로 채우는 저장소 내 드문 지점이다.

4.4 InboxRetentionPolicy — 곱셈 안전계수

// :11-18
 * <p>Retention must exceed the broker's maximum redelivery window. That is not a tuning preference:
 * a row pruned while the broker can still redeliver its message turns the inbox into a no-op for
 * exactly that message, and the side effect runs a second time. The failure is silent, rare, and
 * only happens under the conditions that already made the day bad.
 *
 * <p>The safety margin is multiplicative rather than additive so that it scales with the window
 * itself. A stream whose redelivery window is measured in days needs more slack than one measured
 * in minutes, for the same reason: the estimate of that window is proportionally less certain.

REQUIRED_SAFETY_FACTOR = 2.0, DEFAULT_RETENTION = 7일.

messaging-reliability-apiInboxRepository.purgeProcessedBefore javadoc이 요구하고 강제하지 않은 규칙을 이 leaf가 강제한다. 그 leaf §17이 "미강제"로 기록한 것이 여기서 validate()가 된다 — 다만 validate()InboxCleanupJob 생성자만 부른다. 즉 cleanup job을 만들지 않는 배포에서는 여전히 검사되지 않는다.

required()Math.round(window.toMillis() * 2.0)이다. 곱셈 이유가 적혀 있고, theRequiredRetentionScalesWithTheWindow 테스트가 2일 창 → 4일 요구를 확인한다.

4.5 InboxCleanupJob — 선언과 구현이 어긋난다

javadoc이 두 가지를 약속한다.

// :10-16
 * <p>Deletes in bounded batches. A single unbounded {@code DELETE} over a table that has been
 * accumulating for weeks holds locks long enough to block the very reservations the inbox exists to
 * serve, so the cleanup would cause the outage it is meant to prevent.
 *
 * <p>The policy is validated before the first deletion. Running a cleanup under a retention that is
 * shorter than the redelivery window would actively create the duplicate-processing bug, so the job
 * refuses to start rather than dutifully deleting the rows.

두 번째는 지켜진다 — 생성자가 policy.validate()를 부르고 테스트가 확인한다.

첫 번째는 지켜지지 않는다.

public static final int DEFAULT_BATCH_SIZE = 1_000;   // ← 선언되고 어디서도 쓰이지 않음
...
for (int batch = 0; batch < maxBatches; batch++) {
  int deleted = inbox.purgeProcessedBefore(cutoff);    // ← 무제한 overload
  ...
}

InboxRepository에는 두 오버로드가 있다.

오버로드 구현
purgeProcessedBefore(Instant, int) WITH expired AS (SELECT … LIMIT ? FOR UPDATE SKIP LOCKED) DELETE …
purgeProcessedBefore(Instant) DELETE FROM messaging_inbox WHERE processed_at < ?

job은 후자를 부른다. 첫 호출이 컷오프 이전 전부를 한 문장으로 지우고, 두 번째 호출이 0을 반환해 루프가 끊긴다. maxBatches는 사실상 의미가 없고 DEFAULT_BATCH_SIZE는 죽은 상수다.

javadoc이 "cleanup would cause the outage it is meant to prevent"라고 서술한 바로 그 동작을 한다. §12.1·§17.

4.6 InboxOutcome — 두 상태

(boolean processed, Optional<T> result). processed(value)duplicate() 두 factory.

TransactionalInboxHandlerT = InboxResult로 쓰고 항상 InboxResult.APPLIED를 넣는다 — §12.3.

4.7 migration

CREATE TABLE messaging_inbox
(
    message_id   UUID         NOT NULL,
    consumer_id  VARCHAR(160) NOT NULL,
    processed_at TIMESTAMPTZ  NOT NULL,
    CONSTRAINT pk_messaging_inbox PRIMARY KEY (message_id, consumer_id)
);
CREATE INDEX ix_messaging_inbox_processed_at ON messaging_inbox (processed_at);

message_idUUID 타입이다 — MessageId가 UUIDv7만 허용하므로(messaging-core-api §4.9) 컬럼 타입이 그 제약과 맞는다.

consumer_id VARCHAR(160)IdempotentConsumer가 공백만 거절하고 길이를 보지 않는다. 160자를 넘는 consumerId는 DB가 거절한다. 애플리케이션 층에 대응 검증이 없다. §17.

인덱스 주석이 보존 규칙을 다시 적는다.


5. 주요 실행 경로

수신 처리: handleOnce(name, delivery, action)consumer.runOnce(messageId, name, now, () -> { action.apply(delivery); return APPLIED; }) → runner가 트랜잭션 열기 → repository.reserve(...) → 세 검사 → INSERT … ON CONFLICT DO NOTHING → 1행이면 부작용 실행, 0행이면 duplicate() → 커밋 → HandleResult.success()

실패: action 예외 → ActionFailedException → runner가 롤백(예약도 함께) → HandleResult.Retry("INBOX_ACTION_FAILED")

보존: cleanupJob.runOnce(now)policy.cutoff(now) → 무제한 DELETE 1회 → 두 번째 호출 0 → 종료


6. 실패 경로와 복구/번역

코드 예외 조건
INBOX_TRANSACTION_REQUIRED MessagingConfigurationException 트랜잭션 없음/읽기전용/다른 DataSource
INBOX_RESERVE_FAILED MessagingConfigurationException 예약 SQL 실패
INBOX_QUERY_FAILED MessagingConfigurationException 조회 SQL 실패
INBOX_PURGE_FAILED MessagingConfigurationException 스윕 SQL 실패
INBOX_RETENTION_TOO_SHORT MessagingConfigurationException 보존 < 창 × 2
INBOX_ACTION_FAILED HandleResult.Retry(예외 아님) action 실패

SQL 실패 셋이 전부 MessagingConfigurationException이다. 그 예외의 카테고리는 CONFIGURATION이고 retryable = false다. 그런데 SQLException의 원인은 대부분 일시적 인프라 문제(연결 끊김, 데드락, 타임아웃)다. 즉 재시도 가능한 실패가 재시도 불가로 분류된다. §17.

INBOX_ACTION_FAILEDTRANSIENT_INFRASTRUCTURE/retryable = true이고 예외가 아니라 HandleResult로 흐른다 — 분류가 정확하다.


7. 트랜잭션·동시성·수명주기

이 leaf의 주제 자체가 트랜잭션이다.

지점 메커니즘
중복 제거 복합 PK + ON CONFLICT DO NOTHING의 영향 행 수
예약·부작용 원자성 호출자의 TransactionRunner + requireActiveTransaction 3검사
커넥션 참여 DataSourceUtils.getConnection/releaseConnection — Spring 트랜잭션 동기화 커넥션을 얻는다
스윕 격리 bounded overload가 FOR UPDATE SKIP LOCKED호출되지 않음

DataSourceUtils.getConnection은 활성 트랜잭션에 묶인 커넥션이 있으면 그것을 주고, 없으면 새로 연다. 그래서 requireActiveTransaction먼저 도는 것이 필수다 — 없으면 새 커넥션이 열리고 자동 커밋된다. 그것이 §4.1의 이전 결함이다.

isProcessed와 두 purge*dataSource.getConnection()을 직접 쓴다 — 트랜잭션에 참여하지 않는다. javadoc이 그것을 명시한다("The no-argument overload is provided only for retention sweeps and read-only queries").

동시성 원시 요소는 DB에 있다. Java 쪽에 락이나 원자 변수가 없다.

수명주기 참여 없음 — InboxCleanupJob을 스케줄링하는 것은 starter다.


8. 설정·기능 플래그·환경 차이

상수 사용
InboxCleanupJob.DEFAULT_BATCH_SIZE 1,000 없음
InboxRetentionPolicy.REQUIRED_SAFETY_FACTOR 2.0 required()
InboxRetentionPolicy.DEFAULT_RETENTION 7일 starter가 참조할 수 있음
consumer_id 컬럼 폭 160자 migration

설정 파일 없음. maxBatches와 두 Duration이 생성자 인자다.


9. 퍼시스턴스/외부 시스템 세부

PostgreSQL 전용이다. 세 SQL이 벤더 기능을 쓴다.

구문 용도
ON CONFLICT (…) DO NOTHING 예약. PostgreSQL 고유
FOR UPDATE SKIP LOCKED bounded 스윕. PostgreSQL 9.5+
WITH … DELETE … USING bounded 스윕. CTE + USING
TIMESTAMPTZ 컬럼 타입

leaf 이름이 그 사실을 드러낸다.

statement.setObject(1, messageId.value())java.util.UUID를 그대로 넘긴다 — PostgreSQL JDBC 드라이버가 UUIDuuid 매핑을 지원한다.


10. 테스트 레인과 실제 증명 범위

레인: ./gradlew :messaging:messaging-inbox-jdbc-postgresql:test. BUILD SUCCESSFUL, 25 tests, 0 skipped, 0 failures.

클래스 실제로 증명하는 것 증명하지 않는 것
InboxPostgresIT 6 실 PostgreSQL에서: 첫 예약 성공/둘째 실패, 두 소비자 각각 1회, 조회 가시성, 재전달이 부작용을 두 번 실행하지 않음, 롤백이 예약도 부작용도 남기지 않음, 보존 삭제 bounded 스윕(무제한 overload를 부른다)
JdbcInboxTransactionRequirementTest 4 트랜잭션 없음/읽기전용/다른 DataSource 거절이 커넥션 요청 전에 일어남, 코드가 검색 가능
IdempotentConsumerTest 6 첫 실행/재전달 스킵/두 소비자/한 트랜잭션 공유/조회 가시성/보존 삭제 in-memory fake
InboxOperationsTest 9 보존 규칙 4개, cleanup 루프 2개, 소비자별 1회, 재전달 억제, InboxResult 세 값의 isSafeToSettle bounded 배치(§10.2)

10.1 컨테이너 레인이 실제로 돈다

InboxPostgresIT@Testcontainers이고 기본 test 태스크에서 6개가 통과했다. 이 저장소의 다른 컨테이너 레인 중 일부는 별도 태스크에 격리돼 있는데 이것은 아니다.

aRolledBackTransactionLeavesNoReservationAndNoSideEffect가 §4.1이 말한 "the path nobody exercises before production"을 실 DB에서 검증한다. build.gradle 주석의 주장("only a real database can settle them")이 실현된 지점이다.

10.2 cleanupDeletesInBoundedBatches가 증명하지 않는 것

테스트 이름이 속성을 주장한다. 실제 단언은 이렇다.

@Test
void cleanupDeletesInBoundedBatches() {
  InMemoryInbox inbox = new InMemoryInbox(List.of(1000, 500));
  int removed = new InboxCleanupJob(inbox, policy(7일, 1일), 10).runOnce(NOW);
  assertThat(removed).isEqualTo(1500);
  assertThat(inbox.cutoffs).hasSize(3);
}

InMemoryInbox대본을 읽는 fake다.

@Override
public int purgeProcessedBefore(Instant processedBefore) {
  cutoffs.add(processedBefore);
  return pass < deletions.size() ? deletions.get(pass++) : 0;
}

@Override
public int purgeProcessedBefore(Instant processedBefore, int limit) {
  return Math.min(purgeProcessedBefore(processedBefore), limit);
}

무제한 메서드가 미리 준 목록(1000, 500)을 순서대로 반환하고 이후 0을 준다. 아무것도 삭제하지 않고 아무것도 제한하지 않는다.

그래서 이 테스트가 통과로 증명하는 것은 "job이 0을 받을 때까지 루프를 돈다"이고, "삭제가 배치로 제한된다"는 아니다. 1000과 500은 배치처럼 보이는 숫자일 뿐이다.

bounded overload(purgeProcessedBefore(Instant, int))는 fake에도 구현돼 있지만 job이 부르지 않으므로 실행되지 않는다.

cleanupHonoursTheBatchCeilingSoItCannotRunForever는 다른 성질(루프 상한)을 정확히 검증한다 — maxBatches=2에 6개 대본을 주고 호출이 2회임을 확인한다.

10.3 anAlreadyAppliedMessageIsSafeToSettleButAClaimedOneIsNot

assertThat(InboxResult.APPLIED.isSafeToSettle()).isTrue();
assertThat(InboxResult.ALREADY_APPLIED.isSafeToSettle()).isTrue();
assertThat(InboxResult.CLAIMED_ELSEWHERE.isSafeToSettle()).isFalse();

enum 상수의 boolean 필드를 단언한다. 동작이 아니라 선언이다 — messaging-transport-spiMessagingLifecycleTest가 enum 선언 순서를 단언하는 것(그쪽 §10.2)과 같은 형태다. 그리고 §12.3이 보이듯 CLAIMED_ELSEWHERE는 production에서 생성되지 않는다.


11. 빌드/ArchUnit/CI 강제 지점

게이트 이 leaf에 대해
verifyCleanArchitectureDependencies ["messaging-core-api","messaging-reliability-api"]
verifyRuntimeModuleMembership ["app-bootstrap"]
vendor api 규칙 Spring 타입이 public 시그니처에 없음 → implementation. 통과
SecretLeakStaticScanTest(observability leaf) 이 leaf 소스도 스캔 대상
Flyway migration V2__messaging_inbox.sql — 네이밍이 messaging 네임스페이스
ArchUnit 전용 규칙 없음

12. 실제 사용 여부와 negative-space probes

원시 증거: evidence/raw/294-bounded-purge-never-called.txt.

12.1 Public surface reachability

타입 leaf 밖 판정
IdempotentConsumer 1 starter
InboxCleanupJob 1 starter
InboxRetentionPolicy 1 starter
TransactionalInboxHandler 1 starter
JdbcInboxRepository 0
InboxOutcome 0 내부 반환 타입

JdbcInboxRepository의 0이 주목된다 — starter가 InboxRepository bean을 만들지 않는다(§2). InboxCleanupJob·TransactionalInboxHandler bean이 InboxRepository/IdempotentConsumer를 인자로 받으므로 누군가 그 bean을 공급해야 하고, 이 leaf의 구현이 그 후보인데 연결이 없다. 그 판정은 starter leaf가 소유한다.

메서드 수준 도달성: bounded 스윕이 호출되지 않는다

InboxRepositoryOutboxRepository 둘 다 purge*Before(Instant, int) 오버로드를 선언하고, 두 JDBC 구현이 실제로 LIMIT를 쓰는 SQL로 구현한다. 저장소 전체에서 그 시그니처가 등장하는 9곳은 전부 선언·구현·테스트 fake override이고 호출 지점이 하나도 없다.

2 port declarations + 2 production implementations + 5 test fake overrides = 9
None of them is a call site.

두 cleanup job이 무제한 오버로드를 부른다.

// InboxCleanupJob.java:56
int deleted = inbox.purgeProcessedBefore(cutoff);
// OutboxCleanupJob.java:50
int deleted = outbox.purgePublishedBefore(cutoff);

OutboxRepository의 bounded 오버로드 javadoc이 그 상황을 정확히 예고한다.

The unbounded version deletes everything before the cutoff in one statement. On a table that has been accumulating published rows since the last sweep that is a single long transaction holding locks and generating WAL in proportion to the backlog, which shows up as the relay and the business writes stalling behind retention. The cleanup jobs describe themselves as bounded by batch size; this is the parameter that makes that true.

그 파라미터를 부르는 코드가 없다. 두 cleanup job은 여전히 "bounded by batch size"라고 자기를 서술한다.

InboxCleanupJob.DEFAULT_BATCH_SIZE = 1_000은 저장소 전체에서 자기 선언 한 줄만 등장한다.

12.2 Conditional sibling comparison

Spring 주석 0개. starter의 세 bean이 이 leaf 타입을 만든다.

형제 비교가 결정적이다. messaging-outbox-jdbc-postgresql이 같은 구조를 갖는다.

inbox outbox
bounded purge 구현 o (LIMIT + SKIP LOCKED) o
cleanup job이 부르는 것 무제한 무제한
batch size 상수 DEFAULT_BATCH_SIZE(미사용) (outbox leaf가 답함)

두 leaf가 같은 결함을 갖는다. 우연이 아니라 같은 리팩터가 두 곳에 같은 형태로 적용되고 호출부 갱신이 빠진 것으로 보인다 — 추론이며 커밋 근거는 없다.

12.3 Duplicate mechanism sweep

(a) InboxResult의 세 값 중 하나만 생성된다

TransactionalInboxHandler:70InboxResult.APPLIED를 반환하는 것이 production의 유일한 생성 지점이다. ALREADY_APPLIED·CLAIMED_ELSEWHEREInboxOperationsTest의 단언에만 등장한다.

구조적 이유가 있다. InboxRepository.reserveboolean을 반환하므로 세 갈래를 표현할 수 없다. messaging-reliability-apiInboxResult javadoc이 세 값이 필요한 이유를 이렇게 적는다.

Three outcomes, not two. Collapsing ALREADY_APPLIED and CLAIMED_ELSEWHERE into a single "duplicate" would settle a message whose effect is still only half-written by another instance: if that instance then rolls back, the effect is lost and the broker will never redeliver, because this instance already acknowledged it.

포트의 반환 타입이 그 구분을 표현 불가능하게 만든다. reserve가 false를 주면 IdempotentConsumerduplicate()를 만들고 TransactionalInboxHandlerHandleResult.success()를 반환한다 — 즉 정산한다. javadoc이 정산하면 안 된다고 한 경우와 해도 되는 경우가 같은 false로 들어온다.

이 leaf에서 그 구분이 실제로 필요한지는 PostgreSQL의 ON CONFLICT DO NOTHING 동시성 동작에 달려 있고, 그것을 확인하지 않았다. 미커밋 충돌 행이 있을 때 DO NOTHING이 대기하는지 즉시 0을 반환하는지에 따라 CLAIMED_ELSEWHERE 상황이 발생 가능한지가 갈린다. §16·§17.

(b) 보존 규칙이 세 곳에 있다

위치 형태 강제
InboxRepository.purgeProcessedBefore javadoc "Retention must outlive the broker's maximum redelivery window" 없음
이 leaf InboxRetentionPolicy.validate() retention >= window × 2.0 강제(단 InboxCleanupJob 생성 시에만)
messaging-claim-check ClaimCheckPolicy 생성자 retention >= brokerRetention + maxRedeliveryWindow 강제(항상)

세 곳이 같은 종류의 시간 관계를 다루고 강제 시점과 공식이 다르다 — 곱셈(×2.0) vs 덧셈(brokerRetention + window). 두 leaf가 서로를 참조하지 않는다.

(c) 커넥션 획득 방식이 둘

메서드 방식 트랜잭션 참여
reserve(...) DataSourceUtils.getConnection o
isProcessed, purge* dataSource.getConnection() x

의도된 구분이고 javadoc이 명시한다. 중복 아님.

12.4 Documentation / measured-count drift

문서 주장 재측정 결과
InboxCleanupJob javadoc: "Deletes in bounded batches" 무제한 오버로드 호출, DEFAULT_BATCH_SIZE 미사용 불일치
같은 javadoc: 정책을 첫 삭제 전에 검증 생성자가 policy.validate() 일치
JdbcInboxRepository javadoc: 예약이 ON CONFLICT DO NOTHING의 영향 행 수 SQL 확인 일치
같은 javadoc: 무인자 오버로드는 "only for retention sweeps and read-only queries" 그 스윕이 무인자를 부르므로 문장은 맞다. 다만 그 스윕이 bounded여야 한다는 다른 javadoc과 충돌 부분 불일치
OutboxRepository javadoc: "this is the parameter that makes that true" 그 파라미터 호출자 0 불일치
migration 주석: 보존 창이 재전달 지연보다 길어야 함 InboxRetentionPolicy가 강제 일치
build.gradle 주석: 실 DB 인증 InboxPostgresIT 6개 통과 일치
support-matrix.md:23: 모든 messaging leaf가 unwired 이 leaf는 ["app-bootstrap"] 불일치(family drift)

13. Git/설계 문서에서 확인한 변화와 실패 기록

위치 이전 상태 그것이 만든 실패
JdbcInboxRepository.reserve(Connection,…) javadoc 그 메서드가 public이고, interface 메서드는 raw 커넥션을 열어 자동 커밋 부작용이 롤백돼도 예약은 커밋됨 → 메시지는 처리됨으로 남고 작업은 일어나지 않았으며 재전달이 거부됨
JdbcInboxTransactionRequirementTest javadoc 같은 결함을 테스트 쪽에서 서술 "the message counts as processed, the work never happened, and redelivery is refused because the inbox row is already there"

한 결함이 두 파일에 기록돼 있고, 그중 하나가 그것을 막는 테스트다. 그리고 그 테스트가 "hermetic: the refusal has to happen before any connection is requested, and the data source below fails the test by being asked for one"이라고 자기 설계를 적는다 — DataSource가 요청받으면 테스트가 실패하도록 만들어 검사 순서까지 고정한다.


14. 런타임·터미널 Evidence

id 종류 파일 무엇을 보여주는가 한계
EVD-294 command evidence/raw/294-bounded-purge-never-called.txt 두 포트의 bounded 오버로드 선언과 이유, 두 구현의 SQL, 시그니처 9회 등장이 전부 비호출, 두 cleanup job의 실제 호출, DEFAULT_BATCH_SIZE 단일 등장, 무제한 구현의 SQL, 테스트 fake의 대본, 컨테이너 레인도 무제한 호출 정적 검색
EVD-295 command ./gradlew :messaging:messaging-inbox-jdbc-postgresql:test --rerun-tasks BUILD SUCCESSFUL, 25 / 0 / 0. InboxPostgresIT 6개 포함 Testcontainers 환경 의존

15. 명시적 설계 이유와 추론을 구분한 정리

명시적

  • 복합 PK가 중복 제거 메커니즘인 이유 — 클래스 javadoc + migration 주석
  • 예약이 호출자 트랜잭션에 참여해야 하는 이유와 이전 결함 — reserve(Connection,…) javadoc
  • 세 검사가 커넥션 요청 전에 일어나야 하는 이유 — requireActiveTransaction javadoc + 테스트 javadoc
  • 트랜잭션 경계를 호출자에게 위임하는 이유 — IdempotentConsumer javadoc
  • 중복이 오류가 아닌 이유 — 같은 javadoc + duplicateIsSuccess 주석
  • 예외를 감싸되 삼키지 않는 이유 — 인라인 주석
  • 안전계수가 곱셈인 이유 — InboxRetentionPolicy javadoc
  • 정책을 첫 삭제 전에 검증하는 이유 — InboxCleanupJob javadoc
  • 실 DB 인증이 필요한 이유 — build.gradle 주석

추론

  • 두 cleanup job이 같은 형태로 무제한 오버로드를 부르는 것은 bounded 오버로드가 나중에 추가되고 호출부가 갱신되지 않았기 때문이다 → 추론. 두 곳의 동일한 형태는 관측이고 인과는 추론이다.
  • CLAIMED_ELSEWHERE가 생성되지 않는 것은 포트가 boolean을 반환하기 때문이다 → 관측에 가까운 추론. 반환 타입은 관측이다.
  • consumer_id 길이 검증이 없는 것이 의도인지 → 미상.

16. 확인한 것 / 확인하지 못한 것

확인한 것

  • 6개 타입 542줄과 migration 전문
  • 25개 테스트가 통과하고 컨테이너 레인 6개가 실 PostgreSQL에서 돈다는 것
  • bounded purge 오버로드가 두 포트·두 구현에 있고 호출 지점이 0이라는 것
  • 두 cleanup job이 무제한 오버로드를 부르고 DEFAULT_BATCH_SIZE가 죽은 상수라는 것
  • cleanupDeletesInBoundedBatches가 대본 fake 위에서 통과한다는 것
  • InboxResult 세 값 중 하나만 production에서 생성된다는 것과 그 구조적 이유
  • 세 겹 트랜잭션 검사와 그것이 막는 이전 결함

확인하지 못한 것

  • PostgreSQL의 ON CONFLICT DO NOTHING이 미커밋 충돌 행에 대해 대기하는지 즉시 0을 반환하는지. CLAIMED_ELSEWHERE 상황의 발생 가능성이 여기에 달려 있고, 이 저장소의 테스트가 그것을 재현하지 않는다.
  • InboxRepository bean을 누가 만드는지 — starter leaf가 소유한다.
  • consumer_id가 160자를 넘는 배포가 있는지.
  • 무제한 DELETE가 실제 규모의 테이블에서 얼마나 오래 락을 잡는지 — 측정하지 않았다.
  • InboxCleanupJob을 스케줄링하는 주기 — starter가 소유한다.

17. 손볼 것

P1 — bounded purge가 구현돼 있고 호출되지 않아, cleanup이 스스로 막겠다고 한 장애를 일으킨다

  • 사실. InboxRepository·OutboxRepository 둘 다 purge*Before(Instant, int) 오버로드를 선언하고, JdbcInboxRepository:141·JdbcOutboxRepository:486LIMIT + FOR UPDATE SKIP LOCKED로 구현한다. 저장소 전체에서 그 시그니처가 등장하는 9곳은 선언 2 + 구현 2 + 테스트 fake override 5이고 호출 지점이 0이다. InboxCleanupJob:56OutboxCleanupJob:50이 무제한 오버로드를 부른다. InboxCleanupJob.DEFAULT_BATCH_SIZE = 1_000은 자기 선언 한 줄만 존재한다.
  • 근거. evidence/raw/294 §C·§D·§E.
  • 왜 문제인가. InboxCleanupJob의 javadoc이 스스로 적는다 — "A single unbounded DELETE over a table that has been accumulating for weeks holds locks long enough to block the very reservations the inbox exists to serve, so the cleanup would cause the outage it is meant to prevent." 실행되는 코드가 정확히 그 문장이 서술하는 동작이다. OutboxRepository의 bounded 오버로드 javadoc은 한 발 더 나간다 — "The cleanup jobs describe themselves as bounded by batch size; this is the parameter that makes that true." 그 파라미터를 아무도 넘기지 않는다. 그리고 두 leaf가 동일한 형태로 그렇다.
  • 왜 P1인가. 두 leaf 다 runtime_memberships: ["app-bootstrap"]이고 두 cleanup job이 starter에서 bean으로 만들어진다(MessagingReliabilityAutoConfigurationinboxCleanupJob·outboxCleanupJob). 즉 출하 구성에서 실행되는 경로이며, 백로그가 쌓인 뒤 첫 스윕에서 발현한다. 다른 미배선 발견들과 성격이 다르다.
  • 확인 방법. evidence/raw/294 재실행. 또는 git grep -n -E 'purge(Processed|Published)Before\s*\([^)]*,' -- 'src/**/*.java'로 호출 지점이 없음을 확인.
  • 후보. 두 job이 bounded 오버로드에 배치 크기를 넘기게 한다 — InboxCleanupJob은 이미 DEFAULT_BATCH_SIZE를 갖고 있다.
  • 다음 단계. CASE 후보. 정적 재현이 완결되고, "장치는 있고 회로가 닫히지 않았다"의 변형 중 닫히지 않은 회로가 실행 경로 위에 있는 유일한 사례다. messaging-outbox-jdbc-postgresql leaf와 공동 소유.

P2 — 속성을 이름으로 주장하는 테스트가 그 속성을 보일 수 없는 fake 위에서 통과한다

  • 사실. InboxOperationsTest.cleanupDeletesInBoundedBatchesInMemoryInbox(List.of(1000, 500))에 대해 removed == 1500cutoffs.hasSize(3)을 단언한다. 그 fake의 무제한 메서드는 미리 준 목록을 순서대로 반환하는 대본이고 아무것도 삭제하거나 제한하지 않는다. bounded 오버로드는 fake에도 있지만 job이 부르지 않아 실행되지 않는다.
  • 근거. evidence/raw/294 §G.
  • 왜 문제인가. 이 테스트가 통과로 증명하는 것은 "0을 받을 때까지 루프를 돈다"이고 이름이 주장하는 "배치로 제한된다"가 아니다. 1000·500은 배치처럼 보이는 숫자다. P1이 이 테스트를 통과한 채로 존재할 수 있었던 이유다. 그리고 컨테이너 레인(InboxPostgresIT.retentionRemovesOldRows)도 무제한 오버로드를 한 행에 대해 부르므로 실 DB에서도 드러나지 않는다.
  • 확인 방법. evidence/raw/294 §G·§H.
  • 후보. fake의 무제한 메서드가 실제로 컬렉션에서 삭제하게 하고, bounded 메서드가 limit를 존중하게 한다. 그러면 테스트가 P1을 잡는다.
  • 다음 단계. CASE 후보 + REFERENCE 후보. messaging-transport-spi §10.2(enum 순서를 단언하는 종료 테스트)와 같은 계열이고, "이름이 주장하는 속성을 fake가 표현할 수 있는지 먼저 확인한다"가 재사용 가능한 기준이다.

P2 — SQL 실패가 재시도 불가로 분류된다

  • 사실. INBOX_RESERVE_FAILED·INBOX_QUERY_FAILED·INBOX_PURGE_FAILED 셋 다 MessagingConfigurationException이고, 그 예외의 카테고리는 CONFIGURATION, retryable = false다.
  • 근거. JdbcInboxRepository.java:77-80, 134-137, 165-168, 179-182. MessagingConfigurationException.javaCATEGORY 상수.
  • 왜 문제인가. SQLException의 원인 대부분은 구성 오류가 아니라 일시적 인프라다 — 연결 끊김, 데드락, 락 타임아웃, 커넥션 풀 고갈. FailureCategory는 "the stable classification a retry engine, DLQ router, and dashboard all agree on"이고 retryable = false는 재시도 엔진이 즉시 파킹한다는 뜻이다. 같은 leaf의 INBOX_ACTION_FAILEDTRANSIENT_INFRASTRUCTURE/retryable = true로 정확히 분류된다 — 같은 파일 안에서 기준이 갈린다.
  • 확인 방법. 네 catch 블록과 MessagingConfigurationException의 카테고리 대조.
  • 후보. SQL 실패를 MessageBrokerUnavailableException류(또는 TRANSIENT_INFRASTRUCTURE 카테고리를 갖는 예외)로 바꾸고, 진짜 구성 오류(테이블 없음 등)만 CONFIGURATION으로 남긴다.
  • 다음 단계. CASE 후보. 재시도 정책이 실제로 갈리는 지점이다.

P3 — 세 갈래 판정이 포트의 boolean에서 두 갈래로 접힌다

  • 사실. InboxResult가 세 값과 isSafeToSettle()을 갖는데 production은 APPLIED만 만든다. InboxRepository.reserveboolean을 반환하므로 ALREADY_APPLIEDCLAIMED_ELSEWHERE가 같은 false로 들어온다. TransactionalInboxHandler는 그 경우 HandleResult.success()를 반환한다 — 정산한다.
  • 근거. evidence/raw/294 범위 밖이나 §12.3(a)의 검색 결과. InboxResult javadoc.
  • 왜 문제인가. InboxResult javadoc이 세 값이 필요한 이유로 정확히 그 정산을 든다 — "would settle a message whose effect is still only half-written by another instance". 다만 그 상황이 PostgreSQL에서 실제로 발생 가능한지 확인하지 않았다(§16). ON CONFLICT DO NOTHING이 미커밋 충돌에 대해 대기한다면 CLAIMED_ELSEWHERE는 도달 불가능한 상태이고 enum이 과설계인 것이며, 즉시 0을 반환한다면 이것은 실제 결함이다.
  • 확인 방법. 두 커넥션에서 같은 (message, consumer)를 예약하고 한쪽을 커밋하지 않은 채 다른 쪽의 executeUpdate() 반환을 관측한다 — InboxPostgresIT에 추가 가능하다.
  • 후보. 먼저 확인한다. 발생 가능하면 포트 반환 타입을 InboxResult로 바꾼다.
  • 다음 단계. OPEN QUESTION 후보. 판정이 확인하지 않은 DB 동작에 걸린다.

P3 — consumer_id 길이 제약이 애플리케이션 층에 없다

  • 사실. migration이 consumer_id VARCHAR(160)이다. IdempotentConsumer·TransactionalInboxHandler·JdbcInboxRepository가 공백만 거절하고 길이를 보지 않는다.
  • 근거. V2__messaging_inbox.sql:10, 세 클래스의 검증.
  • 왜 문제인가. 긴 consumerId가 DB에서 SQLException으로 실패하고, §17의 다른 항목대로 그것이 INBOX_RESERVE_FAILED/CONFIGURATION/retryable=false가 된다 — 즉 설정 실수가 메시지 파킹으로 나타난다. messaging-core-api의 값 객체들이 바이트 상한을 생성자에서 강제하는 것(그쪽 §4.5)과 대비된다.
  • 확인 방법. 161자 consumerId로 reserve 호출.
  • 후보. consumerId를 값 객체로 만들거나 길이 검증을 추가한다.
  • 다음 단계. REFERENCE 후보(컬럼 폭은 애플리케이션 검증과 짝을 이룬다).

P3 — 보존 규칙이 세 곳에 있고 공식이 다르다

  • 사실. InboxRepository javadoc(강제 없음), 이 leaf InboxRetentionPolicy(× 2.0, InboxCleanupJob 생성 시에만), messaging-claim-check ClaimCheckPolicy(brokerRetention + maxRedeliveryWindow, 항상).
  • 근거. 세 위치.
  • 왜 문제인가. 같은 종류의 시간 관계를 곱셈과 덧셈으로 다르게 표현하고, 강제 시점도 다르다. 그리고 이 leaf의 validate()cleanup job을 만들 때만 불린다 — cleanup을 배선하지 않은 배포는 보존 검사를 받지 않는다.
  • 확인 방법. 세 위치의 공식 대조.
  • 후보. 공식을 하나로 정하고 정책 생성자에서 강제한다(claim-check처럼).
  • 다음 단계. REFERENCE 후보(같은 안전 규칙은 한 공식과 한 강제 시점을 갖는다).

확인된 설계(문제 아님)

  • 복합 PK + ON CONFLICT DO NOTHING의 영향 행 수를 판정으로 쓰는 것
  • 트랜잭션 경계를 호출자에게 위임하고 그 위임이 지켜졌는지 런타임에 세 겹으로 확인하는 것
  • 세 검사가 커넥션 요청 전에 일어나고, 그것을 DataSource가 요청받으면 실패하는 테스트로 고정한 것
  • 다른 DataSource에 묶인 트랜잭션을 거절하는 것
  • action 예외를 감싸되 삼키지 않아 롤백이 예약까지 되돌리게 하는 것
  • 중복을 성공으로 보고해 완료된 작업을 DLQ로 보내지 않는 것
  • 안전계수를 곱셈으로 둔 것과 그 이유
  • 정책을 첫 삭제 전에 검증하는 것
  • 실 PostgreSQL 컨테이너 레인이 기본 test 태스크에서 도는 것과, 롤백 경로를 그 레인이 검증하는 것

Source anchors

id kind path revision what it proves limitations
MIJ-001 registry src/config/architecture/modules.json 21234e38 deps 2개, memberships ["app-bootstrap"] 선언
MIJ-002 build messaging-inbox-jdbc-postgresql/build.gradle same 실 DB 인증 의도
MIJ-003 code .../inbox/JdbcInboxRepository.java 전문 same §4.1 세 검사, 두 오버로드의 SQL 무제한만 호출됨
MIJ-004 code .../inbox/IdempotentConsumer.java same §4.2 트랜잭션 위임
MIJ-005 code .../inbox/TransactionalInboxHandler.java same §4.3 세 금지와 예외 캐리어 APPLIED만 생성
MIJ-006 code .../inbox/InboxRetentionPolicy.java same §4.4 곱셈 안전계수 validate() 호출 시점 제한
MIJ-007 code .../inbox/InboxCleanupJob.java same §4.5 선언과 구현의 불일치
MIJ-008 migration .../db/migration/messaging/V2__messaging_inbox.sql same 복합 PK, 인덱스, 컬럼 폭
MIJ-009 test InboxPostgresIT (6) same 실 PostgreSQL 롤백·중복·보존 bounded 스윕 미검증
MIJ-010 test JdbcInboxTransactionRequirementTest (4) same 세 거절이 커넥션 전에
MIJ-011 test IdempotentConsumerTest (6), InboxOperationsTest (9) same §10 표 fake가 대본(§10.2)
MIJ-012 cross-leaf code messaging-reliability-api/.../InboxRepository.java:36-52, OutboxRepository.java:132-151 same 두 오버로드 선언과 bounded의 존재 이유 해당 leaf SSOT가 소유
MIJ-013 cross-leaf code messaging-outbox-jdbc-postgresql/.../OutboxCleanupJob.java:50, JdbcOutboxRepository.java:486 same 같은 결함이 형제 leaf에도 해당 leaf SSOT가 소유
EVD-294 command evidence/raw/294-bounded-purge-never-called.txt same §12.1 전부 정적 검색
EVD-295 command ./gradlew :messaging:messaging-inbox-jdbc-postgresql:test --rerun-tasks same 25 / 0 / 0, 컨테이너 6개 포함 Testcontainers 환경 의존