2026-03-11

Kafka 기초: 메시지 큐 vs 이벤트 스트리밍

들어가며

현대 분산 시스템에서 서비스 간 통신은 핵심 과제다. 동기 방식의 REST API 호출만으로는 시스템 간 결합도가 높아지고, 장애 전파 위험이 커진다. 이를 해결하기 위해 메시지 큐이벤트 스트리밍이라는 비동기 통신 패턴이 등장했다.

이 글에서는 메시지 큐와 이벤트 스트리밍의 차이를 명확히 이해하고, Apache Kafka가 왜 이벤트 스트리밍 플랫폼으로 압도적인 선택을 받는지, 그리고 Spring Boot에서 실제로 어떻게 사용하는지까지 다룬다.

메시지 큐(Message Queue)란?

메시지 큐는 Producer가 메시지를 큐에 넣으면, Consumer가 꺼내서 처리하는 가장 전통적인 비동기 통신 패턴이다. 핵심 특징은 메시지가 한 번 소비되면 큐에서 사라진다는 점이다.

대표적인 메시지 큐 시스템

  • RabbitMQ: AMQP 프로토콜 기반, Exchange/Binding을 통한 유연한 라우팅. 복잡한 라우팅 로직이 필요할 때 강력하다.

  • Amazon SQS: AWS 완전 관리형 서비스. 인프라 운영 부담 없이 빠르게 도입 가능하다.

  • ActiveMQ: JMS 표준 구현체. 레거시 Java 시스템에서 많이 사용된다.

메시지 큐의 동작 방식

메시지 큐는 기본적으로 Point-to-Point 모델을 따른다:

  • Producer가 메시지를 큐에 전송한다.

  • Consumer가 메시지를 가져간다(pull 또는 push).

  • Consumer가 처리 완료를 확인(ACK)하면 메시지가 큐에서 삭제된다.

  • 여러 Consumer가 있으면, 각 메시지는 하나의 Consumer만 처리한다(경쟁 소비).

이 모델은 작업 분배(Work Distribution)에 적합하다. 예를 들어 이메일 발송, 이미지 리사이징 같은 백그라운드 작업을 여러 워커에 분산할 때 유용하다.

이벤트 스트리밍(Event Streaming)이란?

이벤트 스트리밍은 메시지 큐와 근본적으로 다른 패러다임이다. 메시지를 "소비하고 삭제"하는 것이 아니라, 이벤트를 영구적인 로그에 순서대로 기록하고, 여러 Consumer가 독립적으로 읽어가는 방식이다.

핵심 차이점

  • 이벤트는 삭제되지 않는다: 보존 기간(retention) 동안 로그에 남아있다.

  • 여러 Consumer가 같은 이벤트를 읽을 수 있다: 각 Consumer(Group)가 자신의 읽기 위치(offset)를 독립적으로 관리한다.

  • 과거 이벤트를 다시 읽을 수 있다(Replay): 버그 수정 후 재처리, 새로운 서비스 추가 시 과거 데이터 소급 적용 등이 가능하다.

이것은 단순한 메시지 전달이 아니라, "무슨 일이 일어났는가"의 기록이다. 이 관점의 전환이 이벤트 스트리밍의 본질이다.

Apache Kafka 핵심 개념

Kafka를 제대로 이해하려면 7가지 핵심 개념을 확실히 잡아야 한다.

Broker

Kafka 클러스터를 구성하는 개별 서버다. 보통 3대 이상으로 클러스터를 구성하며, 각 Broker는 파티션의 일부를 저장하고 관리한다. Broker 하나가 죽어도 클러스터는 계속 동작한다.

Topic

메시지가 저장되는 논리적 채널이다. RDB의 테이블과 비슷한 개념이라고 생각하면 된다. 예를 들어 order-events, user-activities, payment-results 같이 도메인 이벤트 단위로 토픽을 구성한다.

Partition

토픽을 물리적으로 분할한 단위다. 각 파티션은 순서가 보장되는 append-only 로그다. 파티션 수를 늘리면 병렬 처리량이 증가한다. 단, 순서 보장은 파티션 내에서만 적용된다는 점을 기억하자.

