06 전달 보장과 중복 처리

"Kafka는 exactly-once를 지원한다"는 말을 들으면, 이벤트가 어디서든 정확히 한 번 처리된다고 생각하기 쉽다. 실제로는 장치마다 지키는 구간이 다르고, 처음부터 끝까지 덮는 장치는 없다. 이 글은 한 가지 질문을 따라간다.

주문 서비스에서 발행한 이벤트가 다른 서비스의 DB에 "정확히 한 번" 반영되려면, 어느 구간을 무엇으로 지켜야 하는가?

04 프로듀서 심화와 05 컨슈머 심화에서 구간별 장치를 하나씩 봤다. 이 글은 그 장치들을 파이프라인 하나 위에 올려놓고, 남은 하나인 Kafka 트랜잭션이 어디까지 보장하는지 정리한다.

1. 세 가지 전달 보장

전달 보장은 장애가 났을 때 이벤트가 빠지는지, 다시 처리되는지를 말한다.

보장뜻대가
At-most-once (최대 한 번)다시 처리하지 않는다장애 때 누락될 수 있다
At-least-once (최소 한 번)빠뜨리지 않는다장애 때 중복 처리될 수 있다
Exactly-once (정확히 한 번)결과가 한 번만 반영된다보장 범위가 좁다. 범위 밖에서는 다시 앞의 둘 중 하나가 된다

컨슈머에서는 처리와 오프셋 커밋 중 무엇을 먼저 하느냐로 앞의 둘이 갈린다(05 컨슈머 심화 4절). 백엔드 서비스는 보통 at-least-once로 빠뜨리지 않고, 중복은 멱등 처리로 무해하게 만든다. 이것이 사실상의 "업무 결과 기준 exactly-once"다.

2. 중복과 유실은 어디서 생기나

주문 서비스가 OrderCreated를 발행하고, 결제 서비스가 소비해 DB에 반영하는 흐름을 따라가 보자.

어느 장치가 어느 구간을 지키나

장치마다 막대가 덮는 구간이 다르다. 한 장치를 켰다고 다른 구간의 중복·유실이 해결되지 않는다. 이미지 출처: 자체 작성

구간무엇이 잘못될 수 있나막는 장치남는 것다룬 곳
주문 DB → 발행DB 커밋 후 발행 전에 죽어 이벤트 누락Transactional Outbox전달자 재시작 시 중복 발행04 프로듀서 심화 7절
프로듀서 → 브로커저장은 됐는데 응답이 유실돼 재시도 → 중복 기록멱등 프로듀서(PID·시퀀스)앱이 send()를 새로 호출한 중복은 못 막음04 프로듀서 심화 6절
브로커 저장리더 장애로 성공 응답 받은 레코드 유실acks=all + min.insync.replicas + 복제ISR이 모두 사라지는 경우04 프로듀서 심화 4절
브로커 → 컨슈머처리 후 커밋 전 장애·리밸런싱 → 중복 처리소비 측 멱등 처리(eventId)멱등 처리가 안 되는 외부 작업05 컨슈머 심화 5절
컨슈머 → Kafka 출력출력 발행과 오프셋 커밋 사이 장애 → 중복 출력Kafka 트랜잭션Kafka 밖의 작업이 글 3절
컨슈머 → 외부 API호출 성공 후 커밋 전 장애 → 중복 호출대상 API의 멱등 키멱등 키를 지원하지 않는 API이 글 4절

3. Kafka 트랜잭션: Kafka 안에서의 exactly-once

3.1 무엇을 묶나

컨슈머가 Kafka에서 읽고, 가공해서, 다시 Kafka에 쓰는 흐름(consume-process-produce)을 생각해 보자. ex) transfers 토픽에서 이체 요청을 읽어 balances 토픽에 출금·입금 레코드를 쓴다.

트랜잭션이 없으면 출금 레코드만 쓰고 죽을 수 있다. 재시작하면 같은 요청을 다시 읽어 출금을 또 쓴다. Kafka 트랜잭션은 다음 세 가지를 전부 반영하거나 전부 버린다.

  1. 출력 토픽(여러 파티션)에 쓴 레코드
  2. 입력 토픽의 소비 오프셋 커밋(__consumer_offsets에 쓰는 것도 Kafka 쓰기다)
  3. 그래서 "읽은 위치"와 "그 결과로 쓴 레코드"가 함께 확정된다

Kafka의 입력 오프셋과 출력 레코드를 함께 확정하는 트랜잭션

이체 앱이 transfers를 읽고 balances의 두 파티션에 출금·입금을 쓰며, 소비 오프셋까지 한 트랜잭션으로 묶는다. 하나라도 실패하면 전부 취소된다. 이미지 출처: Confluent — Kafka Transactions

3.2 어떻게 동작하나

  • 프로듀서에 transactional.id를 준다. 재시작해도 같은 값을 쓰는, 프로듀서의 고정 이름이다. 트랜잭션을 켜면 멱등 프로듀서도 자동으로 켜진다.
  • 브로커 하나가 그 프로듀서의 트랜잭션 코디네이터가 된다. 프로듀서는 어느 파티션에 쓸지 코디네이터에 등록하고 레코드를 쓴다.
  • 커밋하면 코디네이터가 관련된 모든 파티션에 커밋 마커를 남긴다. 중단하면 중단 마커를 남긴다. 레코드는 이미 로그에 써져 있고, 마커가 그 레코드를 유효하게 할지 정한다.
  • 좀비 차단(fencing): 앱이 멈춘 줄 알았는데 다시 살아나 쓰는 경우를 막는다. 같은 transactional.id로 새 인스턴스가 시작하면 코디네이터가 세대 번호(epoch)를 올리고, 이전 인스턴스의 미완료 트랜잭션을 중단시킨다. 옛 인스턴스의 쓰기는 이후 거부된다.

