Engineering Note

Kafka EOS는 무엇을 한 번만 보장하는가

Kafka Exactly-Once Semantics가 프로듀서 재시도와 트랜잭션, 컨슈머 offset을 어디까지 보호하는지, 메시지 키·헤더·이벤트 ID가 왜 자동 중복 제거 기준이 아닌지 설명합니다.

2026년 7월 21일 · Pletor Engineering kafkaexactly-oncetransactionsconsumersreliability

Kafka 프로듀서에서 멱등성을 켰습니다. 그런데 컨슈머 애플리케이션은 같은 주문 이벤트를 두 번 받았습니다.

두 레코드의 페이로드는 같았습니다. 키도 order-123으로 같고, 헤더의 event_idevt-789로 같았습니다. 그런데 Kafka에는 서로 다른 offset으로 둘 다 저장되어 있었습니다.

orders-0 offset 100: key=order-123, event_id=evt-789
orders-0 offset 101: key=order-123, event_id=evt-789

이 상황은 Kafka의 Exactly-Once Semantics, 줄여서 EOS가 실패했다는 뜻일까요?

그렇지 않을 수 있습니다. Kafka가 중복이라고 판단하는 기준과 애플리케이션이 같은 비즈니스 이벤트라고 판단하는 기준은 서로 다릅니다.

Kafka가 아는 중복
= 같은 프로듀서 식별 정보와 시퀀스로 재전송된 레코드 배치

애플리케이션이 아는 중복
= 같은 event_id, 멱등성 키 또는 비즈니스 의미

Kafka EOS를 이해하려면 먼저 “정확히 무엇이 한 번인가”를 정해야 합니다.

종이 질감의 바탕 위에 같은 모양의 주황색 소포 세 개가 놓여 있고 가운데 소포에 반투명한 잔상이 겹쳐진 리소그래프 일러스트
겉모양이 같다는 사실만으로 같은 사건은 아닙니다. 중요한 것은 내용보다 각 요청이 만들어진 경로와 식별 정보입니다.

‘정확히 한 번’이라는 표현에 여러 기대가 섞인다

개발자가 EOS라는 말을 들을 때 기대하는 모습은 대개 이렇습니다.

같은 내용의 메시지는 Kafka에 하나만 저장된다.
컨슈머 처리 로직은 메시지마다 한 번만 실행된다.
DB 변경이나 결제 API 호출도 한 번만 일어난다.

Kafka가 제공하는 보장은 이 세 문장을 한꺼번에 의미하지 않습니다. 서로 다른 계층의 기능을 구분해야 합니다.

계층이 계층에서 얻는 보장자동으로 보장하지 않는 것
멱등 프로듀서클라이언트 재시도로 같은 배치가 브로커 로그에 중복 기록되는 것을 방지같은 페이로드를 애플리케이션이 다시 전송하는 상황
Kafka 트랜잭션여러 Kafka 파티션 쓰기를 원자적으로 커밋하거나 취소일반 DB 트랜잭션이나 외부 API 호출
Exactly-once 처리Kafka 입력 offset과 Kafka 출력을 한 트랜잭션으로 반영Kafka 밖의 비즈니스 부수 효과
애플리케이션 멱등성event_id로 이미 처리한 이벤트를 식별하고 안전하게 재처리중복 이력 저장소나 멱등한 부수 효과 구현

이 계층을 섞으면 enable.idempotence=true만 보고 전체 서비스가 EOS를 보장한다고 생각하거나, 반대로 중복 레코드 하나를 발견하고 Kafka 트랜잭션이 무의미하다고 결론 내리기 쉽습니다.

멱등 프로듀서가 막는 것은 재시도 중복이다

프로듀서가 브로커로 레코드 배치를 보냈지만 확인 응답(acknowledgement)을 받기 전에 연결이 끊어졌다고 해보겠습니다.

브로커에는 이미 기록됐을 수도 있고, 아직 기록되지 않았을 수도 있습니다. 프로듀서는 결과를 알 수 없으므로 요청을 다시 보냅니다. 멱등성이 없다면 첫 요청과 재시도 요청이 모두 로그에 들어갈 수 있습니다.

