트랜잭셔널 아웃박스 패턴

david1-p·2025년 11월 12일

CS 지식 창고

목록 보기
18/25
post-thumbnail

분산 시스템의 데이터 정합성을 지키는 '트랜잭셔널 아웃박스 패턴'

분산 시스템, 특히 마이크로서비스 아키텍처(MSA)에서 자주 발생하는 고질적인 문제와 그 해결책인 트랜잭셔널 아웃박스 패턴(Transactional Outbox Pattern)에 대해 알아보겠습니다.

1. 분산 시스템의 '이중 쓰기' 문제

마이크로서비스 환경에서는 하나의 서비스가 자신의 데이터베이스를 변경하는 것(Write)과 동시에, 다른 서비스에게 "나에게 변화가 생겼어!"라고 알리기 위해 메시지 브로커(Kafka, RabbitMQ 등)에 이벤트를 발행(Write)하는 경우가 흔합니다.

예를 들어, 상품 서비스가 신규 상품을 등록한 뒤, 검색 서비스나 알림 서비스가 이 사실을 알 수 있도록 ProductCreated 이벤트를 발행하는 상황을 가정해 보겠습니다.

아마 가장 직관적인 코드는 다음과 같을 것입니다.

// 위험한 방식의 예시 코드
@Transactional
public void createProduct(ProductCreateRequest request) {
    // 1. DB에 상품 정보를 저장한다.
    Product product = new Product(request.getName(), request.getPrice());
    productRepository.save(product);

    // 2. 외부에 이벤트를 발행한다.
    eventPublisher.publish(new NewProductEvent(product.getId()));
}

이 코드는 단순해 보이지만 심각한 문제를 내포하고 있습니다. 바로 이중 쓰기(Dual Writing) 문제입니다.

2. 왜 이 코드는 위험할까요?

위험한 이유는 두 개의 서로 다른 시스템(데이터베이스, 메시지 브로커)에 대한 쓰기 작업을 하나의 원자적인 트랜잭션으로 묶을 수 없기 때문입니다.

데이터베이스의 트랜잭션은 productRepository.save(product) 호출 이후, @Transactional 어노테이션에 의해 createProduct 메소드가 성공적으로 종료될 때 COMMIT이 실행됩니다.

이 과정을 간단한 의사 코드로 풀어서 살펴보겠습니다.

public void problematicTransactionLogic() {
    try {
        // 1. DB 트랜잭션 시작
        database.transaction.begin();

        // 2. 비즈니스 로직 수행 (DB 쓰기)
        Product product = new Product("신규 상품");
        productRepository.save(product);

        // 3. 외부 시스템에 이벤트 발행
        eventPublisher.publish(new NewProductEvent(product.getId()));

        // 4. DB 트랜잭션 커밋
        database.transaction.commit();

    } catch (Exception e) {
        // 5. 문제 발생 시 DB 롤백
        database.transaction.rollback();
    }
}

여기서 발생할 수 있는 두 가지 최악의 시나리오가 있습니다.

  1. 시나리오 A: DB 커밋 성공, 이벤트 발행 실패

    • productRepository.save(product)는 성공했습니다.
    • eventPublisher.publish(...)가 네트워크 문제나 브로커 장애로 실패했습니다.
    • 메소드에서 예외가 발생하여 database.transaction.rollback()이 호출됩니다.
    • 결과: 실제로는 상품이 성공적으로 저장될 했지만, 이벤트 발행 실패 때문에 상품 등록 자체가 롤백됩니다. (혹은 예외 처리를 publish에서 따로 한다면, 상품은 DB에 있지만 이벤트는 발행되지 않아 아무도 모르는 유령 데이터가 됩니다.)
  2. 시나리오 B: DB 커밋 실패, 이벤트 발행 성공

    • productRepository.save(product)는 성공했습니다.
    • eventPublisher.publish(...)성공했습니다.
    • 하지만 database.transaction.commit() 시점에 DB 장애, 락(Lock) 문제 등으로 커밋이 실패하고 롤백됩니다.
    • 결과: DB에는 상품 데이터가 없는데, "신규 상품이 등록되었다!"라는 이벤트는 이미 외부 시스템에 전파되었습니다. 다른 서비스들은 존재하지 않는 상품을 참조하려다 장애를 일으킬 것입니다.