같은 transactional.id로 새 인스턴스가 시작하면 이전 인스턴스를 차단하는 모습

출금만 쓰고 오프셋을 커밋하기 전에 죽은 인스턴스를, 새 인스턴스가 시작하며 차단하고 미완료 트랜잭션을 중단시킨다. 하위 컨슈머는 중단된 레코드를 버린다. 이미지 출처: Confluent — Kafka Transactions

3.3 읽는 쪽: isolation.level=read_committed

트랜잭션으로 쓴 레코드는 커밋 전에도 로그에 있다. 그래서 읽는 쪽도 설정해야 보장이 완성된다.

  • read_uncommitted(기본값): 중단된 트랜잭션의 레코드까지 읽는다.
  • read_committed: 커밋된 트랜잭션의 레코드만 읽는다. 아직 열린 트랜잭션이 있으면 그 지점(LSO, last stable offset)에서 멈췄다가, 커밋이나 중단이 확정되면 이어서 읽는다. 트랜잭션 없이 쓴 일반 레코드는 그대로 읽는다.

read_committed 컨슈머가 LSO까지만 읽고 중단된 레코드를 버리는 모습

컨슈머는 열린 트랜잭션이 시작된 LSO 앞까지만 받고, 중단된 트랜잭션의 레코드(57)는 버린다. 이미지 출처: Confluent — Kafka Transactions

  • 대가는 지연이다. 트랜잭션이 오래 열려 있으면 그 뒤의 레코드도 읽지 못한다.

3.4 어떻게 쓰나

방식설정
Kafka Streamsprocessing.guarantee=exactly_once_v2 한 줄
Spring Kafkaspring.kafka.producer.transaction-id-prefix를 설정하면 Spring Boot가 KafkaTransactionManager를 만들어 리스너 컨테이너에 연결한다. 리스너 안의 KafkaTemplate.send()가 컨테이너가 시작한 트랜잭션에 참여한다
순수 클라이언트initTransactions() → beginTransaction() → send() → sendOffsetsToTransaction() → commitTransaction()을 직접 호출
spring:
  kafka:
    producer:
      transaction-id-prefix: transfer-tx- # 인스턴스마다 겹치지 않게
    consumer:
      isolation-level: read_committed     # 이 서비스가 트랜잭션 출력을 읽는다면
  • 여러 인스턴스를 띄우면 transaction-id-prefix가 인스턴스마다 달라야 한다.
  • 리스너가 예외를 던지면 트랜잭션이 롤백되고, 실패한 레코드가 다시 전달된다. 컨테이너 트랜잭션에서는 05 컨슈머 심화 8절의 @RetryableTopic을 쓸 수 없다.

4. Kafka 밖이 끼면: 다시 at-least-once

Kafka 트랜잭션과 외부 시스템 사이의 별도 처리 경계

Kafka와 외부 시스템을 한 트랜잭션으로 묶는 기능은 없다. 결과를 먼저 Kafka 토픽에 쓰고, 외부 시스템으로는 멱등하게 전달하라고 권한다. 이미지 출처: Confluent — Kafka Transactions

Kafka 트랜잭션은 DB 커밋, 외부 API 호출, 이메일 발송을 포함하지 않는다. 리스너 안에서 @Transactional로 DB 트랜잭션도 함께 열 수는 있지만, 두 트랜잭션은 차례로 커밋될 뿐이다. 한쪽이 커밋되고 다른 쪽이 실패하는 틈은 남는다. 그래서 대상마다 따로 지킨다.

대상방법남는 위험
내 서비스 DBat-least-once + 처리한 eventId를 업무 변경과 같은 DB 트랜잭션에 기록(05 컨슈머 심화 5절)처리 기록 보관 기간이 재처리 기간보다 짧으면 중복
외부 결제 API같은 결제 요청에 같은 멱등 키를 넘긴다. ex) Stripe의 멱등 키대상 API의 멱등 보장 범위·보관 기간
이메일·푸시멱등 API가 없으면 중복 가능성을 업무 규칙으로 받아들이거나, 발송 기록으로 줄인다발송 기록과 실제 발송 사이의 틈. 완전히 없앨 수는 없다
다른 서비스로 이벤트 발행 + 내 DB 변경Transactional Outbox(04 프로듀서 심화 7절)중복 발행 → 받는 쪽 멱등 처리

실무 판단

흐름조합
Kafka → 내 DB (가장 흔함)at-least-once + eventId 멱등 처리. Kafka 트랜잭션은 필요 없다
Kafka → 가공 → KafkaKafka 트랜잭션(또는 Kafka Streams exactly_once_v2) + 하위 컨슈머 read_committed
내 DB 변경 + 이벤트 발행Transactional Outbox + 받는 쪽 멱등 처리
Kafka → 외부 APIat-least-once + 대상 API의 멱등 키. 멱등 키가 없으면 중복 허용 범위를 업무적으로 정한다
  • 어떤 조합이든 이벤트 ID는 처음 만들 때 정하고 끝까지 바꾸지 않는다. 모든 구간의 중복 제거가 이 값에 기댄다.
  • "exactly-once를 쓰나요?"라는 질문에는 어느 구간에서를 함께 답한다.

참고: Apache Kafka 설계 — Message Delivery Semantics · Consumer 설정: isolation.level · Kafka Streams 설정 · Spring Kafka — Transactions · Confluent — Kafka Transactions · Uber Engineering · LinkedIn Engineering · Stripe Engineering