에디블로그
Engineer's Field Notes

AI 자동화로 매일
한 편씩 쓰는
엔지니어 운영 노트

Claude Code · 자동화 파이프라인 · 사고 회고까지. 잘 굴러간 기록 + 깨진 흔적도 같이 남깁니다.

사람이 할 수 있는 일은,
AI도 할 수 있어야 합니다.
매일 한 편 쓰면서 검증 중.
— 이번 주 가장 많이 읽힌 글 TOP 3
백엔드/분산 & 운영

[분산시스템] 아웃박스 패턴: DB 커밋과 Kafka 발행을 원자적으로 묶는 법

반응형
[분산시스템] 아웃박스 패턴: DB 커밋과 Kafka 발행을 원자적으로 묶는 법

[분산시스템] 아웃박스 패턴: DB 커밋과 Kafka 발행을 원자적으로 묶는 법

"주문을 저장하고 카프카로 이벤트를 발행한다" — 이 흔한 두 줄에 분산 시스템의 고전 문제가 숨어 있어요. DB와 카프카는 별개 시스템이라 하나의 트랜잭션으로 묶을 수 없거든요. 둘 사이 어느 틈에서든 한쪽만 성공하면 데이터가 어긋나요. 이걸 구조적으로 푸는 게 아웃박스 패턴이에요.

이중 쓰기 문제에서 시작해, 아웃박스의 동작, 릴레이 구현 두 방식(폴링 vs CDC), 그리고 따라오는 조건(컨슈머 멱등성)을 하나씩 풀어요. 스프링 이벤트 편 말미의 "커밋됐으면 반드시 처리돼야 하는 메시지"의 답이기도 해요.

01. 이중 쓰기 문제

이중 쓰기 문제. DB 커밋 후 카프카 발행이 실패하면 주문은 있는데 이벤트가 없고, 발행 후 커밋이 실패하면 유령 이벤트가 나간다. 어느 순서든 두 시스템 사이 틈에서 불일치가 생긴다

순서를 어떻게 잡아도 틈이 있어요.

  • 커밋 → 발행 순서: 커밋 직후 서버가 죽거나 발행이 실패하면, 주문은 있는데 이벤트가 없어요. 정산·알림·재고 시스템이 그 주문을 영영 몰라요.
  • 발행 → 커밋 순서: 발행은 됐는데 트랜잭션이 롤백되면, 존재하지 않는 주문의 유령 이벤트가 퍼져요.

"카프카 트랜잭션으로 묶으면 되지 않나?"라는 생각이 들 수 있는데, 그건 카프카 토픽 사이(consume-process-produce)의 원자성이에요. DB와 카프카를 가로지르는 원자성은 카프카 트랜잭션도 못 줘요. 서로 다른 시스템의 커밋을 묶는 분산 트랜잭션(2PC)은 비용·복잡도 때문에 현대 아키텍처에서 거의 안 쓰고요.

02. 아웃박스, 문제를 한 시스템 안으로 가져온다

발상의 전환이에요. "두 시스템에 쓰기"를 포기하고 "한 DB에 두 번 쓰기"로 바꿔요.

아웃박스 패턴 구조. 주문 테이블과 outbox 테이블 INSERT를 하나의 DB 트랜잭션으로 묶어 같이 커밋되거나 같이 롤백되게 하고, 별도 릴레이가 outbox의 미발행분을 읽어 카프카로 발행한다
@Transactional
public void createOrder(OrderCommand cmd) {
    Order order = orderRepository.save(Order.from(cmd));       // ① 비즈니스 데이터
    outboxRepository.save(OutboxEvent.of(                       // ② 발행할 이벤트
        "ORDER_CREATED", order.getId(), toJson(order)));
    // 둘 다 같은 DB, 같은 트랜잭션 — 같이 커밋되거나 같이 롤백
}

이벤트를 카프카로 바로 보내는 대신, 같은 DB의 outbox 테이블에 INSERT해요. 주문과 이벤트가 한 트랜잭션이니 불일치가 구조적으로 불가능해져요 — 롤백되면 이벤트도 같이 사라지고, 커밋되면 이벤트도 반드시 남아요.

그리고 별도의 메시지 릴레이가 outbox의 미발행 행을 읽어 카프카로 발행하고 발행 표시를 해요. 발행이 실패해도 행은 남아 있으니 다음 주기에 재시도돼요. "커밋된 이벤트는 언젠가 반드시 발행된다"가 보장되는 거예요.

03. 릴레이 구현, 폴링 vs CDC

아웃박스 릴레이 두 방식. 폴링 퍼블리셔는 주기적으로 outbox를 조회해 발행하는 단순한 방식이고, CDC는 Debezium으로 DB 변경 로그를 구독해 즉시 발행한다. 둘 다 at-least-once라 컨슈머 멱등성이 전제다

폴링 퍼블리셔 — 단순하게 시작

