00_Inbox공부를 기다리는 친구들Transactional Outbox

Transactional Outbox

한 문장으로 이해하기

데이터베이스 변경과 함께 “나중에 반드시 보내야 할 이벤트”를 Outbox 테이블에 저장하고, 별도의 Relay가 그 이벤트를 메시지 브로커로 전달하는 패턴

이 문서는 Transactional Outbox 패턴을 처음 접하는 사람을 기준으로 작성했다.

설명 순서는 다음과 같다.

  1. 왜 필요한가
  2. Dual Write 문제가 무엇인가
  3. Outbox가 어떻게 해결하는가
  4. 실제 테이블과 Java 코드
  5. Relay와 CDC
  6. 중복·재시도·순서·멱등성
  7. Spring Modulith와의 관계
  8. 운영과 테스트

공식 자료


1. Transactional Outbox는 어떤 문제를 해결하는가?

주문 서비스가 주문을 완료했다고 하자.

주문 서비스는 보통 두 가지 작업을 해야 한다.

  1. 데이터베이스의 주문 상태를 COMPLETED로 변경한다.
  2. 재고 서비스에 OrderCompleted 메시지를 보낸다.
주문 상태 변경 + 메시지 발행

처음에는 다음처럼 작성할 수 있다.

@Transactional
public void completeOrder(UUID orderId) {
    Order order = orderRepository.findById(orderId)
        .orElseThrow();

    order.complete();
    orderRepository.save(order);

    kafkaTemplate.send(
        "order-completed",
        new OrderCompleted(orderId)
    );
}

하지만 데이터베이스와 Kafka는 서로 다른 시스템이다.

데이터베이스 트랜잭션
Kafka 메시지 발행

@Transactional이 데이터베이스 트랜잭션을 관리한다고 해서 Kafka까지 자동으로 같은 트랜잭션에 포함되는 것은 아니다.

Transactional Outbox는 이 문제를 다음 방식으로 해결한다.

주문 상태 변경
이벤트를 Outbox 테이블에 저장
두 작업을 같은 DB 트랜잭션으로 commit
별도의 Relay가 Outbox를 읽어 브로커로 전송

핵심은 다음 한 문장이다.

메시지를 바로 브로커에 보내지 말고, 데이터베이스 트랜잭션 안에서 “나중에 보내야 할 메시지”를 Outbox 테이블에 먼저 저장한다.


2. 용어부터 이해하기

2.1 Producer 또는 Sender

메시지를 만들어내는 서비스다.

주문 서비스 -> OrderCompleted 이벤트 생성

이 경우 주문 서비스가 Producer다.

2.2 Consumer

메시지를 받아서 처리하는 서비스다.

OrderCompleted 이벤트 -> 재고 서비스가 수신

2.3 Message Broker

서비스 사이에서 메시지를 전달해주는 시스템이다.

대표적인 메시지 브로커:

  • Apache Kafka
  • RabbitMQ
  • Amazon SQS
  • Apache Pulsar
  • Google Pub/Sub

학교 방송으로 비유하면 다음과 같다.

개념학교 방송 비유
Producer방송을 시작하는 사람
Message방송 내용
Broker방송 시스템
Consumer방송을 듣는 부서
Relay방송실에 내용을 전달하는 사람

2.4 Database Transaction

여러 데이터 변경을 하나의 작업으로 묶는 장치다.

은행에서 송금한다고 생각해보자.

내 계좌에서 10,000원 빼기
상대방 계좌에 10,000원 넣기

둘 중 하나만 실행되면 안 된다.

  • 내 돈만 빠지고 상대방에게 안 들어감
  • 상대방에게 돈은 들어갔는데 내 돈은 안 빠짐

그래서 둘을 하나의 트랜잭션으로 묶는다.

둘 다 성공 -> commit
하나라도 실패 -> rollback

Transactional Outbox에서 묶는 것도 같은 원리다.

주문 상태 변경
이벤트를 Outbox에 저장

3. Dual Write 문제

하나의 비즈니스 작업에서 서로 다른 두 시스템에 기록하는 것을 Dual Write라고 한다.

하나의 비즈니스 작업
├── 데이터베이스에 쓰기
└── 메시지 브로커에 쓰기

AWS 공식 가이드도 Transactional Outbox를 데이터베이스와 메시지 또는 이벤트 알림에 동시에 기록해야 하는 Dual Write 문제를 해결하는 패턴으로 설명한다.

참고: AWS Transactional Outbox Pattern

3.1 DB는 성공했지만 메시지 발행 전에 서버가 죽는 경우

sequenceDiagram
    participant App as "주문 서비스"
    participant DB as "데이터베이스"
    participant Broker as "메시지 브로커"
    participant Inventory as "재고 서비스"

    App->>DB: "주문 상태를 COMPLETED로 변경"
    DB-->>App: "commit 성공"
    App-xApp: "메시지 발행 전에 서버 장애"
    Note over Broker,Inventory: "OrderCompleted를 받지 못함"

결과:

주문 DB: 완료
재고 DB: 재고 그대로

주문은 완료되었는데 재고는 줄어들지 않는다.

3.2 메시지는 발행했지만 DB 저장이 실패하는 경우

sequenceDiagram
    participant App as "주문 서비스"
    participant DB as "데이터베이스"
    participant Broker as "메시지 브로커"
    participant Inventory as "재고 서비스"

    App->>Broker: "OrderCompleted 발행 성공"
    Broker->>Inventory: "재고 감소 처리"
    App->>DB: "주문 상태 변경"
    DB-->>App: "commit 실패"

결과:

주문 DB: 미완료
재고 DB: 재고 감소

실제로는 주문이 완료되지 않았는데 재고가 줄어든다.

3.3 DB 변경 후 브로커가 장애가 나는 경우

DB 저장 성공
Kafka 장애
메시지 발행 실패

주문 상태는 이미 바뀌었지만 이벤트는 사라질 수 있다.

3.4 발행 후 완료 표시 전에 Relay가 죽는 경우

sequenceDiagram
    participant Relay as "Relay"
    participant Outbox as "Outbox DB"
    participant Broker as "Broker"

    Relay->>Outbox: "전송할 이벤트 조회"
    Relay->>Broker: "메시지 발행 성공"
    Broker-->>Relay: "ACK"
    Relay-xRelay: "published 표시 전에 서버 장애"
    Note over Outbox: "Outbox에는 아직 미처리처럼 남아 있음"

Relay가 다시 시작되면 같은 메시지를 다시 보낼 수 있다.

동일한 이벤트가 두 번 발행됨

이 문제 때문에 Transactional Outbox는 메시지 중복이 발생할 수 있다는 사실을 전제로 설계해야 한다.


