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>
This commit is contained in:
DongHyeonka
2026-09-04 22:51:59 +09:00
co-authored by Claude Opus 5
parent 43bccd08a8
commit b2963105a8
5017 changed files with 372751 additions and 4943 deletions
@@ -0,0 +1,734 @@
# 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-api``InboxRepository`·`IdempotentMessageHandler` 포트를 PostgreSQL로 구현한다. 이름이 기술을 드러낸다 — `docs/messaging/support-matrix.md`가 그 개명 이유를 적는다(MSG-023).
**메커니즘 전체가 하나의 SQL 문장에 있다.**
```sql
INSERT INTO messaging_inbox (message_id, consumer_id, processed_at)
VALUES (?, ?, ?)
ON CONFLICT (message_id, consumer_id) DO NOTHING
```
```java
// 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이 같은 사실을 반대편에서 적는다.
```sql
-- 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` 주석이 테스트 전략을 명시한다.
```groovy
// 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 타입을 두 곳에서 쓴다 — `DataSourceUtils``TransactionSynchronizationManager`. 둘 다 `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에 있다.
```java
// 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`를 쓴다 — 메시지만 다르다.
```java
// 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` — 트랜잭션을 열지 않는다
```java
// :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` — 세 가지를 할 수 없다
```java
// :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-api``TransactionalMessageAction` javadoc이 구현자에게 요구한 것과 대칭이다 — 그쪽은 action에게, 이쪽은 handler에게.
예외 처리가 그 세 번째를 지킨다.
```java
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를 통과시키기 위한 캐리어**다.
중복은 성공으로 보고한다.
```java
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`에 원인 클래스 단순명 — `FailureDescriptor``Optional<String> exceptionType`을 실제로 채우는 저장소 내 드문 지점이다.
### 4.4 `InboxRetentionPolicy` — 곱셈 안전계수
```java
// :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-api``InboxRepository.purgeProcessedBefore` javadoc이 요구하고 강제하지 않은 규칙을 이 leaf가 강제한다.** 그 leaf §17이 "미강제"로 기록한 것이 여기서 `validate()`가 된다 — 다만 `validate()``InboxCleanupJob` 생성자만 부른다. 즉 **cleanup job을 만들지 않는 배포에서는 여전히 검사되지 않는다.**
`required()``Math.round(window.toMillis() * 2.0)`이다. 곱셈 이유가 적혀 있고, `theRequiredRetentionScalesWithTheWindow` 테스트가 2일 창 → 4일 요구를 확인한다.
### 4.5 `InboxCleanupJob` — 선언과 구현이 어긋난다
javadoc이 두 가지를 약속한다.
```java
// :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()`를 부르고 테스트가 확인한다.
**첫 번째는 지켜지지 않는다.**
```java
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.
`TransactionalInboxHandler``T = InboxResult`로 쓰고 항상 `InboxResult.APPLIED`를 넣는다 — §12.3.
### 4.7 migration
```sql
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_id``UUID` 타입이다 — `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_FAILED``TRANSIENT_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 드라이버가 `UUID``uuid` 매핑을 지원한다.
---
## 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`가 증명하지 않는 것
테스트 이름이 속성을 주장한다. 실제 단언은 이렇다.
```java
@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**다.
```java
@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`
```java
assertThat(InboxResult.APPLIED.isSafeToSettle()).isTrue();
assertThat(InboxResult.ALREADY_APPLIED.isSafeToSettle()).isTrue();
assertThat(InboxResult.CLAIMED_ELSEWHERE.isSafeToSettle()).isFalse();
```
**enum 상수의 boolean 필드를 단언한다.** 동작이 아니라 선언이다 — `messaging-transport-spi``MessagingLifecycleTest`가 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 스윕이 호출되지 않는다**
`InboxRepository``OutboxRepository` 둘 다 `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이 무제한 오버로드를 부른다.
```java
// 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:70``InboxResult.APPLIED`를 반환하는 것이 production의 유일한 생성 지점이다. `ALREADY_APPLIED`·`CLAIMED_ELSEWHERE``InboxOperationsTest`의 단언에만 등장한다.
**구조적 이유가 있다.** `InboxRepository.reserve``boolean`을 반환하므로 세 갈래를 표현할 수 없다. `messaging-reliability-api``InboxResult` 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를 주면 `IdempotentConsumer``duplicate()`를 만들고 `TransactionalInboxHandler``HandleResult.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:486``LIMIT` + `FOR UPDATE SKIP LOCKED`로 구현한다. 저장소 전체에서 그 시그니처가 등장하는 9곳은 **선언 2 + 구현 2 + 테스트 fake override 5**이고 **호출 지점이 0**이다. `InboxCleanupJob:56``OutboxCleanupJob: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으로 만들어진다(`MessagingReliabilityAutoConfiguration``inboxCleanupJob`·`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.cleanupDeletesInBoundedBatches``InMemoryInbox(List.of(1000, 500))`에 대해 `removed == 1500``cutoffs.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.java``CATEGORY` 상수.
- **왜 문제인가.** `SQLException`의 원인 대부분은 구성 오류가 아니라 **일시적 인프라**다 — 연결 끊김, 데드락, 락 타임아웃, 커넥션 풀 고갈. `FailureCategory`는 "the stable classification a retry engine, DLQ router, and dashboard all agree on"이고 `retryable = false`는 재시도 엔진이 즉시 파킹한다는 뜻이다. 같은 leaf의 `INBOX_ACTION_FAILED``TRANSIENT_INFRASTRUCTURE`/`retryable = true`로 정확히 분류된다 — 같은 파일 안에서 기준이 갈린다.
- **확인 방법.** 네 catch 블록과 `MessagingConfigurationException`의 카테고리 대조.
- **후보.** SQL 실패를 `MessageBrokerUnavailableException`류(또는 `TRANSIENT_INFRASTRUCTURE` 카테고리를 갖는 예외)로 바꾸고, 진짜 구성 오류(테이블 없음 등)만 `CONFIGURATION`으로 남긴다.
- **다음 단계.** **CASE 후보.** 재시도 정책이 실제로 갈리는 지점이다.
### P3 — 세 갈래 판정이 포트의 `boolean`에서 두 갈래로 접힌다
- **사실.** `InboxResult`가 세 값과 `isSafeToSettle()`을 갖는데 production은 `APPLIED`만 만든다. `InboxRepository.reserve``boolean`을 반환하므로 `ALREADY_APPLIED``CLAIMED_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 환경 의존 |