Kafka 트랜잭션(EOS) 이해
transactional.id / commit·abort / consume→produce EOS / LEO·HW·LSO로 이해하는 메시지 가시성
Kafka를 운영하다 보면 이런 질문이 결국 한 번은 나옵니다.
- “메시지는 언제 컨슈머에게 보이지?”
- “복제는 된 것 같은데 왜 읽히지 않지?”
- “EOS(Exactly Once Semantics)는 어디까지 보장해 주지?”
이 글은 Kafka의 트랜잭션(EOS) 을 transactional.id 중심으로 정리하고, 그 과정에서 자주 헷갈리는 LEO / HW / LSO 를 “메시지 가시성(visibility)의 경계”라는 관점으로 한 번에 연결합니다.
TL;DR (0) 한 줄 결론
transactional.id를 설정하면 프로듀서는 트랜잭션 모드(EOS) 로 동작하며begin → produce → commit/abort라이프사이클을 가집니다.read_committed컨슈머는 commit된 레코드만 봅니다. abort된 레코드는 “존재해도” 보지 않습니다.- 메시지 “가시성”은 단순히 “리더에 append되면 보인다”가 아니라,
복제 경계(HW) 와 트랜잭션 경계(LSO) 에 의해 제한됩니다.
read_uncommitted: 보통 HW까지read_committed: 보통 min(HW, LSO)까지
1) transactional.id를 켜면 뭐가 달라지나?
Kafka 프로듀서는 원래 “commit”이라는 단어를 쓰지 않습니다. 일반 모드에서는 대략 이런 감각이죠.
send()하고- 브로커 ack를 받으면(acks 설정에 따라)
- “성공”이라고 생각한다
그런데 transactional.id를 설정하는 순간, 프로듀서는 트랜잭션이 있는 쓰기를 하게 됩니다.
즉, “레코드를 보낼 수 있다”와 “레코드가 컨슈머에게 보인다”가 분리됩니다.
트랜잭션 모드(EOS)의 라이프사이클
initTransactions()
- 프로듀서 프로세스 시작 시 1회: Kafka와 트랜잭션 상태 초기화
beginTransaction()send()로 레코드 전송
- 중요: 이 시점의 레코드는
read_committed컨슈머에게 아직 안 보일 수 있음
- (옵션)
sendOffsetsToTransaction()
- consume→produce 파이프라인에서 output + offsets를 한 트랜잭션으로 묶기 위한 단계
commitTransaction()또는abortTransaction()
- commit:
read_committed컨슈머 기준 “확정된 데이터”로 노출 - abort: 트랜잭션에 포함된 레코드는
read_committed컨슈머에게 노출되지 않음
2) “commit”은 정확히 무엇을 의미하나?
Kafka에서 “commit”이 헷갈리는 이유는, 같은 단어가 두 세계에서 쓰이기 때문입니다.
- Consumer commit: 오프셋(offset)을
__consumer_offsets에 저장- “어디까지 읽었다” 체크포인트
- Producer transaction commit: 트랜잭션을 확정
- “이 트랜잭션에 속한 레코드는 이제
read_committed에게 보여도 된다”
- “이 트랜잭션에 속한 레코드는 이제
즉, 트랜잭션 ON에서는 “producer commit”이 레코드의 가시성/확정성을 직접 좌우합니다.
3) 중요한 현실: EOS는 “Kafka 내부”에서만 강력하다
Kafka 트랜잭션은 굉장히 강력하지만, 범위를 정확히 이해해야 합니다.
EOS가 특히 강력한 경우
- Kafka → Kafka 파이프라인
- consume → process → produce에서 결과(output)와 입력 오프셋 커밋을 원자적으로 묶고 싶을 때
이때 트랜잭션은 “중복을 줄이고”, “정확히 한 번에 가까운 처리”를 Kafka 레벨에서 만들어 줍니다.
EOS만으로 해결되지 않는 경우
- Kafka → DB
- Kafka → 외부 API(결제/메일/푸시 등)
외부 시스템의 사이드이펙트는 Kafka 트랜잭션으로 “자동” exactly-once가 되지 않습니다. 이 경우는 별도로 멱등 키 / outbox / 트랜잭션 경계 설계가 필요합니다.
4) Producer-only 트랜잭션: 왜 commit/abort가 가시성을 바꾸나?
프로듀서 트랜잭션의 핵심은 이겁니다.
- 트랜잭션이 열려있는 동안 전송된 레코드는 “로그에 기록될 수는 있어도”
read_committed에게는 “확정 전 데이터”라서 숨겨질 수 있다commitTransaction()이 되면 그제야 “확정 데이터”로 노출된다
이 관점으로 보면, 운영에서 종종 보게 되는 현상(“브로커엔 쌓이는데 컨슈머가 못 읽는다”)이 단순 lag 문제가 아니라 트랜잭션 가시성 경계일 수 있다는 걸 이해할 수 있습니다.
5) consume → produce EOS 패턴: offsets까지 트랜잭션에 넣는 이유
Kafka EOS의 대표 패턴은 “consume→produce”입니다. 요지는 간단합니다.
- input을 읽고
- output을 만들고
- input offsets까지 트랜잭션에 포함시킨 다음
- commit 한 번으로 output + offsets를 같이 확정한다
이렇게 하면 다운스트림이 read_committed로 읽을 때,
“처리 결과는 나갔는데 오프셋 커밋은 안 됨” 같은 애매한 상태를 크게 줄일 수 있습니다.
6) 운영에서 진짜 중요한 제약: abort 이후 “consumer position” 함정
여기서부터가 실전 난이도입니다.
poll()은 컨슈머의 in-memory position을 앞으로 이동시킵니다.- 그런데 처리 중 오류가 나서 트랜잭션을
abortTransaction()하면,- offsets는 커밋되지 않았고
- output도 확정되지 않았지만
- 프로세스 내부 position은 이미 진행된 상태가 될 수 있습니다.
이 상태에서 계속 poll을 반복하면, 같은 프로세스가 살아있는 동안 “실패한 레코드를 건너뛴 것처럼 보이는” 이상한 현상이 생길 수 있습니다.
실전 대응(대표 선택지)
- abort 후 마지막 커밋 지점으로
seek()해서 재처리 - 오류 유형별로 프로세스 종료(재시작 시 커밋된 offset부터 재처리)
- Kafka Streams 같은 프레임워크로 오류/재처리 모델을 위임
결론: EOS는 “API 호출만 하면 끝”이 아니라, 오류 정책/재처리 정책까지 포함해서 완성됩니다.
7) transactional.id 운영 규칙: fencing을 피하는 방법
트랜잭션을 운영할 때 가장 흔한 사고 중 하나가 fencing입니다.
- 동시에 실행되는 인스턴스는 각각 고유한 transactional.id를 가져야 합니다.
- 동일한 transactional.id로 2개가 동시에 뜨면 Kafka는 “새 인스턴스가 소유권을 가져갔다”고 판단하고, 기존 인스턴스를 fence(차단) 합니다.
- 반대로 “같은 인스턴스의 재시작”이라면 보통 동일 id 재사용이 유리할 수 있습니다(상태 연속성).
8) 이제 진짜 핵심: LEO / HW / LSO는 “가시성 경계”다
Kafka에서 “메시지가 존재한다”와 “메시지가 읽힌다”는 같은 말이 아닙니다. 이 차이를 만드는 게 LEO/HW/LSO입니다.
8.1 LEO (Log End Offset)
- 로그의 끝: “여기까지 데이터가 존재한다”
- 리더/팔로워는 각각 자신의 LEO를 가집니다.
8.2 HW (High Watermark)
- 복제 관점의 커밋 경계
- ISR에 충분히 복제되어 안전하게 읽을 수 있는 경계
- 실무적으로
LEO - HW가 커지면 복제 지연/부하를 의심합니다.
8.3 LSO (Last Stable Offset)
- 트랜잭션 관점의 안정 경계
- 아직 commit/abort되지 않은(= open) 트랜잭션 레코드가 포함되지 않는 경계
read_committed컨슈머의 읽기 범위를 제한합니다.
9) read_uncommitted vs read_committed: 컨슈머는 어디까지 읽나?
가시성 규칙은 다음 한 줄로 정리하는 게 가장 안전합니다.
read_uncommitted: 보통 HW까지read_committed: 보통 min(HW, LSO)까지
정상 상태에서는 HW가 빠르게 전진해서 “append되자마자 보이는 것처럼” 느껴질 수 있지만, 엄밀히는 HW/LSO가 가시성을 제한합니다.
10) acks=all은 “가시성”이 아니라 “내구성”이다
헷갈리기 쉬운 포인트를 분리해보면:
acks는 “프로듀서가 성공으로 간주하는 시점” → 내구성/손실 위험에 더 가깝습니다.- “컨슈머에게 보이느냐”는 트랜잭션 유무에 따라 HW/LSO 경계로 결정됩니다.
실무에서 흔히 쓰는 안전 조합:
acks=all+min.insync.replicas >= 2- “ack를 받으면 ISR에 안전하게 복제되었다”에 가까운 신뢰를 확보
11) 체크 질문(면접/실무에서 진짜 자주 나오는 것들)
transactional.id를 켜면 무엇이 달라지고, commit/abort는 어떤 의미인가?read_committed컨슈머가 “바로 안 보이는” 경우는 왜 생기는가?- LEO/HW/LSO를 정의하고, 각 isolation level이 어디까지 읽는지 설명할 수 있는가?
- consume→produce EOS에서 abort/retry 시 offset/position을 어떻게 안전하게 다룰 것인가?
- DB/API 같은 외부 사이드이펙트가 있는 경우, EOS만으로 충분한가?
12) 요약
transactional.id는 프로듀서를 트랜잭션 모드로 바꾸고, commit/abort로 가시성을 제어합니다.- EOS의 진짜 강점은 Kafka 내부에서 produce 결과와 consumed offsets를 원자적으로 묶는 것입니다.
- 메시지 가시성은 LEO가 아니라 복제 경계(HW) + 트랜잭션 경계(LSO) 로 결정됩니다.
- 실전에서는 abort 이후 consumer position/seek/retry 정책이 승부처입니다.
Appendix) 다음에 더 파보면 좋은 주제
- ISR 변화 / Under Replicated Partitions 모니터링과 HW 지연의 관계
- Producer fencing(동일 transactional.id 충돌) 운영 전략
- Kafka Streams가 EOS를 어떻게 추상화하는지
- 외부 시스템(DB)과 “정확-한번”을 맞추기 위한 outbox/CDC 패턴
원하시면 이 글을 **“LEO/HW/LSO를 그림(mermaid)으로 시각화한 버전”**으로도 확장해드릴게요.