4. 2PC로 해결하면 안 되는가?

이 문제를 해결하는 전통적인 방법으로 **2PC(Two-Phase Commit)**가 있다.

2PC는 데이터베이스와 메시지 브로커에 모두 다음처럼 묻는다.

1단계: 둘 다 커밋할 준비가 되었는가?
2단계: 그렇다면 모두 커밋하라.

이론상 깔끔해 보이지만 실제로는 다음 문제가 있다.

  • DB와 메시지 브로커 모두 2PC를 지원해야 함
  • 트랜잭션이 오래 유지될 수 있음
  • 시스템이 복잡해짐
  • 장애가 났을 때 조정자가 필요함
  • 성능 저하 가능성
  • 서로 다른 시스템을 강하게 결합함

Transactional Outbox는 2PC를 사용하지 않는다.

대신 트랜잭션 범위를 데이터베이스 안으로 제한한다.

DB 트랜잭션
├── 비즈니스 데이터 변경
└── Outbox 이벤트 저장

메시지 브로커 발행은 나중에 별도의 프로세스가 수행한다.

canonical pattern 문서인 microservices.io도 데이터베이스와 메시지 브로커에 동시에 쓰되 2PC를 사용하기 어려운 경우의 해결책으로 Outbox를 설명한다. Microservices.io — Transactional Outbox


5. Transactional Outbox의 핵심 구조

Transactional Outbox에는 보통 다음 구성요소가 있다.

1. 주문 서비스
2. 주문 테이블
3. Outbox 테이블
4. Relay
5. 메시지 브로커
6. Consumer 서비스

전체 구조:

flowchart LR
    A["주문 요청"] --> B["주문 서비스"]
    B --> C["하나의 DB 트랜잭션"]
    C --> D["orders 상태 변경"]
    C --> E["outbox_events에 이벤트 저장"]
    D --> F{"commit"}
    E --> F
    F -->|"성공"| G["Relay가 Outbox 조회"]
    F -->|"실패"| H["orders와 outbox 모두 rollback"]
    G --> I["메시지 브로커"]
    I --> J["재고 Consumer"]
    I --> K["알림 Consumer"]
    I --> L["통계 Consumer"]

핵심 순서:

1. 주문 상태 변경
2. Outbox 테이블에 이벤트 저장
3. 두 작업을 같은 DB 트랜잭션으로 commit
4. Relay가 Outbox를 읽음
5. 메시지 브로커에 발행
6. Consumer가 처리

여기서 중요한 점:

Outbox에 저장되었다는 것은 “메시지를 보냈다”는 뜻이 아니라 “반드시 나중에 보내야 할 메시지를 안전하게 기록했다”는 뜻이다.


6. 택배 접수로 비유하기

Transactional Outbox를 택배에 비유해보자.

나쁜 방식

주문 정보를 시스템에 저장한 뒤 바로 택배 기사에게 전화한다.

주문 DB 저장
택배 기사에게 전화

주문 DB 저장 직후 서버가 꺼지면 택배 기사에게 전화하지 못한다.

Outbox 방식

주문 정보와 “택배를 보내야 한다”는 배송 접수 기록을 같은 장부에 같이 적는다.

주문 장부에 주문 기록
배송 대기함에 배송 메시지 기록

두 기록이 모두 장부에 저장되었다면, 나중에 배송 담당자가 배송 대기함을 읽고 택배를 보낸다.

배송 담당자가 중간에 쓰러져도 배송 대기함에는 기록이 남아 있다.

주문 DB + 배송 대기 기록

이 배송 대기함이 Outbox다.


7. 주문 예제로 전체 흐름 보기

사용자가 주문 ORDER-100을 완료한다고 하자.

7.1 주문 완료 요청

사용자
주문 서비스

7.2 하나의 DB 트랜잭션 시작

DB 안에서 다음 두 작업을 한다.

orders.status = COMPLETED

outbox_events에 다음 행 추가:
- id: event-001
- aggregate_type: Order
- aggregate_id: ORDER-100
- type: OrderCompleted
- payload: {"orderId":"ORDER-100"}

7.3 DB commit

orders 변경 성공
outbox_events 추가 성공

두 작업이 모두 커밋된다.

7.4 Relay가 Outbox를 읽음

Relay -> event-001 조회

7.5 Kafka에 발행

topic: order-events
key: ORDER-100
value: {"orderId":"ORDER-100"}

7.6 재고 Consumer가 처리

재고 서비스
ORDER-100에 대한 재고 예약

전체 흐름:

sequenceDiagram
    actor User as "사용자"
    participant Order as "주문 서비스"
    participant DB as "주문 DB"
    participant Outbox as "Outbox 테이블"
    participant Relay as "Relay"
    participant Broker as "Kafka"
    participant Inventory as "재고 서비스"

    User->>Order: "ORDER-100 완료 요청"
    Order->>DB: "주문 상태 COMPLETED"
    Order->>Outbox: "OrderCompleted 저장"
    DB-->>Order: "commit 성공"
    Order-->>User: "주문 완료 응답"
    Relay->>Outbox: "미전송 이벤트 조회"
    Relay->>Broker: "OrderCompleted 발행"
    Broker-->>Relay: "ACK"
    Broker->>Inventory: "OrderCompleted 전달"
    Inventory->>Inventory: "재고 예약"

8. Outbox 테이블은 어떻게 생기는가?

관계형 데이터베이스라면 보통 Outbox를 별도 테이블로 만든다.

CREATE TABLE outbox_events (
    id UUID PRIMARY KEY,
    aggregate_type VARCHAR(100) NOT NULL,
    aggregate_id VARCHAR(100) NOT NULL,
    event_type VARCHAR(200) NOT NULL,
    payload JSONB NOT NULL,
    aggregate_version BIGINT,
    occurred_at TIMESTAMP WITH TIME ZONE NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE NOT NULL
);

각 컬럼의 의미:

컬럼의미
id이벤트의 고유 ID
aggregate_type이벤트가 속한 대상 종류
aggregate_id주문 ID, 회원 ID 등 대상 식별자
event_typeOrderCompleted 같은 이벤트 이름
payloadConsumer에게 전달할 실제 데이터
aggregate_version같은 대상의 이벤트 순서
occurred_at실제 업무 이벤트가 발생한 시간
created_atOutbox 행이 생성된 시간

Debezium 공식 Outbox Event Router 문서도 기본 Outbox 구조로 id, aggregatetype, aggregateid, type, payload 컬럼을 설명한다. 또한 aggregateid를 메시지 키로 사용하면 Kafka 파티션에서 같은 Aggregate의 이벤트 순서를 유지하는 데 도움이 된다고 설명한다. Debezium Outbox Event Router


