Published on

Kafka Saga에서 중복처리·역보상 막는 Outbox

Authors

분산 트랜잭션을 Kafka Saga로 풀면, “결국 일관성”을 얻는 대신 중복 처리역보상(보상 이벤트가 더 늦게 도착한 정방향 이벤트를 이기는 현상) 같은 운영 이슈를 거의 필연적으로 마주칩니다. 특히 네트워크 재시도, 컨슈머 리밸런스, 프로듀서 타임아웃, DB 커밋과 메시지 발행의 분리 같은 요소가 겹치면 같은 이벤트가 여러 번 실행되거나, 보상이 먼저 적용된 뒤 정방향이 뒤늦게 적용되어 상태가 뒤집히는 문제가 생깁니다.

이 글은 Kafka Saga에서 Outbox 패턴을 어떻게 적용해야 중복처리·역보상을 구조적으로 줄일 수 있는지, 그리고 “Outbox만 붙이면 끝”이 아닌 컨슈머 측 상태머신/멱등성/순서 전략까지 포함해 정리합니다.

참고로 운영에서 캐시/상태 불일치 디버깅 감각은 분산 이벤트에서도 그대로 도움이 됩니다. 예를 들어 Next.js 캐시 꼬임을 추적하는 방식처럼 원인-결과를 분해하는 습관이 중요합니다: Next.js 14 App Router 캐시 꼬임 해결법

Kafka Saga에서 실제로 터지는 문제들

1) 중복 처리(At-least-once의 대가)

Kafka는 기본적으로 at-least-once를 쉽게 달성합니다. 하지만 이는 “메시지가 중복으로 도착할 수 있다”는 뜻이고, Saga에서 중복은 단순히 로그가 두 번 찍히는 문제가 아니라 결제 중복, 재고 중복 차감, 포인트 중복 적립 같은 금전적 사고로 연결됩니다.

중복의 흔한 원인:

  • 프로듀서가 타임아웃 후 재시도했지만 실제로는 브로커에 기록됨
  • 컨슈머가 처리 후 커밋 전에 죽어서 재처리됨
  • 컨슈머 리밸런스로 파티션이 이동하며 재처리됨

2) 역보상(Compensation reordering)

Saga는 실패 시 보상 트랜잭션을 발행합니다. 그런데 이벤트 순서가 뒤틀리면 다음이 가능합니다.

  • 정방향 이벤트 ReserveInventory 가 느리게 도착
  • 실패로 인해 ReleaseInventory(보상) 이벤트가 먼저 도착해 재고를 “풀어줌”
  • 이후 정방향이 도착해 다시 “묶어버림”

결과적으로 최종 상태가 실패한 주문인데 재고는 예약된 상태로 남습니다. 이게 역보상입니다.

3) DB 커밋과 Kafka 발행의 원자성 문제

서비스가 DB에 상태를 저장하고 Kafka로 이벤트를 발행할 때, 두 작업이 서로 다른 시스템에 걸쳐 있어 원자적으로 묶기 어렵습니다.

  • DB는 커밋됐는데 이벤트 발행이 실패하면 다운스트림이 상태 변화를 모름
  • 이벤트는 발행됐는데 DB는 롤백되면 다운스트림이 “존재하지 않는 상태”를 처리함

이 영역을 제대로 잡는 것이 Outbox의 핵심 역할입니다.

Outbox 패턴이 해결하는 것과, 해결하지 못하는 것

Outbox 패턴은 “DB 트랜잭션 안에서 이벤트를 Outbox 테이블에 같이 기록”하고, 별도 퍼블리셔가 Outbox를 읽어 Kafka에 발행하는 방식입니다.

Outbox가 직접 해결하는 것

  • DB 상태 변경과 이벤트 기록의 원자성 확보: 같은 트랜잭션으로 묶음
  • 서비스 장애/재시도에도 이벤트 유실 감소
  • 발행 재시도 로직을 애플리케이션 흐름에서 분리

Outbox만으로는 부족한 것

  • Kafka 컨슈머 중복 처리 자체는 여전히 발생 가능
  • 보상/정방향의 순서 뒤틀림은 토픽/키 설계와 컨슈머 로직이 필요
  • Outbox 퍼블리셔의 “중복 발행” 가능성도 설계해야 함

즉, Outbox는 유실 방지 + 원자성의 축이고, 중복 처리/역보상 방지는 Outbox를 기반으로 한 “멱등성/순서/상태머신” 조합으로 완성됩니다.

