PostgreSQL을 사용한 Go의 트랜잭셔널 아웃박스 패턴

데이터와 함께 이벤트를 기록하세요. 절대 분리하지 마세요.

Page content

함께 성공해야 할 두 개의 쓰기 작업이 결국에는 분리되어 실패합니다. 주문 서비스는 주문을 데이터베이스에 저장한 후 메시지 브로커에 order.created 이벤트를 발행합니다.

이 두 연산은 순차적으로 실행됩니다.

그 사이에서 문제가 발생합니다. 브로커가 다운되거나, 네트워크가 타임아웃되거나, 프로세스가 재시작되거나, 컨테이너가 추방(evicted)될 수 있습니다. 데이터베이스 쓰기는 성공했습니다. 그러나 발행은 실패했습니다. 새로운 주문에 대해 알아야 하는 다운스트림 서비스는 이를 전혀 알지 못합니다. 고객이 전화하기 전까지는 아무도 이를 인지하지 못했습니다.

이것이 바로 이중 쓰기(dual-write) 문제이며, 분산 시스템에서 침묵적인 데이터 손실의 가장 흔한 원인 중 하나입니다. 트랜잭션 아웃박스 패턴(transactional outbox pattern)이 이에 대한 표준 해결책입니다.

Transactional outbox pattern – event and data written together

이중 쓰기 문제

이 실패 모드는 한 번 보면 쉽게 이해할 수 있습니다:

BEGIN;
  INSERT INTO orders ...   -- 성공
COMMIT;

PUBLISH order.created ...  -- 실패, 충돌, 또는 도달 불가

데이터베이스와 메시지 브로커는 트랜잭션 경계를 공유하지 않습니다. 둘을 모두 포괄하는 롤백이 존재하지 않습니다. save -> publish를 순차적으로 수행하는 모든 서비스는 이 간극을 가지고 있습니다. 이 패턴은 다양한 형태로 나타납니다:

  • db.Save(order) 이후 events.Publish(OrderCreated{...}) 호출
  • 트랜잭션을 커밋한 후 외부 웹훅을 호출하는 HTTP 핸들러
  • 큐에서 레코드를 처리하고 결과를 다른 큐에 기록하는 워커

모든 경우에 결과는 동일합니다: 한쪽은 성공하고 다른 쪽은 실패하며, 모니터링에 보이지 않는 상태로 시스템이 남게 됩니다. 왜냐하면 각 개별 작업은 어느 시점에서 성공을 반환했기 때문입니다.

재시도 루프는 이를 해결하지 못합니다. 데이터베이스 커밋 후 발행을 재시도하는 것은 재시도 자체가 신뢰할 수 있을 때만 작동하며, 이는 현재 가지고 있지 않은 내구성 보장(durability guarantee)이 필요합니다.

트랜잭션 아웃박스 패턴이 하는 일

아웃박스 패턴은 직접 발행을 완전히 제거하여 이 간극을 제거합니다. 비즈니스 로직 내에서 브로커를 호출하는 대신, 비즈니스 데이터와 동일한 데이터베이스 트랜잭션 내에서 outbox 테이블에 이벤트 기록을 씹니다. 별도의 백그라운드 프로세스인 릴레이(relay)가 아웃박스 테이블에서 읽고 브로커로 발행합니다.

BEGIN;
  INSERT INTO orders ...         -- 비즈니스 데이터
  INSERT INTO outbox_events ...  -- 이벤트 기록
COMMIT;

-- Relay process (separately):
SELECT ... FROM outbox_events FOR UPDATE SKIP LOCKED;
PUBLISH order.created ...
UPDATE outbox_events SET processed_at = NOW() WHERE id = $1;

두 쓰기 작업은 모두 성공하거나 모두 실패합니다. PostgreSQL에서 이미 가지고 있던 트랜잭션 보장이 이제 이벤트 기록도 포괄합니다. 릴레이는 이벤트가 내구적 저장소에 있기 때문에 필요한 만큼 발행을 재시도할 수 있습니다. 릴레이가 중간에 충돌하면 다시 시작하고 재시도합니다. 최악의 결과는 이벤트가 한 번 이상 발행되는 것이며, 이는 소비자를 멱등(idempotent)하게 만들어서 처리합니다(자세한 내용은 분산 시스템에서의 멱등성 참조).