9. Polling 방식에서 사용하는 컬럼

Relay가 직접 Outbox를 조회하는 Polling Publisher 방식이라면 다음 컬럼을 추가할 수 있다.

ALTER TABLE outbox_events
ADD COLUMN status VARCHAR(30) NOT NULL DEFAULT 'PENDING',
ADD COLUMN attempts INTEGER NOT NULL DEFAULT 0,
ADD COLUMN next_attempt_at TIMESTAMP WITH TIME ZONE,
ADD COLUMN published_at TIMESTAMP WITH TIME ZONE,
ADD COLUMN locked_by VARCHAR(100),
ADD COLUMN locked_until TIMESTAMP WITH TIME ZONE;

상태 예시:

PENDING
PROCESSING
PUBLISHED
FAILED

하지만 CDC 방식에서는 Outbox 행을 PUBLISHED로 업데이트하기보다 Outbox를 **추가 전용(Append-only)**으로 유지하는 편이 일반적이다.

Debezium 공식 문서도 Outbox 테이블에서는 보통 INSERT만 발생하고 기존 레코드에 대한 UPDATE는 기대하지 않는다고 설명한다.


10. Producer 코드 작성

10.1 주문 엔티티

@Entity
@Table(name = "orders")
public class Order {

    @Id
    private UUID id;

    @Enumerated(EnumType.STRING)
    private OrderStatus status;

    @Version
    private long version;

    public void complete() {
        if (status != OrderStatus.PAYMENT_COMPLETED) {
            throw new IllegalStateException(
                "결제가 완료되지 않은 주문은 완료할 수 없습니다."
            );
        }

        this.status = OrderStatus.COMPLETED;
    }

    public UUID getId() {
        return id;
    }

    public long getVersion() {
        return version;
    }
}

10.2 이벤트 객체

public record OrderCompleted(
    UUID eventId,
    UUID orderId,
    long orderVersion
) {
}

이벤트 ID와 Aggregate 버전을 함께 가지고 있는 것이 좋다.

  • eventId: 중복 제거에 사용
  • orderId: 어느 주문의 이벤트인지 식별
  • orderVersion: 순서 확인에 사용

10.3 Outbox 엔티티

@Entity
@Table(name = "outbox_events")
public class OutboxEvent {

    @Id
    private UUID id;

    private String aggregateType;
    private UUID aggregateId;
    private String eventType;

    @Column(columnDefinition = "jsonb")
    private String payload;

    private long aggregateVersion;
    private Instant occurredAt;

    protected OutboxEvent() {
    }

    public static OutboxEvent create(
        String aggregateType,
        UUID aggregateId,
        String eventType,
        String payload,
        long aggregateVersion
    ) {
        OutboxEvent event = new OutboxEvent();

        event.id = UUID.randomUUID();
        event.aggregateType = aggregateType;
        event.aggregateId = aggregateId;
        event.eventType = eventType;
        event.payload = payload;
        event.aggregateVersion = aggregateVersion;
        event.occurredAt = Instant.now();

        return event;
    }
}

10.4 주문 완료 서비스

@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;
    private final OutboxEventRepository outboxRepository;
    private final ObjectMapper objectMapper;

    @Transactional
    public void completeOrder(UUID orderId) {
        Order order = orderRepository.findById(orderId)
            .orElseThrow();

        order.complete();

        OrderCompleted event = new OrderCompleted(
            UUID.randomUUID(),
            order.getId(),
            order.getVersion()
        );

        String payload = objectMapper
            .writeValueAsString(event);

        OutboxEvent outboxEvent = OutboxEvent.create(
            "Order",
            order.getId(),
            "OrderCompleted",
            payload,
            order.getVersion()
        );

        outboxRepository.save(outboxEvent);
    }
}

여기서 @Transactional 안에서 다음 두 작업이 함께 실행된다.

1. 주문 상태 변경
2. Outbox 이벤트 저장

매우 중요한 조건

두 테이블이 같은 데이터베이스 트랜잭션에 참여해야 한다.

orders 테이블        -> DB A
outbox_events 테이블 -> DB A

다음처럼 서로 다른 저장소라면 하나의 일반적인 로컬 DB 트랜잭션으로 묶을 수 없다.

orders 테이블        -> PostgreSQL
outbox_events 테이블 -> 별도 MongoDB

그러면 다시 Dual Write 문제가 생긴다.


11. Outbox는 “메시지 발행”이 아니다

다음 코드를 주의한다.

@Transactional
public void completeOrder(UUID orderId) {
    order.complete();
    orderRepository.save(order);

    kafkaTemplate.send(
        "order-events",
        new OrderCompleted(orderId)
    );
}

이 코드는 Outbox 패턴이 아니다.

Kafka에 직접 보내고 있기 때문이다.

Transactional Outbox는 다음처럼 해야 한다.

@Transactional
public void completeOrder(UUID orderId) {
    order.complete();
    orderRepository.save(order);

    outboxRepository.save(
        OutboxEvent.from(new OrderCompleted(orderId))
    );
}

그리고 별도의 Relay가 나중에 발행한다.

비즈니스 서비스
Outbox 테이블
Relay
Kafka

12. Relay란 무엇인가?

Relay는 Outbox에 저장된 이벤트를 메시지 브로커로 전달하는 별도의 프로그램 또는 스케줄러다.

Outbox에 저장된 이벤트
Relay가 조회
Kafka/RabbitMQ/SQS에 발행
성공하면 처리 완료 기록

Relay는 다음과 같은 방식으로 만들 수 있다.

  • Spring @Scheduled
  • 별도 Worker 애플리케이션
  • Debezium CDC Connector
  • 메시지 브로커 전용 Publisher
  • JobRunr
  • Namastack Outbox
  • 클라우드 큐 연동 Worker

13. Polling Publisher 방식

가장 이해하기 쉬운 방식은 Relay가 주기적으로 Outbox 테이블을 조회하는 것이다.

flowchart LR
    A["Spring Scheduler"] --> B["Outbox 테이블 조회"]
    B --> C["PENDING 이벤트 선택"]
    C --> D["메시지 브로커 발행"]
    D -->|"ACK 성공"| E["PUBLISHED 표시 또는 삭제"]
    D -->|"실패"| F["재시도 대기"]

13.1 단순한 예시

@Component
@RequiredArgsConstructor
public class OutboxRelay {

    private final OutboxEventRepository outboxRepository;
    private final MessageBrokerPublisher publisher;

    @Scheduled(fixedDelay = 1000)
    public void publishPendingEvents() {
        List<OutboxEvent> events =
            outboxRepository.findPendingEvents(100);

        for (OutboxEvent event : events) {
            try {
                publisher.publish(
                    event.getEventType(),
                    event.getAggregateId().toString(),
                    event.getPayload()
                );

                outboxRepository.markPublished(event.getId());

            } catch (Exception e) {
                outboxRepository.markFailed(event.getId());
            }
        }
    }
}