Producer

메시지를 토픽에 발행하는 주체다. Producer는 특정 파티션을 직접 지정하거나, 파티션 키를 기반으로 자동 라우팅할 수 있다. 키가 없으면 라운드 로빈으로 분배된다.

Consumer

토픽에서 메시지를 읽어가는 주체다. Consumer는 자신이 어디까지 읽었는지(offset)를 추적하며, 독립적으로 메시지를 소비한다.

Consumer Group

Kafka에서 가장 중요한 개념 중 하나다. 같은 Consumer Group에 속한 Consumer들은 파티션을 나눠서 소비한다. 서로 다른 Consumer Group은 같은 메시지를 독립적으로 읽을 수 있다.

예를 들어, order-events 토픽을 payment-groupnotification-group이 각각 구독하면, 결제 서비스와 알림 서비스가 동일한 주문 이벤트를 독립적으로 처리할 수 있다.

Offset

파티션 내 메시지의 고유 순서 번호다. Consumer Group은 각 파티션에 대해 "어디까지 읽었는지"를 offset으로 관리한다. 이 offset을 되감으면(reset) 과거 메시지를 다시 읽을 수 있다.

왜 Kafka를 쓰는가? — 4가지 핵심 강점

1. 높은 처리량 (High Throughput)

Kafka는 메시지를 디스크에 append-only 로그로 저장한다. 디스크의 랜덤 I/O가 아닌 순차 I/O(Sequential I/O)를 사용하기 때문에, 디스크 기반임에도 메모리 기반 시스템에 버금가는 성능을 낸다.

LinkedIn에서는 하루에 수조 개의 메시지를 Kafka로 처리한다. 단일 Broker에서도 초당 수십만 건의 메시지를 처리할 수 있으며, 파티션을 늘리면 선형적으로 확장된다.

2. 내구성 (Durability)

Kafka는 Replication Factor를 설정하여 데이터를 여러 Broker에 복제한다. 예를 들어 replication factor가 3이면, 같은 파티션 데이터가 3개의 Broker에 존재한다. Broker 2대가 동시에 죽어도 데이터는 유실되지 않는다.

# 토픽 생성 시 replication factor 설정 예시
# 3개의 Broker에 복제본 유지
kafka-topics.sh --create \
  --topic order-events \
  --partitions 6 \
  --replication-factor 3

3. 확장성 (Scalability)

토픽의 파티션 수를 늘리면 Consumer를 그만큼 추가하여 병렬 처리할 수 있다. 파티션 6개인 토픽에 Consumer 6개를 붙이면, 각 Consumer가 1개의 파티션을 담당한다. 트래픽이 늘면 파티션과 Consumer를 함께 늘리면 된다.

단, 파티션 수는 늘릴 수만 있고 줄일 수는 없으므로 초기 설계가 중요하다.

4. 재처리 가능 (Replay Capability)

전통적인 메시지 큐는 소비하면 메시지가 사라지지만, Kafka는 retention 기간 동안 메시지를 보존한다. Consumer Group의 offset을 원하는 시점으로 되감으면 과거 메시지를 다시 처리할 수 있다.

이 특성 덕분에:

  • 버그를 수정한 뒤 과거 데이터를 재처리할 수 있다.

  • 새로운 마이크로서비스를 추가할 때, 기존 이벤트를 소급 적용할 수 있다.

  • 데이터 파이프라인 장애 복구 시, 유실 없이 재처리가 가능하다.

메시지 큐 vs 이벤트 스트리밍 비교

