이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • Dual Write 문제는 DB 저장과 메시지 발행 사이에서 정확히 언제 생기는가?
  • @Transactional 안에서 Kafka를 호출해도 왜 원자성이 보장되지 않는가?
  • dual write를 방치하면 downstream 서비스와 데이터 mart에 어떤 불일치가 생기는가?
  • outbox와 CDC는 이 문제를 어떻게 줄이고, 무엇은 여전히 해결하지 못하는가?

개요

dual write는 하나의 business 사건을 두 저장소에 따로 기록할 때 생기는 문제다.

백엔드 서비스에서는 보통 운영 DB 저장과 Kafka 이벤트 발행 사이에서 나타난다.

1. orders table에 결제 완료 저장
2. order.events topic에 OrderPaid 발행

두 작업이 모두 성공하면 문제가 없어 보인다.

하지만 장애는 두 작업 사이에서 난다.

DB는 commit됐는데 Kafka publish가 실패할 수 있다.

Kafka publish는 성공했는데 DB transaction이 rollback될 수 있다.

이때 API 응답만 보면 정상처럼 보여도 downstream 데이터는 틀어진다.

쉬운 코드의 함정

Spring 서비스에서 다음 코드는 흔히 보인다.

@Transactional
public void pay(OrderPayCommand command) {
    Order order = orderRepository.getById(command.orderId());
    order.markPaid(command.paymentId());
 
    kafkaTemplate.send("order.events", order.id(), OrderPaidEvent.from(order));
}

이 method에 @Transactional이 붙어 있어도 DB transaction과 Kafka publish가 하나로 commit되는 것은 아니다.

kafkaTemplate.send는 broker와 별도 통신을 한다.

DB commit은 method 종료 시점에 이뤄질 수 있다.

따라서 event가 먼저 broker에 들어가고 DB commit이 나중에 실패할 수 있다.

반대로 DB commit은 성공했지만 publish callback이 실패할 수도 있다.

실패 Matrix

단계DB 결과Kafka 결과문제
DB commit 성공 후 publish 실패결제 완료event 없음downstream 누락
publish 성공 후 DB rollback결제 없음결제 event 있음허위 event
publish timeout, 실제 broker 저장 여부 불명알 수 있음알 수 없음중복 발행 또는 누락 판단 어려움
API 응답 후 publish 비동기 실패결제 완료 응답event 없음운영자가 늦게 발견

dual write의 핵심은 “둘 중 하나가 실패한다”가 아니다.

“실패했을 때 어느 쪽이 사실인지 복구 기준이 없다”는 점이다.

Downstream 영향

dual write 문제는 producer 서비스 내부에서 끝나지 않는다.

정산 서비스는 결제 event를 못 받아 정산 누락을 만들 수 있다.

배송 서비스는 존재하지 않는 주문 event를 받아 잘못된 출고를 시작할 수 있다.

분석 mart는 주문 DB와 매출 지표가 맞지 않는 상태가 된다.

알림 서비스는 고객에게 잘못된 결제 완료 알림을 보낼 수 있다.

운영자는 API log에는 성공이 찍혀 있는데 Kafka에는 event가 없는 상황을 조사해야 한다.

이때 공통 correlation id와 business key가 없으면 영향 범위를 찾기 어렵다.

Local Transaction의 한계

관계형 DB transaction은 DB 내부 자원에 대한 원자성을 보장한다.

Kafka broker, Redis, 외부 HTTP API는 같은 local transaction에 들어오지 않는다.

분산 transaction이나 2PC를 고려할 수도 있지만, 현대 서비스에서는 운영 복잡도와 지원 범위 때문에 자주 선택되지 않는다.

대부분의 실무 설계는 다음 원칙으로 간다.

  • DB에 business 사실과 발행할 event를 같은 transaction으로 남긴다.
  • transaction 밖의 발행은 실패할 수 있다고 본다.
  • 발행 실패는 남은 기록을 기준으로 재시도한다.
  • consumer는 같은 event를 두 번 받을 수 있다고 본다.

이 원칙의 대표 패턴이 transactional outbox다.

Outbox가 줄이는 것

outbox는 business row 변경과 event 기록을 같은 DB transaction에 넣는다.

service transaction
  orders update
  outbox_events insert
commit
 
publisher or CDC
  outbox_events read
  Kafka publish