개념을 설명하기 위한 단순한 코드다.

실제 운영 환경에서는 그대로 사용하면 안 된다.

13.2 장점

  • 구조가 단순함
  • 별도 CDC 인프라가 필요 없음
  • 일반적인 Spring Boot 코드로 구현 가능
  • 테스트하기 쉬움

13.3 단점

  • 계속 DB를 조회해야 함
  • 조회 주기에 따라 지연 발생
  • 여러 Relay 간 행 점유 처리가 필요
  • Outbox 처리량이 많으면 DB 부하 증가
  • 장애·재시도·Lock을 직접 구현해야 함

13.4 적합한 상황

  • 이벤트 양이 많지 않음
  • 빠른 실시간성이 필요하지 않음
  • 인프라를 단순하게 유지하고 싶음
  • 작은 규모의 시스템

14. Relay를 여러 대 실행할 때 생기는 문제

Relay를 한 대만 실행하면 처리량이 부족할 수 있다.

그래서 다음처럼 여러 Relay를 실행할 수 있다.

Relay A
Relay B
Relay C

그런데 세 Relay가 같은 이벤트를 동시에 읽으면 어떻게 될까?

Relay A -> event-001 조회
Relay B -> event-001 조회
Relay C -> event-001 조회

같은 메시지가 세 번 발행될 수 있다.

14.1 DB 행 잠금

PostgreSQL에서는 다음과 같은 방식으로 특정 행을 먼저 점유할 수 있다.

SELECT *
FROM outbox_events
WHERE status = 'PENDING'
ORDER BY created_at
LIMIT 100
FOR UPDATE SKIP LOCKED;

의미:

  • 이미 다른 Worker가 잠근 행은 건너뜀
  • 각 Worker가 서로 다른 행을 가져감
  • 여러 Relay를 동시에 실행 가능

14.2 Claim 상태

먼저 이벤트를 자기 작업이라고 표시한다.

PENDING
PROCESSING
PUBLISHED

예시:

UPDATE outbox_events
SET status = 'PROCESSING',
    locked_by = :workerId,
    locked_until = now() + interval '1 minute'
WHERE id = :eventId
  AND status = 'PENDING';

이 UPDATE가 성공한 Worker만 메시지를 발행한다.

14.3 Lease

Relay가 이벤트를 점유했지만 장애로 죽을 수 있다.

그래서 영원히 PROCESSING에 갇히지 않도록 만료 시간을 둔다.

locked_until = 12:01:00

12시 1분이 지나면 다른 Worker가 다시 가져갈 수 있다.


15. Relay에서 가장 중요한 장애 구간

다음 두 작업은 서로 다른 시스템에서 일어난다.

1. 브로커에 메시지 발행
2. Outbox에 발행 완료 기록

이 둘을 완벽하게 하나로 묶기 어렵다.

경우 A: 발행 전에 Relay가 죽음

Outbox: PENDING
Broker: 메시지 없음

다시 시도하면 된다.

경우 B: 발행 성공 후 완료 기록 전에 Relay가 죽음

Outbox: PENDING
Broker: 메시지 있음

다시 시도하면 중복 메시지가 생긴다.

경우 C: 완료 기록을 먼저 하고 발행 전에 Relay가 죽음

Outbox: PUBLISHED
Broker: 메시지 없음

이 경우 메시지가 영원히 사라질 수 있다.

따라서 처리 순서는 반드시 다음이어야 한다.

1. 브로커에 발행
2. 브로커 ACK 확인
3. Outbox를 완료 처리

그럼에도 2번과 3번 사이에 장애가 나면 중복이 생긴다.

그래서 Transactional Outbox는 일반적으로 다음을 보장하는 패턴이다.

메시지가 유실되지 않도록 한다.
중복은 발생할 수 있다.
Consumer가 중복을 안전하게 처리해야 한다.

이를 At-least-once Delivery라고 한다.


16. At-most-once, At-least-once, Exactly-once

16.1 At-most-once

메시지를 최대 한 번 보낸다.

0번 또는 1번

중복은 적지만 메시지를 잃을 수 있다.

16.2 At-least-once

메시지를 최소 한 번 보낸다.

1번 이상

메시지를 잃지 않도록 재시도하지만 중복될 수 있다.

Transactional Outbox는 보통 이 방식으로 동작한다.

16.3 Exactly-once

메시지를 정확히 한 번만 처리한다.

정확히 1번

분산 시스템에서 “끝까지 정확히 한 번”은 매우 어렵다.

Kafka가 내부적으로 Exactly-once 기능을 제공하더라도 다음을 자동으로 해결하는 것은 아니다.

Kafka 메시지 처리
외부 데이터베이스 변경
외부 결제 API 호출
이메일 발송

특히 외부 시스템까지 포함하면 진짜 End-to-End Exactly-once는 훨씬 어렵다.

실무에서는 보통 다음 조합을 사용한다.

Transactional Outbox
+ At-least-once delivery
+ Idempotent Consumer
+ Retry
+ Dead Letter Queue

17. Idempotency란 무엇인가?

Idempotency는 같은 작업을 여러 번 수행해도 결과가 한 번 수행한 것과 같도록 만드는 성질이다.

수학의 예:

abs(abs(-3)) = 3
abs(abs(abs(-3))) = 3

몇 번 적용해도 결과가 같다.

17.1 멱등하지 않은 재고 차감

UPDATE inventory
SET quantity = quantity - 1
WHERE product_id = 'P-100';

이 이벤트가 두 번 처리되면:

100 -> 99 -> 98

실제로는 한 주문인데 재고가 두 개 줄어든다.

17.2 멱등하게 만들기

주문 ID를 이용해 예약 기록을 먼저 저장한다.

CREATE TABLE inventory_reservations (
    order_id UUID PRIMARY KEY,
    product_id UUID NOT NULL,
    quantity INTEGER NOT NULL,
    created_at TIMESTAMP NOT NULL
);

같은 주문 ID를 다시 처리하면 Primary Key 충돌이 발생한다.

INSERT INTO inventory_reservations (
    order_id,
    product_id,
    quantity,
    created_at
)
VALUES (
    :orderId,
    :productId,
    :productQuantity,
    now()
)
ON CONFLICT (order_id) DO NOTHING;

첫 번째 처리:

예약 기록 삽입 성공
재고 차감

두 번째 처리:

이미 예약 기록 존재
재고 차감하지 않음

18. Processed Events 테이블

