Blog
kafkaperformancebrokerpartitionsreplicationtuning

[Kafka 성능 ②] 브로커와 파티션 — 스레드·복제·파티션 수 튜닝

Kafka 브로커의 스레드 모델(num.network.threads/num.io.threads), 소켓 버퍼, 복제 처리량(num.replica.fetchers), 그리고 파티션 수라는 가장 큰 레버를 메트릭 기반으로 튜닝하는 방법을 정리합니다.

Data Dynamics2026년 6월 21일25 min read

1편에서 프로듀서와 컨슈머의 처리량을 끌어올렸다면, 이제 그 트래픽을 실제로 받아내는 쪽 — 브로커를 들여다볼 차례입니다. 같은 하드웨어에서도 브로커는 스레드를 어떻게 나눠 쓰느냐, 파티션을 몇 개로 쪼개느냐에 따라 처리량이 몇 배씩 갈립니다. 그런데 이 영역에서 가장 흔한 실수는 "감(感)으로 숫자를 키우는 것"입니다. 브로커 튜닝의 핵심은 따로 있습니다. 어떤 메트릭이 어떤 파라미터를 가리키는지 아는 것. 이 글은 그 대응 관계를 중심으로 정리합니다.

이 글에서 배우는 것

  • 브로커의 두 스레드 풀(num.network.threads/num.io.threads)과 요청이 흐르는 경로
  • 어떤 JMX 메트릭이 어떤 스레드를 늘려야 하는지 정확히 알려주는가
  • 소켓/버퍼 설정이 고대역폭·고지연 링크에서 갖는 의미
  • 복제 처리량(num.replica.fetchers)과 under-replicated partition의 관계
  • log.flush.*를 건드리면 안 되는 이유 — 내구성은 복제가 책임진다
  • 파티션 수라는 가장 큰 레버: 처리량 이득과 그 대가

1. 브로커 스레드 모델 — 요청은 어떻게 흐르는가

브로커 튜닝을 이해하려면 먼저 요청 하나가 브로커 내부에서 어떤 경로로 흐르는지를 그려야 합니다. Kafka 브로커는 들어온 요청을 단일 스레드로 처리하지 않습니다. 역할이 다른 두 개의 스레드 풀이 중간 큐를 사이에 두고 협력합니다.

두 개의 스레드 풀

설정역할기본값한 줄 요약
num.network.threads네트워크 프로세서3소켓에서 요청을 읽고, 처리 결과를 응답으로 써 보냄
num.io.threads요청 핸들러(request handler)8디스크 I/O, 복제 등 실제 작업을 수행
queued.max.requests요청 큐500네트워크 스레드와 I/O 스레드 사이의 버퍼(대기열)

핵심은 역할 분리입니다. 네트워크 스레드는 디스크를 만지지 않습니다. 소켓에서 바이트를 읽어 요청 객체로 파싱하고, 그것을 공유 요청 큐에 넣는 것까지가 일입니다. 반대로 I/O(요청 핸들러) 스레드는 소켓을 직접 다루지 않습니다. 큐에서 요청을 꺼내 로그에 쓰거나, 디스크에서 읽거나, 복제 상태를 갱신하는 무거운 작업을 담당합니다.

요청 흐름 전체 경로

클라이언트
   │  (TCP)

[Acceptor 스레드]               ─ 연결 수락, 네트워크 프로세서에 분배

[Network Thread]                 ─ num.network.threads
   │  소켓 read → 요청 파싱

[Request Queue]                  ─ queued.max.requests (공유 대기열)

[I/O / Request Handler Thread]   ─ num.io.threads
   │  로그 append / 디스크 read / 복제 처리

[Purgatory]                      ─ 즉시 응답 불가한 요청 대기
   │  (acks=all의 ISR 응답 대기, fetch의 min.bytes 대기 등)

[Response Queue]                 ─ 네트워크 스레드별 응답 큐

[Network Thread]                 ─ 소켓 write → 클라이언트로 응답

