글 / 시스템 연계

Outbox 패턴과 메시지 전송

전송할 메시지를 업무 데이터와 같은 트랜잭션으로 테이블에 기록하고, 전송은 별도 작업에 위임한다.

작성일 · 읽는 시간 2분 · PostgreSQL 18 기준

저장과 전송의 불일치

주문을 DB에 저장하는 작업과 브로커로 메시지를 전송하는 작업은 한 트랜잭션으로 묶이지 않는다. 저장한 뒤 전송에 실패하면 메시지가 누락된다. 먼저 전송한 뒤 저장이 롤백되면 존재하지 않는 주문의 메시지가 전송된다.

저장과 전송이 별도로 실행될 때 발생하는 두 가지 불일치

동일 트랜잭션의 Outbox 행 삽입

transactional outbox 패턴은 메시지를 즉시 전송하지 않고, 전송할 내용을 outbox 테이블에 행으로 기록하는 패턴이다. 주문 행과 outbox 행이 같은 트랜잭션에 속하므로 두 행이 모두 저장되거나 모두 저장되지 않는다.

sql
CREATE TABLE outbox (
    id      bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    topic   text NOT NULL,
    payload jsonb NOT NULL,
    sent_at timestamptz
);

BEGIN;
INSERT INTO orders (item, qty) VALUES ('A-100', 2);
INSERT INTO outbox (topic, payload)
VALUES ('order.created', '{"item": "A-100", "qty": 2}');
COMMIT;

별도 작업의 메시지 전송

relay라고 하는 별도 작업이 아직 전송하지 않은 행을 읽어 브로커로 전송하고, 전송에 성공한 행에 전송 시각을 기록한다.

같은 트랜잭션의 outbox 행을 relay가 브로커로 전송하는 구조

sql
BEGIN;

SELECT id, topic, payload FROM outbox
WHERE sent_at IS NULL
ORDER BY id
LIMIT 50
FOR UPDATE SKIP LOCKED;

-- 읽은 행을 브로커로 보낸 뒤, 성공한 id만 표시한다
UPDATE outbox SET sent_at = now() WHERE id IN (1, 2, 3);

COMMIT;

FOR UPDATE SKIP LOCKED는 다른 트랜잭션이 잠근 행의 잠금 해제를 대기하지 않고 그 행을 제외한다. 따라서 relay를 여러 개 실행해도 같은 행을 중복해서 선택하지 않는다. 다만 여러 relay가 분담하여 전송하면 메시지 순서가 뒤바뀔 수 있다.

수신 측의 중복 처리

relay가 메시지를 전송한 뒤 sent_at을 기록하기 전에 종료되면 그 행은 다시 전송된다. 따라서 outbox의 전달 보장은 at-least-once(최소 한 번)이다. 메시지에 outbox id를 포함하여 전송하고, 수신 측은 이미 처리한 id를 무시한다.

relay 종료로 같은 행이 재전송되고 수신 측이 무시하는 순서