Kafka의 멱등 프로듀서는 이처럼 결과를 확정할 수 없는 실패 구간을 보호합니다.

Kafka 4.3 설계 문서에 따르면 브로커는 프로듀서에게 ID를 부여하고, 프로듀서가 보내는 시퀀스 번호를 이용해 재전송 여부를 판별합니다. 실제 레코드 배치 형식에는 다음 필드가 있습니다.

producerId
producerEpoch
baseSequence

Kafka 메시지 형식 문서는 브로커가 들어온 배치의 처음과 마지막 시퀀스 번호를 해당 프로듀서의 마지막 기록과 비교해 중복을 확인한다고 설명합니다. 페이로드의 JSON 필드를 해석하거나 value의 해시를 계산하는 방식이 아닙니다.

같은 프로듀서 ID, epoch, 시퀀스로 재전송된 배치는 Kafka가 중복으로 거르지만 동일한 페이로드와 이벤트 ID를 애플리케이션이 다시 전송하면 새로운 시퀀스가 부여되어 둘 다 저장되는 비교
Kafka는 같은 내용을 찾는 것이 아니라 프로듀서의 프로토콜 식별 정보를 추적합니다. 클라이언트 내부 재시도와 애플리케이션 차원의 재전송은 Kafka에서 서로 다른 사건입니다.

첫 번째 send()의 내부 재시도라면 같은 프로듀서 식별 정보와 시퀀스를 사용하므로 브로커가 로그에 중복으로 추가되는 것을 막을 수 있습니다. 하지만 애플리케이션이 send()를 두 번 호출하면 두 번째 호출에는 새로운 시퀀스가 부여됩니다. 페이로드가 완전히 같아도 Kafka에는 새로운 정상 레코드입니다.

KafkaProducer API는 애플리케이션 차원의 재전송을 중복 제거할 수 없으며, 일반 멱등 프로듀서의 보장은 한 프로듀서 세션 안에서 전송된 메시지로 제한된다고 명시합니다.

Kafka 4.3 프로듀서 설정에서 enable.idempotence의 기본값은 true입니다. 따라서 별도로 설정하지 않아도 프로듀서 재시도 보호를 받고 있을 수 있습니다. 하지만 이 기본값이 여러 쓰기를 묶는 Kafka 트랜잭션이나 컨슈머 offset까지 포함하는 EOS를 자동으로 켠다는 뜻은 아닙니다.

키와 헤더는 중복 제거 스위치가 아니다

그렇다면 event_id를 메시지 키나 헤더에 넣으면 Kafka가 중복을 알아보지 않을까요?

Kafka 브로커는 그렇게 동작하지 않습니다.

메시지 키의 주된 역할은 파티션 선택입니다. 같은 키를 같은 토픽에 보내면 보통 같은 파티션으로 가므로 키 단위 순서를 만들 수 있습니다. Log compaction이 설정된 토픽에서는 같은 키의 오래된 값을 정리하고 최신 상태를 남기는 기준으로도 사용됩니다.

헤더는 trace context, 스키마 버전, 상관관계 ID, 이벤트 ID 같은 부가 정보를 레코드와 함께 전달하는 통로입니다. Kafka는 헤더 값에 담긴 비즈니스 의미를 해석하지 않습니다.

따라서 다음 두 호출은 모두 기록됩니다.

send(key="order-123", header.event_id="evt-789", value=orderCreated);
send(key="order-123", header.event_id="evt-789", value=orderCreated);

키와 헤더, value가 같아도 서로 다른 send() 호출이며 서로 다른 시퀀스를 받습니다.

Kafka 브로커의 관점애플리케이션에서 쓸 수 있는 관점
메시지 키파티션 선택과 로그 컴팩션의 키업무 객체 단위 순서와 상태 식별
event_id 헤더의미를 해석하지 않는 메타데이터 바이트Inbox 또는 멱등 처리 로직의 중복 판별 키
비즈니스 transaction_id 헤더의미를 해석하지 않는 메타데이터 바이트여러 이벤트와 요청의 상관관계
Kafka transactional.id 설정프로듀서 트랜잭션 복구와 펜싱 식별자비즈니스 이벤트 식별자로 사용하면 안 됨