여기서 Purgatory(연옥)는 헷갈리기 쉬운 개념이라 짚고 갑니다. acks=all 프로듀스 요청은 리더에 쓴 직후 바로 응답할 수 없습니다. ISR(in-sync replica) 팔로워들이 해당 오프셋까지 복제할 때까지 기다려야 하죠. 컨슈머의 fetch 요청도 fetch.min.bytes만큼 데이터가 쌓일 때까지 대기할 수 있습니다. 이렇게 "조건이 충족될 때까지 보류"되는 요청이 머무는 곳이 Purgatory입니다. 즉, Purgatory에 요청이 쌓이는 것 자체는 정상이며, I/O 스레드를 점유하지 않고 대기한다는 점이 중요합니다.

이 구조를 이해하면 다음 절의 튜닝 규칙이 자연스럽게 따라옵니다. 어느 쪽 풀이 병목인지를 숫자가 아니라 메트릭으로 판별하는 것이 출발점입니다.


2. 메트릭 기반 튜닝 규칙 — 추측하지 말고 측정하라

스레드 수를 무작정 늘리는 것은 답이 아닙니다. 스레드가 많아지면 컨텍스트 스위칭과 락 경합이 늘어 오히려 느려질 수 있습니다. Kafka는 다행히 **각 스레드 풀의 유휴율(idle percent)**을 JMX로 정확히 노출합니다. 이 두 메트릭이 사실상 모든 것을 말해줍니다.

두 개의 핵심 유휴율 메트릭

메트릭 (JMX)의미가리키는 파라미터
kafka.server:type=KafkaRequestHandlerPool,name=RequestHandlerAvgIdlePercentI/O(요청 핸들러) 스레드 평균 유휴 비율num.io.threads
kafka.network:type=SocketServer,name=NetworkProcessorAvgIdlePercent네트워크 프로세서 평균 유휴 비율num.network.threads

두 메트릭 모두 0.0 ~ 1.0 범위의 값(1.0 = 100% 유휴, 즉 완전히 놀고 있음)입니다.

판독 규칙

RequestHandlerAvgIdlePercent
  ≈ 0.0  →  I/O 스레드 포화. 디스크/복제 작업이 밀린다.
            → num.io.threads 상향 (단, 디스크 코어 수 이내에서)
  ≈ 1.0  →  I/O 스레드 여유. 줄여도 무방.
 
NetworkProcessorAvgIdlePercent
  ≈ 0.0  →  네트워크 스레드 포화. 소켓 read/write가 밀린다.
            → num.network.threads 상향
  ≈ 1.0  →  네트워크 스레드 여유.

현장 경험칙으로는 유휴율이 0.3(30%) 아래로 지속적으로 떨어지면 해당 풀을 늘릴 후보로 봅니다. 0.0에 닿았다면 이미 요청이 큐에서 밀리고 있다는 뜻이라 늦은 편입니다. 한 번에 하나씩만 조정하고, 변경 후 다시 측정하세요. 두 파라미터를 동시에 올리면 무엇이 효과를 냈는지 알 수 없습니다.

주의: num.io.threads를 디스크 코어/스핀들 수보다 과하게 늘려도 디스크가 병목이면 소용없습니다. I/O 스레드가 늘 바쁜데(유휴율 0) 디스크 사용률도 100%라면, 그건 스레드 부족이 아니라 디스크 대역폭 부족입니다 — 3편(OS·하드웨어)에서 다룹니다.

요청 파이프라인 다이어그램

Loading diagram…

다이어그램의 두 점선 화살표가 이 글 전체의 요약입니다. 메트릭이 스레드 풀을 가리키고, 그 스레드 풀이 곧 파라미터입니다.


3. 소켓과 버퍼 — 고대역폭·고지연 링크의 함정

스레드가 충분해도 소켓 버퍼가 작으면 처리량이 막힐 수 있습니다. 특히 데이터센터 간 복제나 지리적으로 떨어진 MirrorMaker처럼 대역폭은 크지만 지연도 큰(BDP가 큰) 링크에서 그렇습니다.

설정의미기본값(참고)
socket.send.buffer.bytes소켓 송신 버퍼(SO_SNDBUF)102400 (100KB)
socket.receive.buffer.bytes소켓 수신 버퍼(SO_RCVBUF)102400 (100KB)
socket.request.max.bytes단일 요청 최대 크기104857600 (100MB)

BDP를 채울 만큼 버퍼를 키워라