Consumer가 어떤 이벤트를 이미 처리했는지 별도 테이블에 기록할 수도 있다.

CREATE TABLE processed_events (
    consumer_name VARCHAR(100) NOT NULL,
    event_id UUID NOT NULL,
    processed_at TIMESTAMP WITH TIME ZONE NOT NULL,
    PRIMARY KEY (consumer_name, event_id)
);

Consumer 코드는 다음과 같은 구조가 된다.

@Transactional
public void handle(OrderCompleted event) {
    boolean firstTime = processedEventRepository
        .insertIfAbsent(
            "inventory-service",
            event.eventId()
        );

    if (!firstTime) {
        // 이미 처리한 이벤트이므로 아무 작업도 하지 않음
        return;
    }

    inventoryRepository.reserve(
        event.orderId()
    );
}

중요한 점은 다음 두 작업이 같은 Consumer DB 트랜잭션에 있어야 한다는 것이다.

1. processed_events에 event_id 기록
2. 재고 변경

만약 1번만 성공하고 2번이 실패하면 안 된다.

processed_events 기록 성공
재고 변경 실패

그렇게 되면 다음 재시도 때 “이미 처리했다”고 판단해 재고 변경을 건너뛸 수 있다.

따라서 둘을 같은 로컬 DB 트랜잭션으로 묶어야 한다.

processed_events INSERT
재고 UPDATE
둘 다 성공 -> commit
하나라도 실패 -> rollback

19. 외부 API를 호출하는 경우

Consumer가 외부 결제 API를 호출한다고 하자.

Consumer
결제 회사 API

다음 두 작업은 하나의 DB 트랜잭션으로 묶을 수 없다.

내부 DB 변경
외부 결제 API 호출

이때는 외부 API가 제공하는 idempotency key를 사용해야 한다.

paymentClient.pay(
    new PaymentRequest(
        orderId,
        event.eventId().toString() // 외부 API의 idempotency key
    )
);

같은 이벤트가 다시 도착해도 외부 결제 시스템은 같은 idempotency key를 보고 중복 결제를 막을 수 있다.


20. CDC 방식

CDC는 Change Data Capture의 약자다.

데이터베이스가 기록하는 변경 로그를 읽어 Outbox INSERT를 감지한다.

PostgreSQL이라면 WAL, MySQL이라면 binlog 같은 로그를 사용할 수 있다.

flowchart LR
    A["주문 DB"] --> B["Outbox INSERT"]
    B --> C["DB Transaction Log"]
    C --> D["Debezium Connector"]
    D --> E["Outbox Event Router"]
    E --> F["Kafka"]
    F --> G["Consumer"]

Debezium은 DB 변경 로그에서 Outbox 테이블의 변경만 골라 메시지로 바꿔 Kafka로 보낼 수 있다.

20.1 CDC의 장점

  • Polling 없이 변경을 빠르게 감지
  • DB를 계속 조회하는 부하가 줄어듦
  • DB 트랜잭션 로그를 기반으로 하므로 커밋된 변경을 읽을 수 있음
  • 대규모 이벤트 처리에 적합
  • Kafka와 자연스럽게 연결 가능

20.2 CDC의 단점

  • Debezium, Kafka Connect 등 인프라가 필요
  • 운영 복잡도가 증가
  • DB별 설정과 로그 관리가 필요
  • Connector 장애와 Offset 관리가 필요
  • 초기 학습 비용이 큼

20.3 CDC에 적합한 상황

  • 이벤트 양이 많음
  • 낮은 지연 시간이 필요함
  • Kafka를 이미 사용하고 있음
  • CDC 인프라를 운영할 수 있음

21. Outbox와 CDC는 같은 것인가?

아니다.

Transactional Outbox -> 메시지를 DB에 함께 저장하는 패턴
CDC                 -> DB 변경을 감지하는 전달 기술

둘은 함께 사용할 수 있다.

Transactional Outbox
+ CDC Relay

구조:

비즈니스 테이블 변경
Outbox 테이블 INSERT
동일한 DB 트랜잭션
Commit
CDC가 DB 로그에서 감지
Kafka 발행

정리하면 다음과 같다.

  • Outbox는 어떻게 안전하게 기록할 것인가에 대한 패턴
  • CDC는 기록된 이벤트를 어떻게 감지하고 전달할 것인가에 대한 기술

22. 이벤트 순서 문제

Transactional Outbox에서 매우 중요한 문제가 이벤트 순서다.

주문 하나가 다음과 같이 바뀐다고 하자.

OrderCreated
PaymentCompleted
OrderCompleted

Consumer가 다음 순서로 받으면 괜찮다.

1. OrderCreated
2. PaymentCompleted
3. OrderCompleted

하지만 다음처럼 도착하면 문제가 생길 수 있다.

1. OrderCompleted
2. OrderCreated
3. PaymentCompleted

Consumer가 OrderCompleted를 먼저 처리할 수 없을 수도 있다.

22.1 Aggregate란?

여기서 Aggregate는 하나의 업무 단위라고 생각하면 된다.

Order-100

주문 하나가 Aggregate다.

Order-100의 이벤트
├── OrderCreated
├── PaymentCompleted
└── OrderCompleted

22.2 Aggregate별 버전

각 이벤트에 버전을 붙일 수 있다.

Order-100, version 1, OrderCreated
Order-100, version 2, PaymentCompleted
Order-100, version 3, OrderCompleted

Consumer는 다음을 확인할 수 있다.

현재 처리한 버전: 2
도착한 이벤트 버전: 3

정상적으로 다음 버전이면 처리한다.

2 -> 3

이미 처리한 버전이면 무시한다.

도착한 버전: 2
현재 버전: 2
=> 중복이므로 무시

너무 앞선 버전이면 보류할 수 있다.

현재 버전: 1
도착한 버전: 3
=> version 2가 아직 오지 않았으므로 대기

22.3 Kafka에서 Aggregate ID를 메시지 키로 사용

Kafka는 같은 파티션 안에서는 메시지 순서를 보장한다.

따라서 같은 주문의 이벤트가 같은 Kafka 파티션으로 가도록 주문 ID를 메시지 키로 사용할 수 있다.

Kafka message key = aggregate_id
Order-100 -> 항상 같은 파티션
Order-200 -> 다른 파티션일 수 있음

이렇게 하면 다음이 가능하다.

같은 주문의 이벤트 -> 순서 보장
서로 다른 주문 -> 병렬 처리

중요한 점은 전체 시스템의 모든 이벤트가 하나의 전역 순서로 정렬되는 것은 아니라는 것이다.

보통 필요한 것은 다음이다.

같은 Aggregate 안에서의 순서

23. Timestamp만으로 순서를 정하면 안 되는 이유