아웃박스 테이블을 위한 PostgreSQL 스키마

스키마는 의도적으로 간단합니다:

CREATE TABLE outbox_events (
    id             UUID         PRIMARY KEY DEFAULT gen_random_uuid(),
    aggregate_type VARCHAR(100) NOT NULL,
    aggregate_id   VARCHAR(100) NOT NULL,
    event_type     VARCHAR(100) NOT NULL,
    payload        JSONB        NOT NULL,
    attempts       INT          NOT NULL DEFAULT 0,
    created_at     TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
    processed_at   TIMESTAMPTZ
);

-- Partial index: only indexes unprocessed rows, stays small as rows are marked done
CREATE INDEX idx_outbox_unprocessed
    ON outbox_events (created_at)
    WHERE processed_at IS NULL;

created_at WHERE processed_at IS NULL에 대한 부분 인덱스(partial index)는 중요합니다. 이를 사용하지 않으면 인덱스는 작성된 모든 이벤트와 함께 성장하고 릴레이의 폴링 쿼리는 시간이 지남에 따라 느려집니다. 부분 인덱스를 사용하면 인덱스는 대기 중인 행만 커버하며, 발행된 이벤트의 수와 관계없이 정상 상태에서는 작고 제한된 집합을 유지합니다.

주요 필드 선택:

  • aggregate_typeaggregate_id는 이벤트가 속한 엔티티를 설명합니다. 순서 보장 및 라우팅에 유용합니다.
  • event_type은 소비자가 기대하는 이벤트 이름입니다.
  • payload JSONB는 이벤트 본문을 저장합니다. 필요시 쿼리할 수 있도록 TEXT 대신 JSONB를 사용합니다.
  • attempts는 릴레이가 해당 행을 발행하려고 시도한 횟수를 추적합니다. 재시도 제한 및 데드 레터(dead-letter) 처리에 사용됩니다.
  • processed_at은 대기 중인 행에 대해 NULL이며, 릴레이가 성공적으로 발행할 때 설정됩니다.

단일 트랜잭션에서 비즈니스 데이터 및 아웃박스 이벤트 쓰기

비즈니스 로직은 단일 BeginTx / Commit 호출 내에서 두 기록을 모두 씹니다. 여기에는 발행 호출이 없습니다. 데이터베이스 쓰기만 있습니다.

type OrderService struct {
    db *sql.DB
}

func (s *OrderService) CreateOrder(ctx context.Context, order Order) error {
    tx, err := s.db.BeginTx(ctx, nil)
    if err != nil {
        return fmt.Errorf("begin tx: %w", err)
    }
    defer tx.Rollback()

    if _, err := tx.ExecContext(ctx, `
        INSERT INTO orders (id, customer_id, total, created_at)
        VALUES ($1, $2, $3, NOW())
    `, order.ID, order.CustomerID, order.Total); err != nil {
        return fmt.Errorf("insert order: %w", err)
    }

    payload, err := json.Marshal(map[string]any{
        "order_id":    order.ID,
        "customer_id": order.CustomerID,
        "total":       order.Total,
    })
    if err != nil {
        return fmt.Errorf("marshal payload: %w", err)
    }

    if _, err := tx.ExecContext(ctx, `
        INSERT INTO outbox_events
            (aggregate_type, aggregate_id, event_type, payload)
        VALUES ($1, $2, $3, $4)
    `, "order", order.ID, "order.created", payload); err != nil {
        return fmt.Errorf("insert outbox event: %w", err)
    }

    return tx.Commit()
}

tx.Commit()이 실패하면 주문 행도 아웃박스 행도 영속화되지 않습니다. 성공하면 둘 다 데이터베이스에 존재함이 보장됩니다. 릴레이는 그 이후 언제든지 이벤트를 발행할 수 있습니다. 즉시, 1초 후에, 또는 충돌 후 릴레이 재시작 후에 가능합니다.

