Kafka 기초: Topic, Partition, Offset

1. 개요

Kafka는 서비스 간 높은 처리량으로 데이터를 전송하는 분산 메시지 브로커 시스템입니다. 첫 번째 producer/consumer를 작성하기 전에 세 가지 핵심 개념을 알아야 합니다: topic, partition, offset. 이 글에서는 각 개념을 설명하고 구체적인 Spring Boot 예제를 제공합니다. 읽은 후에는 Kafka에서 데이터가 어떻게 조직되고 읽혀지는지 이해하게 되며, Kafka를 처음 사용할 때 발생하는 일반적인 오류를 피할 수 있습니다.

2. Topic이란 무엇인가

Topic은 message를 포함하는 “테이블” 또는 “채널”과 같습니다. Producer가 topic으로 message를 보내고, consumer가 topic에서 message를 읽습니다. 하나의 topic은 동시에 여러 producer와 여러 consumer를 가질 수 있습니다.

예를 들어 전자상거래 시스템에서는 다음과 같은 topic을 가질 수 있습니다:

orders.created
orders.cancelled
payments.processed

각 topic은 특정 유형의 이벤트와 관련된 message를 포함합니다. Kafka는 message에 schema를 강제하지 않습니다 — 형식(JSON, Avro, Protobuf…)을 직접 결정합니다.

CLI로 topic을 만들어 더 잘 이해해봅시다:

kafka-topics.sh --create \
  --topic orders.created \
  --bootstrap-server localhost:9092 \
  --partitions 3 \
  --replication-factor 1

이 명령은 3개의 partition으로 orders.created topic을 만듭니다. 이것이 다음 개념입니다.

3. Partition — 병렬화 단위

3.1. Partition이 필요한 이유

Topic은 여러 partition으로 나뉩니다. 각 partition은 독립적인 로그 파일이며, message는 끝에 추가되고 절대 중간에 수정되거나 삭제되지 않습니다. Partition을 통해 Kafka는:

  • 여러 broker에 데이터를 분산시킵니다.
  • 여러 consumer가 병렬로 읽을 수 있게 합니다(각 consumer가 하나의 partition).
Topic: orders.created (3 partitions)

Partition 0: [msg0][msg1][msg4][msg7]...
Partition 1: [msg2][msg5][msg8]...
Partition 2: [msg3][msg6][msg9]...

3.2. Key가 partition을 결정합니다

message를 보낼 때 Kafka는 key를 사용하여 partition을 계산합니다(해시를 통해). 같은 key를 가진 message는 항상 같은 partition으로 들어가므로, 관련 message들의 순서가 유지됩니다.

// orderId를 key로 사용하여 event를 보낸다고 가정합니다
ProducerRecord<String, String> record =
        new ProducerRecord<>("orders.created", "order-123", payloadJson);
kafkaTemplate.send(record);

이 코드는 key "order-123"으로 ProducerRecord를 만듭니다. 이 주문의 모든 event는 항상 같은 partition으로 들어가므로, consumer가 보낸 순서대로 읽을 수 있습니다.

참고: key를 전달하지 않으면(key = null), Kafka는 round-robin으로 message를 분배합니다(또는 Kafka 2.4+의 sticky partitioner). 이 경우 message 간의 순서는 보장되지 않습니다.

3.3. 순서는 한 partition 내에서만 보장됩니다

이것이 가장 헷갈리는 부분입니다: Kafka는 한 partition 내에서만 순서를 보장하며, 전체 topic에서의 순서는 보장하지 않습니다. 특정 message 그룹의 절대 순서가 필요한 경우(예: 하나의 orderId의 모든 event), 해당 그룹에 같은 key를 사용하세요.

4. Offset — partition 내 읽기 위치

partition 내의 각 message는 0부터 시작하는 offset이라는 증가하는 순서 번호를 가집니다.

Partition 0:
offset:  0    1    2    3    4
message: m0   m1   m2   m3   m4

