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

  • API transaction 성공과 event 발행 성공을 왜 같은 의미로 보면 안 되는가?
  • Spring 서비스에서 DB commit, outbox 저장, broker publish 경계는 어디에 두어야 하는가?
  • downstream 반영 실패를 API 장애와 분리해서 추적하려면 어떤 상태 기록이 필요한가?
  • 데이터 파이프라인 관점에서 API는 무엇을 기다리고 무엇을 기다리지 말아야 하는가?

개요

API transaction은 운영 DB의 원천 상태를 확정한다.

event 발행은 그 사실을 다른 시스템이 사용할 수 있게 전달하는 과정이다.

두 작업을 같은 성공으로 말하면 dual write 문제가 생긴다. DB commit은 됐는데 Kafka publish가 실패하거나, publish는 됐는데 API transaction이 rollback되는 순간 downstream 데이터는 운영 DB와 다른 사실을 믿게 된다.

나쁜 경계

@Transactional
public void pay(Long orderId) {
    Order order = orderRepository.getById(orderId);
    order.pay();
    orderRepository.save(order);
 
    kafkaTemplate.send("order-paid", new OrderPaidEvent(orderId));
}

이 코드는 짧고 이해하기 쉽지만 성공 경계가 애매하다.

  • DB commit 전에 broker publish가 성공할 수 있다.
  • broker publish가 실패해도 DB transaction이 이미 성공할 수 있다.
  • API retry가 같은 event를 중복 발행할 수 있다.
  • consumer가 event를 처리했는데 원천 DB에는 rollback된 상태가 남을 수 있다.

외부 broker는 보통 애플리케이션 DB transaction에 함께 묶이지 않는다.

기본 선택지

방식장점남는 리스크
transaction 안에서 broker publish구현이 단순함DB와 broker 원자성 없음
transaction commit 후 publishrollback event는 줄어듦commit 후 publish 실패
transactional outboxDB commit과 event 기록을 묶음publisher 중복 발행, outbox 운영 필요
CDC 기반 발행애플리케이션 publisher 부담 감소connector lag, schema 변경, offset 운영 필요

백엔드 서비스에서는 outbox를 기본값으로 두고, 규모와 운영 여건에 따라 CDC를 붙이는 방식이 안전하다.

Outbox 경계

CREATE TABLE outbox_events (
  event_id uuid PRIMARY KEY,
  aggregate_type text NOT NULL,
  aggregate_id text NOT NULL,
  event_type text NOT NULL,
  event_version int NOT NULL,
  payload jsonb NOT NULL,
  status text NOT NULL,
  created_at timestamptz NOT NULL,
  published_at timestamptz
);
@Transactional
public void pay(Long orderId) {
    Order order = orderRepository.getById(orderId);
    order.pay();
 
    orderRepository.save(order);
    outboxRepository.save(OutboxEvent.orderPaid(order));
}

API transaction은 order 상태와 outbox row를 함께 commit한다.

broker publish는 별도 publisher가 outbox row를 읽어 수행한다.

따라서 API 성공은 “원천 DB와 outbox 기록 성공”을 의미하고, downstream 반영 성공은 outbox publish와 consumer 처리 지표로 따로 본다.

실패 지점

지점결과관측할 것
API validation 실패DB 변경 없음API error rate
DB commit 실패outbox 없음transaction failure
DB commit 성공, publisher 실패outbox pending 증가pending age, retry count
publish 성공, status update 실패중복 발행 가능event_id 기반 consumer 멱등성
consumer 처리 실패downstream 누락consumer lag, DLQ, processed table

이 표 때문에 consumer는 반드시 idempotent해야 한다.

outbox는 중복 발행을 줄이지만 완전히 없애지는 않는다.

API가 기다릴 것과 기다리지 않을 것

API가 기다려야 하는 것:

  • 운영 DB transaction commit
  • outbox row 저장
  • 비즈니스 불변식 검증

API가 기다리지 않는 것이 보통 더 나은 것:

  • 검색 색인 반영 완료
  • 추천 feature 계산 완료
  • 리포트 mart 갱신 완료
  • 외부 consumer 전체 처리 완료

사용자가 즉시 검색 결과를 기대하는 기능이라면 별도 read model consistency 정책을 설계해야 한다.

그래도 API transaction에 모든 downstream 처리를 묶는 방식은 장애 전파와 timeout을 키운다.

상태 기록

event 발행 경계가 명확하려면 상태를 볼 수 있어야 한다.

SELECT event_type,
       status,
       count(*) AS events,
       max(now() - created_at) AS oldest_age
FROM outbox_events
WHERE created_at >= now() - interval '1 day'
GROUP BY event_type, status;
SELECT aggregate_id, event_type, retry_count, last_error
FROM outbox_events
WHERE status = 'FAILED'
ORDER BY created_at
LIMIT 50;

이 쿼리들이 없으면 API는 정상인데 downstream이 늦는 상황을 설명하기 어렵다.

데이터 팀과의 계약

데이터 팀에는 topic 이름만 전달하면 부족하다.

다음 정보를 함께 제공해야 한다.

  • event가 발생하는 비즈니스 조건
  • aggregate key와 idempotency key
  • event_time과 published_at의 의미 차이
  • 취소, 환불, 삭제를 표현하는 event
  • schema version과 호환성 정책
  • 재처리 가능한 기간과 원천 source

이 계약이 있어야 mart, search index, recommendation feature가 같은 사실을 다르게 해석하지 않는다.

운영 지표

  • outbox_pending_age: 가장 오래 publish되지 않은 event의 나이.
  • outbox_publish_retry_count: publish 재시도 횟수.
  • event_publish_lag: event 생성부터 broker publish까지의 시간.
  • consumer_lag_by_event_type: event 유형별 downstream 지연.
  • duplicate_event_detected: consumer processed table에서 잡힌 중복 수.

이 지표는 API latency와 별개로 봐야 한다.

위험 신호!

  • API 성공을 “Kafka 발행까지 완료”로 모호하게 설명한다.
  • transaction 안에서 broker publish를 호출하면서 실패 matrix를 기록하지 않는다.
  • outbox row에는 있는데 publisher lag를 보는 지표가 없다.
  • consumer가 event_id를 저장하지 않아 중복 발행에 취약하다.
  • 데이터 팀이 event_time과 published_at을 같은 시간으로 사용한다.

확인 질문

확인 질문

  • outbox를 쓰더라도 consumer 멱등성이 필요한 이유는 무엇인가?
  • API transaction이 기다리지 않아야 할 downstream 작업에는 무엇이 있는가?
  • event_time과 published_at을 구분하지 않으면 어떤 분석 오류가 생기는가?
  • DB commit 성공 후 broker publish 실패를 어떤 지표로 발견할 수 있는가?

참고 문서