마지막 행은 특히 중요합니다. 헤더에 넣은 transaction_id=payment-123과 Kafka 프로듀서 설정의 transactional.id는 이름만 비슷할 뿐 다른 개념입니다.

Kafka의 transactional.id는 트랜잭션 코디네이터가 프로듀서를 식별하는 값입니다. 재시작한 프로듀서가 이전 인스턴스의 미완료 트랜잭션을 정리하고, 오래된 프로듀서의 쓰기를 차단하는 펜싱에도 사용합니다. 브로커가 같은 비즈니스 트랜잭션을 찾는 키가 아닙니다.

그렇다고 event_id를 넣을 필요가 없다는 뜻은 아닙니다. 오히려 안정적인 event_id는 중요합니다. Kafka가 자동으로 사용하지 않을 뿐, 컨슈머가 이미 처리한 비즈니스 이벤트를 식별하고 중복 부수 효과를 막는 근거가 되기 때문입니다.

다만 event_id를 키와 헤더, value 중 어디에 둘지는 별도의 설계 문제입니다. 주문 단위 순서가 중요하다면 메시지 키는 order_id로 유지하고, event_id는 헤더나 value에 두는 편이 자연스러울 수 있습니다. 중복 판별만을 위해 메시지 키를 event_id로 바꾸면 원래 의도한 파티션 배치와 업무 객체 단위 순서가 달라질 수 있습니다.

Log compaction도 전송 시점의 중복 제거는 아니다

Log compaction이 설정된 토픽에 같은 키로 보내면 Kafka가 결국 하나만 남긴다는 설명도 자주 오해를 만듭니다.

Log compaction은 레코드가 들어오는 순간 중복을 거르는 기능이 아닙니다. 같은 키로 여러 레코드를 보내면 일단 모두 로그에 추가됩니다. 컨슈머가 compaction 전에 읽으면 모두 볼 수 있습니다.

백그라운드에서 compaction이 실행되면 오래된 값이 정리될 수 있습니다. 하지만 이 기능의 목적은 키별 최신 상태를 장기적으로 보존하는 것입니다.

같은 키의 최신 상태를 남긴다.
!=
같은 이벤트를 한 번만 처리한다.

상태 토픽과 이벤트 토픽을 구분해야 하는 이유이기도 합니다. 계정의 현재 등급처럼 최신 값이 중요한 토픽에는 compaction이 잘 맞을 수 있습니다. 반면 결제 승인 이벤트처럼 발생 사실 하나하나가 중요한 토픽에서는 같은 키를 가진 레코드라고 해서 이전 기록을 중복이라고 볼 수 없습니다.

트랜잭션은 여러 Kafka 쓰기의 가시성을 묶는다

멱등 프로듀서가 재시도 중복을 막는다면 Kafka 트랜잭션은 여러 쓰기를 하나의 커밋 또는 취소 단위로 묶습니다.

beginTransaction
-> 출력 토픽 A에 쓰기
-> 출력 토픽 B에 쓰기
-> commitTransaction

트랜잭션이 커밋되면 모든 쓰기가 함께 확정됩니다. 트랜잭션이 취소되어도 관련 레코드 자체는 로그에 남을 수 있지만, isolation.level=read_committed인 컨슈머에는 노출되지 않습니다. 반면 기본값인 read_uncommitted 컨슈머는 취소된 트랜잭션의 레코드도 볼 수 있습니다. 따라서 프로듀서가 트랜잭션을 사용한다는 사실만으로 전체 경로의 가시성 보장이 완성되지는 않습니다.

read_committed도 내용 기반 중복 제거 기능은 아닙니다. 취소된 트랜잭션의 레코드를 숨길 뿐, 커밋된 두 레코드의 키나 헤더, 페이로드가 같은지는 비교하지 않습니다. 트랜잭션 밖에서 기록된 레코드도 그대로 반환합니다.