설계 원칙: 중복처리·역보상 방지용 Outbox 체크리스트

1) 이벤트에 반드시 들어가야 할 필드

이벤트 스키마에 아래를 포함시키면, 컨슈머가 멱등성과 순서를 판단할 수 있습니다.

  • eventId: 전역 유니크 ID (UUID)
  • aggregateId: Saga의 기준 키 (예: orderId)
  • eventType: 예: PAYMENT_AUTHORIZED, PAYMENT_COMPENSATED
  • sequence: aggregate 단위 증가 시퀀스(또는 버전)
  • causationId: 이 이벤트를 유발한 이벤트 ID
  • correlationId: Saga 전체 트레이싱 ID
  • occurredAt: 생성 시각

핵심은 aggregateIdsequence 입니다. 역보상은 결국 “같은 aggregate에서 이벤트 순서가 뒤집히는 문제”이므로, 컨슈머가 최소한 마지막 처리 시퀀스를 기억하고 비교할 수 있어야 합니다.

2) Kafka 토픽 키는 aggregateId로 고정

Kafka에서 순서 보장은 “파티션 내부”에서만 성립합니다. 따라서 같은 주문(orderId)의 이벤트는 반드시 같은 파티션으로 가야 합니다.

  • 토픽 키: orderId
  • 파티션 수는 확장 가능성을 고려해 초기부터 여유 있게

이렇게 하면 “한 주문”에 대한 정방향/보상 이벤트가 파티션 내에서 순서대로 쌓일 가능성이 커집니다. 다만 재시도/중복 자체는 남으므로 컨슈머 멱등성은 여전히 필요합니다.

3) Outbox 테이블에 aggregateId + sequence를 저장

Outbox는 단순 이벤트 로그가 아니라, 정방향/보상 모두를 포함한 상태 전이 기록입니다.

권장 컬럼:

  • id (PK)
  • event_id (unique)
  • aggregate_id
  • sequence
  • event_type
  • payload (JSON)
  • status (PENDING, PUBLISHED, FAILED)
  • created_at, published_at

event_id에 유니크 인덱스를 걸어 중복 insert 자체를 막는 것이 1차 방어선입니다.

4) 퍼블리셔는 “최소 1번 발행”을 전제로 멱등하게

Outbox 퍼블리셔는 장애/재시작 시 같은 이벤트를 다시 발행할 수 있습니다. 따라서 다음 중 하나를 조합합니다.

  • Kafka 프로듀서 enable.idempotence=true (브로커/클러스터 설정 포함)
  • Outbox status 업데이트를 신중히(발행 성공 후 업데이트)
  • 가능하면 event_id를 Kafka 메시지 키가 아니라 헤더에 넣고, 컨슈머에서 dedup

프로듀서 멱등성은 “같은 프로듀서 세션”에서 강합니다. 하지만 운영에서는 프로세스 재시작이 잦으므로, 컨슈머 dedup까지 가야 완성됩니다.

구현 예시: 트랜잭션 + Outbox 기록

아래는 주문 서비스에서 결제 승인 이벤트를 Outbox에 함께 기록하는 예시입니다. (언어는 Kotlin 스타일로 작성)

data class OutboxEvent(
  val eventId: String,
  val aggregateId: String,
  val sequence: Long,
  val eventType: String,
  val payloadJson: String,
  val status: String = "PENDING"
)

@Transactional
fun authorizePayment(orderId: String, amount: Long) {
  val order = orderRepository.findByIdForUpdate(orderId)
  require(order.status == OrderStatus.PAYMENT_PENDING)

  // 1) 비즈니스 상태 변경
  order.status = OrderStatus.PAYMENT_AUTHORIZED
  order.version += 1
  orderRepository.save(order)

  // 2) 같은 트랜잭션에서 Outbox 적재
  val event = OutboxEvent(
    eventId = UUID.randomUUID().toString(),
    aggregateId = orderId,
    sequence = order.version, // aggregate version을 sequence로 사용
    eventType = "PAYMENT_AUTHORIZED",
    payloadJson = json.encodeToString(mapOf("orderId" to orderId, "amount" to amount))
  )
  outboxRepository.insert(event) // event_id unique
}

포인트:

  • order.version을 aggregate 시퀀스로 재사용하면 구현이 단순해집니다.
  • DB 락(findByIdForUpdate)으로 같은 주문에 대한 동시 갱신을 직렬화하면 sequence가 안정적입니다.

