에디블로그
Engineer's Field Notes

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

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

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

[Kafka] Exactly-once 트랜잭션 설정: 멱등 프로듀서, transactional.id, read_committed와 한계

반응형
[Kafka] Exactly-once 트랜잭션 설정: 멱등 프로듀서, transactional.id, read_committed와 한계

[Kafka] Exactly-once 트랜잭션 설정: 멱등 프로듀서, transactional.id, read_committed와 한계

카프카에서 기본 전달 보장은 "최소 한 번(at-least-once)"이라 중복이 생겨요. 그래서 보통은 멱등성으로 중복을 흡수하죠. 그런데 카프카 안에서 메시지를 받아 가공해 다시 내보내는 흐름이라면, 중복 자체를 없애는 "정확히 한 번(exactly-once)"이 가능해요.

exactly-once가 무엇을 보장하고 무엇은 보장하지 않는지에서 출발해, 멱등 프로듀서, 카프카 트랜잭션, read_committed, 스프링에서 쓰는 법, 비용과 한계, 자주 만나는 문제 순서로 풀어요. 중복을 멱등성으로 막는 방법은 수동 커밋과 멱등성 글에서 다뤘으니, 여기서는 그 상위 주제를 봐요.

01. exactly-once는 "카프카 안에서" 정확히 한 번이에요

먼저 오해를 하나 풀어야 해요. exactly-once(EOS, Exactly-Once Semantics)는 마법이 아니에요. 카프카 토픽에서 읽어 카프카 토픽으로 쓰는 흐름에서, 그 과정이 원자적으로 일어나도록 보장하는 거예요.

카프카 exactly-once 보장 경계. consume, process, produce와 오프셋 커밋까지는 카프카 트랜잭션이 보장하지만 외부 DB 쓰기와 API 호출은 경계 밖이라 멱등성이나 아웃박스 패턴으로 따로 막아야 한다

그래서 카프카에서 읽어 외부 DB에 쓰는 흐름까지 자동으로 정확히 한 번이 되는 건 아니에요. DB 쓰기는 카프카 트랜잭션 밖이거든요. 외부 시스템까지의 정확히 한 번은 여전히 멱등성이나 아웃박스 패턴 같은 별도 설계가 필요해요. 이 경계를 알고 시작하는 게 중요해요. EOS가 적용되는 곳은 "consume → process → produce" 패턴이에요.

02. 멱등 프로듀서, 프로듀서 쪽 중복부터 막기

중복은 컨슈머에서만 생기는 게 아니에요. 프로듀서가 메시지를 보내고 응답(ack)을 못 받아 재시도하면, 사실은 잘 전송됐는데 또 보내서 중복이 생길 수 있어요. 네트워크가 순단됐을 때 흔해요.

프로듀서 전송 코드 구현 자체는 KafkaTemplate을 이용한 Producer 구현(2021)에 있어요. 이 절은 그 위에 얹는 보장 설정이에요.

카프카 멱등 프로듀서 동작. 브로커 기록은 성공했는데 ack가 유실되어 프로듀서가 재전송해도, 브로커가 PID와 시퀀스 번호로 중복을 감지해 폐기하고 ack만 응답한다

멱등 프로듀서를 켜면 이걸 막아요. 프로듀서마다 고유 ID(PID)를 받고, 보내는 메시지마다 시퀀스 번호를 붙여요. 브로커는 파티션별로 마지막 시퀀스 번호를 기억하다가, 같은 번호가 또 오면 중복으로 보고 버려요. 그래서 재시도로 인한 중복이 사라져요.

spring:
  kafka:
    producer:
      acks: all
      properties:
        enable.idempotence: true

멱등 프로듀서는 acks=all(모든 in-sync 복제본이 받았는지 확인)과 함께 동작해요. 최신 카프카(3.0+)에서는 enable.idempotence가 기본으로 켜져 있는 경우가 많지만, 명시해두면 의도가 분명해요. 트랜잭션을 쓰려면 멱등 프로듀서가 전제예요.

03. 카프카 트랜잭션, 읽고-처리하고-쓰기를 한 묶음으로