이것이 비즈니스 계층에서 필요한 유일한 코드 변경 사항입니다. 패턴의 나머지는 릴레이에 있습니다.

Go 릴레이 구현

릴레이는 타이머에 따라 아웃박스 테이블을 폴링하는 백그라운드 워커입니다. 처리되지 않은 행의 배치를 가져와 각각 발행하고 완료로 표시합니다. 애플리케이션과 동일한 바이너리에 유지하거나 별도의 프로세스로 실행할 수 있습니다. 둘 다 작동하지만 동일한 바이너리가 운영이 더 간단합니다.

type OutboxRelay struct {
    db          *sql.DB
    publisher   Publisher
    logger      *slog.Logger
    batchSize   int
    pollInterval time.Duration
    maxAttempts  int
}

func (r *OutboxRelay) Run(ctx context.Context) error {
    ticker := time.NewTicker(r.pollInterval)
    defer ticker.Stop()

    for {
        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-ticker.C:
            if err := r.processBatch(ctx); err != nil {
                r.logger.Error("outbox relay batch failed", "err", err)
            }
        }
    }
}

릴레이는 컨텍스트 취소를 존중하므로 우아한 종료(graceful shutdown)와의 통합이 간단해집니다. 컨텍스트 수명 및 취소 패턴에 대한 자세한 내용은 Go context.Context Done Right를 참조하세요.

FOR UPDATE SKIP LOCKED: 동시 워커 패턴

processBatch 함수는 FOR UPDATE SKIP LOCKED를 사용하여 동시 릴레이 워커를 안전하게 처리합니다:

func (r *OutboxRelay) processBatch(ctx context.Context) error {
    tx, err := r.db.BeginTx(ctx, nil)
    if err != nil {
        return fmt.Errorf("begin tx: %w", err)
    }
    defer tx.Rollback()

    rows, err := tx.QueryContext(ctx, `
        SELECT id, aggregate_type, aggregate_id, event_type, payload
        FROM outbox_events
        WHERE processed_at IS NULL
          AND attempts < $1
        ORDER BY created_at
        LIMIT $2
        FOR UPDATE SKIP LOCKED
    `, r.maxAttempts, r.batchSize)
    if err != nil {
        return fmt.Errorf("query outbox: %w", err)
    }
    defer rows.Close()

    type row struct {
        id            string
        aggregateType string
        aggregateID   string
        eventType     string
        payload       json.RawMessage
    }

    var batch []row
    for rows.Next() {
        var e row
        if err := rows.Scan(
            &e.id, &e.aggregateType, &e.aggregateID, &e.eventType, &e.payload,
        ); err != nil {
            return fmt.Errorf("scan row: %w", err)
        }
        batch = append(batch, e)
    }
    if err := rows.Err(); err != nil {
        return err
    }

    for _, e := range batch {
        if err := r.publisher.Publish(ctx, e.eventType, e.aggregateID, e.payload); err != nil {
            r.logger.Error("publish failed", "event_id", e.id, "err", err)
            if _, err := tx.ExecContext(ctx,
                `UPDATE outbox_events SET attempts = attempts + 1 WHERE id = $1`, e.id,
            ); err != nil {
                r.logger.Error("increment attempts failed", "event_id", e.id, "err", err)
            }
            continue
        }

        if _, err := tx.ExecContext(ctx,
            `UPDATE outbox_events SET processed_at = NOW() WHERE id = $1`, e.id,
        ); err != nil {
            return fmt.Errorf("mark processed: %w", err)
        }
    }

    return tx.Commit()
}

FOR UPDATE SKIP LOCKED는 두 가지 역할을 합니다. 첫째, FOR UPDATE는 트랜잭션 기간 동안 선택된 행을 잠가 다른 트랜잭션이 이를 선택하는 것을 방지합니다. 둘째, SKIP LOCKED는 행이 이미 다른 트랜잭션에 의해 잠겨 있다면 쿼리가 기다리지 않고 이를 건너뜀을 의미합니다. 그 결과 여러 릴레이 워커가 병렬로 실행될 수 있으며 각각은 겹치지 않는 행의 부분집합을拾업합니다.

