분산 배치와 비동기 큐의 데이터 유실을 방지하는 트랜잭셔널 아웃박스(Transactional Outbox) 패턴 설계

분산 배치와 비동기 큐의 데이터 유실을 방지하는 트랜잭셔널 아웃박스(Transactional Outbox) 패턴 설계
분산 배치와 비동기 큐의 데이터 유실을 방지하는 트랜잭셔널 아웃박스(Transactional Outbox) 패턴 설계

배치 자동화 시스템이나 마이크로서비스 백엔드에서 비즈니스 로직을 처리한 뒤 외부 메시지 큐(RabbitMQ, Kafka, Redis Streams)나 서드파티 웹훅으로 이벤트를 발행하는 파이프라인은 매우 흔한 구조입니다. 하지만 이 과정에서 엔지니어가 가장 빈번하게 마주치는 치명적인 결함은 바로 이중 쓰기(Dual-Write) 불일치 문제입니다.

데이터베이스 트랜잭션과 메시지 브로커 전송은 서로 다른 물리적 리소스이므로, 단일 로컬 트랜잭션으로 원자성(Atomicity)을 보장할 수 없습니다. DB 커밋 직후 네트워크 순단으로 메시지 전송이 실패하거나, 반대로 메시지 브로커 전송 후 DB 커밋 단계에서 데드락이 발생해 롤백되면, 결제는 승인되었으나 알림이 유실되거나, DB는 롤백되었는데 외부 시스템에는 배송 명령이 전달되는 심각한 데이터 정합성 붕괴가 발생합니다. 2단계 커밋(2PC, XA)은 분산 락과 네트워크 레이턴시로 인해 현대적인 고가용성 파이프라인에서 적합하지 않습니다. 본 글에서는 이러한 문제를 완전히 해결하고 최소 1회 전송(At-least-once Delivery)을 보장하는 트랜잭셔널 아웃박스(Transactional Outbox) 패턴의 아키텍처 원리와 실무 파이썬/SQL 구현체를 분석합니다.

1. 듀얼 라이트(Dual-Write)의 실패 시나리오 분석

  • 시나리오 A (DB 커밋 후 메시지 발행): 로컬 DB에 데이터를 성공적으로 COMMIT한 직후, 메시지 브로커와의 TCP 연결이 끊기거나 브로커 프로세스가 다운되면 데이터는 영구 저장되었으나 다운스트림 자동화 이벤트가 유실됩니다.
  • 시나리오 B (메시지 발행 후 DB 커밋): 메시지 큐에 이벤트를 먼저 정상 전송했으나, 직후 DB 커밋 단계에서 유니크 키 충돌이나 직렬화 실패로 롤백(Rollback)되면 외부 큐에는 존재하지 않는 유령 데이터(Ghost Event)가 흘러가게 됩니다.

2. 트랜잭셔널 아웃박스 패턴의 동작 메커니즘

트랜잭셔널 아웃박스 패턴은 발행해야 할 메시지 자체를 RDBMS 내부의 전용 아웃박스 테이블(Outbox Table)에 비즈니스 데이터와 동일한 트랜잭션으로 원자적 커밋하는 기법입니다.