TCP 처리량의 상한은 **BDP(Bandwidth-Delay Product = 대역폭 × 왕복 지연)**입니다. 버퍼가 BDP보다 작으면, 송신 측은 ACK를 기다리느라 회선을 다 못 채웁니다.

예시: 10 Gbps 링크, RTT 50ms (DC 간)
  BDP = 10e9 bit/s × 0.05 s / 8 = 62.5 MB
 
  기본 송신 버퍼 100KB로는 회선의 극히 일부만 사용.
  socket.send.buffer.bytes / socket.receive.buffer.bytes 를
  BDP 수준(또는 -1로 OS 자동 튜닝 위임)으로 상향해야 회선을 채운다.

-1로 설정하면 OS의 TCP 자동 튜닝(autotuning)에 위임합니다. 리눅스에서 net.ipv4.tcp_rmem/tcp_wmem이 충분히 크게 잡혀 있다면 이쪽이 더 깔끔할 때가 많습니다. socket.request.max.bytes는 큰 배치(replica.fetch.max.bytes나 큰 프로듀스 배치)를 받을 때 거부당하지 않도록 충분히 커야 합니다 — 단, 메모리 보호 차원의 상한이므로 무한정 키우진 마세요.


4. 복제 처리량 — under-replicated partition을 줄이는 레버

브로커가 클러스터를 이루는 이유는 복제입니다. 그리고 복제가 따라오지 못하면 곧바로 **URP(Under-Replicated Partitions)**로 나타납니다. URP가 0이 아니라는 것은 어떤 팔로워가 리더를 따라잡지 못하고 있다는 신호이며, 이 상태에서 리더가 죽으면 데이터 손실 위험이 커집니다.

팔로워 페치 병렬도

팔로워는 리더로부터 데이터를 fetch해서 복제합니다. 이 fetch를 몇 개의 스레드로 병렬화할지를 정하는 것이 num.replica.fetchers입니다.

설정의미기본값
num.replica.fetchers브로커가 리더로부터 복제 fetch하는 스레드 수1
replica.fetch.max.bytes파티션당 fetch 응답 최대 바이트1048576 (1MB)
replica.fetch.min.bytesfetch 응답을 받기 위한 최소 누적 바이트1

기본값 num.replica.fetchers=1소스 브로커당 단일 스레드라는 뜻입니다. 파티션이 많고 쓰기 부하가 높은 클러스터에서는 이 한 스레드가 복제를 다 감당하지 못해 URP가 치솟습니다. 이때 num.replica.fetchers를 2~4 수준으로 올리면 복제 fetch가 병렬화되어 팔로워가 리더를 더 빨리 따라잡습니다.

증상: kafka.server:type=ReplicaManager,name=UnderReplicatedPartitions > 0 이 지속
      (부하 급증 시 더 심해짐)
 
진단 순서:
  1. 네트워크/디스크가 포화인가?  → 그렇다면 하드웨어 문제(3편)
  2. 아니라면 복제 fetch 병렬도 부족 의심
     → num.replica.fetchers 상향 (예: 1 → 4)
  3. 메시지가 크다면 replica.fetch.max.bytes 도 함께 상향
     (max.message.bytes 보다 작으면 복제가 막힐 수 있음)

replica.fetch.min.bytes를 키우면 팔로워가 여러 메시지를 모아서 한 번에 받게 되어 fetch 횟수가 줄지만(효율↑), 그만큼 복제 지연이 늘 수 있으니 처리량과 지연을 저울질하세요. 핵심 감시 메트릭은 UnderReplicatedPartitions(0이어야 정상)와 ReplicaFetcherManagerMaxLag(팔로워가 리더보다 얼마나 뒤처졌는가)입니다.


5. OS와 싸우지 마라 — log.flush.*는 그대로 두기

브로커 튜닝에서 가장 유혹적인(그리고 거의 항상 잘못된) 손길이 flush 강제입니다. "메시지마다 디스크에 fsync하면 더 안전하지 않을까?"라는 생각으로 다음 값을 건드리는 경우가 많습니다.

설정의미기본 동작
log.flush.interval.messagesN개 메시지마다 강제 fsync사실상 무한대(설정 안 함)
log.flush.interval.msN ms마다 강제 fsync미설정(OS에 위임)