다음처럼 created_at만 이용해 정렬하고 싶을 수 있다.

SELECT *
FROM outbox_events
ORDER BY created_at ASC;

하지만 동시에 여러 트랜잭션이 실행되면 문제가 생길 수 있다.

Transaction A: Order-100 version 2
Transaction B: Order-100 version 3

두 트랜잭션이 거의 동시에 실행되면 timestamp가 다음처럼 기록될 수 있다.

version 3 -> 10:00:00.100
version 2 -> 10:00:00.101

실제 업무 순서와 저장 timestamp 순서가 달라질 수 있다.

더 안전한 방법:

  • Aggregate별 버전 저장
  • Aggregate의 현재 버전을 원자적으로 증가
  • 메시지 키를 Aggregate ID로 사용
  • CDC를 사용해 DB 로그 순서를 활용
  • Consumer에서 버전 검증
  • 순서가 틀린 이벤트를 잠시 보류

24. 장애 상황별 결과

상황DB 데이터Outbox브로커결과
트랜잭션 시작 후 서버 장애없음없음없음정상 rollback
주문 변경 성공, Outbox 저장 실패없음 또는 rollback없음없음이벤트 없음
주문 변경과 Outbox 저장 모두 성공있음있음아직 없음Relay가 나중에 처리
브로커 장애있음있음없음Outbox에 남아 재시도
브로커 발행 후 Relay 장애있음미처리처럼 보일 수 있음메시지 있음재시도 시 중복 가능
Consumer 처리 실패있음처리 완료될 수 있음재전달 또는 DLQConsumer 멱등성 필요
Consumer가 중복 이벤트 수신있음-같은 메시지 재전달event_id로 무시

가장 위험한 상황:

Outbox에 완료 표시를 먼저 함
브로커 발행 전에 장애 발생

그러면 메시지가 사라질 수 있다.

따라서 항상 다음 순서여야 한다.

브로커 발행 성공
ACK 확인
Outbox 완료 처리

25. 재시도 정책

실패한 이벤트를 무작정 빠르게 재시도하면 안 된다.

실패
즉시 재시도
실패
즉시 재시도
실패
...

이렇게 하면 장애가 있는 시스템에 계속 부하를 줄 수 있다.

25.1 Exponential Backoff

재시도 간격을 점점 늘리는 방식이다.

1번째 실패 -> 1초 후
2번째 실패 -> 2초 후
3번째 실패 -> 4초 후
4번째 실패 -> 8초 후
5번째 실패 -> 16초 후

25.2 최대 재시도 횟수

최대 5회까지 재시도

그 이후에는 Dead Letter Queue 또는 실패 테이블로 보낸다.

flowchart LR
    A["이벤트 처리"] --> B{"성공?"}
    B -->|"예"| C["완료"]
    B -->|"아니오"| D{"재시도 횟수 확인"}
    D -->|"남음"| E["Backoff 후 재시도"]
    E --> A
    D -->|"초과"| F["Dead Letter Queue"]
    F --> G["운영자 조사"]

26. Dead Letter Queue란?

계속 실패하는 메시지를 일반 처리 흐름에서 분리해 보관하는 장소다.

정상 Queue
Consumer 처리 실패
재시도
계속 실패
Dead Letter Queue

DLQ로 보낸 뒤 운영자는 다음을 확인할 수 있다.

  • 잘못된 이벤트 payload
  • 존재하지 않는 주문 ID
  • 외부 API 장애
  • 데이터 스키마 불일치
  • 코드 버그
  • 권한 문제

DLQ는 메시지를 버리는 장소가 아니다.

자동 처리를 중단하고 사람이 조사할 수 있도록 격리하는 장소

다.


27. Spring Modulith와 Transactional Outbox의 관계

기존에 공부한 Spring Modulith에도 이벤트 발행 기록 기능이 있다.

Order 모듈
ApplicationEventPublisher
@ApplicationModuleListener
Inventory 모듈

Spring Modulith의 Event Publication Registry는 이벤트 발행과 리스너 처리 상태를 기록하고, 실패한 이벤트를 다시 제출할 수 있도록 도와준다.

하지만 이것과 전통적인 외부 메시지용 Transactional Outbox를 완전히 같은 것으로 생각하면 안 된다.

27.1 Spring Modulith 내부 모듈 이벤트

flowchart LR
    A["Order 모듈"] --> B["Spring Application Event"]
    B --> C["ApplicationModuleListener"]
    C --> D["Inventory 모듈"]
    B --> E["Event Publication Registry"]

같은 Spring Boot 프로세스 안에서 모듈끼리 통신하는 구조다.

27.2 외부 브로커용 Transactional Outbox

flowchart LR
    A["Order 모듈"] --> B["DB Transaction"]
    B --> C["orders"]
    B --> D["outbox_events"]
    D --> E["Relay 또는 CDC"]
    E --> F["Kafka / RabbitMQ / SQS"]
    F --> G["다른 서비스"]

이 구조는 외부 시스템이나 별도 마이크로서비스로 이벤트를 보내는 데 적합하다.

Spring Modulith 공식 이벤트 문서도 기본 외부화 방식은 비동기 이벤트 리스너를 사용하는 실용적인 방법이지만, 실제 Outbox 구현에서 기대하는 고급 기능이 부족할 수 있다고 설명한다.

현재 Spring Modulith 2.1 문서에는 Namastack Outbox와 JobRunr Outbox 지원도 소개되어 있다.

spring.modulith.events.externalization.mode=outbox

참고: Spring Modulith — Working with Application Events

27.3 기능을 구분해서 기억하기

기능주된 목적
@ApplicationModuleListener같은 애플리케이션 내부 모듈 통합
Event Publication Registry이벤트 리스너 처리 기록과 재제출
Transactional OutboxDB 변경과 외부 메시지 발행의 Dual Write 해결
CDCOutbox 변경을 감지해 브로커로 전달
Outbox RelayOutbox 메시지를 브로커로 발행

28. Transactional Outbox와 Event Publication Registry의 차이

둘 다 이벤트 기록을 저장하기 때문에 헷갈릴 수 있다.

Event Publication Registry

주로 다음 상황을 지원한다.

Spring 이벤트 발행
트랜잭션 이벤트 리스너 처리
처리 성공 여부 기록
실패 이벤트 재제출

Transactional Outbox

주로 다음 상황을 지원한다.

업무 데이터 변경
외부로 보낼 메시지의 영구 저장
별도 Relay 또는 CDC
메시지 브로커 발행

차이를 간단하게 표현하면 다음과 같다.

Event Publication Registry
= 애플리케이션 이벤트 처리 기록

Transactional Outbox
= 외부로 전달할 메시지를 DB 트랜잭션에 함께 저장

