본문 바로가기
Engineering

카프카가 빠른 이유

by kghworks 2026. 8. 15.

목차

  • Sequential I/O와 Page Cache
  • Zero-Copy
  • Batching과 압축
  • Partition = Lock-Free 병렬성
  • 네트워크 레이어
  • 종합 + 벤치마크
  • 참고

Sequential I/O와 Page Cache

카프카가 빠른 이유를 한 문장으로 말하면: 디스크에 sequential 하게만 접근하고, OS에게 캐싱을 맡긴다.

 

디스크의 물리학

흔히 "디스크는 느리다"고 하지만, 이건 random access에 한정된 얘기다.

  • HDD: random seek에 ~10ms가 걸리지만 sequential read는 200MB/s 이상 나온다. 같은 디스크에서 1000배 차이.
  • SSD: random 4KB IOPS가 100K 수준이라 해도, sequential bandwidth는 3~7GB/s(NVMe)까지 간다. 여전히 10~50배 차이.

 이유는 단순하다. sequential access는 다음 주소를 예측할 수 있어서 HW controller가 prefetch를 건다. random은 예측이 불가능하니 매번 새 요청을 보내야 한다.

 

 ACM Queue의 유명한 벤치마크(Pathologies of Big Data, 2009)에서 sequential disk write가 random memory access보다 빠르다는 걸 보여줬고, 카프카 설계 문서는 이걸 명시적으로 인용한다.

 

Page Cache

Linux는 모든 파일 I/O에 page cache를 끼운다. 흐름을 살펴보면 다음과 같다:

write() → VFS → page cache (dirty page) → 비동기 flush → block device
read()  → VFS → page cache hit? → 있으면 즉시 리턴, 없으면 disk read + cache 적재

 

 커널은 sequential read를 감지하면 read-ahead를 건다 (/sys/block/*/queue/read_ahead_kb, 기본 128KB). 아직 요청하지 않은 뒷부분을 미리 page cache에 올려놓는 셈이다.

 

카프카는 이걸 의도적으로 이용한다:

  • JVM heap에 캐시를 만들지 않는다. 대신 FileChannel.write()로 바로 OS에 넘기면 된다.
  • 이점 1: GC pressure 제거. 수십 GB 데이터를 JVM heap에 올리면 Full GC가 수 초씩 stop-the-world를 건다.
  • 이점 2: 프로세스 재시작해도 page cache는 살아있다 (warm restart).
  • 이점 3: 동일 데이터의 이중 버퍼링(JVM heap + page cache) 회피 → 메모리 효율 2배.

 

Append-only Log

카프카의 저장 자료구조는 단순한 append-only log 파일이다:

  • 쓰기: 항상 파일 끝에 append → seek 없음 → O(1)
  • 읽기: consumer는 offset(바이트 위치)을 알고 있으므로, seek 한 번 후 sequential read

 

 전통 DB의 B-tree는 write 할 때마다 트리 rebalance가 필요하고, 한 record 쓰기에 여러 page를 건드린다 (write amplification 10~30x). 카프카는 1 write = 1 append. write amplification이 1인 셈이다.


Zero-Copy

전통적 파일 전송 경로

일반적인 "파일을 소켓으로 보내기" 패턴을 살펴보면 이렇다:

 

 4번 복사, 4번 context switch (read syscall 진입/복귀 + send syscall 진입/복귀). CPU가 데이터를 아무 변환 없이 그냥 옮기기만 하는데 2번이나 개입하는 구조다.

 

sendfile(2)

Linux의 sendfile(2)를 쓰면 이 문제를 해결할 수 있다:

ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

 

 유저스페이스를 한 번도 거치지 않는다. scatter-gather DMA를 지원하는 NIC라면, CPU copy는 실질적으로 0번이다. context switch도 2번으로 줄어든다.

 

카프카 코드

실제 카프카 코드를 보면 이렇게 동작한다:

// FileRecords.java (apache/kafka trunk)
@Override
public int writeTo(TransferableChannel destChannel, int offset, int length) throws IOException {
    long position = start + offset;
    int count = Math.min(length, oldSize - offset);
    return (int) destChannel.transferFrom(channel, position, count);
}

TransferableChannel.transferFrom()FileChannel.transferTo() → JNI → sendfile64()로 이어진다.

 

 Consumer가 fetch할 때 broker는 이 경로를 탄다. 디스크(page cache) → NIC으로 데이터가 직행하고, CPU는 metadata만 처리할 뿐 payload를 건드리지 않는다.

 

 참고: RabbitMQ는 메시지를 Erlang VM의 메모리에서 AMQP 프레임으로 직렬화해서 보낸다. 매 메시지마다 user-space serialization이 발생한다.


Batching과 압축

Producer 배칭

카프카 producer는 메시지를 즉시 보내지 않는다. RecordAccumulator가 partition별로 Deque<ProducerBatch>를 유지하며, 두 조건 중 하나가 충족될 때까지 쌓아둔다:

  1. batch.size (기본 16KB)만큼 차거나
  2. linger.ms (기본 0ms, 실전에서는 5~50ms 설정)만큼 기다리거나

 

TCP의 Nagle 알고리즘과 같은 사고방식이다: 작은 패킷 여러 개 보내는 것보다, 모아서 한 번에 보내는 게 효율적이다.

효과를 정리해 보면:

  • 1 batch = 1 network round-trip = 1 TCP segment = 1 syscall
  • per-message overhead가 batch 크기에 비례해 상각(amortize)된다

 

Broker 디스크 배칭

Broker는 RecordBatch를 그대로 segment 파일에 append 해주면 된다. 개별 record를 파싱 하거나 재직렬화하지 않는다.

RecordBatch v2 (Kafka 0.11+) 구조:

  • 배치 단위 CRC (개별 record CRC 불필요)
  • 배치 단위 압축 (전체 record body를 하나의 blob으로 압축)
  • delta-encoded offset/timestamp → per-record overhead 최소화

 

Consumer fetch 배칭

Consumer의 fetch.min.bytes (기본 1)를 설정해 주면 된다. broker가 이 바이트 이상 축적될 때까지 응답을 지연한다 (long poll). 네트워크 패킷의 payload 활용률을 극대화하는 방식이다.

압축

배치 레벨 압축이기 때문에 효과가 크다:

  • 유사 구조의 메시지가 한 배치에 모이면 dictionary 효과가 극대화된다 (JSON key 반복 등)
  • end-to-end: Producer가 압축 → Broker는 그대로 저장 → Consumer가 해제. Broker CPU는 압축/해제에 관여하지 않는다.

lz4/zstd 기준, 일반적인 JSON 메시지에서 50~80% 압축률을 보인다. 같은 network bandwidth로 2~5배 많은 메시지를 보낼 수 있는 효과와 같다.


Partition = Lock-Free 병렬성

독립 자료구조

파티션은 물리적으로 독립된 디렉터리이고, 그 안에 독립된 segment 파일들이 있다:

/kafka-logs/
  topic-0/   ← partition 0
    00000000000000000000.log
    00000000000000000000.index
  topic-1/   ← partition 1
    00000000000000000000.log
    ...

 

파티션 간 공유 상태가 없다:

  • 독립 I/O stream → OS가 각각에 대해 별도 readahead window를 유지
  • 독립 page cache 영역 → 한 파티션의 eviction이 다른 파티션에 영향 주지 않음
  • 쓰기에 cross-partition lock이 불필요

 

자료구조 레벨에서 lock-free 병렬성을 보장하는 구조인 셈이다. 파티션을 늘리면 throughput이 거의 선형으로 스케일 한다.

 

Offset = Atomic Long

Consumer의 "이 메시지를 처리했다"는 표현 방식을 비교해 보면:

  • Kafka: 처리한 마지막 offset을 commit 한다. batch 단위로 한 번에 쓰면 된다. per-message ack이 아니라 offset 하나만 기록하면 되므로 lock이 불필요하다.
  • RabbitMQ: per-message ack. broker는 각 메시지의 delivery 상태를 추적하고, redelivery를 위해 lock을 건다. prefetch window 만큼의 unacked 메시지를 in-memory 관리한다.

Kafka의 파티션 모델에서는 "이 consumer가 현재 어디까지 읽었는가"만 저장하면 된다 (committed offset). 개별 메시지 상태 관리가 없으니 훨씬 가볍다.


네트워크 레이어

Reactor 패턴

  • Acceptor: 새 연결만 accept 해서 Processor에 분배
  • Processor: epoll(Linux)/kqueue(macOS) selector로 I/O 이벤트 다중화. thread-per-connection이 아니라 event-loop 방식으로 동작한다.
  • Handler: 실제 비즈니스 로직(produce, fetch, metadata 등) 처리

 

이 구조로 수천~수만 연결을 thread 수십 개로 처리할 수 있다. context switch가 최소화되는 구조에 해당한다.

 

바이너리 프로토콜

카프카는 HTTP/REST가 아니라 커스텀 바이너리 프로토콜을 사용한다:

  • 첫 4바이트가 전체 프레임 길이 → O(1)으로 경계 파싱 가능
  • HTTP의 text-based 헤더 파싱, chunked encoding 처리 등이 없음
  • correlationId 4바이트로 request-response 매칭 (HTTP/1.1의 head-of-line blocking도 없음)

 

TCP 튜닝

카프카 broker 설정을 살펴보면:

  • socket.send.buffer.bytes / socket.receive.buffer.bytes: TCP 소켓 버퍼 크기
  • 이 값은 BDP(Bandwidth-Delay Product)와 맞춰야 한다: BDP = bandwidth × RTT
  • 예: 10 Gbps link, 1ms RTT → BDP = 1.25MB. 소켓 버퍼가 이보다 작으면 bandwidth를 다 못 쓴다.

종합 + 벤치마크

제거하는 bottleneck OS/HW 근거
Sequential I/O disk seek, random I/O page cache prefetch, write-back
Zero-Copy CPU copy, context switch sendfile(2), DMA gather
Batching per-msg syscall/network overhead amortization, 배치 압축
Partitioning lock contention 독립 I/O stream, atomic offset
Network thread overhead, parsing epoll reactor, binary protocol

 

 이 5가지는 독립적으로 작동하지만, 각 축이 bottleneck을 제거하면 병목이 다음 축으로 이동하고, 모든 축에서 병목을 제거했을 때 하드웨어 한계에 도달한다. 5개 축 모두에서 병목을 제거했기 때문에, 최종적으로 하드웨어 한계(disk bandwidth 또는 network bandwidth)까지 도달할 수 있다.

직접 측정

환경: Docker Desktop / Apple Silicon / wurstmeister/kafka:2.8.1 / 단일 broker / 6 partitions

 

Producer

메시지 크기 records/sec MB/sec avg latency p99 latency
100B 1,748,251 166.7 5.31ms 39ms
1KB 264,970 258.8 111ms 303ms

 

Consumer

메시지 크기 MB/sec records/sec
100B 117.4 1,231,527
1KB 309.5 316,957

 

Docker 오버헤드 + 단일 broker 제약이 있는데도:

  • 소형 메시지(100B): 174만 msg/s. per-message overhead가 무시할 수 있을 만큼 batching이 작동한다는 증거다.
  • 대형 메시지(1KB): Producer 258MB/s, Consumer 309MB/s. Consumer가 더 높은 건 zero-copy가 주된 요인으로 추정된다. Broker CPU가 payload를 건드리지 않으므로 consumer fetch가 producer write보다 가볍다.

참고 — LinkedIn 공식 수치 (2014): 단일 producer에서 821K msg/s (82MB/s), 3-broker cluster에서 2M msg/s 이상. Confluent 벤치마크 (2020): i3.xlarge 6-broker에서 605MB/s producer throughput.

결론

카프카는 "빠르게 만든" 게 아니라, 느려질 수 있는 모든 이유를 설계 단계에서 제거한 것이다.

  • random I/O를 할 이유가 없도록 → append-only log
  • CPU가 데이터를 만질 이유가 없도록 → zero-copy
  • 메시지마다 syscall을 칠 이유가 없도록 → batching
  • lock을 잡을 이유가 없도록 → partition
  • 프레임을 파싱 할 이유가 없도록 → binary protocol

남는 건 하드웨어 한계(disk bandwidth, network bandwidth)뿐이다.


참고

  • Kreps, J. et al. (2011). "Kafka: a Distributed Messaging System for Log Processing." NetDB.
  • LinkedIn Engineering Blog (2014). "Benchmarking Apache Kafka: 2 Million Writes Per Second."
  • Confluent (2020). "Apache Kafka Benchmarks."
  • ACM Queue (2009). "The Pathologies of Big Data."
  • Apache Kafka Design Documentation. https://kafka.apache.org/documentation/#design
 

Documentation Redirect

Apache Kafka

kafka.apache.org

https://product.kyobobook.co.kr/detail/S000201464167

 

카프카 핵심 가이드 - 교보문고

카프카를 창시한 사람들이 쓰고, 카프카 개발에 참여한 한국인 개발자가 옮긴 핵심 실무서

product.kyobobook.co.kr

 

댓글