이번 시간에는 Kafka 메시지가 키에 따라 어느 파티션으로 가는지를 직접 확인하며 배워가는 시간입니다.
예를 들어서 주문 서비스가 order-events라는 토픽에 주문 이벤트를 보내고, 결제·배송·알림 서비스가 이 토픽을 읽는다고 해볼게요. 주문이 몰리면 읽는 쪽도 그만큼 빨라져야 하죠.
Kafka는 토픽 하나를 여러 파티션으로 나누고, 파티션을 동시에 읽게 해서 처리량을 늘립니다. 대신 순서는 파티션 안에서만 지켜져요.
실습 준비
이 글의 명령은 모두 실습 코드의 kafka-topic-partition/ 디렉터리에서 실행합니다.
compose.yaml: KRaft 단일 노드 Kafkaorders.txt: 주문 3건의 이벤트 9줄(키:값)
먼저 Docker로 KRaft 단일 노드 Kafka를 띄울게요. 브로커와 컨트롤러를 한 컨테이너에서 같이 실행하는 구성이라고 보시면 됩니다.
compose.yaml은 이렇게 생겼어요.
services:
kafka:
image: apache/kafka:4.3.1
environment:
KAFKA_NODE_ID: 1
KAFKA_PROCESS_ROLES: broker,controller
KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093,HOST://:9411
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092,HOST://localhost:9411
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,HOST:PLAINTEXT
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093
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_HEAP_OPTS: -Xmx512m -Xms256m
ports:
- "9411:9411"
Kafka 명령 도구는 컨테이너 안 /opt/kafka/bin에 들어 있어요. 매번 긴 명령을 치기 번거로우니 셸 함수를 하나 만들어 둘게요.
docker compose up -d
kafka() { docker compose exec -T kafka /opt/kafka/bin/"$@"; }
실습 1: 같은 주문의 이벤트는 같은 파티션으로
order-events 토픽을 파티션 3개로 만들어 볼게요.
kafka kafka-topics.sh --bootstrap-server localhost:9092 --create --topic order-events --partitions 3 --replication-factor 1
Created topic order-events.
offset은 파티션 안에서 메시지마다 0부터 차례로 붙는 번호예요. 파티션마다 마지막 offset은 kafka-get-offsets.sh로 볼 수 있습니다.
주문 이벤트는 순서가 중요해요. 예를 들어서 결제 서비스가 paid를 created보다 먼저 처리하면 안 되겠죠.
그래서 여기서는 주문 번호를 메시지 키로 붙여 보내는 방식으로 가볼게요. orders.txt에 주문 3건의 이벤트를 섞어서 적어 두었어요. : 앞이 키, 뒤가 값입니다.
order-1001:created
order-1003:created
order-1009:created
order-1001:paid
order-1003:paid
order-1001:shipped
order-1009:paid
order-1003:shipped
order-1009:shipped
콘솔 프로듀서로 보내 볼게요. parse.key=true를 주면 각 줄을 구분자 기준으로 키와 값으로 나눠 줍니다.
kafka kafka-console-producer.sh --bootstrap-server localhost:9092 --topic order-events \
--reader-property parse.key=true --reader-property key.separator=: < orders.txt
이제 콘솔 컨슈머로 처음부터 읽으면서 파티션, offset, 키를 같이 출력해 볼게요.
kafka kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic order-events --from-beginning --max-messages 9 \
--formatter-property print.partition=true --formatter-property print.offset=true --formatter-property print.key=true 2> /dev/null
Partition:2 Offset:0 order-1009 created
Partition:2 Offset:1 order-1009 paid
Partition:2 Offset:2 order-1009 shipped
Partition:1 Offset:0 order-1003 created
Partition:1 Offset:1 order-1003 paid
Partition:1 Offset:2 order-1003 shipped
Partition:0 Offset:0 order-1001 created
Partition:0 Offset:1 order-1001 paid
Partition:0 Offset:2 order-1001 shipped
출력을 보면 두 가지가 눈에 들어와요.
- 같은 주문의 이벤트는 모두 같은 파티션에 들어갔어요.
order-1001은 파티션 0,order-1003은 파티션 1,order-1009는 파티션 2입니다. - 파티션 안에서는 보낸 순서대로
created,paid,shipped가 offset 0, 1, 2를 받았어요.
그런데 토픽 전체의 순서는 지켜지지 않았어요. 보낸 순서로는 order-1001:created가 첫 번째였는데, 컨슈머는 파티션 2의 order-1009 이벤트를 먼저 출력했죠.
왜 그럴까요? 컨슈머는 파티션을 각각 읽기 때문에, 서로 다른 파티션에 있는 메시지 사이의 순서는 보장되지 않아요. 그러니 순서를 지켜야 하는 이벤트끼리는 같은 파티션에 들어가도록 키를 정해 주셔야 합니다.
키가 있는 메시지의 파티션은 키의 해시로 정해져요. Java 클라이언트의 기본 파티셔너는 키 바이트의 murmur2 해시를 파티션 수로 나눈 나머지를 씁니다.
해시는 키가 같으면 늘 같아요. 그래서 파티션 수가 바뀌지 않는 한 같은 키는 늘 같은 파티션으로 갑니다.
반대로 키가 달라도 나머지가 같으면 같은 파티션에 모여요. 보통은 파티션 수보다 키 종류가 훨씬 많아서, 한 파티션에는 여러 키가 섞여 들어갑니다.
실습 2: 한 키에 몰리면 한 파티션만 바쁩니다
키로 순서를 지키면, 대신 그 키의 메시지는 모두 한 파티션으로 가요. 그래서 특정 키에 메시지가 몰리면 파티션 하나만 바빠지는데, 이런 파티션을 핫 파티션이라고 합니다.
예를 들어서 판매자 번호를 키로 쓰는 store-events 토픽에서, 대형 판매자 store-1이 전체 주문의 90%를 차지한다고 해볼게요. store-1로 900개, store-2부터 store-11까지 10개씩 100개를 보냅니다.
kafka kafka-topics.sh --bootstrap-server localhost:9092 --create --topic store-events --partitions 3 --replication-factor 1
{ for i in $(seq 1 900); do echo "store-1:order"; done
for s in $(seq 2 11); do for i in $(seq 1 10); do echo "store-$s:order"; done; done; } \
| kafka kafka-console-producer.sh --bootstrap-server localhost:9092 --topic store-events \
--reader-property parse.key=true --reader-property key.separator=:
kafka kafka-get-offsets.sh --bootstrap-server localhost:9092 --topic store-events
Created topic store-events.
store-events:0:20
store-events:1:40
store-events:2:940
출력을 보면 파티션 2에 940개, 나머지 두 파티션에는 20개와 40개가 들어갔어요. 파티션 2를 맡은 컨슈머는 다른 컨슈머보다 훨씬 많은 일을 하고, 그동안 다른 컨슈머는 놀게 되죠.
이 상태에서는 파티션을 늘려도 나아지지 않아요. store-1의 메시지는 여전히 한 파티션으로 가니까요.
그래서 핫 파티션을 줄이려면 순서가 필요한 단위가 무엇인지부터 다시 보셔야 해요. 판매자 단위 순서가 아니라 주문 단위 순서만 필요하다면, 판매자 번호 대신 주문 번호를 키로 쓰면 됩니다.
키를 좁힐수록 메시지가 여러 파티션에 퍼지고, 순서를 지켜 주는 범위는 그만큼 좁아져요.
실습 3: 파티션을 늘리면 키가 옮겨 갑니다
처리량이 부족하다고 해볼게요. 그래서 order-events의 파티션을 3개에서 6개로 늘리고, 그다음 세 주문에 refunded 이벤트를 하나씩 보내 보겠습니다.
kafka kafka-topics.sh --bootstrap-server localhost:9092 --alter --topic order-events --partitions 6
printf 'order-1001:refunded\norder-1003:refunded\norder-1009:refunded\n' \
| kafka kafka-console-producer.sh --bootstrap-server localhost:9092 --topic order-events \
--reader-property parse.key=true --reader-property key.separator=:
kafka kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic order-events --from-beginning --max-messages 12 \
--formatter-property print.partition=true --formatter-property print.offset=true --formatter-property print.key=true 2> /dev/null
Partition:5 Offset:0 order-1009 refunded
Partition:4 Offset:0 order-1003 refunded
Partition:2 Offset:0 order-1009 created
Partition:2 Offset:1 order-1009 paid
Partition:2 Offset:2 order-1009 shipped
Partition:1 Offset:0 order-1003 created
Partition:1 Offset:1 order-1003 paid
Partition:1 Offset:2 order-1003 shipped
Partition:0 Offset:0 order-1001 created
Partition:0 Offset:1 order-1001 paid
Partition:0 Offset:2 order-1001 shipped
Partition:0 Offset:3 order-1001 refunded
order-1001의 refunded는 원래대로 파티션 0에 붙었어요. 그런데 order-1003의 refunded는 파티션 1이 아니라 4로, order-1009의 refunded는 파티션 2가 아니라 5로 갔죠.
왜 그럴까요? 해시를 나누는 수가 3에서 6으로 바뀌어서 나머지가 달라졌기 때문이에요. 이미 저장된 메시지는 원래 파티션에 그대로 있고, Kafka는 기존 데이터를 새 파티션으로 옮기지 않습니다.
같은 주문의 이벤트가 두 파티션으로 나뉘었으니 순서도 보장되지 않아요. 실제로 위 출력에서 컨슈머는 order-1003의 refunded를 created보다 먼저 출력했어요.
파티션 1의 컨슈머가 밀려 있는 동안 파티션 4의 컨슈머가 먼저 refunded를 처리하면, 아직 배송 처리도 안 된 주문을 환불 처리하게 됩니다.
정리
범위: apache/kafka:4.3.1 단일 노드, Java 클라이언트 기반 콘솔 프로듀서.
- 주문 번호를 키로 보낸 이벤트는 주문마다 한 파티션에 모여
created,paid,shipped가 offset 0, 1, 2를 받았지만, 컨슈머는 먼저 보낸order-1001보다order-1009를 먼저 출력했어요. 순서가 필요한 이벤트끼리는 같은 키로 보내 주세요. store-1에 900개를 보내자 파티션 2에 940개, 나머지 두 파티션에 20개와 40개가 들어갔어요. 파티션을 늘리기 전에 순서가 필요한 단위를 다시 보고 키를 좁혀 보세요.- 파티션을 3개에서 6개로 늘리자
order-1003과order-1009의refunded가 파티션 4와 5로 가서,order-1003의refunded가created보다 먼저 출력됐어요. 순서가 중요한 토픽의 파티션을 늘리면 같은 키의 새 이벤트가 다른 파티션으로 간다고 생각하고 준비해 주세요.