SKIP LOCKED 없이 두 번째 워커는 첫 번째 트랜잭션이 커밋될 때까지 차단된 후 동일한 행을 보게 되며, 이때는 이미 완료로 표시되어 있을 것입니다. SKIP LOCKED를 사용하면 두 번째 워커는 기다리는 대신 즉시 다른 행을拾업하여 안전한 수평 확장성을 제공합니다.

위 코드의 스캔 후 발행 분리를 주의하십시오. 모든 행은 발행 루프가 시작되기 전에 슬라이스로 스캔됩니다. 이는 브로커에 대한 네트워크 호출 동안 열린 *sql.Rows 커서를 유지하여 트랜잭션을 필요 이상으로 길게 열어두는 것을 방지합니다.

멱등성 및 중복 제거

릴레이는 최소 한 번 발행(at-least-once)합니다. 이벤트를 발행한 후 processed_at 업데이트를 커밋하기 전에 충돌하면 재시작 시 동일한 이벤트를 다시 발행합니다. 이는 피할 수 없습니다. 분산 트랜잭션 조정자 없이 데이터베이스와 메시지 브로커 간에 정확 한 번 전달(exactly-once delivery)을 달성하는 것은 불가능하므로 이러한 트레이드오프가 필요합니다.

소비자는 멱등해야 합니다. 가장 간단한 방법은 processed_events 테이블에 처리된 이벤트 ID를 추적하는 것입니다:

