Kafka에서 partition key 설계: 메시지 순서와 로드 밸런싱
1. 개요
Kafka에서 partition key는 메시지가 어느 partition으로 들어갈지를 결정합니다. 이것은 두 가지를 결정합니다: 어떤 메시지가 올바른 순서로 유지되는지, 그리고 로드가 consumer 간에 어떻게 분배되는지.
key를 잘못 선택하면 두 가지 문제 중 하나가 발생합니다. 첫 번째는 같은 주문의 이벤트들이 뒤섞여 처리되는 것입니다. 두 번째는 한 partition이 대부분의 트래픽을 처리하면서 다른 partition들이 유휴 상태가 되는 것입니다.
이 글을 읽은 후, 당신은 Kafka가 key를 partition으로 매핑하는 방식을 이해할 것입니다. 또한 순서 요구사항에 따라 key를 선택하는 방법, hot key를 처리하는 방법, 그리고 실수로 순서를 깨뜨리지 않도록 Spring Boot 3.x에서 producer/consumer를 구성하는 방법을 알게 될 것입니다.
2. Kafka가 partition을 선택하는 방식
2.1. Key가 null이 아닌 경우: hash 후 나머지 계산
메시지가 key를 가질 때, producer는 다음 공식에 따라 partition을 계산합니다:
partition = toPositive(murmur2(keyBytes)) % numPartitions
kafka-clients의 기본 제공 유틸리티 함수를 사용하여 이 로직을 정확히 재현할 수 있습니다:
import org.apache.kafka.common.utils.Utils;
import java.nio.charset.StandardCharsets;
public final class PartitionCalculator {
private PartitionCalculator() {}
public static int partitionFor(String key, int numPartitions) {
byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8);
return Utils.toPositive(Utils.murmur2(keyBytes)) % numPartitions;
}
public static void main(String[] args) {
for (String orderId : new String[]{"order-1001", "order-1002", "order-1001"}) {
System.out.println(orderId + " -> partition " + partitionFor(orderId, 6));
}
}
}
각 부분의 설명:
- Key는
StringSerializer로 byte로 변환되며, 즉 UTF-8입니다. 해당 동작과 일치시키기 위해 UTF-8을 사용합니다. Utils.murmur2는 기본 partitioner가 사용하는 hash 함수입니다.toPositive는 부호 비트를 제거하여 결과가 음수가 되지 않도록 합니다. 그 후%연산이 partition 번호를 반환합니다.
실행 결과는 다음과 같습니다 (구체적인 숫자는 key에 따라 다릅니다):
order-1001 -> partition 3
order-1002 -> partition 0
order-1001 -> partition 3
중요한 점: 같은 key와 같은 partition 수면 항상 같은 partition이 나옵니다. Kafka는 partition 내에서만 순서를 보장합니다. 따라서 특정 메시지 그룹의 순서를 유지하려면 해당 메시지들이 같은 key를 사용해야 합니다.
2.2. Key가 null인 경우: 순서 보장 없음
Kafka 3.3부터, key가 없는 메시지는 ‘uniform sticky’ 메커니즘에 따라 분배됩니다. Producer는 여러 메시지를 한 partition으로 묶은 후 다른 partition으로 전환하며, 각 broker의 속도를 고려합니다. 로드는 상당히 균등하게 분배되지만, 연속된 두 메시지는 다른 partition에 있을 수 있습니다. 이 경우 그 사이에 순서 보장이 없습니다.
참고: Hash는 key의 byte에 의존하며 논리 값에 의존하지 않습니다. 한 서비스가
LongSerializer로 key1001을 보내고 다른 서비스가StringSerializer로"1001"을 보내면, 두 메시지가 다른 partition으로 갈 수 있습니다. 전체 시스템에서 key의 serializer를 통일하세요.
3. Spring Boot에서 key를 가진 메시지 전송
의존성 추가:
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka-test</artifactId>
<scope>test</scope>
</dependency>
application.yml에서 producer 구성:
spring:
kafka:
bootstrap-servers: localhost:9092
producer:
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
acks: all
properties:
enable.idempotence: true
max.in.flight.requests.per.connection: 5
각 설정의 의미:
acks: all과enable.idempotence: true는 재시도 시 중복 레코드가 생기지 않도록 합니다.- 더 중요하게, idempotence를 활성화하면 broker가 순서가 맞지 않는 batch를 거부합니다. 이 덕분에 재시도가 있어도 그리고
max.in.flight이 5까지 올라가도 partition 내의 순서가 유지됩니다.
이벤트를 전송하는 서비스:
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Service;
public record OrderEvent(String orderId, String customerId, String type) {}
@Service
public class OrderEventPublisher {
private final KafkaTemplate<String, OrderEvent> kafkaTemplate;
public OrderEventPublisher(KafkaTemplate<String, OrderEvent> kafkaTemplate) {
this.kafkaTemplate = kafkaTemplate;
}
public void publish(OrderEvent event) {
kafkaTemplate.send("order-events", event.orderId(), event)
.whenComplete((result, ex) -> {
if (ex == null) {
var md = result.getRecordMetadata();
System.out.printf("%s %s -> p%d@%d%n",
event.orderId(), event.type(), md.partition(), md.offset());
}
});
}
}
send(topic, key, value)의 두 번째 매개변수가 key입니다. 여기서는 orderId를 사용합니다. 같은 주문에 대해 순서대로 CREATED, PAID, SHIPPED를 전송하면 출력이 다음과 같이 됩니다:
order-1001 CREATED -> p3@120
order-1001 PAID -> p3@121
order-1001 SHIPPED -> p3@122
세 이벤트 모두 partition 3에 있으며 offset이 증가합니다. Consumer는 이 순서대로 읽을 것입니다.
4. Key 선택: 순서 요구사항에서 시작
첫 번째 질문은 ’어느 key가 가장 균등하게 로드를 분배하는가’가 아닙니다. 올바른 질문은: **어떤 메시지가 반드시 순서대로 처리되어야 하는가?**입니다. Key는 해당 요구사항을 만족하는 가장 작은 그룹의 식별자여야 합니다.
| 비즈니스 요구사항 | 적절한 Key | 비고 |
|---|---|---|
| 한 주문의 이벤트는 순서대로 처리되어야 함 | orderId |
많은 key로 좋은 로드 밸런싱 |
| 고객 지갑 잔액은 올바른 순서로 더하기/빼기 | customerId 또는 walletId |
대규모 고객이 있으면 hot key가 생기기 쉬움 |
| 액세스 로그, 순서 불필요 | null |
가장 균등한 로드 분배 |
| 제품별 재고 | sku |
‘hot’ 제품이 로드 불균형을 유발할 수 있음 |
동반되는 두 가지 원칙:
- Key가 세밀할수록 로드 분배가 나아집니다.
orderId는 수백만 개의 값이 있어 균등하게 분배됩니다.countryCode는 몇 개의 값만 있어 일부 partition이 대부분의 로드를 처리하게 됩니다. - Key의 값의 개수는 partition 수보다 훨씬 커야 합니다. key가 10개뿐인데 topic이 12개의 partition을 가지면, 확실히 비어있는 partition이 있을 것입니다. 보통 한 partition이 여러 개의 무거운 key를 받게 될 수도 있습니다.
5. Hot partition과 처리 방법
key가 customerId이고 한 대규모 고객이 40%의 트래픽을 생성한다고 가정하세요. 이 고객을 포함하는 partition이 lag가 발생합니다. consumer를 더 추가해도 도움이 되지 않습니다. 한 partition은 주어진 시간에 group의 한 consumer만 읽을 수 있기 때문입니다.
5.1. Salting: hot key를 여러 bucket으로 나누기
아이디어는 key에 suffix를 추가하여 hot key를 여러 partition으로 분산시키는 것입니다:
import java.util.Set;
public class OrderKeyStrategy {
private final Set<String> hotCustomers;
private final int buckets;
public OrderKeyStrategy(Set<String> hotCustomers, int buckets) {
this.hotCustomers = hotCustomers;
this.buckets = buckets;
}
public String keyFor(OrderEvent event) {
if (!hotCustomers.contains(event.customerId())) {
return event.customerId();
}
int bucket = Math.floorMod(event.orderId().hashCode(), buckets);
return event.customerId() + "#" + bucket;
}
}
일반 고객의 경우 key는 customerId로 유지됩니다. hot 고객의 경우 bucket은 orderId에서 계산되므로 key는 cust-big#0 … cust-big#7 형태입니다.
명확히 이해해야 할 결과:
- 같은 주문의 모든 이벤트는 여전히 같은 key를 가지므로 순서가 유지됩니다.
- 같은 고객의 다른 주문들 사이의 순서는 더 이상 보장되지 않습니다.
Salting은 비즈니스가 이러한 trade-off를 수락할 때만 사용할 수 있습니다. 고객 단위의 절대적인 순서가 필요한 경우 (예: 지갑 잔액), salting은 데이터 오류를 초래합니다.
주의: bucket으로 임의의 값이나 timestamp를 사용하지 마세요. 그러면 같은 주문의 이벤트들이 여러 partition으로 분산되어 순서가 완전히 손상됩니다.
5.2. Key 전략 테스트
아래 테스트는 두 가지 주요 동작을 검증합니다: 같은 주문은 같은 partition에, hot 고객은 여러 partition으로 분산됩니다.
import org.junit.jupiter.api.Test;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import static org.assertj.core.api.Assertions.assertThat;
class OrderKeyStrategyTest {
private static final int PARTITIONS = 12;
private final OrderKeyStrategy strategy = new OrderKeyStrategy(Set.of("cust-big"), 8);
@Test
void sameOrder_alwaysGoesToSamePartition() {
var created = new OrderEvent("order-1", "cust-big", "CREATED");
var paid = new OrderEvent("order-1", "cust-big", "PAID");
int p1 = PartitionCalculator.partitionFor(strategy.keyFor(created), PARTITIONS);
int p2 = PartitionCalculator.partitionFor(strategy.keyFor(paid), PARTITIONS);
assertThat(p1).isEqualTo(p2);
}
@Test
void hotCustomer_isSpreadAcrossPartitions() {
Set<Integer> partitions = IntStream.range(0, 1_000)
.mapToObj(i -> new OrderEvent("order-" + i, "cust-big", "CREATED"))
.map(e -> PartitionCalculator.partitionFor(strategy.keyFor(e), PARTITIONS))
.collect(Collectors.toSet());
assertThat(partitions).hasSizeGreaterThan(1);
}
@Test
void normalCustomer_keepsCustomerIdAsKey() {
var event = new OrderEvent("order-9", "cust-small", "CREATED");
assertThat(strategy.keyFor(event)).isEqualTo("cust-small");
}
}
테스트는 섹션 2.1의 PartitionCalculator를 사용하며, 이것은 producer의 공식을 정확히 재현합니다. 따라서 broker를 설정할 필요 없이 메시지가 실제로 어디로 가는지 확인할 수 있습니다.
6. Consumer 측: 의도치 않게 순서를 깨뜨리지 마세요
Producer가 올바른 순서를 유지하는 것만으로는 충분하지 않습니다. Consumer도 이를 망칠 수 있습니다.
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;
@Component
public class OrderEventListener {
@KafkaListener(topics = "order-events", groupId = "order-service", concurrency = "6")
public void onEvent(OrderEvent event) {
// listener의 thread 내에서 순차적으로 처리
System.out.println(Thread.currentThread().getName() + " " + event);
}
}
concurrency = "6"은 6개의 consumer thread를 생성합니다. 각 partition은 정확히 한 thread에 할당되므로 partition 내의 순서가 유지됩니다. topic이 6개의 partition을 가지면 각 thread가 하나씩 받습니다. concurrency를 partition 수보다 크게 설정하면 나머지 thread는 유휴 상태가 됩니다.
Consumer 측에서 흔한 실수:
- Listener 내에서 메시지를 별도의 thread pool로 푸시 (
executor.submit(...)). 같은 주문의 두 이벤트가 병렬로 실행될 수 있고PAID가CREATED보다 먼저 완료될 수 있습니다. partition 수준보다 더 깊은 병렬화가 필요한 경우 key별로 병렬화하고, 각 key마다 하나의 순차 큐를 사용하세요. 메시지별로 병렬화하지 마세요. - 다른 topic으로 보내는 방식으로 재시도 (retry topic, non-blocking retry). 오류 메시지가 메인 스트림에서 분리되고 같은 key의 후속 메시지는 계속 처리됩니다. 엄격한 순서 보장이 필요한 경우
DefaultErrorHandler와BackOff를 통한 blocking retry를 사용하세요. 이 방법은 partition이 일시적으로 중지되는 것을 수락합니다.
7. Partition 수 변경: 조용한 함정
공식에 % numPartitions 연산이 있기 때문에 partition을 6에서 12로 늘리면 대부분의 key가 다른 partition으로 이동합니다:
public class RepartitionDemo {
public static void main(String[] args) {
String key = "order-1001";
System.out.println("6 partitions -> " + PartitionCalculator.partitionFor(key, 6));
System.out.println("12 partitions -> " + PartitionCalculator.partitionFor(key, 12));
}
}
6 partitions -> 3
12 partitions -> 9
order-1001의 기존 이벤트는 partition 3에 있고 아직 완전히 처리되지 않았을 수 있습니다. 새 이벤트는 partition 9로 갑니다. 서로 다른 두 consumer가 동시에 처리할 수 있고 순서가 바뀝니다.
위험을 줄이는 방법:
- 처음부터 충분한 partition 수를 예상하세요. 목표 처리량을 하나의 consumer의 처리량으로 나누고 안전 계수를 곱하세요.
- partition을 반드시 늘려야 하면 producer를 중지하고 consumer가 lag를 모두 처리할 때까지 기다린 후 partition을 늘린 다음 producer를 다시 시작하세요.
- 또는 더 많은 partition을 가진 새 topic을 만들고 점진적으로 트래픽을 새 topic으로 이동하세요.
참고: Kafka는 partition 수 감소를 지원하지 않습니다. 너무 많은 partition을 선택하는 것도 비용이 듭니다: broker 리소스 낭비, 더 느린 rebalance, 더 많은 열린 파일. 충분한 여유를 두되 ‘확실하게’ 1000개의 partition을 설정하지 마세요.
8. Custom partitioner가 필요한 경우
Partitioner를 직접 작성할 필요는 거의 없습니다. 대부분의 경우 올바른 key 선택만으로 충분합니다. Custom partitioner는 명시적 매핑이 필요할 때만 합리적입니다. 예를 들어 VIP 고객을 위해 몇 개의 partition을 따로 할당하는 경우입니다.
반면에 custom partitioner에는 단점이 있습니다:
- 해당 topic에 쓰는 모든 producer는 다른 언어로 작성된 서비스를 포함하여 같은 로직을 사용해야 합니다. 그렇지 않으면 순서가 손상됩니다.
- Kafka Streams 또는 Kafka Connect와 같은 도구는 기본적으로 murmur2를 사용합니다. 이것이 당신의 partitioning 방식과 일치하지 않을 수 있습니다.
따라서 partitioner를 고려하기 전에 key 설계로 문제를 해결하는 것을 우선하세요.
9. 결론
Partition key는 단순한 send() 매개변수가 아니라 설계 결정입니다. 기억해야 할 주요 사항:
- Kafka는 한 partition 내에서만 순서를 보장합니다. 같은 key와 같은 partition 수면 murmur2 해시 모듈로 공식에 따라 같은 partition으로 갑니다.
- key를 순서를 유지해야 하는 가장 작은 그룹의 식별자로 선택하세요. key의 값이 많을수록 로드가 더 균등하게 분배됩니다.
- salting을 통해 hot key를 처리하되, 비즈니스가 bucket 간의 순서 손실을 수락할 때만 사용하세요.
- Producer에서 idempotence를 활성화하세요. Consumer에서는 메시지를 임의의 thread pool로 푸시하지 마세요. Non-blocking retry를 사용하기 전에 신중하게 검토하세요.
- 순서가 필요한 경우 실행 중인 topic의 partition을 늘리지 마세요. 처음부터 충분히 예상하세요.
이 사항들을 이해하면 순서도 정확하면서 트래픽이 증가할 때 확장 가능한 topic을 설계할 수 있습니다.