항목 메시지 큐 (RabbitMQ, SQS) 이벤트 스트리밍 (Kafka) 메시지 보존 소비 후 삭제 retention 기간 동안 보존 소비 모델 경쟁 소비 (하나의 Consumer만 처리) Consumer Group별 독립 소비 재처리 불가능 (DLQ로 우회) offset reset으로 가능 순서 보장 큐 단위 (FIFO) 파티션 단위 처리량 중간 (수만 TPS) 매우 높음 (수십만 TPS 이상) 라우팅 Exchange/Binding으로 유연한 라우팅 토픽/파티션 기반 단순 라우팅 프로토콜 AMQP, STOMP 등 표준 프로토콜 자체 바이너리 프로토콜 메시지 우선순위 지원 (RabbitMQ) 미지원 운영 복잡도 상대적으로 단순 높음 (ZooKeeper/KRaft, Broker 관리)

언제 무엇을 써야 하는가?

메시지 큐를 선택해야 할 때

  • 작업 분배(Task Distribution): 이메일 발송, 이미지 처리 등 여러 워커에 작업을 균등 분배해야 할 때

  • Exactly-once 처리가 중요할 때: RabbitMQ의 ACK 메커니즘으로 메시지 유실과 중복을 방지해야 할 때

  • 복잡한 라우팅이 필요할 때: 조건에 따라 메시지를 다른 큐로 분기해야 할 때

  • 메시지 우선순위가 필요할 때: 긴급 메시지를 먼저 처리해야 하는 경우

  • 소규모 시스템: 운영 부담을 최소화하고 싶을 때

Kafka를 선택해야 할 때

  • 이벤트 소싱(Event Sourcing): 모든 상태 변경을 이벤트로 기록하고, 이벤트로부터 현재 상태를 재구성해야 할 때

  • 실시간 분석(Real-time Analytics): 사용자 행동 로그, 클릭 스트림 등을 실시간으로 분석해야 할 때

  • 로그 수집(Log Aggregation): 여러 서버의 로그를 중앙에 모아 분석해야 할 때

  • CDC(Change Data Capture): DB 변경 이벤트를 다른 시스템에 실시간으로 전파해야 할 때

  • 여러 서비스가 같은 이벤트를 소비해야 할 때: 주문 이벤트를 결제, 배송, 알림 서비스가 각각 처리

  • 높은 처리량이 필요할 때: 초당 수십만 건 이상의 메시지를 처리해야 하는 경우

Spring Boot에서 Kafka 사용하기

이론은 충분하다. 실제 코드로 Kafka Producer와 Consumer를 구현해 보자.

1. 의존성 추가

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

2. 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                   # 실패 시 재시도 횟수
      properties:
        enable.idempotence: true   # 멱등성 보장 (중복 전송 방지)
        max.in.flight.requests.per.connection: 5
    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  # Consumer Group 최초 시작 시 처음부터 읽기
      properties:
        spring.json.trusted.packages: "com.example.kafka.event"
    listener:
      ack-mode: manual             # 수동 커밋으로 메시지 유실 방지

3. 이벤트 클래스 정의

public record OrderEvent(
    String orderId,
    String userId,
    String productName,
    int quantity,
    long totalPrice,
    OrderStatus status,
    LocalDateTime occurredAt
) {
    public enum OrderStatus {
        CREATED, PAID, SHIPPED, DELIVERED, CANCELLED
    }
 
    public static OrderEvent created(String orderId, String userId,
                                     String productName, int quantity, long totalPrice) {
        return new OrderEvent(
            orderId, userId, productName, quantity, totalPrice,
            OrderStatus.CREATED, LocalDateTime.now()
        );
    }
}

4. Kafka Producer 구현

@Service
@RequiredArgsConstructor
@Slf4j
public class OrderEventProducer {
 
    private final KafkaTemplate<String, OrderEvent> kafkaTemplate;
 
    private static final String TOPIC = "order-events";
 
    /**
     * 주문 이벤트를 Kafka에 발행한다.
     * orderId를 파티션 키로 사용하여 같은 주문의 이벤트가
     * 동일 파티션에 순서대로 저장되도록 보장한다.
     */
    public CompletableFuture<SendResult<String, OrderEvent>> publish(OrderEvent event) {
        log.info("Publishing order event: orderId={}, status={}",
                event.orderId(), event.status());
 
        return kafkaTemplate.send(TOPIC, event.orderId(), event)
                .thenApply(result -> {
                    RecordMetadata metadata = result.getRecordMetadata();
                    log.info("Event sent successfully: topic={}, partition={}, offset={}",
                            metadata.topic(), metadata.partition(), metadata.offset());
                    return result;
                })
                .exceptionally(ex -> {
                    log.error("Failed to send event: orderId={}", event.orderId(), ex);
                    throw new RuntimeException("Kafka send failed", ex);
                });
    }
 