CREATE TABLE processed_events (
    event_id   UUID PRIMARY KEY,
    processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
func (h *OrderHandler) HandleOrderCreated(ctx context.Context, eventID string, payload []byte) error {
    // Deduplicate using the event ID as the natural key
    _, err := h.db.ExecContext(ctx, `
        INSERT INTO processed_events (event_id) VALUES ($1)
        ON CONFLICT (event_id) DO NOTHING
    `, eventID)
    if err != nil {
        return fmt.Errorf("dedup check: %w", err)
    }

    // Check if the insert actually happened (1 row) or was a no-op (0 rows)
    // A simpler approach: use RETURNING or check rows affected
    // If 0 rows affected, this is a duplicate -- skip it
    ...
}

실무에서는 많은 팀이 브로커 자체의 중복 제거 헤더(Kafka의 로그 압축 토픽에 대한 key 필드 또는 RabbitMQ의 message-id 헤더 등)에 의존하고 데이터베이스 수준의 중복 제거를 폴백으로 취급합니다. 둘 다 적용할 수 있는 유효한 계층입니다.

발행된 메시지에 중복 제거 키로 아웃박스 이벤트 id(UUID)를 포함하십시오. 소비자는 선호하는 중복 제거 메커니즘과 관계없이 이를 사용할 수 있습니다.

재시도 정책 및 독성 메시지(poison messages)

attempts 열이 재시도 정책을驱动합니다. 릴레이는 attempts >= maxAttempts인 행을 건너뛰고 이 행을 데드 레터로 처리합니다. 별도의 프로세스 또는 운영자 알림이 이를 처리합니다.

간단한 데드 레터 뷰:

CREATE VIEW outbox_dead_letters AS
SELECT *
FROM outbox_events
WHERE attempts >= 5
  AND processed_at IS NULL
ORDER BY created_at;

양호한 프로덕션 재시도 정책:

  • 재시도가 얼마나 비용 intensive한지에 따라 maxAttempts를 5-10로 설정합니다.
  • 지수 백오프(exponential backoff)를 고려하십시오: retry_after 열을 포함하고 retry_after > NOW()인 행을 건너뜁니다.
  • COUNT(*) FROM outbox_dead_letters가 임계값을 초과할 때 알림을 설정합니다.
  • 수동 재시도 경로를 제공합니다: 특정 행에 대해 attempts = 0retry_after = NULL로 리셋하는 관리자 엔드포인트 또는 스크립트입니다.

독성 메시지(poison messages) – 소비자 버그 또는 스키마 불일치로 인해 지속적으로 실패하는 행 –은 건강한 메시지를 차단해서는 안 됩니다. 릴레이는 각 틱(tick)당 배치를 처리하고 실패를 제거하는 대신 시도 횟수 증가로 표시하므로, 건강한 행은 정상적으로 진행되면서 독성 행은 데드 레터 임계값에 도달할 때까지 시도 횟수가 누적됩니다.

이벤트 순서 및 파티셔닝

폴링 쿼리는 created_at으로 정렬하여 배치 내에서 선입선출(FIFO) 순서를 제공합니다. 대부분의 사용 사례에는 충분합니다. 엄격한 엔티티별 순서가 중요한 경우(예: 동일한 주문에 대해 order.created보다 order.updated가 먼저 발행되지 않도록 보장), 집계별 순서가 필요합니다.

aggregate_idORDER BY 절에 추가하고 파티셔닝된 토픽(예: Apache Kafka)에 발행할 때 메시지 키로 사용하십시오. Kafka는 동일한 키를 가진 모든 메시지를 동일한 파티션으로 라우팅하고, 파티션은 순서대로 소비됩니다. 이는 전역 순서(단일 릴레이 인스턴스가 필요) 없이 집계별 순서 보장을 제공합니다.

ORDER BY aggregate_id, created_at

파티셔닝된 순서를 지원하지 않는 브로커(기본 AMQP 큐 등)의 경우, 단일 인스턴스 릴레이 또는 소비자 내 애플리케이션 수준의 순서 확인이 실용적인 대안입니다.

LISTEN/NOTIFY를 사용하여 폴링 지연 시간 감소

1초의 폴링 간격은 평균 500ms의 이벤트 지연을 의미합니다. 대부분의 워크로드에는 괜찮습니다. 거의 제로 지연이 필요한 경우, PostgreSQL의 LISTEN/NOTIFY 메커니즘은 새로운 아웃박스 행이 삽입될 때 릴레이가 즉시 깨어나도록 합니다.

아웃박스 테이블에 트리거를 추가합니다:

CREATE OR REPLACE FUNCTION notify_outbox_insert() RETURNS trigger AS $$
BEGIN
    PERFORM pg_notify('outbox_event', NEW.id::text);
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER outbox_insert_notify
AFTER INSERT ON outbox_events
FOR EACH ROW EXECUTE FUNCTION notify_outbox_insert();

릴레이에서 채널을 수신하고 알림에 깨어나되 여전히 주기적 폴링으로 폴백합니다:

func (r *OutboxRelay) Run(ctx context.Context) error {
    listener := pq.NewListener(r.dsn, 10*time.Second, time.Minute, nil)
    defer listener.Close()

    if err := listener.Listen("outbox_event"); err != nil {
        return fmt.Errorf("listen: %w", err)
    }

    ticker := time.NewTicker(5 * time.Second) // fallback poll
    defer ticker.Stop()

    for {
        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-listener.Notify:
            if err := r.processBatch(ctx); err != nil {
                r.logger.Error("outbox batch failed (notify)", "err", err)
            }
        case <-ticker.C:
            if err := r.processBatch(ctx); err != nil {
                r.logger.Error("outbox batch failed (poll)", "err", err)
            }
        }
    }
}

폴백 틱커는 릴레이 재시작 또는 네트워크 문제 동안 놓친 알림을 처리합니다. 폴백 간격을 밀리초가 아닌 몇 초로 유지하십시오. 그 역할은 저지연이 아니라 복구입니다.

관찰 가능성: 메트릭, 로그 및 알림

아웃박스는 인프라입니다. 인프라처럼 취급하고 적절히 계측(instrument)합니다.

주요 메트릭:

var (
    outboxPublished = prometheus.NewCounter(prometheus.CounterOpts{
        Name: "outbox_events_published_total",
        Help: "Total outbox events successfully published.",
    })
    outboxFailed = prometheus.NewCounterVec(prometheus.CounterOpts{
        Name: "outbox_events_failed_total",
        Help: "Total outbox publish failures by event type.",
    }, []string{"event_type"})
    outboxPending = prometheus.NewGauge(prometheus.GaugeOpts{
        Name: "outbox_events_pending",
        Help: "Current number of unprocessed outbox events.",
    })
    outboxBatchDuration = prometheus.NewHistogram(prometheus.HistogramOpts{
        Name:    "outbox_batch_duration_seconds",
        Help:    "Duration of each outbox processing batch.",
        Buckets: prometheus.DefBuckets,
    })
)