결론부터: 이 값들은 건드리지 마세요. Kafka의 기본 철학은 fsync를 OS 페이지 캐시에 맡기는 것입니다. 프로듀서가 보낸 데이터는 일단 페이지 캐시에 쓰이고, 디스크로의 실제 flush는 OS가 효율적인 시점에 배치로 처리합니다. 메시지마다 fsync를 강제하면 이 배치 효과가 깨지면서 처리량이 수 배에서 수십 배까지 떨어집니다.

내구성은 fsync가 아니라 복제가 책임진다

"그럼 데이터 안전은?"이라는 질문이 당연히 따라옵니다. Kafka에서 내구성을 보장하는 메커니즘은 **개별 디스크의 fsync가 아니라 복제(replication)**입니다.

  • 프로듀서 acks=all + 토픽 min.insync.replicas=2 조합이면, 메시지가 **최소 2개 브로커의 로그(페이지 캐시 포함)**에 들어가야 ack가 돌아갑니다.
  • 한 브로커가 fsync 전에 죽어 페이지 캐시 내용을 잃어도, 다른 ISR 브로커가 그 데이터를 갖고 있습니다.
  • 즉, "여러 머신에 복제됨"이 "한 머신에서 디스크에 강제 기록됨"보다 강한 보증입니다. 머신 하나의 디스크가 살아남는 것보다, 데이터가 여러 머신에 있는 편이 안전하니까요.

acksmin.insync.replicas의 의미·조합은 본 시리즈의 **④편(프로듀서 신뢰성)**에서 자세히 다뤘습니다. 여기서는 "내구성은 복제가, flush는 OS가" 두 가지만 기억하면 됩니다. flush를 강제하는 순간 처리량을 내주면서도 복제가 주는 보증을 넘어서는 안전은 얻지 못합니다.


6. 파티션 수 — 가장 큰 레버, 그리고 가장 큰 함정

지금까지의 모든 설정을 합친 것보다 처리량에 더 큰 영향을 주는 단 하나의 결정이 파티션 수입니다. 파티션은 Kafka 병렬성의 기본 단위이기 때문입니다.

왜 처리량이 오르는가

  • 파티션은 순서가 보장되는 독립된 로그이며, 서로 다른 브로커/디스크에 분산됩니다 → 쓰기/읽기가 병렬화됩니다.
  • 컨슈머 그룹의 병렬도 상한 = 파티션 수입니다. 파티션이 10개면 컨슈머는 최대 10개까지 동시에 일할 수 있습니다(그 이상은 놀게 됨).
  • 따라서 집계 처리량(aggregate throughput)은 대체로 파티션 수에 비례해 늘어납니다 — 어느 지점까지는.

그러나 공짜가 아니다 — 파티션 수의 트레이드오프

늘어날 때 좋은 점늘어날 때 치르는 대가
프로듀서/컨슈머 병렬도 ↑브로커당 열린 파일 핸들 수 ↑ (파티션마다 다수의 세그먼트 파일)
집계 처리량 ↑클라이언트 메모리/요청 수 ↑ (파티션마다 버퍼·메타데이터)
컨슈머 수평 확장 여지 ↑리더 선출/복구 시간 ↑ (브로커 장애 시 옮길 리더가 많아짐)
핫 파티션 분산종단 지연(end-to-end latency) ↑ (복제 fan-out 확대)
리밸런스가 무거워짐 (멤버십 변경 시 옮길 파티션이 많음)

특히 주의할 것은 리더 선출/복구 시간입니다. 브로커 하나가 죽으면 그 브로커가 리더였던 모든 파티션에 대해 새 리더를 뽑아야 하는데, 파티션이 수만 개면 이 과정이 길어져 가용성에 직접 타격을 줍니다. "혹시 모르니 파티션을 넉넉히"는 비싼 보험입니다.

사이징 휴리스틱

파티션 수는 목표 처리량을 파티션 하나가 감당하는 처리량으로 나눠 산정합니다. 단, 프로듀서 쪽과 컨슈머 쪽을 따로 계산해 더 큰 값을 택합니다.

목표 처리량 = T (예: 1 GB/s)
 
