이 케이스에서 얻어 갈 것

상황

클릭스트림 수집 파이프라인입니다. 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개의 레플리카를 담당하게 됐습니다.

변경 직후에는 아무 일도 없었습니다. 문제는 세 단계로 나타났습니다.

  1. +30분: 프로듀서 p99 지연이 12ms에서 90ms로 상승. 처리량은 오히려 감소.
  2. +6시간: 컨슈머 파드를 재배포했더니 리밸런스가 90초 넘게 걸리기 시작. 그 시간 동안 처리량 0.
  3. +2일: 브로커 한 대를 정기 패치로 재시작했는데 기동에 40분이 걸렸고, 그 사이 다른 브로커가 Too many open files로 죽었습니다.
case05 — 파티션을 과도하게 늘려 오히려 처리량과 복구 시간이 나빠지는 흐름 정상 흐름에서 파티션은 병렬 처리의 단위이므로 컨슈머 수를 파티션 수 이하로 두고 필요한 만큼만 늘립니다. 어긋나는 지점에서는 "많을수록 빠르다"는 가정으로 파티션 수를 크게 잡아 클러스터 전체 파티션 수가 수만 개가 되고, 브로커마다 열어야 하는 세그먼트 파일과 유지할 리더 수, 복제 fetch 대상이 함께 늘어납니다. 결과적으로 프로듀서는 파티션마다 배치를 따로 쌓기 때문에 배치가 작아져 요청 수가 늘고, 브로커 장애 시 다시 선출해야 할 리더가 많아 복구가 길어집니다. 처방은 목표 처리량과 컨슈머 최대 병렬도에서 역산하고, 파티션은 늘릴 수만 있고 줄일 수 없다는 점을 전제로 여유를 조금만 두는 것입니다. 1. 정상 흐름 파티션 = 병렬 처리의 단위 파티션 6개 컨슈머 6명 배치가 충분히 큼 처리량 선형 증가 컨슈머 그룹에서 한 파티션은 한 컨슈머에게만 할당되므로 병렬도의 상한이 파티션 수입니다. 컨슈머가 파티션보다 많으면 남는 컨슈머는 유휴 상태가 됩니다. 2. 어긋나는 지점 "많을수록 빠르다"는 가정 토픽 200개 × 파티션 500 클러스터 파티션 10만 브로커당 리더 수 급증 파일·메모리 부담 증가 브로커는 파티션마다 세그먼트 파일과 인덱스를 열고 리더 상태를 유지해야 합니다. 복제도 파티션 단위이므로 팔로워의 fetch 대상 수가 함께 늘어납니다. 3. 결과 처리량은 떨어지고 복구는 길어집니다 파티션당 배치 작아짐 요청 수 증가 리더 재선출 대상 급증 장애 복구 지연 프로듀서는 파티션마다 배치를 따로 쌓으므로 파티션이 많을수록 배치가 잘게 쪼개집니다. 브로커 한 대가 빠지면 그 브로커가 리더였던 파티션을 모두 다시 선출해야 합니다. 즉 파티션 수에 비례해 장애 복구 시간이 길어집니다. 처방 목표 처리량과 컨슈머 최대 병렬도에서 역산해 정하고, 확장 여유는 조금만 둡니다. 파티션은 늘릴 수만 있고 줄일 수 없습니다 — 처음부터 크게 잡는 대신 필요할 때 늘립니다. 키 기반 순서를 쓰는 토픽은 파티션을 늘리면 키 → 파티션 매핑이 바뀌므로 함께 고려합니다. 확인: kafka-topics.sh --bootstrap-server :9092 --describe --topic X (파티션 · 리더 분포)
파티션 수 증가가 비용으로 바뀌는 경로 — 정상 규모에서의 자원 사용, 증설 후 브로커당 레플리카·파일 핸들·메모리 맵 증가, 그리고 프로듀서 배치 효율 저하로 지연이 늘어나는 지점

관측된 증상

메트릭이 어떻게 보였는가

파티션 24개 시절과 1000개 이후의 실측 비교 (같은 트래픽, 같은 하드웨어)
지표 파티션 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 예외 메시지에 실려 나오는 것입니다. 따라서 나타나는 위치는 상황에 따라 다릅니다. 다음과 같은 형태로 보입니다.

kafka-2 server.log — OS 자원 고갈
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개의 파일 디스크립터를 허용할 것 (출발점)

이 사고의 숫자를 넣어 보면 이유가 분명해집니다.

브로커 1대 기준 파일 디스크립터 소요 추정 (파티션 크기 4GiB, segment.bytes 기본 1GiB)
항목 파티션 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)가 지나면 전송됩니다.

같은 초당 1,200건 · 레코드 900바이트에서 파티션 수가 배치에 미치는 영향
항목 파티션 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). 상세는 케이스 25장에 있습니다.

비용 6 — 브로커 재시작과 리더 선출

브로커가 기동할 때 각 로그 디렉터리의 세그먼트를 검사·복구합니다. 이 작업의 병렬도는 num.recovery.threads.per.data.dir(기본 2)이 결정합니다. 레플리카가 1,600개면 이 단계가 수십 분이 됩니다.

리더 선출은 그보다 낫습니다. 공식 설계 문서는 KRaft 컨트롤러가 리더십 변경 통지를 배치로 묶어 처리하므로 "파티션이 많을 때 선출 과정이 훨씬 저렴하고 빠르다"고 설명합니다. 그래도 공짜는 아닙니다 — 브로커 1대 손실 시 컨트롤러가 처리해야 할 파티션 수가 그만큼 늘어납니다. KRaft 아키텍처는 3장에서 다룹니다.

비용 7 — 되돌릴 수 없다

위 여섯 가지는 튜닝으로 완화할 수 있습니다. 이것만은 완화할 수 없습니다. Kafka는 토픽의 파티션 수 축소를 지원하지 않습니다. 잘못 늘렸다면 남는 방법은 새 토픽으로 이관하는 것뿐이고, 그 과정에서 키 배치·순서 보장·컨슈머 전환 문제가 전부 따라옵니다. 이 케이스의 진짜 교훈은 여기입니다 — 파티션 증설은 되돌릴 수 없는 결정이므로, 늘리기 전에 계산하세요.

재현 방법

노트북에서 브로커를 죽일 필요는 없습니다. 배치 효율 붕괴는 파티션 200개 정도로도 수치로 재현됩니다. 아래 compose는 Apache Kafka의 공식 단일 노드 예제입니다 (3노드 구성은 예제 1).

docker-compose.yml — 단일 노드 KRaft, FD 한계를 낮춰 재현하기 쉽게
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 한계를 올리는 것이 유일한 완화책입니다.

즉시 조치 1 — linger.ms를 올려 배치를 다시 만든다
# 파티션당 유입이 적으므로 더 기다려야 배치가 찹니다.
# 지연 예산이 허용하는 한도까지 올립니다.
linger.ms=50
batch.size=32768
compression.type=lz4          # 배치가 커지면 압축 효과가 돌아옵니다
buffer.memory=134217728       # 파티션이 많으면 버퍼도 늘려야 합니다 (기본 33554432)
즉시 조치 2 — OS 한계를 공식 권고치로 올린다
# 파일 디스크립터 — 공식 문서 권고: 브로커 프로세스에 최소 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
즉시 조치 3 — 세그먼트를 크게 유지해 파일 수를 줄인다
# 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의 마이그레이션 전략 표를 함께 보세요.

예방 체크리스트

시험 포인트

공식 문서 출처