Files
clean-architecture-backend-…/docs/messaging/outbox-inbox.md
T

113 lines
4.6 KiB
Markdown

# Outbox · Inbox
## 두 패턴이 각각 무엇을 해결하는가
| 패턴 | 해결하는 문제 | 해결하지 않는 문제 |
|---|---|---|
| Transactional Outbox | DB commit과 publish 사이의 창(窓) | 중복 |
| Inbox | 중복 delivery의 side effect | 유실 |
**둘 다 필요하다.** Outbox만으로는 exactly-once가 되지 않는다.
## Outbox
business transaction과 **같은 transaction**에서 row를 쓴다. 둘 다 commit되거나 둘 다 안 된다.
```sql
BEGIN;
UPDATE orders SET status = 'PLACED' WHERE id = ?;
INSERT INTO messaging_outbox (message_id, destination, ...) VALUES (?, ?, ...);
COMMIT;
```
### relay
```text
leaseBatch(100, 30s) -- lease로 다중 relay 인스턴스 안전
→ publish (messageId 그대로)
→ CONFIRMED → markPublished
→ AMBIGUOUS → markAmbiguous (같은 messageId로 재시도 가능)
→ REJECTED → markFailed
```
### 핵심 규칙: ambiguous는 같은 messageId로 재시도
새 id를 발급하면 "전달됐을 수도 있는 메시지"가 "확실히 두 번째인 메시지"가 되어
downstream의 어떤 중복 제거도 복구할 수 없다.
failed로 표시하면 broker가 이미 가지고 있을 수 있는 메시지를 잃는다.
`message_id`를 primary key로 둔 것도 같은 이유다. 어떤 코드 경로도 실수로 새 id를 붙일 수 없다.
### lease
```text
status IN ('PENDING','AMBIGUOUS','IN_FLIGHT')
AND (lease_expires_at IS NULL OR lease_expires_at <= now)
AND next_attempt_at <= now
AND attempts < maxAttempts
```
`IN_FLIGHT`가 목록에 있는 것이 핵심이다. relay가 publish 도중 죽으면 row는 `IN_FLIGHT`로 남는데,
이를 제외하면 그 메시지는 **영원히** 발행되지 않는다 — outbox가 막으려던 바로 그 실패다. 대신
lease가 만료됐을 때만 회수하므로, 살아 있는 relay가 들고 있는 row는 회수되지 않는다.
회수는 **같은 `message_id`로** 이루어지고 `lease_token`이 1 증가한다. 새 id를 발급하면 "전달됐을
수도 있는 메시지"가 "확실히 두 번째"가 되기 때문이다 (위의 AMBIGUOUS 논의와 같은 이유).
이 문단의 근거는 실제 PostgreSQL 컨테이너 레인이다:
- `OutboxPostgresIT#anExpiredLeaseBecomesClaimableAgain` — 만료된 lease의 재회수
- `OutboxPostgresIT#anExpiryReclaimKeepsTheMessageIdAndAdvancesTheToken` — 같은 id, 증가한 token
- `OutboxPostgresIT#aLeasedRowIsInvisibleToASecondRelayInstance` — 살아 있는 lease는 회수 불가
- `OutboxPostgresIT#aSupersededRelayCannotOverwriteTheOutcomeOfTheOneThatReplacedIt` — fencing
partial index `ix_messaging_outbox_claimable`이 이 쿼리를 backlog 크기에 비례하게 유지한다.
PUBLISHED row는 retention job이 지울 때까지 쌓이기 때문이다.
## Inbox
reservation과 side effect가 **같은 transaction**이어야 한다.
```java
transactions.inTransaction(() -> {
if (!inbox.reserve(messageId, consumerId, now)) {
return InboxOutcome.duplicate(); // 이미 처리됨
}
return InboxOutcome.processed(sideEffect.get());
});
```
별도 transaction으로 예약하면 Inbox가 닫으려던 바로 그 창이 다시 열린다.
### 복합 키
`PRIMARY KEY (message_id, consumer_id)`.
message_id만으로 중복 제거하면 같은 event를 소비하는 두 번째 consumer가
첫 번째에 의해 억제된다. 각 consumer가 한 번씩 처리해야 한다.
### retention
broker의 최대 redelivery window보다 **길어야** 한다.
row를 먼저 지우면 늦게 도착한 redelivery가 두 번 처리된다.
## Debezium CDC 대안
polling relay 대신 WAL을 읽는다. polling interval과 lease 경합이 사라지지만
인프라와 그 자체의 실패 모드가 추가된다.
wire contract는 동일하다. `DebeziumOutboxEventRouter`가 polling relay와 같은 reserved header를
방출하므로 consumer는 어느 쪽이 발행했는지 구분할 수 없고, 전환은 배포 결정일 뿐 계약 변경이 아니다.
## Claim Check
1 MiB 초과 payload는 broker 프레임을 키우지 않고 외부 저장소로 offload한다.
`ClaimCheckReference`는 digest를 **필수**로 가진다. claim check는 메시지를 서로 다른 retention과
replication을 가진 두 시스템으로 쪼개므로, consumer는 producer가 저장한 바로 그 bytes를 받았음을
증명할 수 있어야 한다. 그렇지 않으면 잘린 객체와 정상 객체를 구분할 수 없다.
`ClaimCheckIntegrityGuard`는 fetch 전에 만료를, fetch 후에 크기와 digest를 검사한다.
digest 불일치는 `DESERIALIZATION`이 아니라 **validation** 실패로 분류한다.
bytes가 깨진 JSON인 게 아니라, 틀린 bytes이기 때문이다.