Kafka

Apache Kafka 핵심 개념 - 백엔드 개발자를 위한 완벽 가이드

백엔드 개발자 김승원 2026. 3. 25. 10:06

들어가며

마이크로서비스 아키텍처(MSA)가 보편화되면서, 서비스 간 비동기 통신의 중요성은 나날이 커지고 있습니다. 그 중심에 Apache Kafka가 있습니다. LinkedIn에서 탄생하여 이제는 Netflix, Uber, 카카오, 라인 등 수많은 기업에서 핵심 인프라로 사용하고 있는 Kafka. 이 글에서는 백엔드 개발자가 반드시 알아야 할 Kafka의 핵심 개념을 처음부터 끝까지 정리합니다.

1. Kafka란? - 메시지 큐 그 이상의 존재

전통적인 메시지 큐와의 차이

Kafka를 처음 접하면 "메시지 큐 아닌가?"라고 생각하기 쉽습니다. RabbitMQ나 ActiveMQ 같은 전통적인 메시지 큐와 비교하면 근본적인 설계 철학이 다릅니다.

  • 전통적 메시지 큐: Consumer가 메시지를 가져가면(consume) 큐에서 삭제됩니다. 한 메시지는 한 Consumer만 처리합니다.
  • Kafka (이벤트 스트리밍 플랫폼): Consumer가 메시지를 읽어도 삭제되지 않습니다. 여러 Consumer Group이 같은 데이터를 독립적으로 읽을 수 있습니다. 메시지는 설정된 보존 기간(retention period) 동안 유지됩니다.

이 차이가 왜 중요할까요? 예를 들어 "주문 완료" 이벤트가 발생했을 때, 결제 서비스, 재고 서비스, 알림 서비스, 분석 서비스가 모두 이 이벤트를 각자 독립적으로 처리해야 합니다. Kafka에서는 각 서비스가 별도의 Consumer Group으로 동일한 Topic을 구독하면 됩니다.

Kafka의 핵심 특징

  • 높은 처리량(High Throughput): 초당 수백만 건의 메시지를 처리할 수 있습니다.
  • 내구성(Durability): 디스크에 순차적으로 기록하며 복제(Replication)를 통해 데이터 안정성을 보장합니다.
  • 확장성(Scalability): Broker와 Partition을 추가하여 수평 확장이 가능합니다.
  • 실시간 처리: 밀리초 단위의 지연 시간으로 실시간 데이터 파이프라인 구축이 가능합니다.

2. 핵심 구성요소 완전 정복

아키텍처 전체 구조


┌──────────────┐     ┌─────────────────────────────────────────┐     ┌──────────────┐
│  Producer A  │────▶│            Kafka Cluster                │────▶│  Consumer    │
│  (주문 서비스) │     │  ┌─────────┐ ┌─────────┐ ┌─────────┐  │     │  Group 1     │
└──────────────┘     │  │Broker 1 │ │Broker 2 │ │Broker 3 │  │     │  (결제 서비스) │
                     │  │         │ │         │ │         │  │     └──────────────┘
┌──────────────┐     │  │ Topic-A │ │ Topic-A │ │ Topic-A │  │
│  Producer B  │────▶│  │  P0     │ │  P1     │ │  P2     │  │     ┌──────────────┐
│  (회원 서비스) │     │  │ Topic-B │ │ Topic-B │ │         │  │────▶│  Consumer    │
└──────────────┘     │  │  P0     │ │  P1     │ │         │  │     │  Group 2     │
                     │  └─────────┘ └─────────┘ └─────────┘  │     │  (알림 서비스) │
                     └─────────────────────────────────────────┘     └──────────────┘

Producer (프로듀서)

메시지를 생성하여 Kafka Topic으로 전송하는 주체입니다. Producer는 특정 Topic의 어떤 Partition에 메시지를 보낼지 결정합니다.

  • Key가 없는 경우: Round-Robin 또는 Sticky Partitioner 방식으로 Partition에 분배됩니다.
  • Key가 있는 경우: Key의 해시값을 기반으로 항상 같은 Partition에 전송됩니다. 이것이 순서 보장의 핵심입니다.

Consumer (컨슈머)

Topic에서 메시지를 읽어 처리하는 주체입니다. Consumer는 pull 방식으로 Broker에서 메시지를 가져옵니다. push 방식이 아닌 pull 방식을 사용하기 때문에 Consumer가 자신의 처리 속도에 맞게 메시지를 가져올 수 있습니다.

Broker (브로커)

Kafka 서버 인스턴스를 의미합니다. 여러 Broker가 모여 Kafka Cluster를 구성합니다. 각 Broker는 특정 Partition의 데이터를 저장하고 관리합니다. 보통 운영 환경에서는 최소 3대 이상의 Broker를 구성하여 안정성을 확보합니다.