    /**
     * 특정 파티션에 직접 메시지를 보내는 예시
     */
    public void publishToPartition(OrderEvent event, int partition) {
        kafkaTemplate.send(TOPIC, partition, event.orderId(), event);
    }
}

5. Kafka Consumer 구현

@Service
@Slf4j
public class OrderEventConsumer {
 
    /**
     * order-events 토픽을 구독하여 주문 이벤트를 처리한다.
     * 수동 ACK 모드를 사용하여 처리 완료 후에만 offset을 커밋한다.
     */
    @KafkaListener(
        topics = "order-events",
        groupId = "order-service-group",
        containerFactory = "kafkaListenerContainerFactory"
    )
    public void consume(OrderEvent event,
                        @Header(KafkaHeaders.RECEIVED_PARTITION) int partition,
                        @Header(KafkaHeaders.OFFSET) long offset,
                        Acknowledgment acknowledgment) {
        try {
            log.info("Received event: orderId={}, status={}, partition={}, offset={}",
                    event.orderId(), event.status(), partition, offset);
 
            // 비즈니스 로직 처리
            processOrderEvent(event);
 
            // 처리 성공 시 offset 커밋
            acknowledgment.acknowledge();
            log.info("Event processed successfully: orderId={}", event.orderId());
 
        } catch (Exception e) {
            log.error("Failed to process event: orderId={}", event.orderId(), e);
            // 처리 실패 시 acknowledge를 호출하지 않으면
            // 해당 메시지는 다시 소비된다.
            // 필요에 따라 DLT(Dead Letter Topic)로 전송할 수도 있다.
        }
    }
 
    private void processOrderEvent(OrderEvent event) {
        switch (event.status()) {
            case CREATED -> handleOrderCreated(event);
            case PAID -> handleOrderPaid(event);
            case SHIPPED -> handleOrderShipped(event);
            case CANCELLED -> handleOrderCancelled(event);
            default -> log.warn("Unknown order status: {}", event.status());
        }
    }
 
    private void handleOrderCreated(OrderEvent event) {
        log.info("Processing new order: {} - {} x{}",
                event.orderId(), event.productName(), event.quantity());
        // 재고 확인, 결제 요청 등
    }
 
    private void handleOrderPaid(OrderEvent event) {
        log.info("Order paid: {}", event.orderId());
        // 배송 준비 시작
    }
 
    private void handleOrderShipped(OrderEvent event) {
        log.info("Order shipped: {}", event.orderId());
        // 배송 추적 시작
    }
 
    private void handleOrderCancelled(OrderEvent event) {
        log.info("Order cancelled: {}", event.orderId());
        // 환불 처리, 재고 복구
    }
}

6. 알림 서비스 — 별도 Consumer Group 예시

같은 order-events 토픽을 다른 Consumer Group으로 구독하면, 주문 이벤트를 독립적으로 처리할 수 있다. 이것이 Kafka의 핵심 장점이다.

@Service
@Slf4j
public class NotificationConsumer {
 
    private final NotificationService notificationService;
 
    public NotificationConsumer(NotificationService notificationService) {
        this.notificationService = notificationService;
    }
 
    @KafkaListener(
        topics = "order-events",
        groupId = "notification-group"  // 다른 Consumer Group
    )
    public void consume(OrderEvent event, Acknowledgment acknowledgment) {
        try {
            switch (event.status()) {
                case CREATED -> notificationService.sendOrderConfirmation(event.userId(), event.orderId());
                case SHIPPED -> notificationService.sendShippingNotification(event.userId(), event.orderId());
                case CANCELLED -> notificationService.sendCancellationNotification(event.userId(), event.orderId());
                default -> { /* 알림 불필요 */ }
            }
            acknowledgment.acknowledge();
        } catch (Exception e) {
            log.error("Notification failed for order: {}", event.orderId(), e);
        }
    }
}