Kafka 트랜잭션이 원자적이라고 해서 레코드의 value가 같은지 검사하는 것도 아닙니다. 하나의 트랜잭션 안에서 같은 페이로드를 두 번 전송하면 두 레코드 모두 정상적인 트랜잭션 구성원입니다. 트랜잭션은 내용의 유일성을 검사하는 대신, 포함된 Kafka 쓰기들의 성공과 실패를 함께 결정합니다.

컨슈머 offset까지 같은 트랜잭션에 들어가야 EOS가 된다

Kafka에서 읽고, 변환하고, 다시 Kafka로 쓰는 흐름을 생각해보겠습니다.

입력 토픽
-> 컨슈머가 레코드 처리
-> 출력 토픽에 결과 produce
-> 입력 offset 커밋

출력 레코드 전송과 offset 커밋이 서로 분리되어 있으면 둘 사이에 실패 구간이 생깁니다.

출력 레코드 전송 성공
offset 커밋 전 프로세스 중단
입력 레코드를 다시 읽음
출력 레코드를 다시 전송

일반적인 소비-변환-발행 흐름에서는 처리가 끝난 뒤 컨슈머가 commitSync()commitAsync()로 입력 오프셋을 따로 커밋합니다. Kafka에서 Kafka로 이어지는 EOS 처리에서는 이 별도 오프셋 커밋을 sendOffsetsToTransaction으로 대체합니다. 이 호출은 다음에 읽을 오프셋을 현재 프로듀서 트랜잭션에 넣어, Kafka 출력 기록과 컨슈머 오프셋 커밋을 하나의 성공·실패 단위로 묶습니다.

producer.beginTransaction();

// 읽은 레코드를 처리하고 Kafka 출력을 작성
producer.send(outputRecord);

// 별도 컨슈머 오프셋 커밋 대신, 다음 입력 오프셋을 현재 트랜잭션에 포함
producer.sendOffsetsToTransaction(nextOffsets, consumer.groupMetadata());

producer.commitTransaction();

commitTransaction()이 성공하면 Kafka 출력과 컨슈머 그룹의 다음 입력 오프셋이 함께 확정됩니다. 트랜잭션이 취소되면 출력도 오프셋 커밋도 확정되지 않으므로 입력 레코드를 다시 처리할 수 있습니다.

KafkaProducer의 sendOffsetsToTransaction 문서는 이 흐름에서 enable.auto.commit=false를 사용하고, commitSync()commitAsync()로 offset을 따로 커밋하지 말아야 한다고 설명합니다. 커밋할 값도 마지막으로 읽은 offset 자체가 아니라 다음에 처리할 offset이어야 합니다.

Kafka 입력 offset과 Kafka 출력은 하나의 프로듀서 트랜잭션으로 묶을 수 있지만 비즈니스 DB와 결제 API는 기본 Kafka 트랜잭션 경계 밖에 있는 구조
Kafka EOS의 가장 강한 경계는 Kafka 입력에서 Kafka 출력으로 이어지는 읽기·처리·쓰기 경로입니다. DB와 외부 API에는 별도의 원자성 또는 멱등 계약이 필요합니다.

이 구조에서 트랜잭션을 수행하는 주체는 컨슈머가 아니라 프로듀서입니다. 트랜잭션 프로듀서가 출력 레코드를 쓰면서 컨슈머 그룹의 offset도 같은 트랜잭션에 포함합니다. 이 차이를 이해하면 왜 일반적인 컨슈머 offset 커밋만으로 EOS를 만들 수 없는지 분명해집니다.

또한 EOS가 컨슈머 처리 코드의 실행 횟수를 한 번으로 제한하는 것도 아닙니다. 트랜잭션이 취소되거나 프로세스가 재시작되면 같은 입력을 다시 처리할 수 있습니다. 한 번만 반영되는 것은 트랜잭션으로 커밋된 Kafka 결과이지, 처리 함수의 호출 횟수가 아닙니다.

Offset 커밋 순서만 바꿔서는 외부 부수 효과가 해결되지 않는다

컨슈머가 결과를 Kafka가 아니라 서비스 DB에 저장하거나 결제 API를 호출하면 경계가 달라집니다.

처리 전에 offset을 커밋하면 다음과 같은 실패가 가능합니다.