Topic (토픽)

메시지가 발행되는 논리적 채널입니다. 데이터베이스의 테이블과 비슷한 개념으로 이해하면 됩니다. 예를 들어 order-events, user-signup, payment-result 등 비즈니스 도메인에 맞게 Topic을 설계합니다.

Partition (파티션)

Topic을 물리적으로 분할한 단위입니다. 하나의 Topic은 여러 Partition으로 나뉘며, 각 Partition은 독립적인 로그 파일처럼 동작합니다.


Topic: order-events (Partition 3개)

  Partition 0: [msg0] [msg3] [msg6] [msg9]  → offset 순서대로 append
  Partition 1: [msg1] [msg4] [msg7] [msg10]
  Partition 2: [msg2] [msg5] [msg8] [msg11]

각 Partition 내부에서는 메시지가 offset이라는 순차적 번호를 부여받으며, 이 순서는 절대 변하지 않습니다.

3. Consumer Group과 Offset 관리

Consumer Group이란?

같은 group.id를 가진 Consumer들의 집합입니다. Consumer Group의 핵심 규칙은 다음과 같습니다.

  • 하나의 Partition은 같은 Consumer Group 내에서 오직 하나의 Consumer만 읽을 수 있습니다.
  • 하나의 Consumer는 여러 Partition을 읽을 수 있습니다.
  • 서로 다른 Consumer Group은 같은 Partition을 독립적으로 읽을 수 있습니다.

Topic: order-events (Partition 3개)

Consumer Group A (결제 서비스)          Consumer Group B (알림 서비스)
┌──────────┐                          ┌──────────┐
│Consumer 1│ ← Partition 0            │Consumer 1│ ← Partition 0, 1
│Consumer 2│ ← Partition 1            │Consumer 2│ ← Partition 2
│Consumer 3│ ← Partition 2            └──────────┘
└──────────┘

→ 각 Group은 독립적으로 모든 메시지를 처리
→ Group 내 Consumer 수가 Partition 수보다 많으면 놀게 되는 Consumer 발생

Offset 관리의 중요성

Offset은 Consumer가 Partition에서 어디까지 읽었는지를 나타내는 위치 정보입니다. Kafka는 __consumer_offsets라는 내부 Topic에 각 Consumer Group의 offset 정보를 저장합니다.

Offset commit 전략은 크게 두 가지입니다.

  • 자동 커밋 (enable.auto.commit=true): 일정 간격(기본 5초)으로 자동 커밋합니다. 편리하지만 메시지 유실이나 중복 처리 가능성이 있습니다.
  • 수동 커밋: 비즈니스 로직 처리가 완료된 후 명시적으로 커밋합니다. 정확한 처리가 필요한 경우 권장됩니다.

4. Partition과 순서 보장의 관계

Kafka의 순서 보장 원칙

이것은 면접에서도 자주 나오는 질문입니다. Kafka의 순서 보장 규칙을 명확히 이해해야 합니다.

  • 같은 Partition 내에서는 순서가 보장됩니다.
  • 서로 다른 Partition 간에는 순서가 보장되지 않습니다.

따라서 특정 엔티티에 대한 이벤트 순서를 보장하고 싶다면, 해당 엔티티의 식별자(예: 주문 ID, 사용자 ID)를 메시지 Key로 사용해야 합니다.


// 같은 orderId를 Key로 사용하면 항상 같은 Partition에 들어감
// → 해당 주문의 이벤트 순서가 보장됨

Key: "order-123" → hash("order-123") % 3 = Partition 1

  [주문생성] → [결제완료] → [배송시작] → [배송완료]
  이 순서가 Partition 1 내에서 보장됨

실무 팁: Partition 수를 나중에 변경하면 Key-Partition 매핑이 달라질 수 있습니다. 따라서 Topic 생성 시 Partition 수를 신중하게 결정하고, 변경이 필요하면 새로운 Topic을 만드는 것이 안전합니다.

5. Spring Boot에서 Kafka 사용하기

의존성 추가

// build.gradle
dependencies {
    implementation 'org.springframework.kafka:spring-kafka'
}

application.yml 설정

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            # 모든 복제본에 기록 완료 후 응답 (가장 안전)
      retries: 3           # 실패 시 재시도 횟수
    consumer:
      group-id: order-service-group
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
      auto-offset-reset: earliest   # 처음부터 읽기
      enable-auto-commit: false     # 수동 커밋 사용
      properties:
        spring.json.trusted.packages: "*"

이벤트 객체 정의