멱등 프로듀서가 "한 토픽으로의 중복 전송"을 막는다면, 트랜잭션은 "여러 토픽에 대한 쓰기 + 입력 오프셋 커밋"을 하나의 원자적 단위로 묶어요. 전부 커밋되거나 전부 취소되거나, 둘 중 하나예요.

카프카 트랜잭션 원자성. 출력 토픽 send와 입력 오프셋 커밋이 한 트랜잭션으로 묶여 commit이면 둘 다 확정되고 abort면 둘 다 없던 일이 되어 중복과 어긋남이 사라진다

핵심은 transactional.id를 지정하는 거예요. 이걸 주면 프로듀서가 트랜잭션을 시작하고 커밋할 수 있게 돼요. raw API로 보면 흐름이 이래요.

producer.initTransactions();
try {
    producer.beginTransaction();
    // 가공한 메시지를 출력 토픽으로 send
    producer.send(new ProducerRecord<>("orders.processed", processed));
    // 방금 읽은 메시지의 컨슈머 오프셋도 같은 트랜잭션에 포함
    producer.sendOffsetsToTransaction(offsets, consumerGroupMetadata);
    producer.commitTransaction();
} catch (Exception e) {
    producer.abortTransaction();   // 하나라도 실패하면 전부 취소
}

여기서 중요한 게, 출력 메시지 쓰기와 입력 오프셋 커밋이 같은 트랜잭션으로 묶인다는 거예요. 그래서 "처리는 했는데 오프셋만 커밋 안 됨" 같은 어긋남이 사라져요. 트랜잭션이 커밋되면 출력도 나가고 오프셋도 올라가고, 취소되면 둘 다 없던 일이 돼요. 이게 read-process-write 패턴이 정확히 한 번이 되는 원리예요.

transactional.id에는 함정이 있어요. 이 ID는 좀비 펜싱용이에요 — 같은 ID로 새 프로듀서가 뜨면 브로커가 epoch를 올려서, 같은 ID를 들고 있던 옛 인스턴스(좀비)의 커밋을 거부해요. 그래서 ID는 재시작 전후로는 같아야 하고, 동시에 떠 있는 인스턴스끼리는 달라야 해요. 인스턴스마다 무작위 ID를 쓰면 펜싱이 무력화되고, 전부 같은 ID를 쓰면 서로를 좀비로 몰아 펜싱 에러(ProducerFencedException)가 터져요.

04. read_committed, 커밋된 것만 읽기

프로듀서가 트랜잭션으로 써도, 그걸 읽는 컨슈머가 아무거나 읽으면 소용없어요. 그래서 컨슈머는 isolation.levelread_committed로 둬요.

spring:
  kafka:
    consumer:
      isolation-level: read_committed

카프카 read_committed와 LSO. 컨슈머는 모든 트랜잭션이 확정된 지점인 LSO까지만 읽고, 진행 중인 트랜잭션 뒤 메시지는 보이지 않는다. 기본값 read_uncommitted는 취소된 메시지까지 읽어 트랜잭션 효과를 무력화한다

read_committed면 커밋된 트랜잭션의 메시지만 읽어요. 아직 진행 중이거나 취소된(abort) 트랜잭션의 메시지는 컨슈머에게 안 보여요. 정확히는, 컨슈머가 LSO(Last Stable Offset)까지만 읽어요. LSO는 "그 앞의 모든 트랜잭션이 커밋 또는 취소로 확정된 지점"이에요. 그래서 아직 열려 있는 트랜잭션 뒤의 메시지는 그게 끝날 때까지 안 보여요.

기본값이 함정이에요. 컨슈머 isolation.level의 기본은 read_uncommitted라서, 프로듀서가 트랜잭션을 정성껏 써도 컨슈머가 이걸 안 바꾸면 취소된 메시지까지 다 읽어버려요. EOS는 프로듀서 설정만으로 완성되지 않아요 — 읽는 쪽이 안 맞추면 효과가 없어요.

05. 스프링에서 쓰는 법

스프링 카프카는 트랜잭션을 추상화해줘요. transactional.id(스프링에서는 transaction-id-prefix)를 설정하면 KafkaTransactionManager가 만들어지고, 우리가 raw API를 직접 안 불러도 돼요.

spring:
  kafka:
    producer:
      transaction-id-prefix: tx-order-

