메시지 키 설계

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));
    }
}

관련 문서