게이지 리프레시: outbox_events_pending을 정확하게 유지하기 위해 주기적 쿼리를 실행합니다:

SELECT COUNT(*) FROM outbox_events WHERE processed_at IS NULL;

고려해야 할 알림 임계값:

  • outbox_events_pending > 1000이 2분 이상 지속: 릴레이가 뒤처지거나 멈춤.
  • outbox_events_pending이 단조롭게 증가: 브로커 다운 또는 릴레이 충돌.
  • 데드 레터 카운트가 0이 아님: 스키마 또는 소비자 버그 조사 필요.
  • outbox_batch_duration_seconds p95 > 5s: 데이터베이스 느림 또는 배치 크기 과대.

구조화된 로그 필드: 릴레이의 모든 로그 라인에 event_id, event_type, aggregate_id, attempt를 포함합니다. 이 필드는 실패한 발행을 특정 아웃박스 행 및 다운스트림 소비자 트레이스와 상관관계 분석할 수 있게 해줍니다.

아웃박스 vs 직접 큐 vs 사가(Saga)

아웃박스 패턴은 모든 조정 문제에 적합한 도구는 아닙니다. 비교는 다음과 같습니다:

접근 방식 원자성 복잡도 사용 시기
직접 발행 없음 낮음 이벤트 가끔 손실 허용 가능
트랜잭션 아웃박스 강력 중간 단일 서비스에서 신뢰할 수 있는 이벤트 전달
사가 패턴 궁극적 높음 여러 데이터베이스를 가로지르는 다중 서비스 트랜잭션
2단계 커밋 강력 매우 높음 드물게 실용적; 대부분의 분산 시스템에서 피함

아웃박스 패턴은 단일 서비스가 자신의 상태 변경을 반영하는 이벤트를 신뢰할 수 있게 발행함을 보장합니다. 이는 여러 서비스 간의 상태 변경을 조정하지 않습니다. 이는 Saga 패턴의 역할입니다. 브로커 선택(RabbitMQ, SQS 또는 Kafka)은 아웃박스 패턴 자체와 무관합니다. 릴레이는 시스템이 사용하는 브로커로 발행합니다.

사가를 구축 중이라면 아웃박스 패턴은 여전히 유용합니다. 사가의 각 참여자는 아웃박스를 사용하여 단일 트랜잭션 내에서 로컬 상태 변경 및 사가 이벤트를 씁니다. 그 후 사가 오케스트레이터 또는 안무가 해당 이벤트를 신뢰할 수 있게 읽습니다.

WAL 기반 CDC를 대체 릴레이로

폴링 대신 PostgreSQL의 Write-Ahead Log(WAL)을 tail하고 복제 스트림에서 직접 아웃박스 삽입을 읽을 수 있습니다. Debezium과 같은 도구가 이를 수행합니다. 이 방식의 장점은 낮은 지연 시간과 아웃박스 테이블에 대한 잠금 압력 부재입니다. 단점은 운영 복잡성, 전용 PostgreSQL 복제 슬롯, 그리고 실행 및 모니터링해야 하는 외부 서비스입니다.

대부분의 팀에게는 위에서 설명한 폴링 릴레이가 적절한 시작점입니다. WAL tailing은 높은 아웃박스 삽입률(초당 수만 건), 100ms 미만의 이벤트 지연 필요, 또는 다른 변경 캐처(change-capture) 필요를 위해 이미 Debezium을 실행 중인 경우에 의미가 있습니다.

sqlc 통합

타입 안전한 Go 데이터베이스 코드를 위해 sqlc를 사용한다면, 아웃박스 쿼리는 자연스럽게 맞습니다:

-- name: InsertOutboxEvent :exec
INSERT INTO outbox_events (aggregate_type, aggregate_id, event_type, payload)
VALUES (@aggregate_type, @aggregate_id, @event_type, @payload);