Outbox 퍼블리셔(폴링) 구현 예시

폴링 기반은 단순하고 DB 부하를 예측하기 쉽습니다. 대신 지연이 생길 수 있어 배치 크기/주기를 튜닝합니다.

fun publishLoop() {
  while (true) {
    val batch = outboxRepository.findPending(limit = 200)

    batch.forEach { e ->
      try {
        kafkaProducer.send(
          topic = "order-events",
          key = e.aggregateId,
          value = e.payloadJson,
          headers = mapOf(
            "eventId" to e.eventId,
            "eventType" to e.eventType,
            "sequence" to e.sequence.toString()
          )
        )
        outboxRepository.markPublished(e.eventId)
      } catch (ex: Exception) {
        outboxRepository.markFailed(e.eventId, ex.message ?: "unknown")
      }
    }

    Thread.sleep(200)
  }
}

주의점:

  • markPublished는 “send 성공” 이후에만 해야 합니다.
  • send가 비동기라면 flush/ack를 어떻게 기다릴지 결정해야 합니다.
  • 실패 이벤트를 FAILED로만 두면 영구 정체될 수 있으니 재시도 정책(횟수/백오프/데드레터)을 추가하세요.

컨슈머에서 중복처리 막기: Inbox(Processed Events) 테이블

가장 확실한 방법은 컨슈머가 처리한 eventId를 저장하는 것입니다. 흔히 Inbox 패턴 또는 Processed Events 테이블로 부릅니다.

핵심 아이디어

  • 이벤트 처리 트랜잭션 안에서
    • eventId를 “처리됨”으로 기록(유니크)
    • 비즈니스 상태 변경
  • 같은 eventId가 다시 오면 유니크 제약에 걸리므로 아무 일도 하지 않음
CREATE TABLE processed_event (
  event_id VARCHAR(64) PRIMARY KEY,
  aggregate_id VARCHAR(64) NOT NULL,
  sequence BIGINT NOT NULL,
  processed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_processed_event_agg ON processed_event(aggregate_id);

컨슈머 처리 로직(의사코드):

@Transactional
fun onMessage(msg: KafkaMessage) {
  val eventId = msg.headers["eventId"]
  val aggregateId = msg.key
  val sequence = msg.headers["sequence"].toLong()

  // 1) eventId dedup (유니크 insert)
  val inserted = processedEventRepository.tryInsert(eventId, aggregateId, sequence)
  if (!inserted) return // duplicate

  // 2) 순서 체크는 아래 섹션에서 추가
  applyBusinessChange(msg)
}

이 방식은 DB 쓰기가 늘지만, 금전/재고 같은 도메인에서는 비용을 지불할 가치가 큽니다.

역보상 막기: 컨슈머 상태머신 + 시퀀스 가드

중복은 eventId로 막고, 역보상은 “aggregate의 마지막 적용 시퀀스”로 막는 접근이 효과적입니다.

1) aggregate별 last_applied_sequence 저장

주문/결제/재고 등 각 aggregate 테이블에 다음을 둡니다.

  • last_event_sequence

그리고 이벤트 적용 시:

  • 들어온 sequencelast_event_sequence 이하이면 무시(이미 더 최신 상태 적용됨)
  • 크면 적용 후 last_event_sequence 갱신
@Transactional
fun applyWithSequenceGuard(orderId: String, incomingSeq: Long, change: () -> Unit) {
  val order = orderRepository.findByIdForUpdate(orderId)

  if (incomingSeq <= order.lastEventSequence) {
    return // stale event: 역보상/지연 이벤트 방어
  }

  change()
  order.lastEventSequence = incomingSeq
  orderRepository.save(order)
}

이 가드가 있으면, 보상 이벤트가 먼저 적용되어 lastEventSequence가 올라간 상태에서 정방향 이벤트가 늦게 도착하더라도 sequence가 낮으면 무시됩니다. 반대로 정방향이 먼저 적용된 뒤 보상이 늦게 오면, 보상 sequence가 더 크도록 설계해야 합니다.

2) “보상 이벤트의 sequence”를 어떻게 정할까

여기서 흔한 함정이 있습니다.

  • 정방향은 version이 증가하니 sequence가 자연스럽게 커짐
  • 보상은 “실패 처리”로 별도 상태 전이이므로, 반드시 정방향보다 큰 sequence가 되도록 만들어야 함