@Scheduled(fixedDelay = 1000)
@Transactional   // markPublished 변경분이 커밋되게
public void relay() {
    List<OutboxEvent> events = outboxRepository.findTop100ByPublishedFalseOrderById();
    for (OutboxEvent e : events) {
        kafkaTemplate.send(e.getTopic(), e.getAggregateId(), e.getPayload())
            .get(5, TimeUnit.SECONDS);   // 브로커 ack까지 확인하고
        e.markPublished();               // 그 다음에 발행 표시 (실패하면 표시 안 됨 = 다음 주기 재시도)
    }
}

스케줄러로 주기 조회 → 발행 → 표시. 인프라 추가 없이 바로 만들 수 있어요. 폴링 주기만큼의 발행 지연과 DB 폴링 부하가 비용이에요. 대부분의 규모에서는 이걸로 충분해요.

CDC(Debezium) — 규모가 커지면

DB의 변경 로그(MySQL binlog)를 Debezium이 구독해서 outbox INSERT를 감지하는 즉시 카프카로 흘려보내요. 지연이 최소화되고 애플리케이션·DB에 폴링 부하가 없어요. 대신 Kafka Connect 인프라 운영이 추가돼요. "지연에 민감하거나 outbox 트래픽이 큰" 단계에서 넘어가는 카드예요.

어느 방식이든 전달 보장은 at-least-once예요. 발행 성공 후 "표시" 전에 릴레이가 죽으면 같은 이벤트가 재발행돼요. 그래서 아웃박스는 컨슈머 멱등성과 한 세트예요 — outbox 행의 고유 ID를 이벤트에 실어 보내고, 컨슈머가 그 ID로 중복을 흡수해요(컨슈머 멱등성에서 본 그 방법들). 멱등 컨슈머 없이 아웃박스만 깔면 "유실은 막았는데 중복으로 터지는" 반쪽이 돼요.

04. 운영 디테일

  • outbox 정리 — 발행 완료 행은 계속 쌓여요. 보관 기간(예: 7일)을 두고 배치로 지워요. 미발행 행의 나이를 모니터링하면 "릴레이가 멈췄다"를 바로 잡아요 — 이 지표 하나는 꼭 알림에 걸어두세요.
  • 순서 — 같은 애그리거트(같은 주문)의 이벤트 순서가 중요하면, outbox의 aggregate ID를 카프카 메시지 키로 써서 같은 파티션으로 보내요. 릴레이도 ID 순서대로 발행하고요.
  • 스프링 이벤트와의 조합 — 서비스 코드를 깔끔히 유지하려면, @TransactionalEventListener(BEFORE_COMMIT)에서 outbox INSERT를 하는 식으로 "이벤트 발행처럼 쓰되 실제론 outbox에 쌓이는" 구조도 좋아요.

05. 어디까지 필요한가

모든 이벤트에 아웃박스를 깔 필요는 없어요. 기준은 "커밋과 이벤트의 불일치를 감당할 수 있는가"예요. 캐시 무효화 신호나 가벼운 알림은 유실돼도 복구 가능하니 AFTER_COMMIT 이벤트(인메모리)로 충분해요. 정산·재고·타 시스템 연동처럼 "주문이 있는데 이벤트가 없으면 사고"인 흐름이 아웃박스의 자리예요.

06. 자주 만나는 문제

주문은 있는데 이벤트가 안 나갔어요

아웃박스 도입 전이면 이중 쓰기의 그 틈이에요. 도입 후라면 릴레이 중단을 의심 — 미발행 행 나이 지표를 봐요.

같은 이벤트가 두 번 와요

at-least-once의 정상 동작이에요. 컨슈머가 이벤트 ID 기반 멱등 처리를 하는지 확인해요.

outbox 테이블이 비대해져요

발행 완료분 정리 배치가 없는 거예요. 보관 기간 + 정리 배치를 추가해요.

두 시스템, 한 트랜잭션

이중 쓰기 문제의 본질은 "두 시스템은 한 트랜잭션으로 못 묶는다"예요. 아웃박스는 이걸 비틀어 풀어요 — 카프카로 바로 쏘는 대신 같은 DB의 outbox 테이블에 이벤트를 같이 커밋하고, 릴레이(폴링 또는 CDC)가 그걸 카프카로 옮겨요. 그러면 유실은 구조적으로 사라지고, 남는 건 at-least-once의 중복뿐이라 컨슈머 멱등성으로 흡수해요. 이 한 세트가 "커밋됐으면 반드시 전달된다"의 실무 표준이고, 스프링 이벤트 편 말미에 미뤄둔 "반드시 처리돼야 하는 메시지"의 답이기도 해요.

그럼 이벤트가 여러 서비스를 가로지르는 흐름 전체의 정합성은 어떻게 지킬까요. 사가 패턴이 그 위에 올라타요.

출처: microservices.io — Transactional Outbox · Debezium — Outbox Event Router

반응형

📚 같이 보면 좋은

"이 포스팅은 쿠팡 파트너스 활동의 일환으로, 일정액의 수수료를 제공받습니다."