-- name: FetchOutboxBatch :many
SELECT id, aggregate_type, aggregate_id, event_type, payload
FROM outbox_events
WHERE processed_at IS NULL
  AND attempts < @max_attempts
ORDER BY created_at
LIMIT @batch_size
FOR UPDATE SKIP LOCKED;

-- name: MarkOutboxProcessed :exec
UPDATE outbox_events SET processed_at = NOW() WHERE id = @id;

-- name: IncrementOutboxAttempts :exec
UPDATE outbox_events SET attempts = attempts + 1 WHERE id = @id;

-- name: OutboxPendingCount :one
SELECT COUNT(*) FROM outbox_events WHERE processed_at IS NULL;

sqlc는 각 쿼리에 대해 타입 안전한 함수를 생성하여 문자열 보간 오류를 피하고 아웃박스 쿼리 로직을 나머지 데이터베이스 액세스 계층과 함께 배치합니다.

프로덕션 체크리스트

아웃박스 구현을 출시하기 전에 이를 사용하십시오:

데이터베이스

  • 아웃박스 테이블에 created_at WHERE processed_at IS NULL에 대한 부분 인덱스가 있음
  • 기본값 0을 가진 attempts 열이 존재함
  • 데드 레터 뷰 또는 쿼리가 정의됨
  • 오래된 처리된 행이 주기적으로 아카이브되거나 삭제됨(야간 정리 작업으로 충분)

릴레이

  • 폴링 쿼리에 FOR UPDATE SKIP LOCKED 사용
  • 릴레이가 트랜잭션 내에서 실행됨(쿼리 전 시작, 모든 업데이트 후 커밋)
  • 배치 크기가 제한됨(50-200 행이 일반적)
  • 릴레이가 우아한 종료를 위해 컨텍스트 취소를 존중함
  • 실패한 발행이 배치를 중단하는 대신 attempts를 증가시킴

멱등성

  • 발행된 메시지가 중복 제거 키로 아웃박스 id를 포함함
  • 소비자가 멱등하거나 브로커가 중복 제거를 제공함
  • 중복 제거 패턴에 대해 분산 시스템에서의 멱등성 참조

관찰 가능성

  • outbox_events_pending 게이지가 모니터링되고 알림 설정됨
  • 데드 레터 카운트가 알림 설정됨
  • 릴레이 배치 지속 시간이 추적됨
  • 구조화된 로그에 event_id, event_type, aggregate_id가 포함됨

운영

  • 데드 레터 행에 대한 수동 재시도 경로가 존재함
  • 릴레이 재시작 동작이 테스트됨(올바르게 재발행하는가?)
  • 브로커 중단 동작이 테스트됨(아웃박스가 올바르게 성장하고 배수되는가?)

마무리

이중 쓰기 문제는 사고가 발생하기 전까지 모서리 사례(edge case)로 쉽게 간과됩니다. 트랜잭션 아웃박스 패턴은 이미 가지고 있는 도구로 이를 해결합니다: PostgreSQL 트랜잭션, 백그라운드 고루틴, 그리고 추가 테이블 하나. 릴레이는 구축, 운영, 추론하기 간단합니다.

비용은 소비자가 최소 한 번 전달(at-least-once delivery)을 위해 설계되어야 한다는 것입니다. 이는 합리적인 트레이드오프입니다. 분산 트랜잭션 없이 데이터베이스와 브로커 간에 정확 한 번 전달을 달성하는 것은 실용적으로 불가능하며, 이를 무시하는 것은 실패 조건에서 이벤트를 침묵적으로 드롭하거나 이중 처리하는 시스템으로 이어집니다.

데이터와 함께 이벤트를 씁니다. 신뢰할 수 있게 릴레이합니다. 소비자를 멱등하게 만듭니다. 이것이 전체 패턴입니다.

이 기사는 프로덕션용 애플리케이션 아키텍처 클러스터의 일부입니다.

출처

구독하기

시스템, 인프라, AI 엔지니어링에 관한 새 글을 받아보세요.