트랜잭션 아웃박스 패턴(Transaction Outbox Pattern)

송현진·2025년 5월 8일
0

Architecture

목록 보기
6/18

트랜잭션 아웃박스 패턴은 데이터베이스의 상태 변경과 메시지 브로커로의 이벤트 발행을 분리하지만 정합성을 유지하기 위한 아키텍처 패턴이다. 마이크로서비스 환경에서 이벤트 기반 통신을 안정적으로 구현하고자 할 때 사용된다.

이 패턴은 데이터(예: 주문 정보)를 DB에 저장하는 트랜잭션과 동시에 발생시킬 이벤트를 ‘Outbox 테이블’이라는 별도의 DB 테이블에 함께 저장하고 이 이벤트를 실제 브로커(Kafka 등)로 전송하는 작업은 트랜잭션 외부에서 별도로 수행하는 것이다. 이 구조를 통해 업무 로직과 이벤트 전송을 분리하면서도 원자성을 보장할 수 있다.

❓왜 사용하는가?

마이크로서비스 아키텍처에서 서로 다른 서비스 간의 데이터 연동은 보통 이벤트 발행 방식으로 이뤄진다. 예를 들어 OrderService에서 주문을 생성하면 이 정보를 PaymentService 등에 알려줘야 한다. 이때 가장 많이 쓰는 방식은 Kafka, RabbitMQ와 같은 메시지 브로커를 사용하는 것이다. 하지만 아래와 같은 코드에서는 심각한 문제가 발생할 수 있다.

@Transactional
public void createOrder(Order order) {
    orderRepository.save(order);
    kafkaProducer.send(order); // 메시지 전송
}

orderRepository.save()는 성공했지만 kafkaProducer.send()가 실패한 경우엔 주문은 생성됐는데 메시지는 전송되지 않아 데이터가 불일치 하게 된다. 반대로 DB 트랜잭션 롤백이 발생했는데 메시지는 이미 전송된 경우에는 실제로는 주문 데이터가 존재하지 않지만 메시지는 소비자에게 전달되는 문제가 발생한다.

이처럼 DB와 메시지 브로커가 각자의 트랜잭션 환경을 갖고 있고 이 둘은 서로 묶이지 않기 때문에 "업무 처리"와 "이벤트 발행"을 원자적으로 보장하는 것이 불가능하다. 그래서 등장한 것이 트랜잭션 아웃박스 패턴이다. 이 패턴을 적용하면 DB와 메시지 브로커 간의 작업 순서를 분리하지만 결과적으로 서로 일치하는 상태를 유지(Consistent State)할 수 있게 된다.

❓어떻게 동작하는가?

전체 동작 흐름

  1. 서비스는 업무 데이터를 DB에 저장하는 트랜잭션을 시작한다.
  2. 동시에 이벤트 메시지를 outbox_events 테이블에 함께 저장한다. (같은 트랜잭션 내부)
  3. 트랜잭션이 성공적으로 커밋되면 outbox_events 테이블에는 전송 대기 중인 메시지(PENDING)가 남는다.
  4. 별도의 워커/프로세서(Spring Scheduler, Kafka Connect Debezium 등)가 일정 주기로 이 테이블을 읽는다.
  5. 메시지 전송이 성공하면 statusSENT로 바꾸거나, 해당 레코드를 삭제한다.

예시

도메인 테이블 : order

필드 이름
id1
user_id3
total_price19000
statusCREATED

아웃박스 테이블 : outbox_events

필드 이름설명
id1고유 ID
aggregate_typeORDER이벤트가 어떤 도메인에서 발생했는지
aggregate_id1해당 도메인 객체의 식별자 ID
event_typeOrderCreated이벤트 종류
payload(JSON){"orderId": 1, "userId" : 3, "total_price": 19000, "status": "CREATED"}JSON 직렬화된 실제 이벤트 데이터
statusPENDING전송 상태
created_atnow()생성 시각 (전송 지연 등을 분석할 때 활용됨)
  1. 같은 트랜잭션 내에서 주문 저장 + 이벤트 저장
@Transactional
public void createOrder(Order order) {
    orderRepository.save(order);

    OutboxEvent event = OutboxEvent.builder()
        .aggregateType("ORDER")
        .aggregateId(order.getId())
        .eventType("OrderCreated")
        .payload(objectMapper.writeValueAsString(order))
        .status("PENDING")
        .build();

    outboxRepository.save(event);
}
  1. Kafka 메시지 전송 워커