이처럼 두 작업 중 하나만 성공하는 상황은 서비스 전체의 데이터 정합성을 심각하게 훼손시킵니다.

3. 해결책: 트랜잭셔널 아웃박스 패턴 (Transactional Outbox Pattern)

이 문제를 해결하는 것이 바로 트랜잭셔널 아웃박스 패턴입니다.

핵심 아이디어는 간단합니다.

"외부 시스템(메시지 브로커)에 직접 이벤트를 발행하지 말고, '발행할 이벤트' 자체를 내 데이터베이스에 저장하자!"

즉, 비즈니스 데이터(상품)를 저장하는 작업과, 발행할 이벤트(상품 생성 이벤트)를 저장하는 작업을 하나의 DB 트랜잭션으로 묶어 원자성을 보장하는 것입니다.

이를 위해 OUTBOX라는 별도의 테이블을 만듭니다.

예시: 이벤트 저장을 위한 Outbox 테이블
CREATE TABLE product_outbox (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    event_type VARCHAR(255) NOT NULL,
    payload TEXT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    processed BOOLEAN DEFAULT FALSE -- (추가) 처리 여부를 추적할 수 있습니다.
);

4. 아웃박스 패턴의 구현

이제 아웃박스 패턴을 적용하여 코드를 수정해 보겠습니다.

// 트랜잭셔널 아웃박스 패턴이 적용된 코드
@Transactional
public void createProduct(ProductCreateRequest request) {
    // 1. 비즈니스 데이터(상품) 저장
    Product product = new Product(request.getName(), request.getPrice());
    productRepository.save(product);

    // 2. 발행할 이벤트를 Outbox 테이블에 저장
    // (아직 진짜 발행(publish)한 것이 아님!)
    ProductEvent event = new ProductEvent(product.getId(), "PRODUCT_CREATED", request);
    productOutboxRepository.save(event);

    // 3. 트랜잭션 커밋
    // 이제 productRepository.save(product)와
    // productOutboxRepository.save(event)는
    // 같은 DB 트랜잭션으로 묶여 원자성을 보장받습니다.
}

이제 어떻게 될까요?

  • product 저장이 성공하고 event 저장도 성공하면, 트랜잭션이 커밋됩니다. (성공)
  • product 저장은 성공했지만 event 저장이 실패하면, 트랜잭션이 롤백됩니다. (데이터 정합성 유지)
  • product 저장, event 저장이 모두 성공했지만 커밋이 실패하면, 트랜잭션이 롤백됩니다. (데이터 정합성 유지)

이로써 DB와 이벤트 발행 간의 원자성(Atomicity) 문제는 해결됩니다.
다만 이후 이벤트 발행 과정에서 중복 전송이 일어날 수 있으므로, 소비자 측에서 멱등성을 보장해야 완전한 정합성이 유지됩니다.

5. Outbox의 이벤트를 '진짜' 발행하기 (Polling vs. CDC)

"좋아, 이제 이벤트가 DB에 저장된 건 알겠어. 그럼 이걸 언제, 누가 메시지 브로커로 보내주지?"

여기서부터는 별도의 비동기 프로세스가 필요합니다. 이 프로세스는 OUTBOX 테이블을 감시하다가, 새로 추가된 이벤트를 실제 메시지 브로커로 전달하는 '우체부' 역할을 합니다.

이 '우체부'를 구현하는 방식은 크게 두 가지입니다.

A. 폴링 (Polling) 방식