어떤 시스템에서는 Event Publication Registry가 Outbox와 비슷한 역할을 할 수 있지만, 모든 Outbox 기능을 자동으로 제공한다는 뜻은 아니다.


29. Transactional Outbox를 사용해야 하는 경우

다음 상황이라면 Outbox를 고려할 가치가 높다.

  • DB 변경 후 반드시 이벤트를 보내야 함
  • 이벤트가 유실되면 안 됨
  • 외부 메시지 브로커를 사용함
  • 서비스 간 데이터 동기화가 필요함
  • Saga의 다음 단계로 이벤트를 보내야 함
  • 결제·주문·배송 상태 변경을 다른 서비스에 알려야 함
  • DB와 메시지 발행 사이의 장애를 복구해야 함

예:

주문 완료
  -> 재고 예약
  -> 배송 생성
  -> 알림 발송

주문 상태 변경과 이벤트 발행이 어긋나면 실제 비즈니스 문제가 발생하기 때문이다.


30. 반드시 Outbox가 필요한 것은 아닌 경우

다음과 같은 경우에는 단순한 동기 호출이 더 적합할 수 있다.

  • 모든 작업이 하나의 DB 안에서 끝남
  • 외부 서비스가 없음
  • 이벤트 유실이 큰 문제가 아님
  • 처리 결과를 즉시 확인해야 함
  • 시스템 규모가 매우 작음
  • 메시지 발행 자체가 핵심이 아님

Outbox는 공짜가 아니다.

추가로 관리해야 한다.

  • Outbox 테이블
  • Relay
  • 재시도
  • 중복 처리
  • 메시지 순서
  • 보관 기간
  • Dead Letter Queue
  • 모니터링
  • 이벤트 스키마 버전

따라서 “이벤트를 사용하니까 무조건 Outbox”가 아니라 다음을 질문해야 한다.

이 이벤트가 유실되면 실제 비즈니스 문제가 발생하는가?


31. 관련 패턴과의 차이

31.1 Inbox Pattern

Outbox가 Producer 쪽이라면 Inbox는 Consumer 쪽이다.

Producer
  └── Outbox로 안정적인 발행

Consumer
  └── Inbox로 중복 수신 방지
flowchart LR
    A["Producer DB"] --> B["Outbox"]
    B --> C["Broker"]
    C --> D["Consumer"]
    D --> E["Inbox 또는 Processed Events"]
    E --> F["비즈니스 데이터 변경"]

Outbox와 Inbox를 함께 사용하면 다음 구조가 된다.

Producer: 이벤트를 안전하게 발행
Consumer: 이벤트를 중복 없이 처리

31.2 Saga Pattern

Saga는 여러 서비스에 걸친 하나의 비즈니스 작업을 여러 단계로 나누는 패턴이다.

주문 생성
  -> 결제
  -> 재고 예약
  -> 배송 생성

각 단계가 별도의 서비스와 DB를 사용할 수 있다.

각 서비스는 자기 DB를 변경하고 다음 단계 이벤트를 보낸다.

이때 각 단계에서 Transactional Outbox가 필요할 수 있다.

주문 서비스 Outbox
결제 서비스 Outbox
재고 서비스 Outbox
배송 서비스 Outbox

31.3 Event Sourcing

Event Sourcing에서는 이벤트가 시스템의 원본 데이터 자체다.

OrderCreated
PaymentCompleted
OrderShipped

이 이벤트들을 재생해서 현재 상태를 계산한다.

반면 Transactional Outbox의 이벤트는 보통 외부 시스템에 전달하기 위한 메시지다.

현재 상태의 원본 -> 일반 DB
전달용 복사본 -> Outbox

둘은 목적이 다르다.

31.4 CDC

CDC는 Outbox 자체가 아니라 변경 감지 기술이다.

Outbox Pattern = 안전하게 기록하는 설계
CDC           = 기록된 변경을 전달하는 기술

32. 실전 구현 체크리스트

Producer 측

  • 비즈니스 데이터와 Outbox 저장이 같은 DB 트랜잭션인가?
  • Outbox 저장 실패 시 비즈니스 데이터도 rollback되는가?
  • 이벤트에 고유 ID가 있는가?
  • Aggregate ID가 있는가?
  • 이벤트 종류를 구분할 수 있는가?
  • payload가 Consumer에게 충분한가?
  • 내부 DB 엔티티 전체를 그대로 보내고 있지는 않은가?
  • 이벤트 스키마 변경 전략이 있는가?

Relay 측

  • 같은 이벤트를 여러 Worker가 동시에 가져가지 않는가?
  • 브로커 ACK 후에만 완료 표시하는가?
  • 발행 후 Relay가 죽었을 때 중복을 허용하는가?
  • 재시도 횟수가 제한되어 있는가?
  • Exponential Backoff가 있는가?
  • Dead Letter Queue가 있는가?
  • 오래된 Outbox 데이터를 삭제하는가?
  • Relay 자체의 상태와 처리량을 모니터링하는가?

Consumer 측

  • 이벤트 ID로 중복을 제거하는가?
  • Processed Events 기록과 비즈니스 변경이 같은 DB 트랜잭션인가?
  • 외부 API 호출에 idempotency key를 사용하는가?
  • 이벤트 순서를 검증하는가?
  • 처리할 수 없는 이벤트를 DLQ로 보내는가?
  • 이벤트 스키마 버전을 이해하는가?

운영 측

  • Outbox 행 개수와 증가 속도를 모니터링하는가?
  • PENDING 이벤트가 오래 쌓이지 않는가?
  • FAILED 이벤트가 급증하지 않는가?
  • Relay의 지연 시간이 증가하지 않는가?
  • Consumer 처리 지연을 확인할 수 있는가?
  • DLQ에 들어간 이벤트를 재처리할 수 있는가?
  • 이벤트 payload에 개인정보가 과도하게 들어가지 않는가?

33. 테스트 전략

Transactional Outbox는 단순히 “이벤트가 발행되었다”만 테스트하면 부족하다.

테스트 1: DB 변경과 Outbox가 함께 성공하는가?

주문 상태 변경 성공
Outbox 저장 성공
둘 다 commit

테스트 2: Outbox 저장 실패 시 주문도 rollback되는가?

주문 상태 변경
Outbox 저장 실패
주문 상태도 rollback

테스트 3: Relay가 브로커 장애를 재시도하는가?

Broker publish 실패
Outbox는 미처리로 유지
다음 실행에서 재시도

테스트 4: Relay가 중복 발행해도 Consumer가 한 번만 처리하는가?

같은 event_id 두 번 수신
비즈니스 효과는 한 번만 발생

테스트 5: Consumer 처리 도중 장애가 나면 다시 처리할 수 있는가?

