“단일 브로커로 1만 TPS 됩니까?” 라는 질문을 받으면 대답은 항상 같다. “메시지 크기가 얼마인데요?”
1만 TPS는 숫자가 같아도 메시지 크기에 따라 완전히 다른 문제다. 설정을 바꾸기 전에 먼저 뭘 달성하려는 건지 정확히 정의해야 한다.
1만 TPS가 뭔가? 측정해본 적 있나?
“초당 1만 개”라는 말에 사람들이 흔히 놓치는 것이 있다. 메시지 크기다.
| 메시지 크기 | 10,000 TPS의 실제 처리량 | 주요 병목 |
|---|---|---|
| 100 Byte | 1 MB/s | 개수 자체보다 오버헤드 |
| 1 KB | 10 MB/s | 소프트웨어 설정 |
| 10 KB | 100 MB/s | 디스크/네트워크 |
| 100 KB | 1 GB/s | 단일 브로커 한계 근처 |
100 Byte 메시지 1만 개는 1 MB/s 처리량이다. 웬만한 HDD도 순차 쓰기로 100~200 MB/s가 나온다. 디스크가 전혀 문제가 아니다 — 이 경우 병목은 보통 개별 메시지마다 발생하는 네트워크 왕복 횟수다.
1 KB 메시지 1만 개는 10 MB/s다. SSD라면 넉넉하고, 일반 HDD라도 충분하다. 역시 소프트웨어 설정이 병목이다.
이 글은 1 KB × 10,000/s = 10 MB/s를 기준으로 한다.
먼저 현재 수치를 측정한다. Kafka에는 내장 성능 측정 도구가 있다.
# 현재 throughput 기준선 측정
kafka-producer-perf-test.sh \
--topic perf-test \
--num-records 100000 \
--record-size 1024 \
--throughput -1 \
--producer-props bootstrap.servers=localhost:9092
# 출력 예시 (튜닝 전 기본값)
# 100000 records sent, 3184.7 records/sec (3.11 MB/sec),
# 302.45 ms avg latency, 876.00 ms max latency
초당 3천 개. 목표의 30%. 설정을 바꾸기 전에 이 숫자를 먼저 적어둔다.
파티션이 왜 처리량을 결정하나?
Kafka에서 병렬 처리의 단위는 파티션이다.
파티션 하나는 순서가 보장된 append-only 로그 파일이다. 한 파티션에는 한 번에 하나의 쓰기 작업만 발생한다.
파티션이 1개라면?
프로듀서 3개가 동시에 보내도 → [순서대로 차례차례 쓰기]
파티션 0: msg1, msg2, msg3, ...
프로듀서가 아무리 많아도, 결국 단일 쓰기 경로 하나로 줄을 선다.
파티션이 3개라면?
프로듀서 3개 → 파티션 0: msg1, msg4, msg7, ... (병렬로 동시에)
파티션 1: msg2, msg5, msg8, ...
파티션 2: msg3, msg6, msg9, ...
3개 경로가 동시에 쓰기를 수행한다. 이론적으로 처리량이 3배가 된다.
단, 단일 브로커에서는 디스크 I/O가 공유된다. 파티션이 100개여도 디스크 대역폭이 100 MB/s면 100 MB/s 이상 쓸 수 없다. 10 MB/s 목표에서는 파티션 20~30개면 충분한 병렬성이다.
파티션을 무조건 많이 늘리면 부작용이 있다.
- 파티션마다 파일 핸들과 인덱스 파일이 열린다. 파티션 1,000개면 파일 핸들 수천 개
- 브로커 시작 시간이 파티션 수에 비례해 늘어난다
- Controller 선출, 장애 복구 시간이 증가한다
파티션 수는 컨슈머 인스턴스 수와 맞춰야 한다. 파티션이 20개인데 컨슈머가 10개면 컨슈머 하나가 파티션 2개씩 담당한다. 컨슈머가 20개인데 파티션이 10개면 컨슈머 10개는 아무것도 안 한다 — Kafka 컨슈머 그룹에서 파티션보다 많은 컨슈머는 대기 상태가 된다.
파티션을 늘리는 것은 가능하지만 줄이는 것은 불가능하다. 처음 결정을 신중하게 해야 한다.
프로듀서가 메시지를 모아서 보낸다는 게 뭔가?
프로듀서는 send()를 호출할 때마다 바로 네트워크로 보내지 않는다. 내부 버퍼에 모아서 배치(batch)로 전송한다.
producer.send(msg1) → [msg1] (버퍼에 저장)
producer.send(msg2) → [msg1, msg2] (버퍼에 저장)
producer.send(msg3) → [msg1, msg2, msg3] (아직 버퍼)
...
배치 크기(batch.size)에 도달하거나, linger.ms 시간이 지나면 → 한 번에 전송
왜 모아서 보내는 게 빠른가?
네트워크 요청의 비용은 데이터 크기보다 왕복 횟수에 더 크게 비례한다. 요청마다 TCP 헤더, 브로커 파싱, 응답 ack — 이 오버헤드가 있다.
1 KB 메시지를 10,000번 각각 보내면 → 10,000번의 네트워크 왕복 1 KB 메시지 1,000개를 묶어서 10번 보내면 → 10번의 네트워크 왕복
처리량 차이는 실측으로 확인할 수 있다.
# 기본 설정 (batch.size=16KB, linger.ms=0)
kafka-producer-perf-test.sh --topic perf-test --num-records 100000 \
--record-size 1024 --throughput -1 \
--producer-props bootstrap.servers=localhost:9092
# 결과: ~3,000 records/sec
# 배치 설정 (batch.size=128KB, linger.ms=5)
kafka-producer-perf-test.sh --topic perf-test --num-records 100000 \
--record-size 1024 --throughput -1 \
--producer-props bootstrap.servers=localhost:9092 \
batch.size=131072 linger.ms=5
# 결과: ~12,000 records/sec
설정 두 줄 변경으로 4배 차이가 난다.
linger.ms=0이 왜 나쁜가? 0이 빠른 것 아닌가?
linger.ms=0은 “배치가 덜 찼어도 즉시 전송”이다.
linger.ms=0 상황에서 초당 10,000개 메시지를 보내면:
producer.send(msg1) → 즉시 전송 (배치: 1개)
producer.send(msg2) → 즉시 전송 (배치: 1개)
...
10,000번 각각 전송 → 10,000번의 네트워크 왕복
batch.size=128KB로 설정해도 채울 틈이 없다. 메시지 하나가 오면 바로 보내버리기 때문이다.
linger.ms=5로 설정하면:
5ms 동안 기다리면서 쌓인 메시지를 모아서 한 번에 전송
5ms 안에 50개 메시지가 왔다면 → 50개를 하나의 배치로 전송 (1번의 왕복)
10,000개면 → 약 200번의 배치 전송
“0이면 바로 보내니까 더 빠르다”는 착각은 개별 메시지의 latency를 보기 때문이다.
linger.ms=0: 메시지 A가 3ms 안에 도착. 개별 latency 최저.linger.ms=5: 메시지 A가 최대 5ms 뒤에 도착. 개별 latency +5ms.
하지만 전체 throughput은 반대다. 배치가 클수록 단위 시간당 처리할 수 있는 메시지가 많아진다.
트레이드오프:
| 설정 | 개별 Latency | 전체 Throughput |
|---|---|---|
linger.ms=0 | 최저 | 낮음 |
linger.ms=5 | +5ms | 높음 |
linger.ms=20 | +20ms | 최고 (배치가 충분히 찬다) |
실시간 알림처럼 latency가 중요한 토픽과, 이벤트 스트림처럼 throughput이 중요한 토픽을 같은 토픽에 같은 설정으로 쓰지 말고 토픽을 분리해서 각각 다른 프로듀서 설정을 적용한다.
acks=all을 단일 브로커에서 쓰면 왜 의미 없나?
acks는 “몇 개의 브로커가 메시지를 받았을 때 ack를 돌려줄 것인가”다.
acks=0: ack 없이 바로 다음 메시지로. 가장 빠르지만 유실 가능.acks=1: 리더 브로커 1개가 받으면 ack.acks=all: ISR(In-Sync Replicas) 전체가 받으면 ack. 가장 안전.
ISR이 뭔가?
Kafka 토픽에서 파티션은 여러 브로커에 복제될 수 있다. 리더 파티션 1개와 팔로워 파티션 N개. ISR은 “리더를 제대로 따라가고 있는 브로커들의 집합”이다.
acks=all은 ISR에 속한 모든 브로커가 메시지를 받았을 때만 ack를 보낸다. 브로커 3대에 replication.factor=3이면 3대 모두 받아야 한다. 한 대가 죽어도 나머지 2대에 데이터가 있다. 안전하다.
단일 브로커에서는?
replication.factor=1만 가능하다. 팔로워가 없다. ISR = [리더 하나].
acks=all이어도 ISR 멤버가 리더 하나뿐이므로 리더 하나만 확인하면 된다. acks=1과 동일하게 동작한다.
# 토픽 생성 (단일 브로커)
kafka-topics.sh --create --topic my-topic \
--bootstrap-server localhost:9092 \
--replication-factor 1 \
--partitions 20
# min.insync.replicas는 acks=all과 함께 쓰는 설정
# "ISR 중 최소 이 개수는 받아야 한다"
# 단일 브로커에서 min.insync.replicas=2로 설정하면 항상 에러 (ISR이 1개뿐이므로)
min.insync.replicas=1 # 단일 브로커에서 의미 있는 최솟값
단일 브로커의 내구성은 acks 설정으로 해결되지 않는다. 브로커가 죽으면 데이터가 없다. 내구성이 중요하다면 브로커를 3대로 늘리고 replication.factor=3을 써야 한다.
단일 브로커에서 throughput 관점의 최선: acks=1.
Kafka가 왜 디스크에 쓰는데 빠른가?
“디스크 = 느리다”는 맞는 말이다. 하지만 Kafka는 디스크를 직접 fsync하지 않는다.
Producer → Kafka 프로세스 → OS Page Cache → (OS가 나중에) → Disk
↑
ack를 돌려주는 시점이 여기다
OS page cache는 RAM에 있는 디스크 쓰기 버퍼다. Kafka는 page cache에 쓰면 “저장됐다”고 ack를 돌려준다. 실제 디스크 기록은 OS가 백그라운드에서 알아서 한다.
이 구조에서 Consumer 읽기가 빠른 이유:
Consumer 읽기 — page cache hit (프로듀서가 방금 쓴 데이터):
Consumer 요청 → OS Page Cache (RAM) → Consumer
디스크 접근 없음. RAM 속도 (GB/s)
Consumer 읽기 — page cache miss (오래된 데이터):
Consumer 요청 → Disk → OS Page Cache → Consumer
디스크 접근 발생. HDD는 수십 ms.
실시간 컨슈머는 프로듀서가 방금 쓴 데이터를 읽는다. page cache에 그 데이터가 있다. 디스크를 전혀 건드리지 않고 RAM 속도로 전달된다. 이것이 Kafka가 빠른 핵심 이유다.
여기에 하나 더 있다. **zero-copy (sendfile)**다.
일반적인 파일 전송 (4번 복사):
Disk/Page Cache → JVM 힙(유저 공간) → 소켓 버퍼(커널) → NIC
Kafka의 sendfile() (zero-copy):
Disk/Page Cache → 소켓 버퍼(커널) → NIC
유저 공간(JVM 힙)을 거치지 않음
Kafka는 컨슈머에게 데이터를 보낼 때 sendfile() 시스템콜로 zero-copy 전송을 한다. 디스크/페이지캐시의 데이터를 유저 공간(JVM 힙)으로 복사하지 않고, 커널에서 소켓 버퍼로 바로 넘긴다. 그래서 메시지가 page cache에 있으면 디스크도 JVM도 거의 거치지 않는다. JVM 힙을 작게 줘도 컨슈머 전송이 빠른 이유가 여기 있다 — 메시지 데이터가 애초에 힙을 통과하지 않기 때문이다.
Kafka가 왜 빠른가 — 한곳에 정리:
- 순차 디스크 I/O: append-only 로그라 랜덤 액세스가 없다. 끝에 덧붙이기만 한다.
- OS page cache 활용: 직접 fsync하지 않고 page cache에 쓰고 ack. 컨슈머 읽기는 RAM 속도.
- zero-copy (sendfile): 컨슈머 전송 시 JVM 힙을 거치지 않고 커널에서 NIC로 직행.
- 배치 + 압축: 프로듀서가 메시지를 모아서 보내 네트워크 왕복을 줄이고, lz4 등으로 대역폭을 절감.
- 파티션 단위 병렬성: 파티션마다 독립 쓰기 경로. 병렬도가 처리량을 결정한다.
fsync를 매번 하면 어떻게 되나?
# 이렇게 설정하면 메시지마다 fsync 강제
log.flush.interval.messages=1
HDD에서 fsync 한 번에 510 ms가 걸린다. 초당 최대 100200 ops. 1만 TPS는 불가능하다.
# 올바른 설정 — OS에 완전히 맡긴다
log.flush.interval.messages=9223372036854775807 # Long.MAX_VALUE
log.flush.interval.ms=9223372036854775807
“그럼 데이터가 날아가지 않나?”
날아갈 수 있다. 브로커가 갑자기 죽었을 때 아직 디스크에 flush되지 않은 page cache의 데이터는 사라진다. 단일 브로커에서 감수해야 하는 트레이드오프다.
단일 브로커에서 내구성을 높이는 방법은 fsync가 아니라 replication이다. 브로커를 늘려야 한다.
JVM 힙을 크게 주면 왜 성능이 오히려 나빠지나?
page cache 구조를 이해하면 이 질문의 답이 나온다.
page cache는 OS가 관리한다. OS는 남는 RAM을 page cache로 사용한다. 다른 프로세스가 메모리를 더 써서 남는 RAM이 줄어들면 page cache 크기도 줄어든다.
서버 RAM이 16GB고 Kafka JVM 힙에 14GB를 줬다고 생각해보자.
Total RAM: 16GB
JVM Heap: 14GB → Kafka 프로세스 점유
OS/page cache용 가용 메모리: ~2GB
프로듀서가 쓴 데이터: page cache에 최대 2GB까지만 유지
컨슈머가 최근 2GB보다 오래된 데이터를 읽으면 → page cache miss → 디스크 접근
page cache miss가 늘어나면 디스크 I/O가 증가하고 처리량이 떨어진다.
더 있다. 힙이 크면 GC pause가 길어진다. G1GC를 써도 힙 전체를 스캔하는 작업이 있다. 12GB 힙을 스캔하는 동안 Kafka가 멈춰있으면 producer timeout이 발생할 수 있다.
올바른 메모리 배분:
# RAM 32GB 서버
export KAFKA_HEAP_OPTS="-Xmx6g -Xms6g"
# JVM 6GB, 나머지 26GB는 OS와 page cache가 씀
# RAM 16GB 서버
export KAFKA_HEAP_OPTS="-Xmx4g -Xms4g"
# JVM 4GB, 나머지 12GB는 OS와 page cache가 씀
Kafka JVM이 실제로 쓰는 메모리는 많지 않다. 메시지 데이터는 page cache에 있고, JVM이 들고 있는 건 메타데이터와 연결 상태 정도다. 4~6GB면 충분하다.
JVM 힙을 줄이는 대신 서버에 RAM을 더 꽂는 게 Kafka 성능에 직접 도움이 된다. page cache가 커지기 때문이다.
OS 네트워크 버퍼를 왜 튜닝해야 하나?
Kafka는 프로듀서-브로커-컨슈머 간에 대용량 TCP 스트림을 주고받는다. 10 MB/s라면 초당 10MB의 데이터가 TCP 소켓을 통해 흐른다.
OS 기본 TCP 소켓 버퍼는 128KB~256KB 수준이다. 128KB짜리 배치 하나가 소켓 버퍼를 거의 꽉 채운다.
TCP 소켓 버퍼가 작으면 어떻게 되나?
프로듀서: "128KB 배치 보냄"
소켓 버퍼: 128KB 꽉 참
프로듀서: "다음 배치 보내려는데 버퍼가 꽉 차 있음 → 대기"
브로커: "소켓 버퍼에서 읽어서 처리"
프로듀서: "버퍼 비워졌으니 다음 배치 전송"
→ 프로듀서와 브로커가 번갈아 기다리는 패턴 → throughput 저하
소켓 버퍼를 늘리면 프로듀서가 여러 배치를 버퍼에 밀어넣고 브로커가 처리하는 동안 기다리지 않아도 된다.
# /etc/sysctl.d/kafka.conf
# 소켓 수신/송신 버퍼 최대값
net.core.rmem_max=134217728 # 128MB
net.core.wmem_max=134217728 # 128MB
# 새 소켓의 기본 버퍼 크기
net.core.rmem_default=8388608 # 8MB
net.core.wmem_default=8388608 # 8MB
# TCP 자동 조정 범위 [최소, 기본, 최대]
net.ipv4.tcp_rmem=4096 8388608 134217728
net.ipv4.tcp_wmem=4096 8388608 134217728
# 스왑 최소화 — page cache가 swap out되면 성능 급락
vm.swappiness=1
# dirty page flush 정책
vm.dirty_ratio=10 # dirty page가 RAM의 10%를 넘으면 강제 flush
vm.dirty_background_ratio=5 # 5%를 넘으면 백그라운드 flush 시작
vm.dirty_expire_centisecs=3000 # 30초 이상 된 dirty page는 flush
vm.dirty_writeback_centisecs=500
# 적용 (재시작 불필요)
sudo sysctl -p /etc/sysctl.d/kafka.conf
Kafka server.properties에서도 소켓 버퍼를 설정한다. OS 최대값 안에서:
socket.send.buffer.bytes=1048576 # 1MB
socket.receive.buffer.bytes=1048576 # 1MB
socket.request.max.bytes=104857600 # 100MB (최대 요청 크기)
파일시스템 마운트 옵션:
# /etc/fstab — Kafka 데이터 디렉토리
/dev/sdb1 /data/kafka ext4 defaults,noatime,nodiratime 0 0
noatime은 파일을 읽을 때 “마지막 접근 시간(atime)“을 업데이트하지 않는다. 기본적으로 파일을 읽기만 해도 메타데이터 쓰기가 발생한다. 컨슈머가 데이터를 읽을 때마다 디스크에 메타데이터를 쓰는 건 불필요한 부하다. noatime으로 이걸 없앤다.
1만 TPS가 안 나올 때 병목을 어떻게 찾나?
설정을 무작위로 바꾸는 건 의미 없다. 병목이 어디 있는지 모른 채 linger.ms를 바꿔봤자 병목이 디스크에 있다면 아무 효과가 없다.
측정 → 분석 → 변경 → 재측정 순서가 중요하다.
Step 1: 프로듀서 throughput 기준선
kafka-producer-perf-test.sh \
--topic perf-test \
--num-records 100000 \
--record-size 1024 \
--throughput -1 \
--producer-props \
bootstrap.servers=localhost:9092 \
linger.ms=5 \
batch.size=131072 \
compression.type=lz4 \
acks=1
# 출력 해석
# 100000 records sent, 9834.5 records/sec (9.60 MB/sec),
# 12.34 ms avg latency, 234 ms max latency
#
# records/sec < 10000 → 병목 있음
# avg latency > 50ms → 배치가 안 차거나 브로커가 느림
# max latency가 avg의 10배 이상 → GC pause 또는 간헐적 지연
Step 2: 브로커 CPU/디스크 확인
# CPU 확인 — Kafka 프로세스가 CPU를 얼마나 쓰나
top -p $(pgrep -f kafka.Kafka)
# CPU < 30%인데 throughput이 낮으면 → I/O 대기가 원인일 가능성
# CPU > 80%인데 throughput이 낮으면 → 압축 설정 또는 스레드 설정 확인
# 디스크 I/O 확인
iostat -x 1 5
# %util > 90% → 디스크가 병목
# await > 10ms → fsync 설정 확인 (log.flush.interval 확인)
Step 3: 메모리와 스왑 확인
# page cache 크기 확인
free -h
# total used free shared buff/cache
# Mem: 16Gi 3.2Gi 2.1Gi 234Mi 10Gi
# Swap: 2Gi 0B 2Gi
# buff/cache가 클수록 page cache hit 가능성 높음
# 스왑 발생 여부 (si/so가 0이어야 한다)
vmstat 1 5
# si(swap in), so(swap out) → 0이어야 정상
# 숫자가 보이면 → JVM 힙이 너무 커서 page cache가 swap out되는 것
Step 4: 네트워크 확인
# NIC 사용률 확인
sar -n DEV 1 5
# txkB/s, rxkB/s 확인
# 1Gbps NIC = ~125 MB/s
# 10 MB/s 목표에서 NIC는 병목이 아니어야 한다
가장 흔한 원인 두 가지 (전체 케이스의 80%):
linger.ms=0(기본값) → 배치가 만들어지지 않음batch.size=16384(기본 16KB) → 배치가 너무 작아서 자주 전송
이 두 가지만 바꿔도 throughput이 3~5배 오른다.
프로듀서 배치 효율 확인 (Spring Kafka Micrometer):
kafka_producer_batch_size_avg
# batch.size의 70% 이상이면 배치가 잘 차고 있는 것
# 30% 미만이면 linger.ms를 늘리거나 batch.size를 줄이는 게 맞다
모니터링 없이 운영하면 어떤 일이 생기나?
모니터링 없이 운영하면 문제가 생긴 다음에 알게 된다. 그때는 이미 늦은 경우가 많다.
JMX 활성화:
# kafka-server-start.sh 실행 전 환경변수 설정
export JMX_PORT=9999
export KAFKA_JMX_OPTS="-Dcom.sun.management.jmxremote \
-Dcom.sun.management.jmxremote.authenticate=false \
-Dcom.sun.management.jmxremote.ssl=false \
-Djava.rmi.server.hostname=0.0.0.0"
브로커 핵심 JMX 지표:
# 처리량 확인
kafka.server:type=BrokerTopicMetrics,name=MessagesInPerSec
kafka.server:type=BrokerTopicMetrics,name=BytesInPerSec
kafka.server:type=BrokerTopicMetrics,name=BytesOutPerSec
# 요청 성능 — 이게 느리면 브로커가 병목
kafka.network:type=RequestMetrics,name=TotalTimeMs,request=Produce
# avg > 20ms → 브로커 응답이 느린 것. 디스크 확인
# fsync 확인 — 이 값이 크면 log.flush.interval 설정 잘못된 것
kafka.log:type=LogFlushStats,name=LogFlushRateAndTimeMs
# 배치 요청당 메시지 수 — 이 값이 작으면 배치가 안 만들어지는 것
kafka.network:type=RequestMetrics,name=RequestsPerSec,request=Produce
프로듀서 지표 (Spring Kafka + Micrometer):
# application.yml
management:
metrics:
tags:
application: ${spring.application.name}
kafka_producer_record_send_rate # 초당 전송 레코드 수 (목표: 10,000)
kafka_producer_record_error_rate # 에러율 (0이어야 함. 조금이라도 있으면 조사)
kafka_producer_batch_size_avg # 평균 배치 크기 (batch.size의 70% 이상)
kafka_producer_request_latency_avg # 요청 → 응답 시간 (< 10ms)
kafka_producer_records_per_request_avg # 요청당 레코드 수 (클수록 배치 효율 좋음)
컨슈머 지표:
kafka_consumer_records_lag # 이 값이 증가 추세면 컨슈머가 못 따라가는 것
kafka_consumer_fetch_latency_avg # fetch 지연 (< 100ms)
랙을 직접 JMX에서 볼 때 지표명은 **records-lag-max**다 (consumer-fetch-manager-metrics 그룹). Micrometer가 이걸 kafka_consumer_fetch_manager_records_lag_max 형태로 노출한다. 위의 kafka_consumer_records_lag는 편의상 줄여 쓴 이름이고, 실제 대시보드/알람에서 찾을 지표는 records-lag-max임을 기억해두면 된다.
Grafana 알람 기준 (단일 브로커 1만 TPS):
| 지표 | Warning | Critical |
|---|---|---|
MessagesInPerSec | < 9,000/s | < 7,000/s |
BytesInPerSec | < 9 MB/s | < 7 MB/s |
Produce TotalTimeMs avg | > 20 ms | > 100 ms |
kafka_producer_record_error_rate | > 0 | > 0.01 |
kafka_consumer_records_lag | > 1,000 | > 10,000 |
LAG 알람이 가장 중요하다. LAG이 증가 추세면 컨슈머가 처리 속도를 못 따라가고 있는 것이다. 방치하면 메모리가 쌓이고, 결국 컨슈머가 죽거나 처리 지연이 누적된다.
최적화 — 설정 치트시트
# ======== 브로커 server.properties ========
# 스레드 (CPU 코어 수의 2배가 일반적 시작점)
num.network.threads=4
num.io.threads=8
# 소켓 버퍼
socket.send.buffer.bytes=1048576 # 1MB
socket.receive.buffer.bytes=1048576 # 1MB
socket.request.max.bytes=104857600 # 100MB
# fsync — OS에 완전히 맡긴다 (핵심)
log.flush.interval.messages=9223372036854775807
log.flush.interval.ms=9223372036854775807
# 로그 보존
log.retention.hours=168
log.segment.bytes=1073741824 # 1GB per segment
log.retention.check.interval.ms=300000
# ======== 프로듀서 (Spring application.yml) ========
spring:
kafka:
producer:
batch-size: 131072 # 128KB (기본 16KB에서 8배 — 가장 큰 효과)
linger-ms: 5 # 5ms 대기 (기본 0에서 변경 — 두 번째로 중요)
compression-type: lz4 # CPU 부하 낮고 압축률 좋음
acks: "1" # 단일 브로커에서 all과 동일하지만 명시적으로
buffer-memory: 67108864 # 64MB (기본 32MB에서 2배)
properties:
max.in.flight.requests.per.connection: 5
retry.backoff.ms: 100
delivery.timeout.ms: 120000
enable.idempotence는 Kafka 3.0+에서 기본값이 true다. 멱등 프로듀서가 켜지면 재시도(retries)가 발생해도 max.in.flight.requests.per.connection ≤ 5 범위에서 파티션 내 순서와 중복이 보장된다. 즉 재시도 때문에 메시지 순서가 뒤바뀌거나 중복 적재될 걱정 없이 acks·배치 튜닝에 집중하면 된다.
# ======== 컨슈머 (Spring application.yml) ========
spring:
kafka:
consumer:
max-poll-records: 500 # 한 번에 가져오는 최대 레코드 수
properties:
fetch.min.bytes: 1024 # 1KB 이상 쌓여야 fetch 응답
fetch.max.wait.ms: 100 # 100ms 기다린 후 응답 (fetch.min.bytes 안 찼어도)
max.partition.fetch.bytes: 1048576 # 파티션당 최대 fetch 크기 1MB
max.poll.interval.ms: 300000 # 5분 (처리 로직이 느리면 늘려야 함)
session.timeout.ms: 30000 # 브로커가 컨슈머 생존 확인 주기
# ======== OS sysctl (/etc/sysctl.d/kafka.conf) ========
vm.swappiness=1
vm.dirty_ratio=10
vm.dirty_background_ratio=5
vm.dirty_expire_centisecs=3000
vm.dirty_writeback_centisecs=500
net.core.rmem_max=134217728
net.core.wmem_max=134217728
net.core.rmem_default=8388608
net.core.wmem_default=8388608
net.ipv4.tcp_rmem=4096 8388608 134217728
net.ipv4.tcp_wmem=4096 8388608 134217728
net.core.netdev_max_backlog=5000
net.ipv4.tcp_tw_reuse=1
# ======== JVM ========
# RAM 32GB 서버
export KAFKA_HEAP_OPTS="-Xmx6g -Xms6g"
# RAM 16GB 서버
export KAFKA_HEAP_OPTS="-Xmx4g -Xms4g"
튜닝 — 순서가 중요하다
측정 없이 설정을 바꾸면 어디서 개선됐는지 알 수 없다. 하나씩 바꾸고 측정한다.
Step 1: 기준선 측정 (kafka-producer-perf-test.sh)
→ 현재 records/sec 와 avg latency 기록
Step 2: linger.ms=5, batch.size=131072 변경
→ 재측정 (보통 여기서 3~5배 개선)
→ 목표 달성하면 다음 스텝 불필요
Step 3: compression.type=lz4 추가
→ 재측정 (네트워크/디스크 대역폭 절감으로 5~30% 추가 개선)
Step 4: JVM 힙 확인
→ 현재 힙 크기 확인: jmap -heap <pid>
→ RAM의 25~30%로 줄이기 (나머지는 page cache용)
→ 재측정
Step 5: OS sysctl 튜닝 적용
→ sysctl -p /etc/sysctl.d/kafka.conf
→ 재측정
Step 6: 파티션 수 조정
→ 현재 파티션 수와 컨슈머 수 비교
→ 컨슈머 수만큼 파티션 설정
→ 재측정
운영상 주의사항 — 프로덕션에서 실제로 깨지는 것들
1. 스왑이 발생하면 Kafka가 죽은 것처럼 느려진다
page cache가 swap out되는 순간, 컨슈머 읽기가 RAM 속도에서 디스크 속도로 떨어진다. LAG이 폭발적으로 증가한다.
# 실시간 스왑 확인
vmstat 1 | awk '{print "si=" $7, "so=" $8}'
# si/so가 0 이어야 한다. 숫자가 보이면 즉시 대응
# 스왑 강제 해제 (메모리 압박 상황에서는 OOM 위험 — 먼저 메모리 여유 확인)
swapoff -a && swapon -a
vm.swappiness=1을 설정해도 메모리가 실제로 부족하면 스왑이 발생한다. JVM 힙을 줄이고 page cache 공간을 늘리는 게 근본 해결책이다.
2. 파티션 수는 한 번 늘리면 줄일 수 없다
Kafka는 파티션을 늘리는 것은 지원하지만 줄이는 것은 지원하지 않는다. 처음부터 신중하게 결정해야 한다.
“나중에 줄이면 되지”는 없다. 유일한 방법은 새 토픽을 만들고 마이그레이션하는 것이다. 1만 TPS 환경에서 이 작업은 며칠짜리 작업이 될 수 있다.
3. max.poll.records를 올리면 max.poll.interval.ms도 함께 확인해야 한다
max.poll.records=1000으로 늘리면 poll()이 최대 1,000개를 가져온다. 1,000개를 처리하는 데 6분이 걸리는 컨슈머라면?
max.poll.interval.ms=300000(기본 5분) 안에 다음 poll()을 호출하지 못한다. 브로커는 “이 컨슈머가 죽었다”고 판단하고 해당 컨슈머를 그룹에서 제거한다. rebalance가 발생한다. rebalance 중에는 해당 파티션이 처리되지 않는다.
증상: consumer group에서 consumer가 자주 빠지고 rebalance가 반복됨
확인: kafka-consumer-groups.sh --describe 에서 consumer가 자주 바뀜
원인: 처리 시간 > max.poll.interval.ms
조치: max.poll.interval.ms 증가 OR max.poll.records 감소 OR 처리 로직 최적화
4. 압축은 프로듀서가 하고 브로커는 그대로 저장하는 게 맞다
# 브로커 설정에 이걸 넣으면
compression.type=snappy # 브로커가 수신 → 재압축 → 저장
# 이렇게 되면
# 프로듀서가 lz4로 보냄 → 브로커가 lz4를 풀고 snappy로 다시 압축
# CPU 부하가 브로커에 집중된다
단일 브로커에서는 프로듀서가 lz4로 압축하고, 브로커는 compression.type=producer(기본값)로 그대로 저장하는 게 낫다. 재압축으로 인한 브로커 CPU 부하를 없앤다.
5. 로그 세그먼트와 디스크 용량 계획
log.segment.bytes=1073741824 # 1GB
log.retention.hours=168 # 7일
활성 세그먼트(현재 쓰고 있는 파일)는 삭제되지 않는다. 파티션 20개에 세그먼트 1GB면 활성 세그먼트만 최소 20GB가 항상 남아있다.
실제 필요 디스크:
파티션 수 × 세그먼트 크기 + 보존 기간 중 쌓이는 데이터
= 20파티션 × 1GB + (10MB/s × 7일 × 86400초)
= 20GB + 6,048GB
= 약 6TB
디스크 용량 알람을 85%에 걸어두지 않으면 새벽에 디스크가 꽉 차서 브로커가 죽는다.
단일 브로커에서 1만 TPS는 아키텍처 문제가 아니다. 설정 문제다.
병목의 80%는 linger.ms=0(기본값)과 batch.size=16384(기본 16KB) 두 줄에서 온다. 이것부터 바꾸고, 나머지는 측정한 다음 바꾼다. 측정 없는 튜닝은 낭비다.