실수 케이스 · 5
파티션을 1000개로 늘렸더니 더 느려졌다
"파티션이 병렬성이니까 늘리면 빨라진다"는 판단으로 파티션을 24개에서 1000개로 늘렸습니다.
결과는 프로듀서 p99 지연 12ms → 240ms, 브로커 재시작 시간 3분 → 40분이었고,
마지막에는 브로커가 Too many open files로 죽었습니다.
되돌릴 수도 없었습니다. 파티션 수는 줄일 수 없습니다.
이 케이스에서 얻어 갈 것
- 파티션 하나가 소비하는 자원을 파일 디스크립터·메모리 맵·메모리·스레드 단위로 계산할 수 있습니다.
- 파티션을 늘리면 왜 end-to-end 지연이 늘어나는지 배치 관점에서 설명할 수 있습니다.
- 리밸런스 시간과 브로커 재시작·리더 선출 시간이 파티션 수에 어떻게 비례하는지 압니다.
- 필요한 파티션 수를 처리량과 컨슈머 처리 능력으로 계산할 수 있습니다.
상황
클릭스트림 수집 파이프라인입니다. page-view 토픽은 원래
파티션 24개, 복제 계수 3, 브로커 3대에서 일 4000만 건(피크 초당 1,200건)을 받았습니다.
평균 레코드 크기는 900바이트입니다.
분기 프로모션을 앞두고 트래픽이 3배로 늘 것으로 예상되어 용량 계획을 세웠습니다. 컨슈머 파드를 늘리려면 파티션이 더 필요했고, 여기서 판단이 어긋났습니다.
# 컨슈머 파드를 최대 200개까지 늘릴 수 있게, 여유를 5배로 잡아 1000개로
kafka-topics.sh --bootstrap-server kafka-1:9092 \
--alter --topic page-view --partitions 1000
같은 클러스터에는 다른 토픽도 34개 있었고, 파티션 총합은 이미 620개였습니다.
page-view를 1000개로 늘린 뒤 클러스터 전체 파티션은
1,596개 × 복제 계수 3 = 레플리카 4,788개가 되었고,
브로커 3대이므로 브로커 한 대가 약 1,600개의 레플리카를 담당하게 됐습니다.
변경 직후에는 아무 일도 없었습니다. 문제는 세 단계로 나타났습니다.
- +30분: 프로듀서 p99 지연이 12ms에서 90ms로 상승. 처리량은 오히려 감소.
- +6시간: 컨슈머 파드를 재배포했더니 리밸런스가 90초 넘게 걸리기 시작. 그 시간 동안 처리량 0.
- +2일: 브로커 한 대를 정기 패치로 재시작했는데 기동에 40분이 걸렸고,
그 사이 다른 브로커가
Too many open files로 죽었습니다.
관측된 증상
메트릭이 어떻게 보였는가
| 지표 | 파티션 24개 | 파티션 1000개 | 변화 |
|---|---|---|---|
| 프로듀서 p99 지연 | 12ms | 240ms | 20배 악화 |
프로듀서 batch-size-avg |
14,900바이트 | 1,100바이트 | 배치가 거의 형성되지 않음 |
| 브로커 네트워크 요청 수 | 초당 1,900 | 초당 24,000 | 작은 요청이 폭증 |
RequestHandlerAvgIdlePercent |
0.82 | 0.11 | 핸들러 스레드 포화 |
| 컨슈머 리밸런스 시간 | 2초 | 90초 이상 | 재배포마다 처리 정지 |
| 브로커 재시작 시간 | 3분 | 40분 | 로그 복구 시간이 파티션 수에 비례 |
| 브로커 프로세스 열린 파일 수 | 약 9,000 | 약 68,000 | OS 한계(65,536)에 도달 |
브로커 로그 — 자원 한계
Too many open files는 Kafka가 만드는 메시지가 아니라
OS가 반환하는 EMFILE이 JVM 예외 메시지에 실려 나오는 것입니다.
따라서 나타나는 위치는 상황에 따라 다릅니다. 다음과 같은 형태로 보입니다.
ERROR Error while accepting connection (kafka.network.Acceptor)
java.io.IOException: Too many open files
at sun.nio.ch.ServerSocketChannelImpl.accept0(Native Method)
...
ERROR Failed to clean up log for __consumer_offsets-17 in dir /var/lib/kafka/data due to IOException
java.io.FileNotFoundException: /var/lib/kafka/data/page-view-843/00000000000000000000.index (Too many open files)
메모리 맵 한계에 먼저 부딪히는 경우도 있습니다.
공식 문서는 vm.max_map_count를 파티션 수 상한을 고려할 때 주의할 OS 속성으로 명시합니다.
java.lang.OutOfMemoryError: Map failed
at sun.nio.ch.FileChannelImpl.map0(Native Method)
at sun.nio.ch.FileChannelImpl.map(FileChannelImpl.java:...)
원인 분석
비용 1 — 파일 디스크립터
공식 하드웨어·OS 문서는 파일 디스크립터 소요량을 다음 식으로 제시합니다.
필요 FD 최소치 = (파티션 수) × (파티션 크기 / 세그먼트 크기) + 연결 수
권고: 브로커 프로세스에 최소 100,000개의 파일 디스크립터를 허용할 것 (출발점)
이 사고의 숫자를 넣어 보면 이유가 분명해집니다.
| 항목 | 파티션 24개 시절 | 파티션 1000개 이후 |
|---|---|---|
| 브로커당 레플리카 수 | 약 240 | 약 1,600 |
| 레플리카당 세그먼트 수 (4GiB / 1GiB) | 4 | 4 |
세그먼트당 파일 (.log + .index + .timeindex) |
3 | 3 |
| 로그 파일 FD 합계 | 약 2,880 | 약 19,200 |
| 클라이언트·복제 연결 | 약 6,000 | 약 6,000 |
| 합계 (여유 없음) | 약 9,000 | 약 25,000 이상 — 실측 68,000까지 상승 |
비용 2 — 메모리 맵 영역 (vm.max_map_count)
여기서 중요한 것은 세그먼트 수가 곱해진다는 점입니다.
segment.bytes(기본 1073741824 = 1GiB)나 segment.ms(기본 604800000 = 7일)를
작게 설정한 토픽이 있으면 세그먼트가 훨씬 많아지고, 맵 영역과 FD 소요가 함께 늘어납니다.
세그먼트 설정과 리텐션의 관계는 7장 스토리지·리텐션·컴팩션에서 다룹니다.
비용 3 — 프로듀서 배치 효율 붕괴 (지연이 늘어나는 진짜 이유)
이것이 이 사고에서 가장 직관에 반하는 부분입니다. 파티션을 늘렸는데 지연이 늘어난 이유는 배치가 형성되지 못했기 때문입니다.
프로듀서는 파티션마다 별도의 배치 버퍼를 유지합니다.
배치는 batch.size(기본 16384바이트)만큼 채워지거나
linger.ms(4.0부터 기본 5)가 지나면 전송됩니다.
| 항목 | 파티션 24개 | 파티션 1000개 |
|---|---|---|
| 파티션당 초당 레코드 | 50건 | 1.2건 |
| 파티션당 초당 바이트 | 45,000 | 1,080 |
batch.size(16384) 채우는 시간 |
약 360ms | 약 15초 |
| 실제 전송 트리거 | batch.size 도달 또는 linger.ms |
거의 항상 linger.ms(5ms) — 배치가 1~2건 |
| 초당 produce 요청 수 | 수백 건 | 수만 건 |
| 압축 효율 | 배치가 커서 좋음 | 배치가 작아 거의 압축되지 않음 |
비용 4 — 컨슈머 메모리와 fetch 응답 크기
컨슈머의 max.partition.fetch.bytes 기본값은 1048576(1MiB),
fetch.max.bytes 기본값은 52428800(50MiB)입니다.
max.partition.fetch.bytes × 할당된 파티션 수가
fetch 응답 크기를 키우는 방향으로 작동하고,
fetch.max.bytes가 그 상한을 잡습니다.
그래서 파티션 200개를 할당받은 컨슈머는 이론상 200MiB를 요청하지만 실제로는 50MiB에서 잘립니다. 결과는 두 가지입니다 — fetch 응답 하나에 담기는 파티션당 데이터가 줄어들어 같은 데이터를 더 많은 fetch 요청으로 나눠 받게 되고, 파티션이 아주 많으면 일부 파티션이 한 fetch 라운드에서 아무 데이터도 못 받는 상황도 생깁니다. 이 설정들의 상호작용은 케이스 10에서 자세히 다룹니다.
브로커 쪽에도 대응하는 비용이 있습니다.
replica.fetch.max.bytes(기본 1048576)와
replica.fetch.response.max.bytes(기본 10485760)가
복제 fetch에 같은 구조로 적용되고, num.replica.fetchers 기본값은 1입니다.
레플리카가 브로커당 1,600개면 이 스레드 하나가 훨씬 많은 파티션을 나눠 처리하게 되어
복제 지연과 end-to-end 지연이 함께 늘어납니다.
비용 5 — 리밸런스 시간
리밸런스에서 오가는 정보량은 파티션 수에 비례합니다.
JoinGroup·SyncGroup 요청에 담기는 할당 정보가 커지고, 할당 계산 비용도 커집니다.
eager 리밸런스(기본 할당 전략 목록의 앞쪽인 RangeAssignor)에서는
모든 멤버가 모든 파티션을 반납하고 다시 받으므로 그 시간 동안 처리량이 0입니다.
완화 수단은 세 가지입니다. CooperativeStickyAssignor,
group.instance.id(정적 멤버십), 그리고
group.protocol=consumer(KIP-848, 4.0 GA — 기본값은 classic).
상세는 케이스 2와 5장에 있습니다.
비용 6 — 브로커 재시작과 리더 선출
브로커가 기동할 때 각 로그 디렉터리의 세그먼트를 검사·복구합니다.
이 작업의 병렬도는 num.recovery.threads.per.data.dir(기본 2)이 결정합니다.
레플리카가 1,600개면 이 단계가 수십 분이 됩니다.
리더 선출은 그보다 낫습니다. 공식 설계 문서는 KRaft 컨트롤러가 리더십 변경 통지를 배치로 묶어 처리하므로 "파티션이 많을 때 선출 과정이 훨씬 저렴하고 빠르다"고 설명합니다. 그래도 공짜는 아닙니다 — 브로커 1대 손실 시 컨트롤러가 처리해야 할 파티션 수가 그만큼 늘어납니다. KRaft 아키텍처는 3장에서 다룹니다.
비용 7 — 되돌릴 수 없다
위 여섯 가지는 튜닝으로 완화할 수 있습니다. 이것만은 완화할 수 없습니다. Kafka는 토픽의 파티션 수 축소를 지원하지 않습니다. 잘못 늘렸다면 남는 방법은 새 토픽으로 이관하는 것뿐이고, 그 과정에서 키 배치·순서 보장·컨슈머 전환 문제가 전부 따라옵니다. 이 케이스의 진짜 교훈은 여기입니다 — 파티션 증설은 되돌릴 수 없는 결정이므로, 늘리기 전에 계산하세요.
재현 방법
노트북에서 브로커를 죽일 필요는 없습니다. 배치 효율 붕괴는 파티션 200개 정도로도 수치로 재현됩니다. 아래 compose는 Apache Kafka의 공식 단일 노드 예제입니다 (3노드 구성은 예제 1).
services:
broker:
image: apache/kafka:4.3.1
hostname: broker
container_name: broker
ports:
- '9092:9092'
# 재현 포인트 — FD 한계를 일부러 낮춥니다. 운영에서는 100000 이상을 권장합니다.
ulimits:
nofile:
soft: 4096
hard: 4096
environment:
KAFKA_NODE_ID: 1
KAFKA_PROCESS_ROLES: 'broker,controller'
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: 'CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT'
KAFKA_LISTENERS: 'CONTROLLER://:29093,PLAINTEXT://:19092,PLAINTEXT_HOST://:9092'
KAFKA_ADVERTISED_LISTENERS: 'PLAINTEXT://broker:19092,PLAINTEXT_HOST://localhost:9092'
KAFKA_CONTROLLER_QUORUM_VOTERS: '1@broker:29093'
KAFKA_CONTROLLER_LISTENER_NAMES: 'CONTROLLER'
KAFKA_INTER_BROKER_LISTENER_NAME: 'PLAINTEXT'
CLUSTER_ID: '4L6g3nShT-eMCtK--X86sw'
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
KAFKA_LOG_DIRS: '/tmp/kraft-combined-logs'
# 세그먼트를 작게 만들어 파일 수를 늘립니다 (재현 전용)
KAFKA_LOG_SEGMENT_BYTES: 1048576
docker compose up -d
K=/opt/kafka/bin
# 1. 파티션 4개 토픽과 400개 토픽을 나란히 만든다
docker exec -it broker $K/kafka-topics.sh --create --topic few-parts \
--partitions 4 --replication-factor 1 --bootstrap-server localhost:9092
docker exec -it broker $K/kafka-topics.sh --create --topic many-parts \
--partitions 400 --replication-factor 1 --bootstrap-server localhost:9092
# 2. 같은 조건으로 부하를 준다. 처리량을 제한해 "파티션당 유입 감소"를 만듭니다.
# --print-metrics 로 batch-size-avg / request-rate 를 함께 봅니다.
docker exec -it broker $K/kafka-producer-perf-test.sh \
--topic few-parts --num-records 200000 --record-size 900 --throughput 2000 \
--producer-props bootstrap.servers=localhost:9092 linger.ms=5 batch.size=16384 \
--print-metrics 2>&1 | grep -E 'batch-size-avg|request-rate|records/sec'
docker exec -it broker $K/kafka-producer-perf-test.sh \
--topic many-parts --num-records 200000 --record-size 900 --throughput 2000 \
--producer-props bootstrap.servers=localhost:9092 linger.ms=5 batch.size=16384 \
--print-metrics 2>&1 | grep -E 'batch-size-avg|request-rate|records/sec'
# → many-parts 쪽에서 batch-size-avg 가 크게 줄고 request-rate 가 크게 늡니다.
# 이것이 지연 증가의 직접 원인입니다.
# 브로커 프로세스가 연 파일 수
docker exec broker sh -c 'ls /proc/1/fd | wc -l'
# 메모리 맵 영역 수 (세그먼트당 2개씩 늘어납니다)
docker exec broker sh -c 'wc -l < /proc/1/maps'
# 파티션 400개 × 세그먼트 파일들 — 디렉터리 수를 확인
docker exec broker sh -c 'ls -d /tmp/kraft-combined-logs/many-parts-* | wc -l'
docker exec broker sh -c 'ls /tmp/kraft-combined-logs/many-parts-0/'
# → 00000000000000000000.log / .index / .timeindex / leader-epoch-checkpoint ...
# 호스트의 현재 vm.max_map_count 확인 (공식 문서가 주의하라고 한 값)
sysctl vm.max_map_count
# 컨슈머 2개를 붙였다 떼면서 상태 전이를 관찰합니다
docker exec -it broker $K/kafka-console-consumer.sh --topic many-parts \
--group rb-test --from-beginning --bootstrap-server localhost:9092 &
sleep 5
docker exec -it broker $K/kafka-console-consumer.sh --topic many-parts \
--group rb-test --from-beginning --bootstrap-server localhost:9092 &
watch -n 1 'docker exec -it broker /opt/kafka/bin/kafka-consumer-groups.sh \
--describe --group rb-test --state --bootstrap-server localhost:9092'
# → PreparingRebalance / CompletingRebalance 에 머무는 시간을
# few-parts 로 같은 실험을 했을 때와 비교합니다.
해결
즉시 조치 — 파티션은 못 줄이므로 주변을 조정한다
파티션 수를 되돌릴 수 없으므로, 당장은 배치가 다시 형성되게 만들고 OS 한계를 올리는 것이 유일한 완화책입니다.
linger.ms를 올려 배치를 다시 만든다# 파티션당 유입이 적으므로 더 기다려야 배치가 찹니다.
# 지연 예산이 허용하는 한도까지 올립니다.
linger.ms=50
batch.size=32768
compression.type=lz4 # 배치가 커지면 압축 효과가 돌아옵니다
buffer.memory=134217728 # 파티션이 많으면 버퍼도 늘려야 합니다 (기본 33554432)
# 파일 디스크립터 — 공식 문서 권고: 브로커 프로세스에 최소 100000
ulimit -n # 현재 값 확인
# /etc/security/limits.d/kafka.conf
# kafka soft nofile 200000
# kafka hard nofile 200000
# systemd 유닛이라면
# [Service]
# LimitNOFILE=200000
# 메모리 맵 영역 — 세그먼트당 2개씩 필요합니다
sysctl -w vm.max_map_count=262144
echo 'vm.max_map_count=262144' >> /etc/sysctl.d/99-kafka.conf
# segment.bytes 를 작게 설정한 토픽이 있으면 파일 수가 급증합니다.
# 기본값은 1073741824(1GiB)입니다. 작게 바꾼 토픽을 찾아 되돌립니다.
for t in $(kafka-topics.sh --bootstrap-server kafka-1:9092 --list); do
kafka-configs.sh --bootstrap-server kafka-1:9092 \
--entity-type topics --entity-name "$t" --describe | grep -q segment.bytes \
&& echo "== $t" \
&& kafka-configs.sh --bootstrap-server kafka-1:9092 \
--entity-type topics --entity-name "$t" --describe
done
근본 해결 — 파티션 수를 계산해서 정한다
"여유 있게"라는 근거로 정했습니다. 컨슈머 파드 상한을 파티션 수로 잡았지만, 그 파드가 실제로 필요한지 계산하지 않았습니다.
--partitions 1000 # "컨슈머 200개까지 늘릴 수 있게 5배 여유"
목표 처리량 ÷ 파티션 하나가 낼 수 있는 처리량으로 계산하고, 증설 여유는 2배 이내로 둡니다.
목표 유입 = 초당 3,600건 (피크 3배 반영)
컨슈머 1스레드 = 초당 300건 처리 (실측)
필요 병렬도 = 3,600 / 300 = 12
증설 여유 2배 = 24
브로커 3대 정합 = 24 (브로커당 8 리더)
→ --partitions 24. 기존 값과 같습니다. 애초에 증설이 필요하지 않았습니다.
파티션을 늘리지 않고 병렬성을 얻는 방법
| 방법 | 내용 | 주의 |
|---|---|---|
| 컨슈머 내부 병렬화 | 파티션은 그대로 두고 워커 풀로 처리 (케이스 2) | 파티션 내 순서가 깨짐 (케이스 4). 같은 키는 같은 워커로. |
| Share Groups (KIP-932) | 4.2에서 production-ready. 한 파티션을 여러 컨슈머가 함께 소비하고 레코드 단위로 ack | 순서 있는 스트림이 아니라 작업 큐 성격 워크로드용. 11장 참조 |
| 처리 로직 최적화 | 건당 처리 시간을 줄이면 필요한 파티션 수가 그대로 줄어듦 | 가장 저렴한 해결책인데 가장 늦게 검토됨 |
| 토픽 분할 | 우선순위·테넌트별로 토픽을 나눠 독립적으로 확장 | 토픽 수도 자원을 씁니다. 파티션 총합으로 판단하세요 |
| 브로커 증설 | 브로커를 늘려 브로커당 레플리카 수를 낮춤 | 파티션 재배치(kafka-reassign-partitions) 필요. 네트워크 부하 주의 |
이미 늘려 버렸다면 — 새 토픽으로 이관
# 1. 계산으로 정한 파티션 수로 새 토픽을 만든다
kafka-topics.sh --bootstrap-server kafka-1:9092 --create \
--topic page-view-v2 --partitions 24 --replication-factor 3 \
--config min.insync.replicas=2
# 2. 프로듀서를 새 토픽으로 전환한다 (양쪽에 쓰는 기간을 두면 무중단 전환 가능)
# 3. 컨슈머가 두 토픽을 함께 구독하게 하고, 기존 토픽 lag 이 0이 되면 구독 해제
# 4. 기존 토픽의 리텐션이 지난 뒤 삭제
kafka-topics.sh --bootstrap-server kafka-1:9092 --delete --topic page-view
키가 있는 토픽이라면 이관 과정에서 키 배치가 바뀌므로 순서 경계가 생깁니다. 케이스 4의 마이그레이션 전략 표를 함께 보세요.
예방 체크리스트
시험 포인트
이어서 볼 곳
공식 문서 출처
- Operations — OS-level tuning — 파일 디스크립터 계산식과 100,000 권고,
mmap()참조 특성,vm.max_map_count와 세그먼트당 맵 영역 2개,OutOfMemoryError (Map failed) - Operations — Modifying topics — 파티션 증설 부작용 4가지, 파티션 축소 불가, 파티션 수 100,000 가정과 토픽 이름 249자 제한
- Producer Configs —
linger.ms·batch.size·buffer.memory— 기본값 5 / 16384 / 33554432, 4.0에서linger.ms변경 사실 - Producer Configs —
compression.type— 압축은 배치 전체 단위, 배치가 클수록 압축률 향상 - Consumer Configs —
fetch.max.bytes·max.partition.fetch.bytes— 기본값 52428800 / 1048576 - Broker Configs —
num.recovery.threads.per.data.dir— 기본값 2 - Topic Configs —
segment.bytes·segment.ms— 기본값 1073741824 / 604800000 - Design — Replication — 컨트롤러가 리더십 변경 통지를 배치로 묶어 선출 비용을 낮추는 구조
- Monitoring —
RequestHandlerAvgIdlePercent