본문 바로가기
Lab/Mini-Broker

[Mini-Broker] 06. 벤치마크 측정

by junseokoo 2026. 5. 29.

Intro

5편까지 mini-broker의 핵심 기능을 모두 구현했다. append-only log, sparse index, NIO TCP 서버, Producer/Consumer 클라이언트, 파티션, 컨슈머 그룹.

이제 궁금한 건 하나다. 얼마나 빠를까?

그냥 빠른지 느린지가 아니라, Apache Kafka와 비교했을 때 어느 정도인지. 그래서 JMH(Java Microbenchmark Harness)로 벤치마크를 작성했다.

그런데 첫 번째 시도는 완전히 틀린 비교였다. 숫자는 나왔지만 의미가 없었다. 이 글은 그 실수와 수정, 그리고 최종 결과를 기록한다.


첫 번째 시도 - 틀린 비교

처음 작성한 벤치마크는 두 가지였다.

AppendBenchmark — LogSegment.append() 직접 호출:

@Benchmark
public void appendMessage() throws IOException {
    segment.append(key, value);
}

KafkaBenchmark — KafkaProducer.send() 호출:

@Benchmark
public void sendMessage() {
    producer.send(new ProducerRecord<>("bench-topic", key, value));
}

결과는 이랬다:

AppendBenchmark.appendMessage  thrpt  5   570,149 ops/s
KafkaBenchmark.sendMessage     thrpt  5  2,181,010 ops/s

Kafka가 mini-broker보다 3.8배 빠르다. 그런데 뭔가 이상하다.

Kafka는 ZooKeeper(또는 KRaft), 복잡한 프로토콜, 레플리케이션까지 처리하는 분산 시스템이다. 그게 파일에 직접 쓰는 것보다 3.8배 빠르다고?


왜 틀렸을까? - 비교 대상이 달랐다

문제는 측정하는 대상이 전혀 달랐다는 것이다.

AppendBenchmark:

  • LogSegment.append() 직접 호출
  • 네트워크 없음
  • 파일에 실제로 쓴다
  • 동기 — 쓰고 나서 리턴

KafkaBenchmark (첫 번째):

  • producer.send() 호출
  • Kafka Producer 내부 버퍼에만 집어넣고 즉시 리턴
  • 실제 네트워크 전송, 디스크 쓰기는 백그라운드 스레드가 나중에 처리
  • 비동기 — 디스크에 쓰기 전에 리턴

즉, mini-broker는 "파일에 쓴 횟수" 를 세고, Kafka는 "버퍼에 넣은 횟수" 를 세고 있었다. 사과와 오렌지를 비교한 것이다.


공정한 비교를 위한 수정

공정한 비교가 되려면 조건을 맞춰야 한다.

1. Kafka도 동기로 측정 — send().get()으로 실제 브로커 응답까지 기다린다:

@Benchmark
public void sendMessage() throws Exception {
    producer.send(new ProducerRecord<>("bench-topic", key, value)).get();
}

2. mini-broker도 네트워크 경유로 측정 — ProducerClient.send()로 TCP 왕복 포함:

@Benchmark
public void sendMessage() throws Exception {
    producer.send("bench-topic", key, value);
}

AppendBenchmark은 그대로 남겨뒀다. 네트워크 오버헤드가 얼마나 되는지 비교 기준점으로 쓰기 위해서다.


최종 결과

Benchmark                       Mode  Cnt       Score       Error  Units
AppendBenchmark.appendMessage  thrpt    5  549,717 ±  51,222  ops/s
ProducerBenchmark.sendMessage  thrpt    5   50,863 ±   1,578  ops/s
KafkaBenchmark.sendMessage     thrpt    5    4,781 ±     241  ops/s

결과 해석

AppendBenchmark vs ProducerBenchmark - 10배 차이

둘 다 mini-broker에 쓰는데 왜 10배 차이가 날까?

AppendBenchmark은 LogSegment.append()를 직접 호출한다. 네트워크가 없다. 파일에 바이트를 쓰는 시간만 측정한다.

ProducerBenchmark은 ProducerClient.send()를 호출한다. TCP 연결 → 요청 직렬화 → 송신 → 브로커 처리 → 응답 수신 → 역직렬화까지 전부 포함된다. 네트워크 왕복 비용이 파일 I/O보다 훨씬 크다는 걸 보여준다.

549,717 ops/s  ← 파일만 쓸 때
 50,863 ops/s  ← 네트워크까지 거칠 때

네트워크를 통하면 약 91% 성능이 깎인다.

ProducerBenchmark vs KafkaBenchmark - 10배 차이

이번엔 mini-broker가 Kafka보다 10배 빠르다. 왜?

send().get() 동기 방식은 Kafka한테 불공정한 조건이다.

Kafka Producer는 원래 배치 전송으로 설계됐다. 메시지를 버퍼에 모아뒀다가 batch.size가 차거나 linger.ms가 지나면 한꺼번에 보낸다. 이 방식이면 네트워크 왕복 1번에 수백~수천 개 메시지를 처리할 수 있다.

그런데 send().get()을 쓰면 메시지 1개 보낼 때마다 응답을 기다린다. 버퍼에 메시지가 쌓일 틈이 없다. 배치가 전혀 안 된다. 결국 네트워크 RTT가 그대로 레이턴시가 되고, Kafka의 핵심 강점인 배치 처리를 완전히 버리는 셈이다.