public record OrderEvent(
    String orderId,
    String userId,
    String productName,
    int quantity,
    long totalPrice,
    String status,      // CREATED, PAID, SHIPPED, DELIVERED
    LocalDateTime occurredAt
) {}

Producer 구현

@Service
@RequiredArgsConstructor
@Slf4j
public class OrderEventProducer {

    private final KafkaTemplate<String, OrderEvent> kafkaTemplate;
    private static final String TOPIC = "order-events";

    /**
     * 주문 이벤트를 Kafka로 발행합니다.
     * orderId를 Key로 사용하여 같은 주문의 이벤트 순서를 보장합니다.
     */
    public void publishOrderEvent(OrderEvent event) {
        kafkaTemplate.send(TOPIC, event.orderId(), event)
            .whenComplete((result, ex) -> {
                if (ex != null) {
                    log.error("메시지 전송 실패: topic={}, key={}, error={}",
                        TOPIC, event.orderId(), ex.getMessage());
                    // 실패 처리 로직 (재시도, DLQ 전송 등)
                } else {
                    RecordMetadata metadata = result.getRecordMetadata();
                    log.info("메시지 전송 성공: topic={}, partition={}, offset={}",
                        metadata.topic(), metadata.partition(), metadata.offset());
                }
            });
    }
}

Consumer 구현

@Service
@Slf4j
public class OrderEventConsumer {

    /**
     * 주문 이벤트를 수신하여 처리합니다.
     * 수동 커밋 모드를 사용하여 처리 완료 후에만 offset을 커밋합니다.
     */
    @KafkaListener(
        topics = "order-events",
        groupId = "payment-service-group",
        containerFactory = "kafkaListenerContainerFactory"
    )
    public void handleOrderEvent(
            @Payload OrderEvent event,
            @Header(KafkaHeaders.RECEIVED_PARTITION) int partition,
            @Header(KafkaHeaders.OFFSET) long offset,
            Acknowledgment acknowledgment) {

        log.info("이벤트 수신: orderId={}, status={}, partition={}, offset={}",
            event.orderId(), event.status(), partition, offset);

        try {
            // 비즈니스 로직 처리
            processOrder(event);

            // 처리 완료 후 수동 커밋
            acknowledgment.acknowledge();
            log.info("이벤트 처리 완료 및 offset 커밋: orderId={}", event.orderId());

        } catch (Exception e) {
            log.error("이벤트 처리 실패: orderId={}, error={}",
                event.orderId(), e.getMessage());
            // offset을 커밋하지 않으면 재처리됨
            // 필요시 DLQ(Dead Letter Queue)로 전송
        }
    }

    private void processOrder(OrderEvent event) {
        switch (event.status()) {
            case "CREATED" -> initiatePayment(event);
            case "PAID" -> confirmPayment(event);
            default -> log.warn("처리하지 않는 상태: {}", event.status());
        }
    }
}

수동 커밋을 위한 Consumer 설정

@Configuration
public class KafkaConsumerConfig {

    @Bean
    public ConcurrentKafkaListenerContainerFactory<String, OrderEvent>
            kafkaListenerContainerFactory(
                ConsumerFactory<String, OrderEvent> consumerFactory) {

        ConcurrentKafkaListenerContainerFactory<String, OrderEvent> factory =
            new ConcurrentKafkaListenerContainerFactory<>();
        factory.setConsumerFactory(consumerFactory);

        // 수동 커밋 모드 설정
        factory.getContainerProperties()
            .setAckMode(ContainerProperties.AckMode.MANUAL_IMMEDIATE);

        // 동시 Consumer 스레드 수 (Partition 수 이하로 설정)
        factory.setConcurrency(3);

        return factory;
    }
}

6. 실무에서 자주 겪는 문제와 해결법

문제 1: 메시지 유실

메시지가 사라지는 상황은 여러 지점에서 발생할 수 있습니다.

Producer 측 유실 방지:

  • acks=all: 모든 ISR(In-Sync Replica)에 기록 완료 후 응답을 받습니다. acks=0이나 acks=1은 성능은 좋지만 유실 위험이 있습니다.
  • retries 설정: 일시적 오류 시 자동 재시도합니다.
  • enable.idempotence=true: 네트워크 문제로 인한 중복 전송을 방지합니다. (Kafka 3.0+ 기본 활성화)

Consumer 측 유실 방지:

  • 수동 커밋 사용: 비즈니스 로직 처리가 완료된 후에 offset을 커밋합니다.
  • 자동 커밋을 사용하면 메시지를 읽은 직후 커밋되어, 처리 중 장애가 발생하면 해당 메시지를 다시 읽지 못합니다.