7. Kafka Consumer 설정 (수동 ACK)

@Configuration
@EnableKafka
public class KafkaConsumerConfig {
 
    @Bean
    public ConcurrentKafkaListenerContainerFactory<String, OrderEvent>
            kafkaListenerContainerFactory(ConsumerFactory<String, OrderEvent> consumerFactory) {
 
        ConcurrentKafkaListenerContainerFactory<String, OrderEvent> factory =
                new ConcurrentKafkaListenerContainerFactory<>();
 
        factory.setConsumerFactory(consumerFactory);
        factory.setConcurrency(3);  // 3개의 Consumer 스레드
        factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL);
 
        // 에러 핸들러: 3회 재시도 후 DLT로 전송
        factory.setCommonErrorHandler(new DefaultErrorHandler(
            new DeadLetterPublishingRecoverer(new KafkaTemplate<>(new DefaultKafkaProducerFactory<>(Map.of()))),
            new FixedBackOff(1000L, 3L)
        ));
 
        return factory;
    }
}

8. REST API에서 이벤트 발행

@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {
 
    private final OrderEventProducer orderEventProducer;
 
    @PostMapping
    public ResponseEntity<String> createOrder(@RequestBody CreateOrderRequest request) {
        String orderId = UUID.randomUUID().toString();
 
        OrderEvent event = OrderEvent.created(
            orderId,
            request.userId(),
            request.productName(),
            request.quantity(),
            request.totalPrice()
        );
 
        orderEventProducer.publish(event);
 
        return ResponseEntity.ok(orderId);
    }
 
    public record CreateOrderRequest(
        String userId,
        String productName,
        int quantity,
        long totalPrice
    ) {}
}

실전 팁: Kafka 운영을 위한 핵심 전략

1. 파티션 키 전략

파티션 키는 Kafka에서 순서 보장과 데이터 분배를 결정하는 핵심 요소다. 잘못된 키 선택은 특정 파티션에 데이터가 몰리는 핫 파티션(Hot Partition) 문제를 일으킨다.

시나리오 권장 파티션 키 이유 주문 처리 orderId 같은 주문의 이벤트 순서 보장 사용자 행동 추적 userId 같은 사용자의 행동 순서 보장 IoT 센서 데이터 deviceId 같은 디바이스의 데이터 순서 보장 로그 수집 (순서 불필요) null (라운드 로빈) 파티션 간 균등 분배 우선

키의 카디널리티(고유값 수)가 파티션 수보다 충분히 높아야 균등 분배가 된다. 예를 들어 파티션이 12개인데, 키 값이 3종류뿐이면 3개 파티션만 사용되고 나머지는 비게 된다.

2. Consumer Group 설계

  • 하나의 논리적 서비스 = 하나의 Consumer Group: 결제 서비스는 payment-group, 알림 서비스는 notification-group

  • Consumer 수 ≤ 파티션 수: 파티션보다 Consumer가 많으면 놀고 있는 Consumer가 생긴다

  • Consumer 장애 시 자동 리밸런싱: Kafka가 파티션을 남은 Consumer에게 재할당한다. 이 과정에서 일시적인 처리 지연이 발생할 수 있다

# Consumer Group과 파티션의 관계 예시
#
# Topic: order-events (6 partitions)
#
# Consumer Group: order-service-group
#   Consumer-1 → Partition 0, 1
#   Consumer-2 → Partition 2, 3
#   Consumer-3 → Partition 4, 5
#
# Consumer Group: notification-group
#   Consumer-A → Partition 0, 1, 2
#   Consumer-B → Partition 3, 4, 5
#
# 두 그룹은 완전히 독립적으로 동일한 메시지를 소비한다

3. 모니터링 필수 지표