Offset은 partition 범위 내에서만 고유하며, 전체 topic에서 고유하지 않습니다. Consumer는 offset을 사용하여 “지금까지 어디까지 읽었는가”를 추적합니다.

4.1. Consumer group과 offset commit

Kafka는 consumer group을 통해 읽은 offset을 추적합니다. 각 consumer group은 각 partition에 대해 자체 offset을 가지고 있으며, 내부 topic __consumer_offsets에 저장됩니다.

# application.properties
spring.kafka.consumer.group-id=order-service
spring.kafka.consumer.auto-offset-reset=earliest
spring.kafka.consumer.enable-auto-commit=false
  • group-id: consumer group을 식별합니다. 같은 group-id를 가진 여러 인스턴스는 topic의 partition을 나눠가집니다.
  • auto-offset-reset=earliest: 저장된 offset이 없으면(새로운 consumer group) topic의 처음부터 읽습니다.
  • enable-auto-commit=false: auto-commit을 비활성화하여 offset을 언제 commit할지 직접 제어합니다(처리 중 오류로 인한 message 손실 방지).

4.2. 같은 group 내 여러 consumer

Kafka는 같은 group 내 consumer에게 partition을 다음 규칙에 따라 할당합니다: 한 partition은 동시에 그룹 내 한 consumer에 의해서만 읽힐 수 있습니다.

Topic orders.created에 3개의 partition이 있음
Consumer group "order-service"에 2개의 인스턴스가 있음:

Consumer A -> partition 0, partition 1
Consumer B -> partition 2

3개의 partition을 가진 topic에 대해 4개의 consumer를 실행하면, 4번째 consumer는 유휴 상태가 되어 partition을 할당받지 않습니다. 따라서 최대 유용한 consumer 수 = partition 수입니다.

5. Spring Boot에서 Kafka 설정하기

5.1. 의존성

<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>

5.2. Producer가 message 보내기

@Service
public class OrderEventProducer {

    private final KafkaTemplate<String, String> kafkaTemplate;

    public OrderEventProducer(KafkaTemplate<String, String> kafkaTemplate) {
        this.kafkaTemplate = kafkaTemplate;
    }

    public void publishOrderCreated(String orderId, String payloadJson) {
        kafkaTemplate.send("orders.created", orderId, payloadJson);
    }
}

kafkaTemplate.send(topic, key, value)는 message를 orders.created topic으로 보내며, orderId를 key로 사용합니다. Spring Boot는 spring.kafka.bootstrap-servers 구성을 기반으로 KafkaTemplate bean을 자동으로 생성합니다.

5.3. Consumer가 message를 읽고 수동으로 offset commit하기

@Component
public class OrderEventConsumer {

    @KafkaListener(topics = "orders.created", groupId = "order-service")
    public void onOrderCreated(
            ConsumerRecord<String, String> record,
            Acknowledgment ack) {

        System.out.printf("partition=%d offset=%d key=%s value=%s%n",
                record.partition(), record.offset(), record.key(), record.value());

        // 비즈니스 로직을 여기서 처리합니다
        ack.acknowledge(); // 처리 성공 후 offset commit
    }
}
  • record.partition()과 record.offset()는 이 message가 어느 partition에 있고 어느 위치에 있는지 보여줍니다.
  • ack.acknowledge()는 처리 완료 후 offset만 commit합니다 — service가 중간에 충돌하면, message는 다음 재시작 시 다시 읽힙니다(enable-auto-commit=false와 AckMode.MANUAL의 조합 덕분).

Acknowledgment이 작동하려면 추가 구성이 필요합니다:

spring.kafka.listener.ack-mode=manual

실행해보면 출력(서로 다른 key로 3개 message 보내기):

partition=1 offset=0 key=order-123 value={"status":"CREATED"}
partition=0 offset=0 key=order-456 value={"status":"CREATED"}
partition=2 offset=0 key=order-789 value={"status":"CREATED"}