반면 mini-broker는 프로토콜이 단순하다. 헤더 오버헤드가 적고, 핸드셰이크도 없다. 그래서 1건 왕복 비용이 훨씬 작다.

이 비교는 "1건씩 동기로 보낼 때"에 한정된 결과다.

정리

벤치마크 ops/s 설명

AppendBenchmark 549,717 파일 I/O만, 네트워크 없음
ProducerBenchmark 50,863 mini-broker, TCP 왕복 포함
KafkaBenchmark 4,781 Kafka, 동기 1건씩

배운 것

벤치마크는 측정 대상을 먼저 정의해야 한다.

"Kafka보다 빠르다"는 말은 조건 없이는 의미가 없다. 비동기 vs 동기, 배치 vs 단건, 네트워크 경유 vs 직접 호출 — 이 조건들이 결과를 수십 배 바꾼다.

첫 번째 결과(Kafka 2.18M ops/s)를 보고 이상하다고 느낀 게 오히려 중요했다. 숫자가 기대와 다를 때 "내가 뭘 잘못 측정하고 있나"를 물어보는 것, 그게 벤치마크에서 제일 중요한 태도인 것 같다.

네트워크 비용은 생각보다 훨씬 크다.

파일 I/O만 하면 549K ops/s인데, TCP 왕복 하나 추가하면 50K ops/s로 떨어진다. 10분의 1이다. 분산 시스템에서 네트워크를 줄이는 게 왜 그렇게 중요한지, 왜 Kafka가 배치 전송에 집착하는지 숫자로 체감했다.


느낀 점

처음엔 "Kafka보다 빠르다"는 결과가 나왔을 때 뭔가 잘못됐다는 걸 직감했다. 내가 만든 장난감 브로커가 프로덕션 Kafka보다 빠를 리 없으니까.

그 직감을 따라서 비교 조건을 뜯어보니 완전히 다른 걸 측정하고 있었다. 수정하고 나서 나온 결과가 훨씬 납득이 됐다. Kafka가 단건 동기에서 느린 건 설계 의도가 다르기 때문이고, mini-broker가 단순한 프로토콜 덕분에 단건 왕복은 빠른 것.

숫자 자체보다 왜 그 숫자가 나왔는지를 이해하는 게 벤치마크의 진짜 목적인 것 같다.


마치며

6편을 쓰고 나서 돌아보면, 이 프로젝트에서 코드보다 더 많이 배운 건 "왜 이렇게 설계했나" 에 대한 감각이다.

왜 append-only?

순차 I/O가 빠르고, 동시성 문제가 없으니까.

왜 파티션? 병렬 처리를 위해서.

왜 pull 방식?

Consumer 속도를 브로커가 맞출 수 없으니까.

왜 배치 전송? 네트워크 왕복 비용이 크니까.

왜 sparse index?

정확한 인덱스는 너무 크고, 없으면 너무 느리니까.

 

이 설계 결정들이 머릿속에 들어오고 나니까, Kafka 문서나 설정들이 다르게 읽히기 시작했다. linger.ms가 왜 있는지, fetch.min.bytes가 뭘 위한 건지, log.segment.bytes가 뭘 조절하는 건지.

Kafka를 직접 만든다는 게 처음엔 무모한 것 같았는데, 오히려 운영하는 Kafka를 더 잘 이해하게 됐다.

 

* linger.ms - Producer가 메시지를 보내기 전에 최대 몇 ms 기다릴지. 기다리는 동안 다른 메시지가 오면 배치로 묶어서 한 번에 보낸다. 배치 전송이 왜 중요한지 알고 나니까 이 설정이 뭘 트레이드오프하는지 보였다. 낮추면 레이턴시가 줄고, 높이면 throughput이 오른다.

* fetch.min.bytes - Consumer가 fetch 요청을 보낼 때 브로커가 최소 이 바이트만큼 쌓일 때까지 기다렸다가 응답한다. pull 방식이라 Consumer가 계속 "줘줘줘" 요청을 보낼 수 있는데, 데이터가 없을 때마다 빈 응답을 주면 네트워크 낭비다. 이걸 조절하면 Consumer 쪽 네트워크/CPU 오버헤드를 줄일 수 있다.

* log.segment.bytes - 세그먼트 하나의 최대 크기. 이 크기를 넘으면 새 .log + .index 파일을 만든다. 파일이 너무 크면 인덱스 탐색이 느려지고, retention 삭제 단위도 세그먼트라서 너무 크면 오래된 데이터를 제때 못 지운다. mini-broker에서 세그먼트 롤링을 안 만든 이유가 여기 있다 - 안 만들면 파일 하나가 계속 커진다.


NEXT

실제 Kafka와 비교하면 아직 빠진것들이 너무나도 많다. 그래서 앞으로 조금씩 나아가 보려고한다. 지금 당장에는 못하지만 추후를 기약하며 앞으로 할 일을 대충이나마 까먹을수도 있으니 정리해보고자 한다.

 

세그먼트 롤링 구현 (파일 크기 임계값 넘으면 새파일로), offset 영속화 (파일이나 별도 저장소에 커밋 offset 저장), 배치 produce API추가, Selector 기반 멀티플렉싱 NIO, KRaft 동작 방식, 트랜잭션 구현 원리, Strimzi 클러스터에서 발생하는 문제들과 원인 찾아보기 등등