권장 방식:

  • 보상도 aggregate 상태 전이로 보고 같은 테이블 version을 올린 뒤 Outbox에 기록
  • 즉, 보상은 “되돌리기”가 아니라 “새로운 상태로 전이”입니다

예:

  • PAYMENT_AUTHORIZED sequence 10
  • 실패 감지 후 PAYMENT_COMPENSATED sequence 11

이렇게 하면 순서가 뒤집혀 도착해도 sequence 가드로 최종 상태가 안정됩니다.

3) 상태머신으로 불가능한 전이를 차단

sequence만으로는 “동일 sequence 내 의미 충돌” 같은 문제를 완전히 막기 어렵습니다. 따라서 도메인 상태머신을 두고 전이를 검증하세요.

예: 결제 상태

  • PENDING -> AUTHORIZED -> CAPTURED
  • 실패 시 AUTHORIZED -> COMPENSATED
  • COMPENSATED -> CAPTURED 는 금지

이 검증은 컨슈머/프로듀서 양쪽에 있어도 좋지만, 최소한 “상태를 최종 기록하는 곳”에는 있어야 합니다.

토픽 설계: 정방향/보상을 같은 스트림에 둘 것인가

역보상 관점에서는 “같은 aggregate의 이벤트가 한 파티션에서 순서대로”가 가장 중요합니다.

  • 정방향과 보상을 같은 토픽에 두면 파티션 순서 보장에 유리
  • 토픽을 분리하면(예: order-eventsorder-compensations) 순서가 더 쉽게 깨짐

따라서 특별한 이유가 없다면:

  • 하나의 order-events 토픽
  • eventType으로 정방향/보상을 구분
  • 키는 orderId

이 구성이 운영 난이도를 낮춥니다.

운영에서 자주 겪는 장애 포인트와 진단 팁

1) 컨슈머 리밸런스 후 중복 급증

리밸런스 자체는 정상 동작이지만, 처리량이 부족해 lag이 쌓이면 재처리/타임아웃이 연쇄적으로 발생합니다.

  • 컨슈머 처리 시간을 줄이거나
  • 파티션 수/컨슈머 수를 조정하거나
  • 외부 API 호출을 분리(비동기)하는 식으로 병목을 줄이세요.

2) Outbox 폴러가 멈춰서 이벤트가 “DB에만 존재”

Outbox 테이블의 PENDING가 쌓이면 다운스트림은 아무것도 모릅니다.

  • PENDING 건수, oldest age를 메트릭으로 뽑아 알람
  • 퍼블리셔 프로세스 healthcheck에 “최근 발행 시각” 포함

컨테이너 환경에서 이런 이슈는 이미지/시크릿/권한 문제로도 시작됩니다. 원인 분해 방식은 아래 글과 유사하게 체크리스트화하는 게 좋습니다: Kubernetes ImagePullBackOff·ErrImagePull 해결 체크리스트

3) 캐시/조회 모델이 stale해 보여서 “보상이 안 됐다”로 오해

Saga는 결국 일관성이므로, 조회 모델(캐시, CQRS read model)이 늦게 반영되면 장애처럼 보입니다.

  • 이벤트 처리 지연(lag)
  • read model 업데이트 지연
  • 캐시 TTL/무효화 실패

이건 웹에서도 흔한 문제입니다. “상태는 바뀌었는데 화면은 안 바뀌는” 류의 디버깅 감각은 다음 글이 도움이 됩니다: Next.js 14 RSC 캐시 꼬임과 stale 데이터 해결법

실전 결론: Outbox는 시작이고, 완성은 3종 세트

Kafka Saga에서 중복처리·역보상을 실질적으로 막으려면 다음 3가지를 세트로 가져가야 합니다.

  1. Outbox(프로듀서 측 원자성)

    • DB 상태 변경과 이벤트 기록을 한 트랜잭션으로
    • 퍼블리셔는 재시도 가능하게
  2. Inbox/Processed Events(컨슈머 측 멱등성)

    • eventId 기반 dedup을 DB 유니크로 강제
  3. Sequence 가드 + 상태머신(역보상/순서 뒤틀림 방어)

    • aggregateId 기준 sequence 증가
    • last_applied_sequence로 stale 이벤트 무시
    • 불가능한 상태 전이 차단

이 조합을 적용하면 “중복은 어쩔 수 없다”가 아니라, 중복이 와도 안전한 시스템으로 바뀝니다. Saga를 운영 가능한 수준으로 끌어올리는 데 Outbox는 필수지만, 그 자체가 종착점은 아니라는 점이 핵심입니다.