정확히 한 번
카프카는 기본적으로 최소 한 번(at-least-once) 전달을 보장한다.
여기에 멱등적 프로듀서와 트랜잭션 두 기능을 더하면 정확히 한 번(exactly-once) 처리가 가능하다.
멱등적 프로듀서 (Idempotent Producer)
해결하는 문제
- 브로커가 메시지를 저장했지만 응답(ack)이 유실되면 프로듀서가 재시도하면서 중복 메시지가 생긴다.
동작 방식
- 프로듀서마다 고유한 프로듀서 ID(PID) 가 부여되고, 파티션별로 시퀀스 번호가 메시지에 붙는다.
- 브로커는 PID/파티션별로 최근 시퀀스 번호(최대 5개 배치)를 기억하고, 이미 받은 번호가 오면 중복으로 판단해 저장하지 않는다.
- 번호가 건너뛰어 도착하면 OutOfOrderSequenceException이 발생한다.
사용법과 제약
- enable.idempotence=true (카프카 3.0부터 기본값)
- 조건: acks=all, retries > 0, max.in.flight.requests.per.connection ≤ 5
- 한계
- 프로듀서 내부 재시도로 인한 중복만 막는다. 애플리케이션이 send()를 두 번 호출하거나, 프로듀서 인스턴스 여러 개가 같은 데이터를 보내는 경우는 막지 못한다.
- 프로듀서가 재시작되면 새 PID를 받으므로 이전 상태가 이어지지 않는다.
트랜잭션 개요
보장하는 것
- 여러 파티션/토픽에 대한 원자적 쓰기 (전부 성공하거나 전부 실패)
- 카프카 내부 읽기-처리-쓰기(consume-process-produce) 패턴에서 정확히 한 번 처리
보장하지 않는 것
- 외부 시스템에 대한 부수 효과(이메일 발송, REST 호출 등)
- 카프카에서 읽어 외부 DB에 쓰는 경우 (카프카 → DB)
- DB에서 읽어 카프카에 쓰는 경우 (DB → 카프카)
- 클러스터 간 복제
- 발행/구독 패턴에서 컨슈머 측의 중복 처리 방지
트랜잭션이 해결하는 두 가지 문제
- 애플리케이션 크래시: 처리 후 오프셋 커밋 전에 죽어 재처리하면 결과가 중복된다. 결과 쓰기와 오프셋 커밋을 한 트랜잭션으로 묶어 해결한다.
- 좀비 애플리케이션: 리밸런싱 후에도 이전 인스턴스가 살아 있으면 같은 데이터를 중복 처리한다. 펜싱(fencing) 으로 막는다.
좀비 펜싱
- 전통적 방식: transactional.id를 지정하면, 같은 ID로 새 프로듀서가 initTransactions()를 호출할 때 에포크(epoch) 가 증가하고 옛 프로듀서의 요청은 ProducerFencedException으로 거부된다.
- KIP-447 (카프카 2.5+): 입력 파티션마다 transactional.id를 따로 두지 않아도 된다. sendOffsetsToTransaction(offsets, consumer.groupMetadata())로 컨슈머 그룹 메타데이터(세대 ID 등)를 이용해 펜싱한다. 프로듀서 하나로 여러 파티션을 처리할 수 있어 구조가 단순해지고 자원 사용량이 줄었다.
트랜잭션 사용 흐름 (코드)
producer.initTransactions(); // 한 번만 호출, 좀비 펜싱 수행
while (true) {
records = consumer.poll(...);
producer.beginTransaction();
for (record : records) {
producer.send(변환된 결과);
}
producer.sendOffsetsToTransaction(offsets, consumer.groupMetadata());
producer.commitTransaction(); // 실패 시 abortTransaction()
}
- 컨슈머는 enable.auto.commit=false로 두고, 오프셋 커밋을 트랜잭션에 맡긴다.
- ProducerFencedException 등 복구 불가능한 예외가 나면 프로듀서를 닫고 종료해야 한다. abortTransaction() 후 재시도할 수 있는 예외도 있다.
컨슈머 설정: isolation.level
- read_uncommitted (기본값): 커밋 여부와 관계없이 모든 메시지를 읽는다. 중단된 트랜잭션의 메시지도 보인다.
- read_committed: 커밋된 트랜잭션의 메시지와 트랜잭션에 속하지 않은 메시지만 읽는다. LSO(Last Stable Offset) 까지만 읽으므로, 진행 중인 트랜잭션이 있으면 그 뒤 메시지는 트랜잭션이 끝날 때까지 전달되지 않아 지연이 늘어날 수 있다.
성능 고려사항
- 트랜잭션 오버헤드는 트랜잭션 안의 메시지 수와 거의 무관한 트랜잭션당 고정 비용(코디네이터 호출, 마커 쓰기)이다. 한 트랜잭션에 메시지를 많이 담을수록 처리량이 좋아지고, 짧게 끊을수록 지연이 줄어든다.
- initTransactions()는 비용이 크므로 프로듀서 생성 시 한 번만 호출한다.
Kafka Streams
- processing.guarantee=exactly_once_v2 설정만으로 정확히 한 번 처리를 켤 수 있다. (구버전의 exactly_once는 exactly_once_v2로 대체되었다.)
- EOS 모드에서는 commit.interval.ms 기본값이 100ms로 짧아진다.
트랜잭션 동작 흐름과 개념
구성 요소와 용어
용어
| transactional.id | 프로듀서 설정값. 애플리케이션 재시작 후에도 같은 논리적 프로듀서임을 식별하는 사용자 지정 ID이다. 이 값이 있어야 트랜잭션 API를 쓸 수 있고, 설정하면 멱등성이 자동으로 켜진다. |
| PID (Producer ID) | 브로커(코디네이터)가 할당하는 프로듀서 식별자. transactional.id와 매핑되어 저장된다. |
| Producer Epoch | 같은 transactional.id에 대해 initTransactions()가 호출될 때마다 증가하는 숫자. 낮은 에포크의 요청은 거부된다. |
| 트랜잭션 코디네이터 | 특정 transactional.id의 트랜잭션 상태를 관리하는 브로커. __transaction_state 토픽 파티션의 리더가 담당한다. |
| __transaction_state | 트랜잭션 상태를 저장하는 내부 토픽. 파티션 수는 transaction.state.log.num.partitions(기본 50)이다. transactional.id의 해시값으로 파티션이 정해지고, 그 파티션의 리더 브로커가 코디네이터가 된다. |
| 컨트롤 레코드(트랜잭션 마커) | 트랜잭션의 커밋/중단을 표시하기 위해 데이터 파티션 로그에 기록되는 특수 레코드. 애플리케이션에는 전달되지 않는다. |
| 그룹 코디네이터 | 컨슈머 그룹의 오프셋(__consumer_offsets)을 관리하는 브로커. 트랜잭션에서 오프셋 커밋을 받는 쪽이다. |
| LSO (Last Stable Offset) | read_committed 컨슈머가 읽을 수 있는 상한. 진행 중인 트랜잭션 중 가장 이른 첫 오프셋 직전까지이다. |
| HW (High Watermark) | 모든 ISR에 복제가 완료된 마지막 오프셋 다음 위치. LSO는 항상 HW 이하이다. |
트랜잭션 상태 (코디네이터 관점)
코디네이터는 각 transactional.id마다 다음 상태 중 하나를 트랜잭션 로그에 기록한다.
상태 의미
| Empty | 진행 중인 트랜잭션 없음 |
| Ongoing | 트랜잭션이 시작되어 파티션/오프셋 등록이 진행 중 |
| PrepareCommit | 커밋 결정이 로그에 기록됨 (커밋 확정 시점) |
| PrepareAbort | 중단 결정이 로그에 기록됨 |
| CompleteCommit | 모든 파티션에 커밋 마커 기록 완료 |
| CompleteAbort | 모든 파티션에 중단 마커 기록 완료 |
| PrepareEpochFence | 에포크 증가에 따라 이전 트랜잭션을 정리하는 중 |
| Dead | 만료되어 제거 대상 |
상태 전이 (정상 경로):
Empty ──(파티션 등록)──▶ Ongoing ──(EndTxn commit)──▶ PrepareCommit ──(마커 기록 완료)──▶ CompleteCommit
│
└──(EndTxn abort / 타임아웃)──▶ PrepareAbort ──(마커 기록 완료)──▶ CompleteAbort
단계별 동작 흐름
아래 흐름은 전통적인 프로토콜 기준이다. 카프카 최근 버전에서는 KIP-890에 따라 일부 RPC(특히 AddPartitionsToTxn)의 호출 방식이 달라지고 있으므로, 정확한 요청 종류는 사용하는 버전의 문서를 확인해야 한다.
개념적 순서(등록 → 쓰기 → 커밋 결정 → 마커 기록)는 동일하다.
1) initTransactions()
- 프로듀서가 아무 브로커에 FindCoordinator(타입: TRANSACTION) 요청을 보내 자신의 transactional.id를 담당하는 코디네이터를 찾는다.
- 코디네이터에 InitProducerId 요청을 보낸다.
- 코디네이터의 처리:
- 처음 보는 transactional.id: 새 PID를 발급하고 에포크를 0으로 시작한다.
- 이미 존재하는 transactional.id: 에포크를 증가시킨다. 이 시점부터 이전 에포크를 가진 프로듀서(좀비)의 요청은 모두 거부된다.
- 이전 인스턴스가 남긴 미완료 트랜잭션이 있으면 중단 처리(또는 이미 커밋 결정된 경우 커밋 완료)한 뒤에야 응답한다.
- 코디네이터는 transactional.id → PID, 에포크, 트랜잭션 타임아웃 매핑을 트랜잭션 로그에 기록한다.
이 호출은 프로듀서당 한 번만 수행하며, 재시작 시 이전 인스턴스를 정리하는 역할도 겸한다.
2) beginTransaction()
- 로컬 상태만 변경한다. 브로커로 요청을 보내지 않는다.
- 이후 send(), sendOffsetsToTransaction() 호출이 트랜잭션에 속하게 된다.
3) send() - 트랜잭션 내 쓰기
- 이 트랜잭션에서 처음 쓰는 파티션이면, 프로듀서가 코디네이터에 해당 파티션을 등록한다(AddPartitionsToTxn). 코디네이터는 파티션 목록을 트랜잭션 로그에 기록하고 상태를 Ongoing으로 만든다.
- 프로듀서가 해당 파티션의 리더 브로커에 Produce 요청을 보낸다. 요청에는 PID, 에포크, 시퀀스 번호, 트랜잭션 여부가 포함된다.
- 리더는 이 레코드를 즉시 로그에 기록하고 복제한다. 커밋 전이라도 로그에는 이미 존재한다. 레코드에는 "트랜잭션에 속함" 표시가 붙는다.
- 이 레코드를 read_uncommitted 컨슈머는 바로 읽을 수 있지만, read_committed 컨슈머는 트랜잭션이 끝날 때까지 읽지 못한다.
- 해당 트랜잭션의 첫 레코드 오프셋이 파티션의 LSO 계산에 반영된다. 즉 트랜잭션이 열려 있는 동안 LSO는 그 첫 오프셋에서 멈춘다.
멱등성(PID + 시퀀스 번호)이 함께 동작하므로 트랜잭션 내 재시도 중복도 방지된다.
4) sendOffsetsToTransaction() - 오프셋을 트랜잭션에 포함
- 프로듀서가 코디네이터에 컨슈머 그룹의 __consumer_offsets 파티션을 트랜잭션에 등록한다(AddOffsetsToTxn).
- 프로듀서가 그룹 코디네이터에 TxnOffsetCommit 요청을 보낸다. 이 요청에는 컨슈머 그룹 메타데이터(그룹 ID, 세대 ID, 멤버 ID 등)가 포함된다.
- 그룹 코디네이터는 세대 ID가 현재 세대와 맞는지 검사한다. 리밸런싱으로 세대가 바뀐 뒤 이전 세대의 인스턴스가 보낸 요청은 거부된다. 이것이 KIP-447 방식의 좀비 펜싱이다.
- 오프셋은 보류(pending) 상태로 저장된다. 트랜잭션이 커밋되기 전에는 다른 컨슈머가 이 오프셋을 조회해도 반영되지 않는다.
결과적으로 "출력 레코드 쓰기"와 "입력 오프셋 커밋"이 같은 트랜잭션에 묶인다.
5) commitTransaction() - 2 Phase Commit
1단계: 커밋 결정 기록
- 프로듀서가 코디네이터에 EndTxn(commit) 요청을 보낸다.
- 코디네이터는 트랜잭션 로그에 PrepareCommit 을 기록한다.
- 이 기록이 복제까지 완료되면 트랜잭션은 반드시 커밋된다. 이후 코디네이터가 장애로 교체되더라도 새 코디네이터가 로그를 읽어 커밋을 이어서 완료한다. 이 지점이 커밋 확정 시점(commit point)이다.
- 코디네이터가 프로듀서에게 EndTxn 응답을 보낸다. 따라서 commitTransaction()이 반환되는 시점에는 커밋이 확정되어 있지만, 모든 파티션에 마커가 기록되었다는 의미는 아니다.
2단계: 마커 기록
- 코디네이터가 트랜잭션에 참여한 모든 파티션의 리더(데이터 파티션 + 오프셋을 저장하는 __consumer_offsets 파티션)에 WriteTxnMarkers 요청을 보낸다.
- 각 리더는 파티션 로그에 COMMIT 컨트롤 레코드를 추가한다.
- 마커가 기록되면 그 파티션의 해당 트랜잭션은 종료된 것으로 처리되어 LSO가 앞으로 이동할 수 있게 된다. 보류 중이던 오프셋도 이 시점에 유효한 커밋 오프셋이 된다.
- 모든 파티션에 마커가 기록되면 코디네이터는 트랜잭션 로그에 CompleteCommit 을 기록하고, 다음 트랜잭션을 받을 수 있는 Empty 상태가 된다.
6) abortTransaction()
커밋과 같은 구조이며 결정 기록이 PrepareAbort, 마커가 ABORT 컨트롤 레코드로 바뀐다. 중단된 트랜잭션의 데이터 레코드는 로그에서 삭제되지 않고 남아 있으며, read_committed 컨슈머가 걸러낸다. 보류 중이던 오프셋은 폐기된다.
4. 전체 시퀀스 (정상 커밋)