각 key는 다른 hash로 인해 다른 partition으로 들어갑니다 — 섹션 3.2의 이론과 정확히 일치합니다.

6. JUnit 5로 테스트하기

spring-kafka-test를 사용하여 unit test에서 내장 Kafka를 실행하면 실제 broker가 필요하지 않습니다.

<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka-test</artifactId>
    <scope>test</scope>
</dependency>
@SpringBootTest
@EmbeddedKafka(partitions = 1, topics = "orders.created")
class OrderEventProducerTest {

    @Autowired
    private OrderEventProducer producer;

    @Autowired
    private EmbeddedKafkaBroker embeddedKafka;

    @Test
    void shouldPublishMessageWithGivenKey() throws Exception {
        Consumer<String, String> consumer = createTestConsumer();
        embeddedKafka.consumeFromAnEmbeddedTopic(consumer, "orders.created");

        producer.publishOrderCreated("order-123", "{\"status\":\"CREATED\"}");

        ConsumerRecord<String, String> record =
                KafkaTestUtils.getSingleRecord(consumer, "orders.created");

        assertThat(record.key()).isEqualTo("order-123");
        assertThat(record.value()).contains("CREATED");
        assertThat(record.offset()).isEqualTo(0L);
    }

    private Consumer<String, String> createTestConsumer() {
        Map<String, Object> props = KafkaTestUtils.consumerProps(
                "test-group", "true", embeddedKafka);
        props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
        DefaultKafkaConsumerFactory<String, String> factory =
                new DefaultKafkaConsumerFactory<>(props);
        return factory.createConsumer();
    }
}

@EmbeddedKafka는 test용 메모리 내 Kafka broker를 시작합니다. KafkaTestUtils.getSingleRecord는 topic에서 첫 번째 message를 가져오고, key, value, offset이 예상대로인지 assert합니다 — offset 0은 유일한 partition의 첫 번째 message이기 때문입니다.

7. 일반적인 오류

  • partition 수와 consumer 수 혼동: partition보다 많은 consumer를 만들면 일부 consumer가 유휴 상태가 됩니다. partition 수가 최대 병렬화 수준을 결정합니다.
  • 순서 유지가 필요할 때 key를 설정하지 않음: 같은 entity(order, user…)의 event가 올바른 순서로 처리되어야 하면 key를 사용해야 하며, null로 두면 안 됩니다.
  • 이미 production 중인 상태에서 partition 증가: 데이터가 있는 topic의 partition 수를 늘리면 key 해시 방식이 변경되어 같은 key를 가진 message가 다른 partition으로 들어갈 수 있으므로, 기존 데이터의 순서 보장이 깨집니다. partition 수는 처음부터 신중하게 결정해야 합니다.
  • 기본 auto-commit(enable-auto-commit=true): offset은 고정 간격(기본값 5초)으로 commit되며, message 처리 완료 여부와 상관없습니다 — consumer가 처리 중에 충돌하면 message 손실이 발생하기 쉽습니다.

참고: 애플리케이션이 엄격한 순서를 필요로 하지 않고 처리량이 문제가 아니면, key/partition 최적화를 건너뛸 수 있습니다 — Kafka는 기본 구성으로도 올바르게 작동합니다. 실제 필요가 없으면 조기 최적화를 하지 마세요.

8. 결론

Topic은 주제에 따라 message를 포함하는 장소이고, partition은 병렬화와 분산을 위한 단위이며, offset은 각 partition 내의 읽기 위치입니다. 이 세 가지 개념을 명확히 이해하면 올바르게 설계할 수 있습니다: 순서를 유지하려면 적절한 key를 선택하고, consumer 수에 맞는 partition 수를 선택하며, offset commit을 제어하여 오류 발생 시 데이터 손실을 피해야 합니다. 이것은 rebalancing, exactly-once semantics, Kafka Streams 같은 더 고급 주제로 깊이 들어가기 전에 필수적인 기초입니다.

kafkaspring-bootmessagingjava