@Scheduled(fixedDelay = 1000)
public void processOutbox() {
    List<OutboxEvent> events = outboxRepository.findByStatus("PENDING");

    for (OutboxEvent event : events) {
        try {
            kafkaTemplate.send("order-created-topic", event.getPayload());
            event.markAsSent(); // status를 SENT로 변경
            outboxRepository.save(event);
        } catch (Exception e) {
        	log.error("메시지 전송 실패: {}", event.getId(), e);
            // 다음 스케줄러에서 재시도 가능
        }
    }
}

트랜잭션 아웃박스 패턴 도입 시 장점

  1. 정합성 보장
    DB 저장과 이벤트 저장이 동일 트랜잭션에서 처리되므로 둘 중 하나라도 실패하면 전체 트랜잭션이 롤백된다. 즉, DB에는 존재하지만 메시지는 없는 상태 또는 그 반대 상태가 발생하지 않는다. 그렇기 때문에 업무 데이터와 이벤트 메시지 간의 데이터 정합성을 100% 보장할 수 있다.

  2. 재시도 가능
    메시지 전송이 실패하더라도 이벤트는 PENDING 상태로 DB에 남아 있기 때문에 스케줄러나 워커가 다음 주기에 다시 전송을 시도할 수 있다. 즉, 네트워크 오류나 일시적 장애에 강한 시스템을 만들 수 있다.

  3. 관찰성 향상
    outbox_events 테이블에는 어떤 이벤트가 언제 생성되었고, 어떤 상태(PENDING, SENT, FAILED)인지 남아 있기 때문에 이벤트 흐름을 추적하거나 장애 분석을 손쉽게 수행할 수 있다. 따라서 모니터링, 로깅, 관리 용이성 증가한다.

  4. 마이크로서비스 간 비동기 연동에 최적화
    서비스 간 결합도를 낮추고 안정적인 이벤트 흐름을 구현할 수 있다.

⚠️ 주의할 점

  1. Outbox 테이블의 데이터가 무한정 쌓일 수 있다. 아웃박스 이벤트는 별도로 삭제하지 않으면 계속 남는다.이는 테이블이 커지면 성능 저하, 인덱스 문제, 스토리지 문제 등이 발생한다. 따라서 SENT 상태의 데이터는 주기적으로 삭제하거나 별도 보관소로 아카이빙해야 한다.

  2. Payload 구조 관리가 필요하다. Outbox 테이블에 저장되는 이벤트는 보통 JSON 문자열로 저장된다. 이때 도메인 객체의 구조가 변경되면 이전 이벤트와의 호환성 문제가 생길 수 있다. 따라서 이벤트 버전 관리 또는 Avro/Protobuf 같은 스키마 기반 직렬화 도입을 고려해야 한다.

  3. 실시간성이 필요한 경우에는 적합하지 않을 수 있다. 스케줄러 또는 Debezium CDC 등의 워커가 주기적으로 Outbox를 읽고 전송하기 때문에 몇 초간의 지연이 생길 수 있다. 따라서 실시간 반응성이 중요한 시스템이라면 워커를 더 자주 돌리거나 CDC 기반 처리로 전환해야 한다.

  4. 중복 이벤트 처리는 소비자가 보장해야 한다. Kafka는 기본적으로 at-least-once 전송을 보장하기 때문에 동일 메시지가 두 번 이상 소비자에게 도착할 수 있다. 그래서 소비자 서비스에서는 이벤트가 중복 처리되어도 무해하도록 멱등성 처리를 해야 한다.
    -> 예: 주문 ID를 기준으로 이미 처리한 이벤트는 무시

📝 배운점

처음엔 단순히 “Kafka로 이벤트만 잘 보내면 되지”라고 생각했지만 시스템이 커지고 이벤트 기반 연동이 많아질수록 데이터 정합성과 메시지 신뢰성 확보가 얼마나 중요한지를 깨달았다. 트랜잭션 아웃박스 패턴은 메시지 브로커와 DB 간의 비동기 통신 구조에서 원자성을 보장하면서 안정성을 높이는 가장 실용적인 방법이라는 걸 알 수 있었다. 이번 기회에 패턴의 개념과 실제 적용 시 고려할 사항들까지 정리할 수 있었다.

참고

profile
개발자가 되고 싶은 취준생

0개의 댓글