가장 구현하기 쉬운 방식입니다.

  • 작동 방식: 별도의 스케줄러(예: Spring의 @Scheduled)가 주기적으로(예: 매 1초마다) OUTBOX 테이블을 SELECT 합니다.
  • 아직 처리되지 않은(processed = false) 이벤트를 가져와 메시지 브로커로 발행합니다.
  • 발행에 성공하면 해당 이벤트를 OUTBOX 테이블에서 삭제하거나, processed = true로 업데이트합니다.
  • 단점:
    폴링 주기가 너무 짧거나 Outbox 데이터가 많아질 경우 DB 부하가 증가할 수 있습니다.
    하지만 일반적인 트래픽 규모에서는 실질적인 부담이 크지 않으며, 구현이 간단하다는 장점이 있습니다.
    이벤트 발생 시점과 실제 발행 시점 사이에 지연(Latency)이 발생합니다. (스케줄링 주기만큼)

B. CDC (Change Data Capture) 방식

더욱 효율적이고 세련된 방식입니다.

  • 작동 방식: 애플리케이션이 DB를 직접 폴링하는 것이 아니라, 데이터베이스의 트랜잭션 로그(Transaction Log)를 모니터링합니다.
  • MySQL의 Binlog, PostgreSQL의 WAL 등이 트랜잭션 로그입니다.
  • Debezium 같은 CDC 도구가 이 로그를 읽다가, OUTBOX 테이블에 INSERT가 발생한 것을 '캡처'합니다.
  • 캡처된 이벤트를 즉시 Kafka와 같은 메시지 브로커로 전달(Relay)합니다.
  • 장점:
    • DB에 폴링 부하를 전혀 주지 않습니다.
    • 이벤트가 커밋되는 즉시 로그에 기록되므로, 거의 실시간(Near real-time)으로 이벤트를 발행할 수 있습니다.
  • 단점: Debezium, Kafka Connect 등 별도의 CDC 파이프라인을 구축해야 하므로 초기 설정이 복잡합니다.
  • Debezium은 Kafka Connect 기반으로 동작하며, 데이터베이스의 트랜잭션 로그를 읽어 Kafka 토픽으로 바로 내보내는 Connector 역할을 합니다.
    이후 애플리케이션은 Kafka 토픽을 구독하여 이벤트를 처리하면 됩니다.

6. 아웃박스 패턴의 보장과 고려사항

  • 적어도 한 번 전송 (At-least-once Delivery)
    아웃박스 패턴은 이벤트 발행 프로세스(우체부)가 브로커에게 이벤트를 '성공할 때까지' 재시도할 수 있게 해줍니다. (예: 발행 후 OUTBOX에서 삭제하기 직전에 '우체부' 프로세스가 죽는 경우, 재시작 시 동일 이벤트를 다시 발행함)
    따라서 이 패턴은 "적어도 한 번" 이벤트가 발행되는 것을 보장합니다.

  • 소비자의 멱등성 (Idempotency)
    '적어도 한 번'이 보장된다는 것은, 네트워크 문제 등으로 인해 '중복 발행'이 발생할 수 있다는 의미이기도 합니다. 따라서 이 이벤트를 수신하는 소비자(Consumer)는 반드시 멱등성(Idempotent)을 갖도록 설계해야 합니다. 즉, 같은 이벤트를 여러 번 수신하더라도 단 한 번만 처리된 것과 동일한 결과를 내도록 만들어야 합니다.

7. 요약

  1. 단일 트랜잭션에서 DB 쓰기와 외부 메시지 발행을 함께 처리하면 '이중 쓰기' 문제로 데이터 정합성이 깨진다.
  2. 트랜잭셔널 아웃박스 패턴은 외부 메시지를 직접 발행하는 대신, '발행할 이벤트'를 DB 내 OUTBOX 테이블에 저장한다.
  3. 비즈니스 로직과 이벤트 저장은 동일한 DB 트랜잭션으로 묶여 원자성을 보장받는다.
  4. 별도의 릴레이 프로세스(Polling 또는 CDC)가 OUTBOX 테이블을 감시하여 실제 메시지 브로커로 이벤트를 전송한다.
  5. 이 패턴은 “DB 변경 → 이벤트 발행”이 반드시 일관되게 수행되도록 보장합니다.
    따라서 이벤트 기반 아키텍처에서 데이터 정합성을 확보하는 핵심 패턴으로 널리 활용됩니다.
profile
DONE IS BETTER THAN PERFECT.

0개의 댓글