DB commit이 성공하면 outbox row도 반드시 남는다.

Kafka publish가 실패해도 outbox row를 보고 다시 발행할 수 있다.

따라서 “DB에는 있는데 event가 없는지”를 outbox pending으로 추적할 수 있다.

outbox는 dual write를 transaction 내부와 외부로 분리해 복구 가능한 문제로 바꾼다.

Outbox가 해결하지 않는 것

outbox가 모든 것을 해결하지는 않는다.

publisher가 같은 outbox row를 두 번 발행할 수 있다.

Kafka publish 성공 후 status update에 실패하면 다시 발행될 수 있다.

consumer가 event를 처리하다 죽으면 같은 event를 다시 받을 수 있다.

따라서 consumer idempotency는 여전히 필요하다.

또한 outbox table이 계속 커지면 운영 DB 성능에 영향을 준다.

pending과 published, cleanup 정책이 없으면 outbox 자체가 장애 원인이 된다.

CDC가 줄이는 것

CDC는 DB commit log를 읽어 변경 이벤트를 만든다.

Debezium 같은 도구는 outbox table insert를 감지해 Kafka로 전달할 수 있다.

이 방식은 application publisher가 DB를 polling하지 않아도 된다.

DB commit 순서를 따라 event를 읽을 수 있다는 장점도 있다.

하지만 CDC connector도 장애와 lag가 있다.

connector가 멈추면 outbox row는 쌓이고 Kafka event는 늦게 전달된다.

schema 변경이나 snapshot 설정이 잘못되면 예상치 못한 payload가 흘러갈 수 있다.

감지 방법

dual write를 코드 리뷰에서만 잡으려 하면 늦다.

운영 지표와 검증 query가 필요하다.

SELECT count(*)
FROM orders o
LEFT JOIN outbox_events e
  ON e.aggregate_type = 'Order'
 AND e.aggregate_id = o.id::text
 AND e.event_type = 'OrderPaid'
WHERE o.status = 'PAID'
  AND e.id IS NULL;

이 query는 결제 완료 주문 중 event 기록이 없는 건을 찾는다.

outbox를 쓰지 않는 구조라면 이런 검증이 훨씬 어려워진다.

운영 지표

  • missing event count: business row는 있는데 event 기록이 없는 건수다.
  • outbox pending count: 아직 발행되지 않은 outbox row 수다.
  • oldest pending age: 가장 오래 발행되지 않은 event의 나이다.
  • publish failure count: Kafka 발행 실패 수다.
  • duplicate event count: consumer가 중복으로 본 event 수다.
  • source-target reconciliation gap: 운영 DB와 downstream 집계 차이다.

dual write 문제는 “에러 로그가 없다”로 판단할 수 없다.

source와 downstream의 의미 있는 숫자를 맞춰 봐야 한다.

개인 프로젝트 기준

개인 프로젝트에서는 완전한 Debezium 구성이 없어도 된다.

다만 dual write 위험을 보여주는 실패 테스트는 있어야 한다.

  • DB 저장 후 Kafka publish가 실패하는 테스트
  • Kafka publish 후 DB rollback이 생기는 테스트
  • outbox를 적용하면 pending row로 복구할 수 있음을 보여주는 테스트

이 세 가지가 있으면 왜 outbox가 필요한지 설명할 수 있다.

위험 신호!

  • @Transactional 안의 Kafka 호출이 DB와 함께 rollback된다고 설명한다.
  • publish 실패를 log만 남기고 끝낸다.
  • event 발행 성공 여부를 business row와 대조할 방법이 없다.
  • downstream 불일치를 consumer 버그로만 본다.
  • outbox 없이 “나중에 재시도하면 된다”고 말하지만 재시도 기준 row가 없다.

확인 질문

확인 질문

  • 이 business 사건의 기준 사실은 어디에 저장되는가?
    • 보통 운영 DB row가 기준이며 event는 그 사실의 전달 표현이다.
  • DB commit 성공 후 Kafka publish 실패를 어떻게 찾고 복구하는가?
    • outbox row, pending metric, publisher retry가 답이어야 한다.
  • Kafka event는 발행됐지만 DB rollback이 난 경우 consumer는 무엇을 보게 되는가?
    • 존재하지 않는 사실을 downstream이 처리할 수 있으므로 구조적으로 막아야 한다.

참고 문서