본문 바로가기
Research Notes (개인)

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)의 라이프사이클

  1. initTransactions()
  • 프로듀서 프로세스 시작 시 1회: Kafka와 트랜잭션 상태 초기화
  1. beginTransaction()
  2. send()로 레코드 전송
  • 중요: 이 시점의 레코드는 read_committed 컨슈머에게 아직 안 보일 수 있음
  1. (옵션) sendOffsetsToTransaction()
  • consume→produce 파이프라인에서 output + offsets를 한 트랜잭션으로 묶기 위한 단계
  1. 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)으로 시각화한 버전”**으로도 확장해드릴게요.