offset 커밋 성공
DB 갱신 전 프로세스 중단
재시작 후 다음 offset부터 읽음
DB 갱신 유실

처리 후 offset을 커밋하면 반대 상황이 생길 수 있습니다.

DB 갱신 성공
offset 커밋 전 프로세스 중단
같은 레코드 재처리
DB 갱신 또는 외부 API 호출 중복

커밋 순서를 어느 쪽으로 바꿔도 Kafka offset과 외부 시스템의 결과가 하나의 원자적 상태가 되지는 않습니다. 일반적인 Kafka 트랜잭션은 평범한 DB 트랜잭션이나 HTTP API 호출을 자동으로 포함하지 않기 때문입니다.

이 경우에는 Kafka와 대상 시스템의 상태를 맞출 별도 장치가 필요합니다.

  • DB 처리 결과와 처리 위치를 같은 DB 트랜잭션에 저장하고, 재시작할 때 DB에 저장한 위치를 기준으로 읽기를 재개합니다.
  • event_id + 컨슈머 식별자에 고유 제약 조건(unique constraint)을 둔 Inbox를 사용합니다.
  • 처리 로직은 같은 이벤트를 다시 받아도 결과가 달라지지 않도록 멱등하게 만듭니다.
  • 외부 API가 지원한다면 안정적인 멱등성 키(idempotency key)를 전달합니다.
  • 외부 호출의 결과를 알 수 없는 경우에 대비해 조회, 재시도, 보상 정책을 정합니다.

이 지점은 Kafka는 왜 Outbox와 Inbox 사이에 잘 맞을까에서 다룬 애플리케이션 계약과 연결됩니다. Kafka EOS가 불필요하다는 뜻이 아닙니다. Kafka 트랜잭션의 경계를 넘어서는 문제에는 추가 계약이 필요하다는 뜻입니다.

Kafka Streams의 exactly_once_v2는 무엇을 묶는가

Kafka Streams는 Kafka에서 읽고, 변환하고, 다시 Kafka로 쓰는 트랜잭션 패턴을 프레임워크 안에서 구성해 줍니다.

processing.guarantee=exactly_once_v2를 사용하면 원본 토픽에서 읽은 offset, 상태 저장소 갱신을 위한 변경 로그, 출력 토픽 쓰기를 하나의 처리 트랜잭션으로 다룹니다. 장애로 트랜잭션이 취소되면 후속 read_committed 컨슈머는 중간 결과를 보지 않습니다. 입력 레코드는 마지막으로 커밋된 offset부터 다시 처리됩니다.

Kafka Streams 설정 문서에서 처리 보장의 기본값은 여전히 at_least_once입니다. exactly_once_v2를 명시해야 하며, 운영 클러스터에서는 트랜잭션 상태 토픽을 비롯한 관련 토픽의 복제 계수와 min.insync.replicas도 EOS 수준에 맞게 구성해야 합니다.

Streams EOS도 비즈니스 이벤트의 내용 중복을 판별하지는 않습니다. 상위 시스템이 같은 event_id를 새로운 Kafka 레코드로 두 번 발행하면 Streams는 두 입력 레코드를 각각 정확히 처리할 수 있습니다.

서로 다른 두 입력 레코드를
각각 정확히 한 번 반영하는 것
!=
두 레코드가 같은 비즈니스 이벤트인지 알아내는 것

어떤 중복을 막으려는지 먼저 분류해야 한다

“중복을 막아야 한다”는 요구는 구체적인 실패 원인으로 바꿔야 합니다.

문제 상황적합한 방어선
프로듀서가 확인 응답을 받지 못해 같은 배치를 재시도멱등 프로듀서
여러 Kafka 토픽 또는 파티션 쓰기가 일부만 보임Kafka 트랜잭션 + read_committed
읽기·처리·쓰기 중 프로세스가 중단되어 출력을 다시 작성offset과 출력을 같은 Kafka 트랜잭션에 포함
애플리케이션이 같은 비즈니스 이벤트를 두 번 발행안정적인 event_id, 프로듀서 측 발행 계약
컨슈머가 DB를 반영한 뒤 offset 커밋에 실패Inbox 또는 DB 결과와 처리 위치의 원자적 저장
결제·알림 API가 성공했지만 응답을 받지 못해 재호출외부 API 멱등성 키와 결과 조회
같은 키의 이전 상태를 장기 보관할 필요가 없음Log compaction

