Kafka 3.x 아키텍처 완전 정리 — 구조·복제·컨슈머부터 KRaft 전환, 실무 튜닝까지
Kafka를 메시지 큐로만 이해하면 운영에서 반드시 벽에 부딪힌다. 분산 Commit Log라는 본질부터 전체 구조·저장·프로듀서·복제/ISR·컨슈머·전달 보증·Consumer Lag, 그리고 3.x를 정의하는 KRaft 전환과 실무 튜닝까지 다이어그램과 함께 정리한다.
개요
Kafka를 처음 접하면 "메시지 큐"로 이해하기 쉽다. Producer가 메시지를 넣고 Consumer가 꺼내 가니까. 하지만 이 관점으로 Kafka를 쓰면 운영에서 반드시 벽에 부딪힌다.
Kafka는 분산 Commit Log다. 메시지를 소비해도 사라지지 않는다. 디스크에 순차적으로 append되고, 보존 정책에 따라 일정 기간 유지된다. Consumer는 로그의 특정 위치(offset)를 기억하고 있을 뿐이다.
이 차이가 실무에서 왜 중요한가? 장애 복구 시 Consumer의 offset만 되돌리면 과거 메시지를 재처리할 수 있다. 같은 토픽을 서로 다른 Consumer Group이 독립적으로 소비할 수도 있다. RabbitMQ처럼 한번 꺼내면 사라지는 구조에서는 불가능한 일이다.
실무 포인트: Kafka는 "꺼내면 사라지는 큐"가 아니라 "위치를 기억하는 로그"다. 이 차이가 장애 복구, 재처리, 다중 소비를 가능하게 한다.
그리고 Kafka 3.x는 메타데이터 관리를 ZooKeeper와 KRaft 둘 다 지원하는 마지막 메이저 버전대다. 4.0(2025년 3월)부터 ZooKeeper가 완전히 제거되어 KRaft가 유일한 모드가 됐기 때문에, 3.x를 이해한다는 건 사실상 "ZooKeeper에서 KRaft로 넘어가는 과도기 아키텍처"를 이해하는 일과 같다.
아래에서 전체 구조 → 저장 → 메타데이터(KRaft) → 복제/ISR → 프로듀서 → 컨슈머/리밸런싱 → 전달 보증 → Consumer Lag → 파티션 설계 → 실무 튜닝 순으로, 옵션은 빠짐없이 정리한다. 아래 색은 이 글의 모든 다이어그램에서 같은 의미로 쓰인다.
전체 그림
카프카는 발행한 쪽(프로듀서)과 구독하는 쪽(컨슈머)이 직접 연결되지 않고, 그 사이에 브로커 클러스터가 로그를 저장·중계하는 구조다.
- 브로커(Broker) — 파티션 데이터를 디스크 로그로 저장하고 클라이언트 요청을 처리하는 서버. Kafka 프로세스 하나가 브로커 하나이며, 여러 대가 모여 클러스터를 이룬다.
- 토픽(Topic) — 메시지를 논리적으로 분류하는 이름 단위.
order-events,user-signup처럼 이름을 붙이며, 모든 발행·구독은 토픽을 기준으로 이뤄진다. - 파티션(Partition) — 토픽을 물리적으로 쪼갠 단위이자 병렬 처리·순서 보장의 최소 단위. 각 파티션은 독립된 Commit Log다.
- 오프셋(Offset) — 파티션 내 각 메시지의 일련번호. 0부터 시작해 단조 증가하며, 컨슈머는 "어디까지 읽었는지"를 오프셋으로 관리한다.
- 컨트롤러(Controller) — 어떤 브로커가 어떤 파티션의 리더인지 등 클러스터 메타데이터를 총괄. 카프카 3.x에서 이 역할의 구현이 ZooKeeper에서 KRaft로 넘어갔다.
핵심은 파티션 내에서는 순서가 보장되지만, 파티션 간에는 순서가 보장되지 않는다는 것이다. 주문 생성 → 결제 완료 → 배송 시작이 순서대로 처리되어야 한다면, 같은 주문 ID를 키로 사용해서 같은 파티션에 들어가도록 해야 한다.
저장 구조
카프카는 파티션을 디스크에 추가 전용(append-only) 로그로 쌓고, 일정 크기·시간마다 세그먼트 파일로 끊어 관리한다.
디스크에서는 세그먼트가 다음과 같은 파일들로 보인다. 파일명이 곧 해당 세그먼트의 시작 offset이다.
00000000000000000000.log # 실제 메시지 데이터
00000000000000000000.index # offset → 파일 내 물리 위치
00000000000000000000.timeindex # timestamp → offset
00000000000000004096.log
00000000000000004096.index
00000000000000004096.timeindex오래된 세그먼트부터 통째로 삭제하므로 retention 정책은 세그먼트 단위로 동작한다.
- retention(보존) —
retention.ms(시간) 또는retention.bytes(용량) 기준으로 오래된 데이터를 삭제. 토픽 단위로 다르게 줄 수 있다. cleanup.policy— 세 가지를 지정할 수 있다.delete(기본) — 보존 기간이 지나면 삭제.compact— 키별 최신 값만 유지. 사용자 프로필처럼 "최종 상태"만 필요한 상태성 데이터에 적합하다.compact,delete— 압축하되 전체 보존 기간도 함께 적용.
- tiered storage — KIP-405. 오래된 세그먼트를 S3 같은 원격 저장소로 내려 브로커 디스크 부담을 줄이는 기능으로, 3.6 얼리액세스로 도입됐다. 장기 보존이 필요할 때 검토할 가치가 있다.
- 제로카피 — 컨슈머에게 데이터를 보낼 때 커널 영역에서 바로 네트워크로 전송해 CPU·메모리 복사를 줄이는 게 카프카 고성능의 한 축이다.
실무 포인트:
retention.ms(구log.retention.hours)를 너무 짧게 잡으면 Consumer 장애 복구 시 재처리할 데이터가 없다. 최소 72시간은 확보하는 것을 권장한다.
메타데이터 관리 — ZooKeeper에서 KRaft로
메타데이터 관리 방식의 변화가 카프카 3.x를 정의하는 가장 큰 사건이다. 두 모드를 나란히 두면 차이가 분명하다.
- KRaft 정의 — Kafka Raft의 줄임말로, Raft 합의 알고리즘을 카프카 내부에 구현한 메타데이터 관리 방식. ZooKeeper라는 외부 시스템 없이 컨트롤러 노드들이 복제된 로그(
__cluster_metadata토픽)로 메타데이터를 관리한다. - 버전 타임라인 — KRaft는 3.3에서 프로덕션 레디로 표시됐고, 3.5에서 ZooKeeper 모드가 deprecated, 4.0에서 완전히 제거됐다. ZooKeeper→KRaft 마이그레이션 도구는 3.6에 도입되어 3.7에서 프로덕션 레디가 됐다.
- 3.9의 위치 — 3.9가 ZooKeeper 모드를 지원하는 마지막 버전이며, 4.0으로 가는 사실상의 "브리지 릴리스"다. ZooKeeper 모드 클러스터는 4.0으로 직행할 수 없고, 반드시 3.x에서 KRaft로 먼저 전환해야 한다.
- KRaft의 실익 — 운영 컴포넌트가 둘에서 하나로 줄어 모니터링·온콜이 단순해지고, 컨트롤러 장애 복구가 거의 즉시 이뤄지며, 대규모 파티션 확장에 유리하다.
- 실무 시사점 — 신규 클러스터는 KRaft로 시작하는 게 정답. 기존 ZooKeeper 클러스터를 운영 중이라면 3.9를 경유한 마이그레이션을 미리 계획해두는 게 좋다. 프로덕션은 브로커와 컨트롤러를 분리한 전용 노드 구성이 권장된다.
KRaft의 세 가지 실익은 모두 같은 설계 변화에서 나온다. 메타데이터를 "외부 저장소 + 단일 컨트롤러 + RPC 전체 푸시"에서 "복제된 이벤트 로그 + 컨트롤러 쿼럼 + 오프셋 기반 증분 전파"로 바꾼 것이다. 자세한 메커니즘은 접어둔다.
① KRaft가 모니터링·온콜을 단순하게 만드는 이유
핵심은 "관리 대상 시스템이 둘에서 하나로 줄었다"는 점이다.
- 시스템 통합 — ZooKeeper는 카프카와 완전히 다른 분산 시스템이라 별도로 배포·관리·트러블슈팅해야 한다. KRaft는 그 역할을 브로커/컨트롤러 안으로 흡수해 운영 대상이 하나가 된다.
- 운영 표면 통합 — ZooKeeper는 별도의 JVM 튜닝, 별도의 디스크 I/O 모니터링, 분리된 보안 설정을 요구한다. KRaft는 카프카 데이터 플레인과 동일한 설정·장애 처리·보안 메커니즘을 사용해 배우고 운영하기가 더 쉽다.
- 교차 장애 추적 제거 — 두 시스템을 모두 이해해야 하던 온콜 부담이 준다. ZK의 문제가 카프카로 전이되는 식의 "어느 쪽이 원인인지" 교차 디버깅이 사라지고, 런북·대시보드·알람도 하나의 시스템 기준으로 통합된다.
- split-brain 제거 — 두 분산 시스템 사이에서 생길 수 있는 상태 불일치(split-brain) 시나리오가 없어진다.
- 상태 가시성 — 쿼럼·메타데이터 상태를
kafka-metadata-quorum.sh describe --status같은 카프카 자체 도구와ActiveControllerCount같은 KRaft 전용 메트릭으로 확인한다.
② 컨트롤러 장애 복구가 빨라진 이유
복구 경로에 "외부 저장소에서 전체 상태를 다시 읽는 단계"가 있느냐 없느냐의 차이다.
- ZK 방식의 복구 경로 — 액티브 컨트롤러는
/controller임시(ephemeral) znode를 먼저 만든 브로커가 맡는다. 그 컨트롤러가 죽으면 새로 선출된 컨트롤러는 액티브로 동작하기 전에 모든 토픽 정보를 포함한 메타데이터를 ZooKeeper에서 다시 가져와야 한다. 이 적재량은 파티션 수에 비례한다. - 그래서 느리다 — 현장 사례에서 ZooKeeper 기반 컨트롤러 페일오버는 보통 5~10초가 걸렸고, 파티션이 수백만 규모이면 이 적재가 수 분까지 늘었다. 이 시간 동안 리더 선출 같은 메타데이터 변경이 멈춘다.
- KRaft 방식 — 팔로워 컨트롤러들이 평소에 액티브 컨트롤러의 데이터를 복제하며 웜 스탠바이로 대기한다. 리더십이 바뀌면 새 액티브 컨트롤러는 이미 커밋된 모든 메타데이터 레코드를 메모리에 갖고 있어 외부에서 상태를 적재할 필요가 없다.
- 결과 — ZooKeeper의 5~10초 대비 서브초 단위의 컨트롤러 복구. ZK의 불리함은 알고리즘이 아니라 "복구할 때마다 외부 저장소에서 전체 메타데이터를 다시 읽어야 한다"는 경로 그 자체에서 온다.
③ 대규모 파티션 확장에 KRaft가 유리한 이유
같은 "외부 저장소 + RPC 전체 푸시" 구조가 파티션이 많아질수록 더 무거워지기 때문이다.
- ZK 전파 비용의 증가 — 메타데이터 변경을 RPC로 다른 브로커에 전파하는데, 그 전파량이 관련 파티션 수에 비례해 커진다. 또 파티션 상태 변경 하나하나가 앙상블 합의를 거치는 동기 ZooKeeper 쓰기였다.
- 복구 시 폭증 — 대규모 클러스터에서 컨트롤러가 복구할 때 수천 건의 순차적 ZooKeeper 쓰기를 해야 했고, 파티션이 수백만이면 복구가 몇 시간까지 늘어날 수 있었다.
- 실질 한계 — 약 20만 파티션이라는 한계는 임의의 값이 아니라 ZooKeeper가 대량 메타데이터를 다룰 때의 성능 특성에서 비롯됐다.
- KRaft의 증분 전파 — 메타데이터가 로그라서 브로커는 오프셋으로 자기 상태를 추적하고, 재접속 시 처음부터 전부 읽는 대신 마지막 오프셋 이후만 따라잡는다. 로그가 무한히 커지지 않도록 스냅샷으로 압축하고, 컨트롤러 쿼럼이라는 일부 노드만 메타데이터를 처리한다.
- 결과 — Confluent 실험에서 200만 파티션(ZooKeeper 최대치의 약 10배)을 운영했고, 테스트 클러스터는 500만 파티션 이상을 돌렸으며 메타데이터 연산이 클러스터 크기와 무관하게 거의 일정한 지연을 보였다.
컨트롤러 쿼럼은 홀수로
KRaft Controller는 과반수(quorum)가 살아 있어야 동작한다.
| 컨트롤러 수 | 과반수 | 허용 장애 수 |
|---|---|---|
| 3 | 2 | 1대 |
| 4 | 3 | 1대 |
| 5 | 3 | 2대 |
3대와 4대의 허용 장애 수가 동일하다. 4대 구성이 동작하지 않는 것은 아니지만, 장비를 하나 더 쓰면서 장애 허용성은 올라가지 않는다. 리소스 대비 효율이 낮으므로 홀수 구성(3대 또는 5대)을 권장한다.
실무 포인트: 아직 ZooKeeper 모드를 쓰고 있다면 KRaft 마이그레이션은 "하면 좋은 것"이 아니라 "반드시 해야 하는 것"이다. Kafka 4.0에서 ZooKeeper 지원이 완전히 사라졌다.
복제와 ISR
카프카의 신뢰성을 떠받치는 핵심은 파티션 복제다. 하나의 파티션은 여러 브로커에 복제되며, 그중 하나가 리더(Leader) 역할을 한다.
- 리더 단일 처리 — 한 파티션의 모든 읽기·쓰기는 리더 복제본 한 곳으로만 들어온다. 팔로워는 리더를 따라 복제(fetch)만 하며 클라이언트 요청은 받지 않는다. 리더가 죽으면 ISR 내의 팔로워가 새 리더로 승격된다.
replication.factor— 파티션을 몇 벌 복제할지. 운영 환경은 보통 3을 권장한다. 1이면 브로커 한 대 장애로 데이터가 사라진다.min.insync.replicas—acks=all일 때 쓰기를 인정받기 위해 필요한 최소 ISR 수. 복제 계수 3에 이 값을 2로 두면 브로커 한 대가 죽어도 쓰기가 계속된다.unclean.leader.election.enable— ISR 밖 복제본을 리더로 허용할지.true면 가용성↑·유실 위험↑,false(권장)면 안전성↑. 정합성이 중요한 토픽은false로 둔다.
ISR은 어떻게 줄어드는가
ISR은 리더와 동기화 상태가 유지되고 있는 팔로워 집합이다. 팔로워가 ISR에서 빠지는 기준은 다음과 같다.
# 팔로워가 이 시간 내에 리더를 따라잡지 못하면 ISR에서 제거
replica.lag.time.max.ms=30000ISR에서 빠지는 실무적 원인:
- 팔로워 브로커의 디스크 I/O 병목
- 네트워크 지연이나 일시적 단절
- 팔로워의 GC pause가
replica.lag.time.max.ms를 초과
ISR이 줄어들면 min.insync.replicas 조건을 못 채워 프로듀서가 쓰기 실패(NotEnoughReplicasException)를 받을 수 있다.
실무 포인트: ISR 축소 알림은 "곧 장애가 올 수 있다"는 선행 지표다. 프로듀서 에러가 터지기 전에 대응할 수 있는 유일한 타이밍이므로, 운영 환경에서는 ISR 크기를 반드시 모니터링해야 한다.
Follower Fetching — cross-AZ 비용 절감
기본적으로 컨슈머는 리더에서만 읽는다. 다만 Kafka 2.4부터 Follower Fetching(replica.selector.class)을 설정하면 컨슈머가 가까운 팔로워에서 읽을 수 있다. 이 기능은 성능 향상이 목적이 아니라, cross-AZ 네트워크 비용 절감이 주된 목적이다. 같은 AZ의 팔로워에서 읽으면 AZ 간 데이터 전송 비용을 줄일 수 있다.
프로듀서
send() 한 번이 곧바로 네트워크 전송이 되는 게 아니라, 직렬화·파티셔닝·배치를 거쳐 별도 스레드가 묶어서 보낸다.
- 파티셔너 — 키가 있으면
murmur2해시 기반으로 항상 같은 파티션에 보내 키 단위 순서를 보장한다. - Sticky Partitioner — 키가 없는 레코드는 sticky 방식으로 처리된다. 과거에는 라운드로빈으로 레코드마다 파티션을 바꿔 배치가 잘게 쪼개졌지만, Sticky Partitioner는 키가 없는 레코드에만 적용되며 하나의 배치가 채워질 때까지 같은 파티션에 계속 보낸다. 배치 효율이 올라가고 지연이 줄어든다. 키가 있는 레코드는 항상 키의 해시로 파티션이 결정되므로 무관하다.
- 배치·
linger.ms— 레코드를 곧바로 보내지 않고batch.size만큼 모으거나linger.ms만큼 기다렸다가 묶어 보낸다. linger를 늘리면 처리량↑·지연↑의 트레이드오프가 생긴다. acks— 쓰기 인정 기준. 값은 세 가지가 전부다.
0— 응답을 기다리지 않음. 유실 가능, 가장 빠름.1— 리더에만 기록되면 인정. 리더 장애 시 유실 가능.all(=-1, 별칭) — ISR 전체에 기록돼야 인정. 가장 안전.enable.idempotence—true면 재시도 시 중복 쓰기를 브로커가 걸러내 정확히 한 번 기록을 보장한다. 카프카 3.x부터 기본값이true다. 내부적으로acks=all,retries가 0보다 큼,max.in.flight.requests.per.connection가 5 이하를 함께 요구하며, 이 조건이 충족돼야 순서까지 보장된다.compression.type— 배치를 압축해 네트워크·디스크를 절약. 코덱 선택이 처리량·압축률을 좌우하는 핵심 변수다(아래 상세).
acks=all만으로 안전하지 않은 이유
acks=all은 ISR에 있는 모든 브로커에 기록된 후 응답한다. 그런데 ISR에 리더 하나만 남아 있으면? acks=all이어도 리더 하나에만 쓰고 성공 응답이 온다. 리더가 죽으면 데이터가 유실된다.
안전한 구성은 세 가지를 함께 설정해야 한다.
# 프로듀서
acks=all
# 브로커 (토픽 단위)
replication.factor=3
min.insync.replicas=2이 상태에서는 최소 2개 브로커에 기록되어야 성공 응답을 보내므로, 브로커 1대가 죽어도 데이터는 안전하다.
실무 포인트: 메시지 유실이 허용되지 않는 시스템이라면
acks=all단독이 아니라replication.factor=3+min.insync.replicas=2조합이 최소 조건이다.
압축 — 모든 코덱과 레벨
프로듀서 레벨 compression.type의 유효 값은 다섯 가지이고, 토픽·브로커 레벨에서는 두 개가 더 있다.
- none — 압축하지 않음. CPU 비용 0, 네트워크·디스크 사용량 최대. 메시지가 매우 작거나 이미 압축된 데이터(이미지·동영상)일 때 적합. (토픽 레벨의
uncompressed가 none과 동일한 의미) - gzip — 압축률은 가장 높지만 CPU 비용도 가장 큼. 저장·대역폭 절감이 최우선이고 처리량 여유가 있을 때. 비슷한 압축률을 더 적은 CPU로 원하면 zstd가 대안.
- snappy — 속도가 빠르고 압축률은 보통. Google이 만든 코덱으로, CPU 여유가 적고 적당한 절감만 원할 때. 레벨 설정을 지원하지 않음.
- lz4 — 속도와 압축률의 균형이 가장 좋아 가장 무난한 기본 선택. 처리량이 최우선이면 1순위.
- zstd — 압축률은 gzip급인데 속도는 훨씬 빠름. 레벨 조정 폭이 가장 넓고 Kafka 2.1부터 지원. 압축률과 성능을 모두 원할 때 균형 잡힌 선택.
- producer (토픽·브로커 레벨 전용, 기본값) — 프로듀서가 설정한 원본 압축 코덱을 그대로 유지. 브로커가 재압축하지 않아 브로커 CPU를 아낌.
- uncompressed (토픽·브로커 레벨) — none과 동일하게 압축하지 않음.
운영 권장 패턴은 "프로듀서에서 압축 + 토픽은 producer로 유지"다. 토픽의 코덱이 producer가 아니고 프로듀서가 쓴 코덱과도 다르면, 브로커가 배치를 압축 해제했다가 토픽 설정대로 재압축하면서 CPU 부담이 커지기 때문이다.
압축 레벨(KIP-390)과 코덱별 벤치마크 수치
압축 강도를 더 세밀하게 조정하려면 레벨 설정을 쓴다. KIP-390으로 compression.gzip.level, compression.lz4.level, compression.zstd.level 세 가지가 프로듀서·토픽·브로커 설정에 추가됐다. snappy는 레벨을 지원하지 않아 제외되고, none·snappy일 때는 레벨 값이 무시된다.
- gzip — 레벨 1~9, 기본값 6. 숫자가 높을수록 압축률↑·CPU↑.
- zstd — 레벨 범위가 음수(최고 속도)부터 22(최고 압축률)까지로 가장 넓고, 기본값은 3.
- lz4 — 정수 레벨을 받으며 기본값은 라이브러리 기본을 따름.
- 효과의 크기 — 압축 크기를 좌우하는 주 변수는 코덱 선택이고, 레벨이 압축 크기에 주는 영향은 상대적으로 작다. 레벨을 낮출수록 초당 처리 메시지 수는 늘어난다. 즉 레벨 미세조정보다 코덱 선택이 먼저다.
코덱별 트레이드오프(약 50만 건 규모의 예시 벤치마크이며, 실제 수치는 데이터 특성에 따라 크게 달라진다):
| 코덱 | 압축률 | 처리량 | CPU | 레벨 조정 |
|---|---|---|---|---|
| none | 1.0x | 가장 높음 | 없음 | — |
| snappy | 약 3.4x | 높음 | 낮음 | 불가 |
| lz4 | 약 4.0x | 높음 | 낮음 | 가능 |
| zstd | 약 6.1x | 중간 | 중간 | 가능(넓음) |
| gzip | 약 7.7x | 낮음 | 높음 | 가능 |
예시 측정값: none 약 85만 msg/s·476MB, snappy 약 68만 msg/s·142MB(3.4배), lz4 약 78만 msg/s·118MB(4.0배), zstd 약 52만 msg/s·78MB(6.1배), gzip 약 32만 msg/s·62MB(7.7배).
압축은 배치 단위로 적용되므로 배치를 잘 채울수록 효율이 좋아진다. batch.size·linger.ms를 키워 배치를 키우면 압축률과 처리량이 함께 올라간다. 반대로 100바이트 미만의 작은 단건은 압축 효과가 미미하거나 오히려 손해이고, 컨슈머도 압축 해제에 CPU를 쓴다는 점을 고려해야 한다.
컨슈머와 컨슈머 그룹
읽는 쪽은 컨슈머 그룹 단위로 동작한다. 토픽의 파티션들이 그룹 내 컨슈머에게 나뉘어 배정되는 구조다.
- 컨슈머 그룹 — 같은
group.id를 가진 컨슈머들의 묶음. 하나의 파티션은 그룹 내에서 정확히 한 컨슈머에게만 배정되어 중복 소비를 막는다. 반대로, 서로 다른 컨슈머 그룹은 같은 파티션을 독립적으로 소비할 수 있다. - 병렬성의 상한 — 그룹의 유효 병렬 처리량은 파티션 수가 결정한다. 파티션 3개면 같은 그룹 내 최대 병렬 소비자도 3개이고, 컨슈머를 4개로 늘려도 1개는 유휴 상태가 된다.
auto.offset.reset— 커밋된 오프셋이 없을 때의 동작. 세 가지다.earliest— 파티션의 처음부터 읽음.latest— 최신 메시지부터 읽음.none— 커밋된 오프셋이 없으면 예외를 던짐. 오프셋 유실을 조용히 넘기지 않고 명시적으로 잡아야 할 때 쓴다.
- 컨슈머 생존 감지 — 리밸런싱 오작동을 막으려면 세 값을 함께 봐야 한다.
heartbeat.interval.ms— 백그라운드 스레드가 보내는 생존 신호 주기.session.timeout.ms— 이 시간 동안 신호가 없으면 그룹에서 제외.max.poll.interval.ms—poll()사이 처리 시간 상한. 처리가 느려 이 값을 넘기면 그룹에서 쫓겨난다.
오프셋 커밋 방식
Consumer가 "여기까지 읽었다"는 정보를 기록하는 것이 offset commit이다. 이 커밋 위치가 Consumer 재시작 시 어디서부터 읽을지를 결정한다.
| 방식 | 특성 | 위험 |
|---|---|---|
enable.auto.commit=true | 주기적으로 자동 커밋(auto.commit.interval.ms) | 처리 완료 전에 커밋되면 메시지 유실 |
commitSync() | 처리 완료 후 동기 커밋 | 커밋 실패 시 블로킹, 처리량 저하 |
commitAsync() | 처리 완료 후 비동기 커밋 | 커밋 실패 시 재시도 없음, 중복 소비 가능 |
실무에서 가장 많이 쓰는 패턴은 auto commit을 끄고 처리 완료 후 commitSync()를 호출하는 것이다. 처리량이 중요한 경우에는 commitAsync()를 기본으로 쓰되, Consumer 종료 시점에 commitSync()를 한 번 호출하는 하이브리드 방식도 있다.
커밋된 offset은 Kafka 내부 토픽인 __consumer_offsets에 저장된다. 이 토픽도 파티션이 나뉘어 있고(기본 50개), Consumer Group ID를 키로 해서 해당 그룹의 offset 정보를 보관한다. 별도의 외부 저장소 없이 Kafka 자체가 offset을 관리하므로, 클러스터만 정상이면 읽기 위치 정보도 안전하다.
실무 포인트: auto commit은 편하지만 "처리는 실패했는데 offset만 전진"하는 유실 시나리오를 만든다. 메시지 유실이 치명적인 도메인이라면 수동 커밋이 기본이다.
리밸런싱과 파티션 할당 전략
컨슈머가 들어오거나 빠지면, 또는 토픽의 파티션 수가 바뀌면 파티션을 재배정(리밸런싱)한다. partition.assignment.strategy는 네 가지다.
- RangeAssignor — 토픽별로 파티션 범위를 연속 배정. (eager: 리밸런싱 시 전체 중단)
- RoundRobinAssignor — 모든 파티션을 라운드로빈으로 고르게 배정. (eager)
- StickyAssignor — 기존 배정을 최대한 유지하며 균형을 맞춤. (eager)
- CooperativeStickyAssignor — 유일하게 점진적(cooperative) 재배정을 한다. 전체 중단(stop-the-world) 없이 옮길 파티션만 단계적으로 회수·재배정해 멈춤이 줄어든다. Spring Kafka에서는 이것이 기본 설정이다.
차세대 컨슈머 리밸런스 프로토콜(KIP-848)은 3.x에서 preview였다가 4.0에서 GA가 됐고, 업그레이드 완료 시 서버에서 자동 활성화된다. 조정 로직을 클라이언트에서 브로커 측 그룹 코디네이터로 옮겨 기존 stop-the-world를 없애 대규모 그룹의 리밸런싱 안정성을 크게 높인다. 다만 이 프로토콜을 한 번 쓰면 클러스터는 3.4.1 이상으로만 다운그레이드 가능하니 운영 전 확인이 필요하다.
리밸런싱이 반복되는 원인
실무에서 리밸런싱이 끊임없이 반복되는 "리밸런싱 폭풍"을 겪는 경우가 있다.
session.timeout.ms=45000 # heartbeat 없으면 죽은 것으로 판단
heartbeat.interval.ms=3000 # session.timeout.ms의 1/3 이하 권장
max.poll.interval.ms=300000 # poll() 간격이 이 값을 넘기면 그룹에서 제외- GC pause: Full GC가
session.timeout.ms를 초과하면 브로커가 컨슈머를 죽은 것으로 판단한다. - 처리 지연: 한 번의
poll()로 가져온 레코드를max.poll.interval.ms안에 처리하지 못하면 그룹에서 강제 제거된다. - 네트워크 불안정: heartbeat가 도달하지 못하면 같은 결과다.
대응은 max.poll.records를 줄여 한 번에 처리할 양을 줄이거나 max.poll.interval.ms를 늘리는 것이다. 근본적으로는 컨슈머 처리 로직 자체를 개선해야 한다.
실무 포인트: 리밸런싱 반복의 80%는 컨슈머 처리 지연이 원인이다.
max.poll.records를 먼저 줄여보는 것이 가장 빠른 대응이다.
전달 보증 (Delivery Semantics)
위 설정들의 조합으로 전달 보증 수준이 결정된다. 핵심은 처리(process)와 오프셋 커밋(commit)의 순서다.
| 수준 | 의미 | 발생 조건 |
|---|---|---|
| At-most-once | 최대 한 번. 유실 가능, 중복 없음 | offset을 먼저 커밋하고 처리 |
| At-least-once | 최소 한 번. 유실 없음, 중복 가능 | 처리 후 offset 커밋 |
| Exactly-once | 정확히 한 번. 유실 없음, 중복 없음 | EOS 구성 필요 |
대부분의 시스템은 at-least-once로 설계하고, 컨슈머 쪽에서 멱등성(idempotency)을 보장하는 것이 현실적인 선택이다.
Idempotent Producer의 범위
enable.idempotence=true프로듀서가 네트워크 오류로 재시도할 때, 같은 메시지가 브로커에 중복 기록되는 것을 방지한다. 프로듀서가 각 메시지에 시퀀스 번호를 붙이고, 브로커가 이를 검증해 중복을 걸러낸다. 단, idempotent producer는 단일 프로듀서 세션 내, 단일 파티션에 대한 중복 방지만 보장한다. 여러 토픽에 걸친 트랜잭션이나, 프로듀서 재시작 후의 중복까지는 막지 못한다.
Exactly-Once Semantics(EOS) 완전 구성
진정한 exactly-once를 위해서는 세 가지를 함께 설정해야 한다.
# 프로듀서
enable.idempotence=true
transactional.id=my-transactional-producer
# 컨슈머
isolation.level=read_committedenable.idempotence=true: 파티션 내 중복 방지.transactional.id: 프로듀서가 여러 파티션에 원자적으로 쓸 수 있게 한다. 프로듀서 재시작 후에도 미완료 트랜잭션을 정리(abort)한다.isolation.level=read_committed: 컨슈머가 커밋된 트랜잭션의 메시지만 읽는다. 미완료 트랜잭션의 메시지는 보이지 않는다.
EOS는 주로 Kafka Streams의 consume-transform-produce 패턴(processing.guarantee=exactly_once_v2)에서 사용된다. 일반적인 Consumer-Producer 조합에 적용하면 처리량이 크게 떨어지므로, 정말 필요한 경우에만 쓴다.
실무 포인트: 대부분의 서비스는 at-least-once + 컨슈머 멱등성 처리가 가장 현실적인 선택이다. EOS는 비용이 크므로 금융 거래 등 정확히 한 번이 비즈니스 요구사항인 경우에 한정한다.
Consumer Lag
Consumer Lag은 프로듀서가 마지막으로 쓴 offset과 Consumer가 마지막으로 커밋한 offset의 차이다.
Lag이 0이면 컨슈머가 실시간으로 따라가고 있다는 뜻이다. Lag이 지속적으로 증가하면 컨슈머가 프로듀서의 쓰기 속도를 따라가지 못하고 있다는 신호다.
Lag이 증가하는 원인:
- 컨슈머 처리 로직이 느림(외부 API 호출, 무거운 DB 쿼리 등)
- 파티션 수 대비 컨슈머 인스턴스가 부족함
- 리밸런싱 반복으로 소비가 멈추면서 Lag 누적
- 이벤트 세일·배치 작업 등으로 프로듀서 트래픽 급증
왜 모니터링이 필수인가: Lag이 증가하면 사용자에게 보이는 데이터가 지연되고(실시간 대시보드·알림), 보존 정책에 의해 아직 못 읽은 메시지가 삭제될 수 있으며, 컨슈머 재시작 시 밀린 메시지를 한꺼번에 처리하며 다운스트림에 부하를 준다. Burrow, Kafka Exporter + Prometheus + Grafana 같은 도구로 파티션 단위 Lag을 모니터링하고 임계값 초과 시 알림을 걸어야 한다.
실무 포인트: Consumer Lag은 Kafka 운영의 가장 중요한 메트릭이다. Lag 알림 없이 Kafka를 운영하는 것은 속도계 없이 고속도로를 달리는 것과 같다.
파티션 수 설계
파티션 수는 운영 중에 늘릴 수 있다. 하지만 줄일 수는 없다. 그리고 키 기반 파티셔닝을 사용하고 있다면, 파티션 수를 늘리는 순간 키와 파티션의 매핑이 달라진다.
같은 키의 메시지가 다른 파티션으로 가면 해당 키의 순서 보장이 깨진다. compact 토픽에서는 상태 정합성 문제도 생길 수 있다.
파티션 수 증가의 장단점:
- 장점 — 병렬 소비 단위가 늘어 처리량이 증가하고, 컨슈머를 더 많이 투입할 수 있다.
- 단점 — 파티션당 세그먼트 파일이 늘어 브로커의 파일 디스크립터 사용량이 증가하고, 브로커 장애 시 리더 재선출 대상이 많아져 선출 시간이 늘며, 복제 부담으로 end-to-end 지연이 커질 수 있다.
산정은 "목표 처리량 ÷ 파티션당 처리량"으로 하되, 늘리기는 쉬워도 줄이기 어렵고 키 순서가 깨질 수 있으니 처음부터 여유를 둔다. 특정 키에 트래픽이 몰리면 핫 파티션이 생기므로 키 분포도 확인한다.
실무 포인트: 파티션 수는 처음에 예상 최대 처리량 기준으로 넉넉하게 잡는다. 키 기반 파티셔닝을 쓰고 있다면 운영 중 파티션 추가는 매우 신중해야 한다.
실무 튜닝 체크리스트
- 파티션 수 설계 — 목표 처리량 ÷ 파티션당 처리량으로 산정하되 처음부터 여유를 둔다. 과도하면 리밸런싱·메타데이터 부담이 커진다.
- 키 설계 — 순서 보장이 필요한 단위(예: 주문 ID, 딜러 ID)를 키로 삼아 같은 파티션으로 모은다. 단, 핫 파티션이 생기지 않게 분포를 확인한다.
- 프로듀서 튜닝 — 안전이 우선이면
acks=all+enable.idempotence=true, 처리량이 우선이면linger.ms·batch.size를 키우고compression.type=lz4또는zstd를 적용한다. - 컨슈머 튜닝 —
max.poll.records와 처리 시간을 함께 보고max.poll.interval.ms를 넉넉히 잡아 리밸런싱 오작동을 막는다.fetch.min.bytes·fetch.max.wait.ms로 폴링 효율을 조정한다. - 모니터링 — consumer lag(
records-lag-max)이 가장 중요한 건강 지표다. 그 외 under-replicated partitions, ISR 축소, 디스크 사용률, KRaft의ActiveControllerCount등을 함께 본다. - 운영 안정성 —
replication.factor=3,min.insync.replicas=2,unclean.leader.election.enable=false를 안전 기본값으로 삼고, 토픽별로 보존·압축 정책을 명시한다.
Spring Kafka 매핑
Spring Kafka를 쓰는 환경이라면 위 개념들이 다음과 같이 연결된다.
@KafkaListener+ConcurrentKafkaListenerContainerFactory—concurrency가 그룹 내 컨슈머 수에 해당하므로 파티션 수와 맞춘다.AckMode— 오프셋 커밋 시점을 제어해 at-least-once를 명확히 구현한다. 일곱 가지다.RECORD— 레코드 하나 처리 후 커밋.BATCH(기본) —poll()로 받은 배치 처리 후 커밋.TIME— 지정 시간마다 커밋.COUNT— 지정 건수마다 커밋.COUNT_TIME— 건수 또는 시간 중 먼저 도달한 쪽에서 커밋.MANUAL— 애플리케이션이ack를 호출하고 다음 poll 시 커밋.MANUAL_IMMEDIATE—ack호출 즉시 커밋.
DefaultErrorHandler+DeadLetterPublishingRecoverer— 재시도·DLT(데드레터 토픽)를 구성해 독성 메시지(poison message)가 컨슈머를 막지 않게 한다.KafkaTransactionManager— 트랜잭션 프로듀서를 묶어 exactly-once 구간을 처리한다.- 그룹 ID 전략 — 고정 그룹은 오프셋 이어받기, 랜덤 그룹은 매번 처음부터(broadcast성) 소비에 쓰며,
auto.offset.reset과 함께 동작을 결정한다.
마무리
Kafka는 "위치를 기억하는 분산 Commit Log"라는 본질에서 출발해, 전체 구조 → 저장 → 메타데이터(KRaft) → 복제/ISR → 프로듀서 → 컨슈머/리밸런싱 → 전달 보증 → Lag → 파티션 설계 → 실무 튜닝이 하나의 흐름으로 이어진다. 그 중심에는 메타데이터를 외부 시스템에 두던 구조를 카프카 안의 복제 로그로 끌어들인 KRaft 전환이 있다.
신규 클러스터라면 KRaft로 시작하고, 기존 ZooKeeper 클러스터라면 3.9를 브리지로 삼은 마이그레이션을 미리 그려두는 것 — 그게 3.x를 이해하는 이유이자 4.0을 맞이하는 준비다.
주간 기술 뉴스레터
Backend · AI · Java 핵심 내용을 매주 이메일로 보내드립니다.