// 위험한 패턴 (자동 커밋)
// 1. 메시지 읽음 → 2. 자동 커밋(offset 전진) → 3. 처리 중 장애 발생
// → 메시지 유실! 다시 읽을 수 없음

// 안전한 패턴 (수동 커밋)
// 1. 메시지 읽음 → 2. 비즈니스 로직 처리 → 3. 수동 커밋(offset 전진)
// → 3번 전에 장애 발생하면 재시작 시 같은 메시지를 다시 읽음

문제 2: 메시지 중복 처리

수동 커밋을 사용하면 유실은 막지만, 처리 후 커밋 전에 장애가 나면 같은 메시지를 두 번 처리할 수 있습니다. 이것은 "at-least-once" 전달 보장의 특성입니다.

해결법: 멱등성(Idempotency) 보장

@Service
@RequiredArgsConstructor
public class IdempotentOrderProcessor {

    private final ProcessedEventRepository processedEventRepository;

    @Transactional
    public void processOrder(OrderEvent event) {
        // 이미 처리된 이벤트인지 확인 (eventId = orderId + status 조합)
        String eventId = event.orderId() + ":" + event.status();

        if (processedEventRepository.existsById(eventId)) {
            log.info("이미 처리된 이벤트 - 스킵: {}", eventId);
            return;
        }

        // 비즈니스 로직 처리
        doProcess(event);

        // 처리 완료 기록 (같은 트랜잭션 내)
        processedEventRepository.save(new ProcessedEvent(eventId, LocalDateTime.now()));
    }
}

핵심은 고유한 이벤트 식별자를 기반으로 중복 여부를 판단하고, 비즈니스 로직 처리와 이벤트 기록을 같은 트랜잭션으로 묶는 것입니다.

문제 3: Consumer Rebalancing으로 인한 일시적 중단

Consumer Group에 Consumer가 추가되거나 제거되면 Partition 재할당(Rebalancing)이 발생합니다. 이 과정에서 모든 Consumer가 일시적으로 메시지 처리를 중단합니다.

해결법:

  • session.timeout.msheartbeat.interval.ms를 적절히 설정하여 불필요한 Rebalancing을 방지합니다.
  • Kafka 2.4+ 에서 도입된 Cooperative Sticky Assignor를 사용하면 점진적 Rebalancing이 가능하여 전체 중단을 피할 수 있습니다.
  • max.poll.interval.ms를 비즈니스 로직 처리 시간에 맞게 여유 있게 설정합니다. 이 시간을 초과하면 Consumer가 죽은 것으로 간주되어 Rebalancing이 발생합니다.

문제 4: 대량 메시지 적체 (Consumer Lag)

Producer의 속도를 Consumer가 따라가지 못하면 Lag이 쌓입니다.

해결법:

  • Consumer 인스턴스를 추가합니다. 단, Partition 수 이하로만 유의미합니다.
  • Partition 수를 늘립니다 (Topic 재생성이 안전).
  • max.poll.records를 조정하여 한 번에 가져오는 메시지 수를 늘립니다.
  • Consumer 내부 로직을 최적화하거나, 무거운 처리는 별도 스레드풀로 분리합니다.

Kafka 모니터링 필수 지표

운영 환경에서는 다음 지표를 반드시 모니터링해야 합니다.

  • Consumer Lag: 각 Consumer Group의 처리 지연 정도. 가장 중요한 지표입니다.
  • Broker의 Under-Replicated Partitions: 복제가 제대로 되지 않는 Partition 수. 0이어야 정상입니다.
  • Request Latency: Producer/Consumer의 요청 응답 시간.
  • ISR Shrink Rate: ISR에서 이탈하는 Broker 빈도. 잦다면 Broker 상태 점검이 필요합니다.

Burrow, Kafka-UI, Grafana + Prometheus 등의 도구를 활용하면 효과적으로 모니터링할 수 있습니다.

마무리

Kafka는 단순한 메시지 큐가 아닌, 대규모 실시간 데이터 스트리밍을 위한 플랫폼입니다. 핵심 개념을 정리하면 다음과 같습니다.

  • Topic과 Partition으로 데이터를 분산 저장하고 병렬 처리합니다.
  • Consumer Group을 통해 각 서비스가 독립적으로 이벤트를 소비합니다.
  • 메시지 Key를 활용하여 Partition 단위의 순서를 보장합니다.
  • 수동 커밋 + 멱등성 처리로 메시지 유실과 중복을 방지합니다.

다음 글에서는 Kafka Connect, Kafka Streams 등 Kafka 에코시스템의 고급 기능과 실전 활용 패턴에 대해 다뤄보겠습니다.