프로듀서 기준:  파티션당 쓰기 처리량 = Tp  →  필요 파티션 = ceil(T / Tp)
컨슈머 기준:    파티션당 읽기 처리량 = Tc  →  필요 파티션 = ceil(T / Tc)
 
파티션 수 = max( ceil(T / Tp), ceil(T / Tc) )
 
예) T = 1000 MB/s, Tp = 50 MB/s, Tc = 25 MB/s
    프로듀서 기준 = ceil(1000/50) = 20
    컨슈머 기준   = ceil(1000/25) = 40
    → 파티션 수 = max(20, 40) = 40

Tp, Tc는 환경마다 다르므로 반드시 대표 메시지 크기로 벤치마크해서 측정한 값을 쓰세요(1편의 프로듀서/컨슈머 처리량 측정 방법 참고).

늘리기는 쉬워도 줄이기는 어렵다 — 그리고 키→파티션 안정성

파티션 수를 정할 때 가장 중요한 제약입니다.

  • 파티션은 늘릴 수는 있어도 줄일 수는 없습니다(쉽게는). 줄이려면 토픽을 새로 만들고 재적재해야 합니다.
  • 더 중요한 함정: 파티션을 추가하면 키→파티션 매핑이 깨집니다. 기본 파티셔너는 hash(key) % partitionCount로 파티션을 정하는데, partitionCount가 바뀌는 순간 같은 키가 이전과 다른 파티션으로 갑니다.
  • 결과적으로, 같은 키의 메시지 순서 보장이 추가 시점을 경계로 깨질 수 있습니다. 키별 순서(예: 사용자 ID별 이벤트 순서)에 의존하는 시스템이라면 운영 중 파티션 추가는 치명적일 수 있습니다.

키 기반 순서 보장의 원리와 주의점은 본 시리즈 **⑩편(메시지 순서·키 파티셔닝)**에서 다룹니다. 여기서의 교훈은 명확합니다 — 파티션 수는 처음에 여유 있게, 그러나 과하지 않게 정하고, 운영 중 변경은 순서 의존성을 반드시 검토한 뒤에 하세요.


마치며

브로커와 파티션 튜닝을 관통하는 한 문장은 이것입니다 — 추측하지 말고, 메트릭이 가리키는 파라미터를 바꿔라.

  • 스레드 풀은 둘입니다. num.network.threads(소켓 read/write)와 num.io.threads(디스크/복제 작업). 그 사이를 queued.max.requests 큐가 잇습니다.
  • RequestHandlerAvgIdlePercent가 0에 가까우면 num.io.threads를, NetworkProcessorAvgIdlePercent가 0에 가까우면 num.network.threads 올리세요. 한 번에 하나씩, 측정하면서.
  • 고대역폭·고지연 링크에서는 소켓 버퍼를 BDP만큼 키우거나 -1로 OS 자동 튜닝에 위임하세요.
  • URP가 떨어지지 않으면 num.replica.fetchers를 올려 복제 fetch를 병렬화하세요. 단, 그 전에 하드웨어 포화 여부를 먼저 배제할 것.
  • log.flush.*는 건드리지 마세요. 내구성은 acks=all + min.insync.replicas 복제가 책임지고, flush는 OS 페이지 캐시에 맡깁니다(④편).
  • 파티션 수는 가장 큰 레버지만 공짜가 아닙니다. 목표 처리량 ÷ 파티션당 처리량으로 산정하되, 파일 핸들·복구 시간·리밸런스 비용을 함께 보세요. 늘리긴 쉬워도 줄이긴 어렵고, 추가는 키→파티션 안정성을 깹니다(⑩편).

다음 3편에서는 마지막 한 겹 — OS와 하드웨어 레이어(페이지 캐시, 디스크/파일시스템, JVM·GC, 네트워크 커널 파라미터)를 다루고, 1~3편의 설정을 상황별 프로파일(고처리량 / 저지연 / 고내구성)로 묶어 정리합니다. 운영 중 무엇을 먼저 봐야 하는지는 시리즈의 **⑨편(장애 대응 런북·핵심 메트릭)**과, 리밸런스로 인한 처리량 출렁임은 **⑥편(컨슈머 리밸런스)**을, 프로듀서/컨슈머 자체 튜닝은 성능 1편을 함께 참고하세요.

참고 자료


— Data Dynamics 엔지니어링 팀