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

4.6 KiB

Outbox · Inbox

두 패턴이 각각 무엇을 해결하는가

패턴 해결하는 문제 해결하지 않는 문제
Transactional Outbox DB commit과 publish 사이의 창(窓) 중복
Inbox 중복 delivery의 side effect 유실

둘 다 필요하다. Outbox만으로는 exactly-once가 되지 않는다.

Outbox

business transaction과 같은 transaction에서 row를 쓴다. 둘 다 commit되거나 둘 다 안 된다.

BEGIN;
  UPDATE orders SET status = 'PLACED' WHERE id = ?;
  INSERT INTO messaging_outbox (message_id, destination, ...) VALUES (?, ?, ...);
COMMIT;

relay

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

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이어야 한다.

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이기 때문이다.