메시지 키 설계
Kafka에서 메시지 키는 파티션 할당과 메시지 순서 보장에 핵심적인 역할을 합니다.
메시지 키 개요
메시지 키의 역할
┌─────────────────────────────────────────────────────────────────┐
│ Message Key Functions │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. 파티션 할당 │
│ - 동일 키 → 동일 파티션 │
│ - hash(key) % num_partitions │
│ │
│ 2. 순서 보장 │
│ - 같은 키의 메시지는 순서대로 처리 │
│ - 파티션 내에서만 순서 보장 │
│ │
│ 3. Log Compaction │
│ - 키 기반으로 최신 값만 유지 │
│ - 키가 없으면 Compaction 불가 │
│ │
│ 4. 데이터 그룹핑 │
│ - 관련 데이터를 같은 파티션에 배치 │
│ - 효율적인 Consumer 처리 │
│ │
└─────────────────────────────────────────────────────────────────┘
키 유무에 따른 동작
| 키 | 파티션 할당 | 순서 보장 | Compaction |
|---|---|---|---|
| 있음 | 해시 기반 (일관됨) | 키 단위 보장 | 가능 |
| 없음 | 라운드 로빈/스티키 | 보장 안됨 | 불가 |
키 설계 패턴
패턴 1: 엔티티 ID 키
// 주문 시스템 - 주문 ID를 키로
ProducerRecord<String, Order> record = new ProducerRecord<>(
"orders",
order.getOrderId(), // 키: "order-12345"
order
);
// 사용자 이벤트 - 사용자 ID를 키로
ProducerRecord<String, UserEvent> record = new ProducerRecord<>(
"user-events",
event.getUserId(), // 키: "user-789"
event
);장점
- 동일 엔티티의 이벤트가 순서대로 처리됨
- Log Compaction으로 최신 상태 유지 가능
사용 사례
- 주문 상태 변경
- 사용자 프로필 업데이트
- 계좌 거래 내역
패턴 2: 복합 키
// 테넌트 + 엔티티 ID
String key = tenantId + ":" + entityId;
// 예: "tenant-A:order-12345"
ProducerRecord<String, Order> record = new ProducerRecord<>(
"multi-tenant-orders",
key,
order
);
// 날짜 + 사용자 ID (일별 집계용)
String key = date + ":" + userId;
// 예: "2025-12-08:user-789"장점
- 다차원 그룹핑 가능
- 더 세밀한 파티션 제어
주의점
- 키가 너무 길어지면 오버헤드 증가
- 복합 키 파싱 로직 필요
패턴 3: 해시 분산 키
// 핫스팟 방지를 위한 해시 접두사
int bucket = userId.hashCode() % 100;
String key = bucket + ":" + userId;
// 예: "42:user-789"
// 또는 랜덤 접두사 (순서 보장 불필요 시)
String key = random.nextInt(10) + ":" + orderId;장점
- 핫 파티션 방지
- 부하 분산
단점
- 동일 엔티티가 다른 파티션으로 갈 수 있음
- 순서 보장 포기
패턴 4: 시간 기반 키
// 시간 윈도우 기반
String key = timestamp / windowSizeMs + ":" + entityId;
// 예: "1733616000:sensor-001" (1시간 윈도우)
// 날짜 기반
String key = LocalDate.now() + ":" + category;
// 예: "2025-12-08:electronics"사용 사례
- 시계열 데이터
- 일별/시간별 집계
- 세션 기반 처리
패턴 5: Null 키 (의도적)
// 순서 무관, 최대 분산
ProducerRecord<String, LogEntry> record = new ProducerRecord<>(
"logs",
null, // 키 없음
logEntry
);사용 사례
- 로그 수집 (순서 무관)
- 메트릭 수집
- 이벤트 스트림 (순서 불필요)
키 설계 고려사항
카디널리티 (Cardinality)
┌─────────────────────────────────────────────────────────────────┐
│ Key Cardinality Impact │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 낮은 카디널리티 (예: 3개 카테고리) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Partition 0: ████████████████████ (카테고리 A) │ │
│ │ Partition 1: ████ (카테고리 B) │ │
│ │ Partition 2: ██████████ (카테고리 C) │ │
│ │ Partition 3: (비어있음) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ 문제: 파티션 불균형, 일부 파티션 미사용 │
│ │
│ 높은 카디널리티 (예: 사용자 ID) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Partition 0: ████████ │ │
│ │ Partition 1: ████████ │ │
│ │ Partition 2: ████████ │ │
│ │ Partition 3: ████████ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ 결과: 균등 분산 │
│ │
└─────────────────────────────────────────────────────────────────┘
권장: 키 카디널리티 ≥ 파티션 수 * 10
핫 키 (Hot Key) 문제
// 문제: 인기 상품의 모든 주문이 하나의 파티션으로
ProducerRecord<String, Order> record = new ProducerRecord<>(
"orders",
order.getProductId(), // 인기 상품 ID
order
);
// 해결 1: 복합 키로 분산
String key = productId + ":" + (orderId.hashCode() % 10);
// 해결 2: 별도 토픽
if (isHotProduct(productId)) {
producer.send(new ProducerRecord<>("hot-product-orders", orderId, order));
} else {
producer.send(new ProducerRecord<>("orders", productId, order));
}
// 해결 3: 커스텀 파티셔너
public class HotKeyPartitioner implements Partitioner {
private Set<String> hotKeys = Set.of("product-123", "product-456");
@Override
public int partition(String topic, Object key, byte[] keyBytes,
Object value, byte[] valueBytes, Cluster cluster) {
int numPartitions = cluster.partitionCountForTopic(topic);
if (hotKeys.contains(key)) {
// 핫 키는 여러 파티션에 분산
return ThreadLocalRandom.current().nextInt(numPartitions);
}
// 일반 키는 기본 해시 분배
return Utils.toPositive(Utils.murmur2(keyBytes)) % numPartitions;
}
}키 크기 최적화
// 비효율: 긴 키
String key = "organization:acme-corporation:department:engineering:team:platform:user:john.doe@example.com";
// 효율: 짧은 키 (해시 또는 ID)
String key = "u:" + userId; // "u:12345"
// 또는 숫자 ID 사용
Long key = userId; // 8바이트
// 권장 키 크기: < 256 bytes
// 이유: 네트워크 오버헤드, 인덱스 크기키 직렬화
// 문자열 키 (가장 일반적)
props.put("key.serializer", StringSerializer.class);
// Long 키 (효율적)
props.put("key.serializer", LongSerializer.class);
// 복합 키 (Avro)
props.put("key.serializer", KafkaAvroSerializer.class);
// 복합 키 스키마
{
"type": "record",
"name": "OrderKey",
"fields": [
{"name": "tenantId", "type": "string"},
{"name": "orderId", "type": "string"}
]
}도메인별 키 설계 예시
이커머스
// 주문 이벤트: 주문 ID
"order-12345"
// 결제 이벤트: 결제 ID
"payment-67890"
// 재고 이벤트: 상품 ID + 창고 ID
"product-abc:warehouse-1"
// 사용자 활동: 세션 ID
"session-xyz789"금융
// 계좌 거래: 계좌 번호
"account-1234567890"
// 주식 주문: 주문 ID (순서 보장)
"stock-order-001"
// 시세 데이터: 종목 코드
"AAPL"
// 리스크 이벤트: 포트폴리오 ID
"portfolio-P001"IoT
// 센서 데이터: 디바이스 ID
"device-sensor-001"
// 위치 추적: 차량 ID
"vehicle-V123"
// 시간 윈도우 집계: 디바이스 + 시간
"device-001:2025-12-08T10"로깅/모니터링
// 애플리케이션 로그: null (순서 무관)
null
// 감사 로그: 트랜잭션 ID (순서 보장)
"txn-abc123"
// 메트릭: 호스트 + 메트릭명
"host-web01:cpu_usage"Best Practices
1. 요구사항 기반 설계
질문:
1. 순서 보장이 필요한가?
YES → 엔티티 ID 키
NO → null 또는 분산 키
2. Log Compaction을 사용할 것인가?
YES → 반드시 키 필요
3. 핫스팟이 예상되는가?
YES → 분산 전략 적용
4. 카디널리티가 충분한가?
NO → 복합 키 또는 해시 추가
2. 일관된 키 전략
// 좋은 예: 일관된 키 형식
public class KeyGenerator {
public static String orderKey(Order order) {
return "order:" + order.getId();
}
public static String userKey(User user) {
return "user:" + user.getId();
}
}
// 나쁜 예: 불일치한 키 형식
"order-123" // 하이픈
"user:456" // 콜론
"payment_789" // 언더스코어3. 키 변경 주의
파티션 수 변경 시:
- 동일 키가 다른 파티션으로 갈 수 있음
- 기존 Consumer 처리 순서 영향
키 형식 변경 시:
- 하위 호환성 유지 필요
- 마이그레이션 계획 수립
4. 테스트
@Test
void testKeyDistribution() {
Map<Integer, Integer> partitionCounts = new HashMap<>();
int numPartitions = 12;
for (int i = 0; i < 100000; i++) {
String key = "entity-" + i;
int partition = Utils.toPositive(Utils.murmur2(key.getBytes())) % numPartitions;
partitionCounts.merge(partition, 1, Integer::sum);
}
// 각 파티션이 균등하게 분배되었는지 확인
double expectedAvg = 100000.0 / numPartitions;
for (int count : partitionCounts.values()) {
assertThat(count).isBetween((int)(expectedAvg * 0.8), (int)(expectedAvg * 1.2));
}
}
댓글 (0)