Processed Events INSERT
재고 변경 중 장애
전체 rollback
다음 재시도에서 정상 처리

테스트 6: 같은 Aggregate의 순서가 보장되는가?

OrderCreated version 1
PaymentCompleted version 2
OrderCompleted version 3

Consumer가 순서를 확인하도록 테스트해야 한다.


34. 가장 간단한 구현 흐름

전체를 아주 짧게 표현하면 다음과 같다.

Producer

@Transactional
public void completeOrder(UUID orderId) {
    Order order = orderRepository.findById(orderId)
        .orElseThrow();

    order.complete();

    outboxRepository.save(
        OutboxEvent.from(
            new OrderCompleted(
                UUID.randomUUID(),
                orderId
            )
        )
    );
}

Relay

@Scheduled(fixedDelay = 1000)
public void relay() {
    List<OutboxEvent> events =
        outboxRepository.claimPendingEvents(100);

    for (OutboxEvent event : events) {
        try {
            broker.publish(
                event.getEventType(),
                event.getAggregateId(),
                event.getPayload()
            );

            outboxRepository.markPublished(event.getId());

        } catch (Exception e) {
            outboxRepository.scheduleRetry(event.getId());
        }
    }
}

Consumer

@Transactional
public void handle(OrderCompleted event) {
    if (processedEvents.exists(event.eventId())) {
        return;
    }

    processedEvents.save(
        ProcessedEvent.of(
            "inventory-service",
            event.eventId()
        )
    );

    inventory.reserve(event.orderId());
}

세 부분이 핵심이다.

Producer:
DB 변경 + Outbox 저장

Relay:
Outbox 조회 + 브로커 발행

Consumer:
중복 확인 + 비즈니스 처리

35. 장애 시나리오 다시 보기

정상 흐름

주문 변경
Outbox 저장
commit
Relay 발행
Consumer 처리

DB 트랜잭션 실패

주문 변경
Outbox 저장 실패
둘 다 rollback
이벤트 없음

주문이 성공하지 않았으므로 이벤트도 없는 것이 정상이다.

Broker 장애

주문 변경 성공
Outbox 저장 성공
Broker 장애

이 경우 주문은 성공할 수 있다.

이벤트는 Outbox에 남아 있다.

다음 Relay 실행에서 재시도

Relay 장애

Broker 발행 성공
완료 표시 전 Relay 장애

중복 발행이 발생할 수 있다.

Consumer가 event_id로 중복 무시

Consumer 장애

Consumer 처리 실패

브로커의 재전달 또는 DLQ를 이용한다.

재시도
성공하면 완료
계속 실패하면 DLQ

36. Transactional Outbox의 진짜 의미

Transactional Outbox는 “모든 것을 완벽하게 한 번만 처리하는 마법”이 아니다.

이 패턴이 해결하는 문제는 정확히 다음이다.

DB 변경은 성공했는데 메시지가 사라지는 문제
DB 변경은 실패했는데 메시지가 나가는 문제

이를 다음처럼 바꾼다.

DB 변경 + 메시지 의도 기록

이 두 가지는 하나의 DB 트랜잭션으로 묶는다.

그 뒤의 메시지 발행은 언젠가 성공할 때까지 재시도한다.

대신 새로운 문제를 관리해야 한다.

중복
순서
재시도
보관
DLQ
Consumer 멱등성

즉, Outbox는 문제를 없애는 것이 아니라 문제를 다루기 좋은 형태로 바꾸는 패턴이다.

해결 전:
DB와 Broker 사이의 유실·불일치

해결 후:
DB에 안전하게 저장하고,
중복·재시도·순서를 관리

37. 마지막으로 이것만 기억하기

Transactional Outbox를 한 문장으로 다시 쓰면 다음과 같다.

데이터베이스 변경과 함께 “보내야 할 이벤트”를 Outbox 테이블에 저장하고, 별도의 Relay가 나중에 그 이벤트를 메시지 브로커로 전달하는 패턴

전체 흐름:

flowchart TD
    A["비즈니스 명령"] --> B["Producer 서비스"]
    B --> C["DB Transaction 시작"]
    C --> D["업무 데이터 변경"]
    C --> E["Outbox 이벤트 저장"]
    D --> F{"Commit"}
    E --> F
    F -->|"실패"| G["전체 rollback"]
    F -->|"성공"| H["이벤트가 DB에 안전하게 존재"]
    H --> I["Polling Relay 또는 CDC"]
    I --> J["Message Broker"]
    J --> K["Consumer"]
    K --> L["event_id 중복 확인"]
    L --> M["비즈니스 처리"]

실무에서 가장 현실적인 보장 수준:

Producer:
DB 변경과 Outbox 저장은 원자적으로 처리

Relay:
메시지를 최소 한 번 이상 전달

Broker:
전달 과정에서 중복 가능

Consumer:
중복을 안전하게 무시하도록 설계

따라서 최종 공식은 다음과 같다.

Transactional Outbox
+ At-least-once delivery
+ Idempotent Consumer
+ Retry
+ Dead Letter Queue
+ Aggregate별 순서 관리

이 조합이 분산 시스템에서 데이터 변경과 이벤트 발행을 안정적으로 연결하는 현실적인 방법이다.


38. 빠른 복습 질문

Q1. Outbox 테이블에 저장되면 메시지가 이미 발행된 것인가?

아니다. 나중에 Relay가 발행해야 하는 메시지를 안전하게 기록한 것이다.

Q2. Outbox는 DB와 Kafka를 하나의 트랜잭션으로 묶는가?

아니다. DB 변경과 Outbox 저장만 하나의 DB 트랜잭션으로 묶는다. Kafka 발행은 나중에 Relay가 수행한다.

Q3. Transactional Outbox는 중복 메시지를 완전히 막는가?

아니다. Relay가 발행 후 완료 표시 전에 죽으면 중복될 수 있다.

Q4. 중복 메시지는 어떻게 처리하는가?

이벤트의 고유 ID를 기록하고, 이미 처리한 이벤트라면 비즈니스 작업을 다시 실행하지 않는다.

Q5. CDC와 Outbox는 같은 것인가?

아니다. Outbox는 안전한 기록 패턴이고, CDC는 DB 변경을 감지하는 전달 기술이다.

Q6. Consumer가 같은 이벤트를 두 번 받아도 결과가 한 번과 같게 만드는 성질은?

Idempotency, 즉 멱등성이다.

Q7. 가장 중요한 설계 원칙은 무엇인가?

먼저 DB에 안전하게 기록한다.
그다음 외부로 전달한다.
중복을 허용하고 Consumer에서 안전하게 제거한다.
Built with LogoFlowershow