Kafka를 운영할 때 반드시 모니터링해야 할 핵심 지표들이 있다.

지표 의미 임계값 예시 Consumer Lag Producer가 쓴 최신 offset과 Consumer가 읽은 offset의 차이 Lag > 10,000이면 경고 Under-Replicated Partitions 복제가 완료되지 않은 파티션 수 0이 아니면 즉시 확인 Request Latency Produce/Fetch 요청의 응답 시간 p99 > 100ms이면 확인 Disk Usage Broker의 디스크 사용량 80% 이상이면 경고

Consumer Lag는 가장 중요한 지표다. Lag가 계속 증가하면 Consumer의 처리 속도가 Producer를 따라가지 못한다는 뜻이므로, Consumer를 스케일아웃하거나 처리 로직을 최적화해야 한다.

// Consumer Lag를 프로그래밍으로 확인하는 예시
@Component
@RequiredArgsConstructor
public class KafkaLagMonitor {
 
    private final KafkaAdmin kafkaAdmin;
 
    public Map<TopicPartition, Long> getConsumerLag(String groupId, String topic) {
        try (AdminClient client = AdminClient.create(kafkaAdmin.getConfigurationProperties())) {
            // Consumer Group의 현재 offset 조회
            Map<TopicPartition, OffsetAndMetadata> offsets =
                client.listConsumerGroupOffsets(groupId)
                      .partitionsToOffsetAndMetadata()
                      .get();
 
            // 토픽의 최신 offset 조회
            Map<TopicPartition, ListOffsetsResult.ListOffsetsResultInfo> endOffsets =
                client.listOffsets(
                    offsets.keySet().stream()
                        .collect(Collectors.toMap(tp -> tp, tp -> OffsetSpec.latest()))
                ).all().get();
 
            // Lag 계산
            return offsets.entrySet().stream()
                .filter(e -> e.getKey().topic().equals(topic))
                .collect(Collectors.toMap(
                    Map.Entry::getKey,
                    e -> endOffsets.get(e.getKey()).offset() - e.getValue().offset()
                ));
        } catch (Exception e) {
            throw new RuntimeException("Failed to get consumer lag", e);
        }
    }
}

4. 추가 운영 팁

  • Retention 정책: 기본 7일이지만, 이벤트 소싱이라면 더 길게, 로그 수집이라면 더 짧게 설정한다. log.retention.hourslog.retention.bytes를 함께 고려하자.

  • 메시지 크기: 기본 최대 1MB다. 큰 데이터는 Kafka에 직접 넣지 말고, S3 같은 스토리지에 저장한 뒤 참조(URL)만 이벤트에 담자.

  • Schema Registry 도입: Producer와 Consumer 간 메시지 스키마를 관리하지 않으면 호환성 문제가 발생한다. Confluent Schema Registry와 Avro/Protobuf 사용을 권장한다.

  • Idempotent Consumer: 네트워크 이슈로 메시지가 중복 전달될 수 있다. Consumer 로직은 반드시 멱등성을 보장하도록 설계하자.

정리

메시지 큐와 이벤트 스트리밍은 서로 대체재가 아니라 용도가 다른 도구다. 단순한 작업 분배에는 RabbitMQ나 SQS가 적합하고, 이벤트 중심 아키텍처에서 높은 처리량과 재처리 가능성이 필요하다면 Kafka가 정답이다.

Kafka를 도입한다면 다음 순서로 학습을 진행하자:

  • 1단계: Docker Compose로 로컬 Kafka 클러스터 구성 + Spring Boot 연동

  • 2단계: 파티션 키 전략, Consumer Group 설계, 에러 핸들링

  • 3단계: Schema Registry, Kafka Streams, Kafka Connect 등 생태계 확장

  • 4단계: 모니터링 구축 (Kafka Exporter + Prometheus + Grafana)

다음 글에서는 Docker Compose로 Kafka 클러스터를 구성하고, Kafka Connect를 활용한 CDC 파이프라인을 구축하는 방법을 다룰 예정이다.

관련 글

벡터 유사도 기반