-- 1. 트랜잭셔널 아웃박스 테이블 스키마 DDL
CREATE TABLE event_outbox (
    event_id BIGINT NOT NULL AUTO_INCREMENT,
    aggregate_type VARCHAR(50) NOT NULL,
    aggregate_id VARCHAR(100) NOT NULL,
    event_type VARCHAR(100) NOT NULL,
    payload JSON NOT NULL,
    status ENUM('PENDING', 'PUBLISHED', 'FAILED') NOT NULL DEFAULT 'PENDING',
    retry_count INT NOT NULL DEFAULT 0,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    published_at TIMESTAMP NULL,
    PRIMARY KEY (event_id),
    INDEX idx_status_created (status, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

비즈니스 테이블(예: orders)에 데이터를 INSERT할 때, 동일한 트랜잭션(BEGIN ... COMMIT) 안에서 event_outbox 테이블에도 레코드를 함께 삽입합니다. RDBMS의 ACID 속성에 의해 비즈니스 상태 변경과 이벤트 기록은 100% 동일한 성공/실패 라이프사이클을 갖게 됩니다.

3. 메시지 릴레이(Message Relay) 구현: 폴링 퍼블리셔

아웃박스 테이블에 쌓인 PENDING 상태의 이벤트를 안전하게 읽어 외부 브로커로 전송하는 워커 프로세스를 구축합니다. 이때 동시 실행 워커 간의 중복 처리를 방지하기 위해 SELECT ... FOR UPDATE SKIP LOCKED를 활용합니다.

import json
import logging
from typing import List, Dict, Any

logger = logging.getLogger("OutboxPublisher")

class OutboxPublisherService:
    def __init__(self, db_connection, message_broker_client):
        self.db = db_connection
        self.broker = message_broker_client

    def process_pending_events(self, batch_size: int = 100) -> int:
        processed_count = 0
        with self.db.cursor() as cursor:
            # 1. 다른 워커가 선점한 행은 건너뛰고 PENDING 이벤트 잠금 획득
            fetch_query = """
                SELECT event_id, event_type, payload
                FROM event_outbox
                WHERE status = 'PENDING'
                ORDER BY event_id ASC
                LIMIT %s
                FOR UPDATE SKIP LOCKED;
            """
            cursor.execute(fetch_query, (batch_size,))
            records = cursor.fetchall()

            if not records:
                return 0

            # 2. 외부 메시지 브로커로 전송 후 상태 갱신
            for record in records:
                event_id, event_type, payload_raw = record
                try:
                    payload = json.loads(payload_raw) if isinstance(payload_raw, str) else payload_raw
                    # 메시지 브로커 전송 (멱등성 보장을 위해 event_id를 메시지 헤더에 포함)
                    self.broker.publish(
                        topic=event_type,
                        message=payload,
                        headers={"X-Idempotency-Key": str(event_id)}
                    )
                    
                    # 3. 발행 성공 상태 업데이트
                    cursor.execute(
                        """
                        UPDATE event_outbox
                        SET status = 'PUBLISHED', published_at = NOW()
                        WHERE event_id = %s;
                        """,
                        (event_id,)
                    )
                    processed_count += 1
                except Exception as exc:
                    logger.error("이벤트 발행 실패 (event_id=%s): %s", event_id, exc)
                    cursor.execute(
                        """
                        UPDATE event_outbox
                        SET retry_count = retry_count + 1,
                            status = IF(retry_count >= 5, 'FAILED', 'PENDING')
                        WHERE event_id = %s;
                        """,
                        (event_id,)
                    )

            # 트랜잭션 커밋
            self.db.commit()

        return processed_count
💡 프로덕션 환경 아웃박스 아키텍처 3대 핵심 원칙:<br>
1. 컨슈머의 멱등성(Idempotent Consumer) 필수: 아웃박스 패턴은 최소 1회 전송을 보장하므로, 네트워크 재시도 시 동일 메시지가 2회 이상 전달될 수 있습니다. 소비자는 event_id를 고유 식별자로 사용하여 중복 처리를 방어해야 합니다.<br>
2. 트랜잭션 로그 마이닝(CDC) 고려: 초당 수천 건 이상의 극한 처리량이 요구되는 경우, 폴링 쿼리 대신 Debezium 같은 CDC(Change Data Capture) 도구로 MySQL binlog를 직접 스트리밍하는 방식으로 전환합니다.<br>
3. 오래된 아웃박스 데이터 파티션 아카이빙: PUBLISHED 처리된 완료 레코드는 테이블 팽창을 막기 위해 RANGE 일자별 파티션 드롭이나 백그라운드 청크 삭제로 주기적으로 정리해야 합니다.

4. 결론 및 도입 효과

트랜잭셔널 아웃박스 패턴은 분산 환경의 비동기 파이프라인에서 데이터베이스 상태와 외부 메시지 발행 간의 원자성을 보장하는 가장 표준적인 아키텍처입니다. 폴링 퍼블리셔의 SKIP LOCKED 락 제어와 컨슈머의 멱등성 설계를 결합하면, 네트워크 단절이나 예기치 않은 서버 장애 속에서도 단 1건의 이벤트 유실 없는 무결성 배치 자동화 시스템을 완성할 수 있습니다.


출처 및 1차 공식 기술 문서

Similar Posts

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다