이 표에서 한 가지 기능으로 모든 행을 해결할 수는 없습니다. EOS라는 이름은 넓어 보이지만, 실제 보장은 명확한 시스템 경계 안에서만 성립합니다.

운영 전에 확인할 체크리스트

EOS를 사용한다고 선언하기 전에 다음 질문에 답할 수 있어야 합니다.

  • 지금 사용하는 것은 멱등 프로듀서인가, Kafka 트랜잭션인가, Kafka Streams EOS인가?
  • transactional.id가 인스턴스와 태스크 소유권에 맞게 안정적이고 유일하게 부여되는가?
  • 컨슈머의 자동 커밋을 끄고 offset을 sendOffsetsToTransaction으로 넣는가?
  • offset 값은 마지막으로 처리한 레코드가 아니라 다음에 처리할 위치인가?
  • 후속 컨슈머가 read_committed로 트랜잭션 출력을 읽는가?
  • 트랜잭션 타임아웃과 취소율, 프로듀서 펜싱, 커밋 실패를 관측하는가?
  • 트랜잭션 상태 토픽과 사용자 토픽의 복제 및 ISR 설정이 충분한가?
  • event_id는 비즈니스 재시도에서도 유지되는가, 아니면 재시도마다 새 UUID가 만들어지는가?
  • 컨슈머가 이미 처리한 event_id를 어떤 범위와 기간 동안 기억하는가?
  • DB와 외부 API처럼 Kafka 트랜잭션 밖의 부수 효과는 어떻게 멱등하게 만드는가?

Kafka EOS를 운영할 때 브로커 상태만 보는 것도 부족합니다. 프로듀서 트랜잭션의 취소와 오류, 컨슈머 그룹의 offset과 lag, 애플리케이션 중복 건수, Inbox 충돌, 외부 API의 멱등 처리 결과를 함께 봐야 합니다.

Konduo는 Kafka 같은 클러스터 리소스를 플러그인으로 연결해 브로커, 토픽, 컨슈머 그룹의 상태와 메트릭 근거, 진단, 알림 대응을 하나의 운영 흐름에서 확인할 수 있도록 합니다. EOS 자체가 애플리케이션 계약까지 대신하지는 않지만, 트랜잭션 실패와 컨슈머 진행 상태를 주변 인프라 신호와 함께 읽으면 문제가 발생한 경계를 찾는 데 도움이 됩니다.

마무리

Kafka EOS는 “같은 내용은 세상에 한 번만 존재한다”는 약속이 아닙니다.

멱등 프로듀서는 프로듀서 재시도로 같은 배치가 Kafka 로그에 중복 기록되는 것을 막습니다. Kafka 트랜잭션은 여러 Kafka 쓰기를 함께 커밋하거나 취소합니다. Exactly-once 처리는 Kafka 입력 offset과 Kafka 출력을 같은 트랜잭션에 넣어, 같은 입력이 재처리되더라도 커밋된 출력 결과가 한 번만 반영되도록 합니다.

그 경계 밖에는 여전히 애플리케이션의 책임이 남습니다.

Kafka는 프로듀서 식별 정보와 시퀀스를 안다.
애플리케이션은 이벤트의 비즈니스 식별자를 안다.
외부 시스템은 실제 부수 효과의 결과를 안다.

키와 헤더에 event_id를 넣는 일은 그래서 중요합니다. Kafka가 대신 중복을 제거해 주기 때문이 아니라, Kafka가 알 수 없는 비즈니스 중복을 컨슈머와 운영자가 식별할 수 있게 하기 때문입니다.

Exactly-once를 설계할 때 가장 먼저 물어야 할 질문은 설정 이름이 아닙니다.

무엇의 중복을, 어느 시스템 경계 안에서 막으려는가?

함께 읽기 좋은 글