목차
- 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>를 유지하며, 두 조건 중 하나가 충족될 때까지 쌓아둔다:

batch.size(기본 16KB)만큼 차거나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
'Engineering' 카테고리의 다른 글
| K8s + Spring Boot: Graceful Shutdown 제대로 이해하기 (0) | 2026.08.12 |
|---|---|
| [HTTP] POST, PUT, PATCH 그리고 멱등성 (1) | 2024.01.02 |
댓글