컨슈머 측 동작 (read_committed)
- 브로커는 Fetch 응답에 LSO까지의 레코드만 포함한다. 진행 중인 트랜잭션의 레코드와 그 뒤에 쓰인 다른 레코드는 LSO 뒤에 있으므로 반환되지 않는다.
- Fetch 응답에는 해당 범위에서 중단된 트랜잭션 목록(PID, 첫 오프셋)이 함께 담긴다.
- 컨슈머 클라이언트는 이 목록과 컨트롤 레코드(ABORT 마커)를 이용해 중단된 트랜잭션의 데이터 레코드를 버린다. COMMIT된 레코드는 애플리케이션에 전달한다.
- 컨트롤 레코드 자체는 애플리케이션에 전달되지 않지만 오프셋 번호는 차지한다. 그래서 오프셋이 연속적이지 않거나, 마지막 레코드의 오프셋 + 1이 다음 레코드의 오프셋과 다를 수 있다.
- 지연 영향: 한 파티션에서 오래 열려 있는 트랜잭션이 하나만 있어도, 그 뒤에 쓰인 모든 메시지(다른 프로듀서의 것 포함)는 그 트랜잭션이 끝날 때까지 read_committed 컨슈머에게 보이지 않는다. 트랜잭션을 짧게 유지해야 하는 이유이다.
장애 시나리오
| 프로듀서가 트랜잭션 도중 크래시 | 같은 transactional.id로 재시작한 인스턴스가 initTransactions()를 호출하면 에포크가 증가하고 이전 미완료 트랜잭션이 중단 처리된다. 재시작이 없으면 transaction.timeout.ms 초과 시 코디네이터가 중단한다. |
| 좀비 프로듀서가 뒤늦게 요청 전송 | 에포크가 낮으므로 ProducerFencedException(또는 InvalidProducerEpochException)으로 거부된다. |
| 리밸런싱 후 이전 세대 컨슈머가 오프셋 커밋 시도 | 그룹 코디네이터가 세대 ID 불일치로 TxnOffsetCommit을 거부한다. |
| PrepareCommit 기록 전 코디네이터 장애 | 트랜잭션은 Ongoing으로 남고, 프로듀서가 재시도하거나 타임아웃으로 중단된다. |
| PrepareCommit 기록 후 코디네이터 장애 | 새 코디네이터가 트랜잭션 로그를 읽어 마커 기록을 이어서 완료한다. 커밋 결과는 바뀌지 않는다. |
| 파티션 리더 장애 중 마커 기록 | 코디네이터가 새 리더에 마커 기록을 재시도한다. |
| 트랜잭션 시간이 transaction.timeout.ms 초과 | 코디네이터가 중단시키고 에포크를 올려 해당 프로듀서를 펜싱한다. |
예외 처리
- 치명적(프로듀서를 닫아야 함)
- ProducerFencedException, InvalidProducerEpochException, AuthorizationException, OutOfOrderSequenceException 등. 새 프로듀서 인스턴스로 다시 시작해야 한다.
- 복구 가능: 그 외 KafkaException 계열. abortTransaction()을 호출한 뒤 컨슈머 위치를 마지막 커밋 오프셋으로 되돌리고(seek) 처음부터 다시 처리한다.
중단 후 재시도할 때는 컨슈머의 읽기 위치를 마지막으로 커밋된 오프셋으로 되돌려야 한다. 그렇지 않으면 이미 poll()로 읽은 레코드가 처리되지 않고 건너뛰어진다.
관련 설정
| transactional.id | 프로듀서 | 트랜잭션 사용 및 펜싱의 기준 ID |
| transaction.timeout.ms | 프로듀서 | 코디네이터가 트랜잭션을 기다려 주는 최대 시간 (기본 60초). 브로커의 transaction.max.timeout.ms(기본 15분)를 넘길 수 없다. |
| isolation.level | 컨슈머 | read_committed / read_uncommitted |
| enable.auto.commit | 컨슈머 | 트랜잭션 사용 시 false |
| transaction.state.log.replication.factor | 브로커 | __transaction_state 복제 계수 (기본 3) |
| transaction.state.log.min.isr | 브로커 | __transaction_state 최소 ISR (기본 2) |
| transaction.state.log.num.partitions | 브로커 | __transaction_state 파티션 수 (기본 50) |
보장 범위 정리
트랜잭션이 제공하는 것:
- 원자성: 한 트랜잭션에서 쓴 모든 파티션의 레코드와 오프셋 커밋이 함께 보이거나 함께 사라진다.
- 격리(읽기 측): read_committed 컨슈머는 커밋된 결과만 본다.
- 펜싱: 같은 논리적 프로듀서의 이전 인스턴스가 이후 쓰기를 할 수 없다.
- 내구성: 커밋 결정은 복제되는 트랜잭션 로그에 기록되어 브로커 장애 후에도 유지된다.
트랜잭션이 제공하지 않는 것:
- 카프카 밖의 상태(DB, 외부 API)에 대한 원자성
- 서로 다른 트랜잭션 간의 직렬화 보장 (잠금 개념 없음)
- 입력 토픽 소비와 무관한 중복 send() 호출 방지 (이것은 멱등적 프로듀서도 막지 못한다.)
'공부 > Kafka' 카테고리의 다른 글
| [카프카] 신뢰성있는 데이터 전달 (0) | 2026.10.11 |
|---|---|
| [카프카] Kafka 내부 메커니즘 (0) | 2026.10.11 |
| [카프카] Kafka Consumer (0) | 2026.09.28 |
| [카프카] Kafka Producer (1) | 2026.09.13 |
| [Kafka] Kafka CLI (0) | 2025.08.29 |






