Kafka는 왜 메시지 큐가 아닌 분산 로그인가
메시지를 소비해도 삭제하지 않는 설계 결정부터 순차 I/O, Zero-Copy, KRaft까지 — Kafka의 모든 선택이 '로그'라는 하나의 철학에서 나온다.
- 01 Kafka는 왜 메시지 큐가 아닌 분산 로그인가
- 02 Kafka는 어떻게 메시지를 잃지 않는가
- 03 Kafka는 어떻게 메시지를 정확히 전달하는가
- 04 Kafka Consumer는 왜 멈추는가
- 05 Kafka 처리량은 어디서 결정되는가
- 06 Kafka Streams의 모든 설계는 어디에서 왔는가
- 07 Spring Kafka는 왜 그렇게 설계되었는가
Kafka를 처음 접하면 “빠른 메시지 큐”로 이해하기 쉽다. RabbitMQ를 쓰던 방식 그대로 붙여도 일단 동작하기 때문이다. 하지만 6개월쯤 지나면 이상한 일이 생기기 시작한다 — “이미 소비한 메시지를 왜 Kafka가 지우지 않지?”, “새 분석 시스템을 붙이려는데 이전 메시지를 어떻게 다시 읽지?”, “JVM heap을 늘렸더니 오히려 느려졌다.” 이 모든 혼란은 Kafka의 설계 철학을 MQ처럼 읽은 데서 온다. Kafka는 편지함이 아니라 로그다 — 그 차이가 모든 것을 바꾼다.
분산 커밋 로그라는 출발점
MQ에서 메시지는 Consumer가 ACK를 보내는 순간 삭제된다. 편지를 읽으면 없어지는 구조다. Kafka는 반대다. 파티션은 append-only 로그로, 메시지는 retention 기간이 지날 때까지 디스크에 남는다. Consumer는 로그에서 자신의 읽기 위치인 offset만 기억한다.
파티션 0의 로그:
┌──────────┬──────────┬──────────┬──────────┬──────────┐
│ offset:0 │ offset:1 │ offset:2 │ offset:3 │ offset:4 │
│ key=A │ key=B │ key=A │ key=C │ key=B │
└──────────┴──────────┴──────────┴──────────┴──────────┘
↑ 쓰기: 항상 끝에만 추가
Consumer Group A: offset=3 (3까지 처리 완료)
Consumer Group B: offset=1 (아직 1까지만 처리)
→ A와 B는 완전히 독립적. 서로 영향 없음
이 구조가 “여러 시스템이 동일 이벤트를 독립적으로 소비한다”는 Kafka의 핵심 가치를 만든다. 실시간 결제 처리, 이력 분석, 감사 로그가 동일 토픽을 각자의 offset으로 소비한다. 새 시스템이 생기면 offset 0부터 시작해 과거 전체를 재처리할 수 있다. MQ였다면 이미 사라진 메시지다.
파티션 — 병렬성의 기본 단위
로그는 파티션 단위로 나뉜다. 파티션은 Kafka 병렬성의 최소 단위다. 동일 Consumer Group 안에서 파티션 1개는 Consumer 1개에만 할당된다.
Consumer Group "order-processors" (파티션 3개):
Consumer 3개 (최적): Consumer 4개 (낭비):
Consumer 1 → Partition 0 Consumer 1 → Partition 0
Consumer 2 → Partition 1 Consumer 2 → Partition 1
Consumer 3 → Partition 2 Consumer 3 → Partition 2
Consumer 4 → (IDLE)
파티션 수보다 Consumer를 많이 늘려도 처리량은 늘지 않는다. Consumer 수는 파티션 수가 상한이다.
메시지 키를 지정하면 hash(key) % numPartitions로 파티션이 결정된다. 동일 키는 항상 동일 파티션 → 파티션 내 순서 보장. 단, 파티션 수를 나중에 늘리면 키 해시 분포가 바뀌어 순서 보장이 깨진다. 처음 설계 시 여유 있게 설정해야 하는 이유다.
kafka-topics --alter --partitions로 파티션 수를 감소시키려 하면 InvalidPartitionsException이 발생한다. 기존 데이터를 어떤 파티션으로 병합할지 결정 불가능하기 때문이다. 처음부터 예상 최대 처리량의 2~3배로 설정하라.
왜 빠른가 — 순차 I/O와 Zero-Copy
“Kafka가 빠른 건 메모리를 쓰기 때문”이라는 오해가 많다. 그래서 JVM heap을 16 GB로 늘렸다가 GC 압박으로 오히려 느려지는 실수가 생긴다. Kafka의 성능은 세 가지에서 나온다.
첫째, 순차 I/O. .log 파일 끝에만 append하므로 디스크 헤드 이동이 최소화된다. HDD에서 랜덤 I/O는 ~1 MB/s이지만 순차 I/O는 ~150 MB/s다. 150배 차이다.
둘째, OS 페이지 캐시. Kafka는 JVM이 직접 캐싱하지 않는다. write() 시스템 콜로 OS 페이지 캐시에 쓰고, flush는 OS에 위임한다. Producer가 방금 쓴 메시지는 페이지 캐시에 있으므로, Consumer가 바로 읽으면 디스크 접근이 0이다. JVM heap을 작게(6~8 GB) 유지하고 나머지를 OS 페이지 캐시에 양보하는 것이 최적인 이유다.
셋째, Zero-Copy. Consumer의 Fetch 요청에 sendfile() 시스템 콜로 응답한다.
일반 전송: 디스크 → 커널 버퍼 → 유저 버퍼(JVM) → 소켓 버퍼 → NIC (복사 4회)
Zero-Copy: 디스크 → 커널 버퍼 → NIC (복사 2회, 유저 공간 없음)
JVM 내에서 데이터를 읽고 다시 쓰는 과정이 없다. 디스크 기반임에도 높은 처리량을 내는 핵심이다.
Producer와 Consumer의 설계 철학
Producer는 send()를 호출해도 메시지가 즉시 브로커로 전송되지 않는다. RecordAccumulator라는 내부 버퍼에 파티션별로 쌓이고, 별도 Sender 스레드가 배치로 전송한다.
배치 전송 조건은 두 가지다 — batch.size에 도달하거나, linger.ms가 만료되거나. 둘 중 먼저 충족되는 것이 기준이다. linger.ms=0이면 메시지 하나 들어올 때마다 즉시 전송해 네트워크 요청이 많아지고 처리량이 낮다. linger.ms=20으로 설정하면 배치가 꽉 차서 전송될 가능성이 높아져 처리량이 3~4배 높아지지만, 20ms 지연이 추가된다.
Consumer는 Pull 방식이다. 브로커가 밀어주는 것이 아니라 Consumer가 자신의 처리 속도에 맞게 Fetch한다. Consumer가 느려도 브로커에 영향이 없다. Lag(처리 지연)만 쌓일 뿐이다.
max.poll.records=500, 레코드당 처리 시간 100ms라면 배치 처리에 50초가 걸린다. max.poll.interval.ms(기본 5분)보다 짧지만, 외부 API 호출이 느려지면 초과할 수 있다. max.poll.records × 레코드당 최대 처리 시간 < max.poll.interval.ms를 항상 보장해야 한다.
트레이드오프
Kafka의 설계는 명확한 비용을 가진다.
- 개별 메시지 라우팅 불가: RabbitMQ의 Exchange/Routing Key 같은 복잡한 라우팅은 없다. 토픽을 세분화하거나 Consumer에서 필터링해야 한다.
- 메시지 우선순위 미지원: 우선순위 높은 메시지를 먼저 처리하려면 별도 토픽으로 분리해야 한다.
- 디스크 비용: 모든 메시지를 retention 기간 동안 보관한다.
retention.ms와retention.bytes로 제어하거나,cleanup.policy=compact로 키별 최신 값만 보존하는 Log Compaction을 쓸 수 있다. - 운영 복잡성: ZooKeeper 모드에서는 ZooKeeper 앙상블을 별도로 운영해야 한다.
마지막 항목은 KRaft로 해소됐다. Kafka 3.3부터 Production Ready로 선언된 KRaft는 ZooKeeper를 제거하고 Raft 합의로 메타데이터를 Kafka 내부(__cluster_metadata 토픽)에서 직접 관리한다. Controller 재시작 시간이 파티션 10만 개 기준 5분에서 10초로 줄었다. Kafka 4.0부터는 ZooKeeper 모드 자체가 제거됐다. 신규 클러스터라면 KRaft를 기본으로 선택해야 한다.
정리
- Kafka의 모든 설계는 “분산 커밋 로그”라는 추상에서 나온다 — 메시지를 삭제하지 않고, Consumer가 offset으로 위치를 추적한다.
- 성능의 핵심은 메모리가 아니다. 순차 I/O + OS 페이지 캐시 + Zero-Copy의 조합이다. JVM heap은 작게, 나머지는 OS에 양보하라.
- 파티션 수 = 최대 병렬 Consumer 수. 처음부터 여유 있게 설정하라. 줄이는 것은 불가능하다.
- KRaft는 Kafka의 미래다. Kafka 4.0부터 ZooKeeper는 없다.
다음 글에서는 파티션 복제(Replication)가 어떻게 내구성을 보장하는지, ISR과 acks 설정이 어떤 트레이드오프를 만드는지 추적한다.