그러면 리스너 컨테이너가 트랜잭션을 인식해서, 출력 send와 입력 오프셋 커밋을 자동으로 한 트랜잭션에 묶어줘요. sendOffsetsToTransaction을 직접 부를 필요가 없어요.

@KafkaListener(topics = "orders")
public void consume(Order order) {
    var processed = process(order);
    kafkaTemplate.send("orders.processed", processed);
    // 메서드가 정상 종료되면 출력 send + 입력 오프셋 커밋이 한 트랜잭션으로 커밋됨
}

리스너 밖에서 트랜잭션을 직접 묶고 싶으면 executeInTransaction을 써요.

// transaction-id-prefix가 설정돼 있어야 동작해요
kafkaTemplate.executeInTransaction(ops -> {
    ops.send("orders.processed", processed);
    ops.send("orders.audit", audit);
    return null;   // 둘 다 한 트랜잭션 → 원자적
});

06. 공짜가 아니에요

EOS는 강력하지만 비용이 있어요. 트랜잭션 시작·커밋·중단 과정이 추가되니 처리량이 떨어지고, 지연도 늘어요. 컨슈머도 read_committed로 LSO까지만 읽으니, 열린 트랜잭션이 있으면 그만큼 읽기가 지연돼요. 행잉 트랜잭션 하나가 LSO를 막으면 그 파티션의 뒤 메시지 전체가 대기하고요.

트랜잭션 단위 크기도 트레이드오프예요. 너무 작으면(메시지 하나당 트랜잭션) 오버헤드가 커지고, 너무 크면 실패 시 되돌리는 양이 많아지고 LSO 지연도 길어져요. 보통은 한 poll 묶음 단위로 묶어요.

언제 쓰고 언제 안 쓰나요

그래서 모든 컨슈머에 EOS를 깔 필요는 없어요. "카프카에서 읽어 카프카로 쓰는데 중복이 절대 안 되는" 흐름(예: 정산, 집계 파이프라인)에만 쓰고, 그 외에는 at-least-once + 멱등성 조합이 더 단순하고 빨라요. 외부 DB까지 엮이면 EOS만으로는 부족하니 멱등성을 같이 챙겨야 하고요.

07. 자주 만나는 문제

트랜잭션을 켰는데 컨슈머가 중복을 봐요

컨슈머의 isolation.level이 기본값 read_uncommitted인 거예요. read_committed로 바꿔야 커밋된 것만 읽어요. 프로듀서만 트랜잭션을 써도 컨슈머가 안 맞추면 효과가 없어요.

처리량이 확 떨어졌어요

트랜잭션 오버헤드예요. 트랜잭션 단위가 너무 잘면 더 심해요. 한 poll 묶음 단위로 묶고, 정말 EOS가 필요한 흐름인지 다시 따져봐요. 단순 중복 흡수면 멱등성이 더 가벼워요.

외부 DB 저장이 두 번 돼요

EOS는 카프카 안에서만 정확히 한 번이에요. DB 쓰기는 트랜잭션 밖이라 보장 안 돼요. DB 쪽은 멱등성(upsert·unique)이나 아웃박스 패턴으로 따로 막아야 해요.

정리

exactly-once는 카프카 토픽 사이의 read-process-write를 원자적으로 만드는 보장이에요. 멱등 프로듀서로 프로듀서 중복을 막고, 트랜잭션으로 출력 쓰기와 입력 오프셋 커밋을 묶고, 컨슈머는 read_committed로 커밋된 것만 읽어요. 단 처리량 비용이 있으니 꼭 필요한 흐름에만 쓰고, 외부 시스템 연동은 여전히 멱등성으로 보완해야 해요.

도입 전에 스스로 물어보면 좋아요. 이 흐름이 정말 카프카 안에서 끝나는 read-process-write인가, 아니면 외부 DB까지 엮여 멱등성이 더 필요한가? 답이 후자라면 at-least-once + 멱등성 조합이 더 단순하고 빨라요. 시리즈의 @KafkaListener 설정, 수동 커밋과 멱등성, 리밸런싱, 에러 핸들링과 DLQ를 함께 보면 됩니다.

출처: Spring for Apache Kafka — Transactions · Kafka — Message Delivery Semantics · Kafka — enable.idempotence

반응형

📚 같이 보면 좋은

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