Spring Boot에서 Kafka 제대로 쓰기: Producer부터 DLT까지

이번 포스팅에서는 Spring Boot Kafka 연동에 대해서 정리하고자 한다. Kafka는 대용량 이벤트 스트림을 다루는 사실상의 표준 메시지 브로커이고, Spring Boot는 spring-kafka를 통해 그 복잡한 클라이언트 설정을 KafkaTemplate@KafkaListener라는 두 개의 추상화로 압축해 준다. 문제는 “메시지를 보내고 받는” 기본 예제만으로는 실무가 굴러가지 않는다는 점이다. 객체를 JSON으로 주고받는 직렬화, 소비 중 예외가 났을 때의 재시도와 Dead Letter Topic, 오프셋을 언제 커밋할지, 그리고 브로커 없이 테스트하는 법까지 알아야 비로소 쓸 만해진다. 이 글에서는 Spring Boot 4.1과 spring-kafka 4.1을 기준으로 의존성 추가부터 DLT 처리와 테스트까지 실무에서 실제로 부딪히는 순서대로 짚는다.

Spring Boot에서 Kafka를 붙인다는 것

Spring Boot Kafka 연동의 핵심은 spring-kafka가 제공하는 두 추상화다. 메시지를 보낼 때는 KafkaTemplate, 받을 때는 @KafkaListener를 쓴다. Spring Boot의 auto-configuration이 application.ymlspring.kafka.* 설정만으로 KafkaTemplate과 리스너 컨테이너 팩토리를 자동으로 빈으로 등록하기 때문에, 순수 Kafka 클라이언트를 직접 다룰 때보다 설정 코드가 크게 줄어든다.

2026년 8월 기준 Spring Initializr의 기본 버전은 Spring Boot 4.1.0이고, 이 버전이 관리하는 Spring for Apache Kafka는 4.1.0이다. spring-kafka는 내부적으로 Apache Kafka의 공식 자바 클라이언트를 감싸며, 프로듀서/컨슈머 생성, 스레드 관리, 오프셋 커밋, 재시도 같은 반복 작업을 대신 처리한다.

Spring Boot Kafka 스택은 세 층으로 나눠 보면 그림이 잡힌다. 가장 아래에 Apache Kafka 브로커가 있고, 그 위에 카프카 자바 클라이언트가, 다시 그 위에 spring-kafka가 얹힌다. 개발자는 대부분 최상단의 KafkaTemplate@KafkaListener만 만지고, 아래 두 층은 설정으로만 제어한다. 그래서 이 글의 대부분도 이 두 추상화를 어떻게 실무 수준으로 다루는지에 집중한다. 참고로 이 글의 코드는 Java 21과 Spring Boot 4.1 기준이며, 이전 버전과의 차이가 있는 부분은 그때그때 짚는다.

의존성 추가와 로컬 Kafka 브로커 5분 만에 띄우기

Spring Boot Kafka를 시작하려면 spring-kafka 의존성 하나와 로컬 브로커가 필요하다. 의존성은 Spring Initializr에서 “Spring for Apache Kafka”를 고르면 되고, 로컬 브로커는 Docker Compose로 Apache Kafka를 KRaft 모드(Zookeeper 없이 단독 실행)로 띄우는 게 가장 간단하다. Spring Boot 4의 Docker Compose 지원과 맞물리면 애플리케이션을 켤 때 브로커도 함께 뜬다.

// build.gradle
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.kafka:spring-kafka'
    // 애플리케이션 기동 시 compose.yaml을 자동으로 띄운다 (Spring Boot 3.1+)
    developmentOnly 'org.springframework.boot:spring-boot-docker-compose'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.kafka:spring-kafka-test'
}
Groovy

의존성은 spring-kafka 하나가 핵심이다. spring-boot-docker-compose를 넣으면 프로젝트 루트의 compose.yaml을 애플리케이션 시작 시 자동으로 실행해 주므로, 브로커를 따로 켜 둘 필요가 없다. 테스트용으로는 spring-kafka-test를 추가한다. Spring Boot의 Docker Compose 연동 자체가 궁금하다면 Spring Boot Docker Compose 지원 정리를 참고하면 된다.

# compose.yaml — Apache Kafka(KRaft) 단일 노드
services:
  kafka:
    image: apache/kafka:3.9.0
    ports:
      - "9092:9092"
    environment:
      KAFKA_NODE_ID: 1
      KAFKA_PROCESS_ROLES: broker,controller
      KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
      KAFKA_CONTROLLER_QUORUM_VOTERS: 1@localhost:9093
      KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
YAML

이 compose 파일은 Zookeeper 없이 동작하는 KRaft 모드 단일 노드 브로커를 정의한다. KRaft에서는 환경 변수가 브로커의 정체성과 통신 방식을 결정하므로, 각 설정이 무슨 역할을 하는지 알아 두면 문제가 생겼을 때 원인을 빨리 찾는다. 아래 표에 항목별 역할을 정리했다.

설정역할
image: apache/kafka:3.9.0Apache 공식 Kafka 이미지. 3.9.0은 KRaft를 안정적으로 지원하는 버전이다
ports: "9092:9092"호스트 9092 포트를 컨테이너 9092에 연결한다. 애플리케이션은 localhost:9092로 접속한다
KAFKA_NODE_ID: 1KRaft 클러스터에서 이 노드를 식별하는 고유 번호. 노드가 여럿이면 서로 다른 값을 준다
KAFKA_PROCESS_ROLES: broker,controller이 프로세스가 맡을 역할. 데이터를 처리하는 broker와 메타데이터를 관리하는 controller를 한 프로세스가 겸한다(단일 노드용 combined 모드)
KAFKA_LISTENERS브로커가 실제로 열어 두고 대기할 소켓. PLAINTEXT://:9092는 클라이언트용, CONTROLLER://:9093은 컨트롤러 내부 통신용이다
KAFKA_ADVERTISED_LISTENERS클라이언트에게 “접속하려면 이 주소로 오라”고 알려 줄 주소. 클라이언트는 최초 접속 뒤 이 주소로 실제 통신한다
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@localhost:9093KRaft 컨트롤러 쿼럼의 구성원 목록. {노드ID}@{호스트}:{포트} 형식이며, 단일 노드라 하나만 적었다
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER위 리스너 중 어느 것이 컨트롤러 통신용인지 이름으로 지정한다
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP리스너 이름별 보안 프로토콜을 매핑한다. 로컬 개발이라 모두 암호화 없는 PLAINTEXT로 뒀다
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1컨슈머 오프셋을 저장하는 내부 토픽 __consumer_offsets의 복제 계수. 기본값 3은 단일 노드에서 만들 수 없어 1로 낮춘다

여기서 가장 헷갈리는 건 KAFKA_LISTENERSKAFKA_ADVERTISED_LISTENERS의 차이다. 앞의 것은 브로커가 실제로 바인딩해 대기하는 주소이고, 뒤의 것은 브로커가 클라이언트에게 “여기로 접속하라”고 응답하는 주소다. 클라이언트는 처음 bootstrap-servers로 아무 브로커에나 접속한 뒤, 브로커가 돌려주는 advertised 주소로 다시 연결해 실제 메시지를 주고받는다. 그래서 이 둘이 어긋나면 접속은 되는데 이후 통신이 실패하는, 원인을 찾기 힘든 오류가 난다. 로컬에서는 둘 다 localhost 기준이라 문제가 없지만, Docker 네트워크 안이나 원격 브로커에서는 클라이언트가 실제로 닿을 수 있는 주소로 advertised 값을 정확히 맞춰야 한다.

KRaft 모드는 Kafka 3.3에서 정식(production-ready)으로 자리 잡으며 오래 쓰이던 Zookeeper 의존을 걷어냈다. 운영 환경에서는 노드를 3대 이상 두고 KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR도 3 이상으로 올리지만, 로컬 개발에는 이 단일 노드 구성이면 충분하다.

application.yml, 최소한 이 세 줄은 알아야 한다

Spring Boot Kafka 설정의 출발점은 spring.kafka.* 프로퍼티다. 최소한 브로커 주소(bootstrap-servers), 컨슈머 그룹(consumer.group-id), 그리고 오프셋 초기화 정책(auto-offset-reset)은 지정해야 한다. 이 세 가지만 잡아도 Producer와 Consumer가 동작하고, 나머지는 필요할 때 덧붙이면 된다.

# application.yml
spring:
  kafka:
    bootstrap-servers: localhost:9092
    consumer:
      group-id: order-service
      auto-offset-reset: earliest      # 그룹의 오프셋이 없을 때 가장 처음부터 읽는다
    producer:
      acks: all                        # 모든 in-sync 복제본이 받을 때까지 대기 (유실 방지)
    listener:
      ack-mode: batch                  # 기본값. 폴링한 배치를 처리한 뒤 오프셋 커밋
YAML

bootstrap-servers는 접속할 브로커 목록이고, group-id는 이 애플리케이션의 컨슈머 그룹 이름이다. 같은 그룹에 속한 인스턴스끼리 파티션을 나눠 갖는다. auto-offset-reset: earliest는 저장된 오프셋이 없는 새 그룹이 토픽을 처음부터 읽게 하고, latest로 두면 접속 이후 도착한 메시지만 읽는다. producer.acks: all은 데이터 유실을 막는 가장 안전한 설정이다.

여기까지가 뼈대다. 실제로는 직렬화 설정, 재시도 정책 등이 더 붙지만, 그것들은 각 절에서 필요한 시점에 하나씩 추가한다. 한 번에 모든 프로퍼티를 외우려 하기보다, “지금 필요한 최소한”만 잡고 시작하는 편이 낫다.

Producer: KafkaTemplate으로 메시지 보내기

메시지 전송은 auto-configuration이 만들어 준 KafkaTemplate을 주입받아 send()를 호출하면 된다. send()는 즉시 반환되고 CompletableFuture<SendResult>를 돌려주므로, 전송 성공/실패는 콜백으로 비동기 처리한다. Spring Boot가 KafkaTemplate 빈을 자동 등록하기 때문에 프로듀서 팩토리를 직접 만들 필요가 없다.

import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.support.SendResult;
import org.springframework.stereotype.Service;
import java.util.concurrent.CompletableFuture;

@Service
public class OrderProducer {

    private final KafkaTemplate<String, String> kafkaTemplate;

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

    public void send(String orderId, String payload) {
        // 토픽, 키(orderId), 값(payload) — 같은 키는 항상 같은 파티션으로 간다
        CompletableFuture<SendResult<String, String>> future =
                kafkaTemplate.send("orders", orderId, payload);

        future.whenComplete((result, ex) -> {
            if (ex == null) {
                var meta = result.getRecordMetadata();
                System.out.printf("전송 성공: partition=%d offset=%d%n",
                        meta.partition(), meta.offset());
            } else {
                System.err.println("전송 실패: " + ex.getMessage());
            }
        });
    }
}
Java

kafkaTemplate.send("orders", orderId, payload)orders 토픽에 메시지를 보낸다. 두 번째 인자인 키(orderId)가 중요한데, Kafka는 같은 키를 항상 같은 파티션으로 보내므로 같은 주문의 이벤트 순서가 보장된다. whenComplete 콜백에서 성공 시 파티션·오프셋을 확인하고, 실패 시 예외를 처리한다. 콜백을 등록하지 않으면 전송 실패를 놓칠 수 있으니 실무에서는 반드시 결과를 확인하는 게 좋다.

다음 로직이 전송 성공에 의존한다면 future.get(10, TimeUnit.SECONDS)로 동기 전송할 수도 있다. 다만 동기 전송은 처리량을 떨어뜨리므로, 특별한 이유가 없다면 비동기 콜백 방식을 기본으로 삼는다.

Consumer: @KafkaListener로 메시지 받기

메시지 수신은 메서드에 @KafkaListener를 붙이는 것으로 끝난다. topics로 구독할 토픽을, groupId로 컨슈머 그룹을 지정하면, Spring이 백그라운드 컨테이너를 띄워 메시지가 도착할 때마다 이 메서드를 호출한다. Spring Boot가 리스너 컨테이너 팩토리를 자동 구성하므로 별도 설정 없이 바로 동작한다.

import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.support.KafkaHeaders;
import org.springframework.messaging.handler.annotation.Header;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.stereotype.Component;

@Component
public class OrderListener {

    @KafkaListener(topics = "orders", groupId = "order-service")
    public void listen(
            @Payload String payload,
            @Header(KafkaHeaders.RECEIVED_KEY) String key,
            @Header(KafkaHeaders.RECEIVED_PARTITION) int partition,
            @Header(KafkaHeaders.RECEIVED_TOPIC) String topic) {

        System.out.printf("수신: topic=%s partition=%d key=%s payload=%s%n",
                topic, partition, key, payload);
        // 여기서 실제 비즈니스 로직 처리
    }
}
Java

@KafkaListener(topics = "orders", groupId = "order-service")가 붙은 메서드는 orders 토픽의 메시지를 받는다. @Payload로 메시지 본문을, @Header로 키·파티션·토픽 같은 메타데이터를 함께 받을 수 있다. 이 메서드가 예외 없이 리턴하면 Spring이 오프셋을 커밋하고, 예외를 던지면 뒤에서 다룰 에러 핸들러가 개입한다.

처리량을 높이려면 컨슈머 스레드를 늘린다. @KafkaListenerconcurrency 속성이나 컨테이너 팩토리의 setConcurrency(n)으로 파티션 수만큼 스레드를 병렬로 돌릴 수 있다. 단, 동시성은 토픽의 파티션 수를 넘어서면 의미가 없다. 파티션이 3개면 스레드 4개를 줘도 하나는 놀게 된다.

문자열 말고 객체를 주고받기: JSON 직렬화

실무에서는 문자열이 아니라 도메인 객체를 주고받는다. spring-kafka는 JacksonJsonSerializerJacksonJsonDeserializer로 객체를 JSON으로 변환해 전송·수신한다. 프로듀서에는 값 직렬화기를, 컨슈머에는 역직렬화기와 신뢰 패키지(trusted.packages)를 지정하면 된다. 여기서 버전 관련 함정이 하나 있다.

# application.yml — JSON 직렬화 설정
spring:
  kafka:
    producer:
      value-serializer: org.springframework.kafka.support.serializer.JacksonJsonSerializer
    consumer:
      value-deserializer: org.springframework.kafka.support.serializer.JacksonJsonDeserializer
      properties:
        spring.json.trusted.packages: "com.example.order"   # 역직렬화 허용 패키지
        spring.json.value.default.type: "com.example.order.OrderEvent"
YAML

value-serializervalue-deserializer에 Jackson 기반 직렬화기를 지정했다. spring.json.trusted.packages는 보안상 중요한데, 역직렬화를 허용할 패키지를 명시하지 않으면 컨슈머가 클래스를 복원하지 못하거나 신뢰할 수 없는 타입을 역직렬화하는 위험이 생긴다. 모든 패키지를 열려면 "*"를 주지만, 운영에서는 실제 패키지만 지정하는 게 안전하다.

여기서 주의할 breaking change가 있다. spring-kafka 4.x부터 직렬화기 클래스 이름이 기존 JsonSerializer/JsonDeserializer에서 JacksonJsonSerializer/JacksonJsonDeserializer로 바뀌었다. Jackson 3 전환에 따른 변경이므로, Spring Boot 3.x 시절 예제를 그대로 가져오면 클래스를 찾지 못한다. 마찬가지로 KafkaProperties도 Spring Boot 4에서 autoconfigure 모듈이 분리되며 org.springframework.boot.kafka.autoconfigure 패키지로 이동했다.

// 주고받을 도메인 객체 (record로 간결하게)
package com.example.order;

public record OrderEvent(String orderId, String product, int quantity) {}
Java

이렇게 OrderEvent record를 정의해 두면, 프로듀서에서 kafkaTemplate.send("orders", event.orderId(), event)처럼 객체를 그대로 전송하고, 컨슈머에서 @Payload OrderEvent event로 바로 받을 수 있다. 직렬화·역직렬화는 앞의 설정이 자동으로 처리한다.

소비 중 예외가 나면? 재시도와 Dead Letter Topic

Consumer에서 예외가 발생하면 어떻게 처리할지가 Spring Boot Kafka의 진짜 실무 영역이다. 가장 깔끔한 방법은 @RetryableTopic을 이용한 논블로킹 재시도다. 이 애너테이션을 리스너에 붙이면 spring-kafka가 재시도 전용 토픽과 최종 실패용 Dead Letter Topic(DLT)을 자동으로 만들고, 실패한 메시지를 지연을 두고 재처리한 뒤 끝내 실패하면 DLT로 보낸다.

import org.springframework.kafka.annotation.DltHandler;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.annotation.RetryableTopic;
import org.springframework.kafka.retrytopic.TopicSuffixingStrategy;
import org.springframework.retry.annotation.Backoff;
import org.springframework.stereotype.Component;

@Component
public class OrderRetryableListener {

    @RetryableTopic(
            attempts = "4",                                  // 최초 1회 + 재시도 3회
            backoff = @Backoff(delay = 2000, multiplier = 2.0), // 2s → 4s → 8s
            topicSuffixingStrategy = TopicSuffixingStrategy.SUFFIX_WITH_INDEX_VALUE
    )
    @KafkaListener(topics = "orders", groupId = "order-service")
    public void listen(OrderEvent event) {
        // 재고가 음수면 예외 → 재시도 대상
        if (event.quantity() <= 0) {
            throw new IllegalArgumentException("잘못된 수량: " + event.quantity());
        }
        System.out.println("주문 처리 완료: " + event.orderId());
    }

    @DltHandler
    public void handleDlt(OrderEvent event) {
        // 4번 시도 후에도 실패한 메시지가 여기로 온다
        System.err.println("DLT 적재 — 수동 확인 필요: " + event.orderId());
    }
}
Java

@RetryableTopicattempts = "4"는 최초 처리 1회와 재시도 3회를 뜻하고, @Backoff(delay = 2000, multiplier = 2.0)는 2초 → 4초 → 8초로 지연을 늘려 가며 재시도하는 지수 백오프다. 재시도는 원본 컨슈머를 막지 않는(non-blocking) 방식으로, 실패한 메시지를 orders-retry-0, orders-retry-1 같은 별도 토픽으로 넘겨 처리한다. 끝내 실패하면 orders-dlt 토픽으로 이동하고 @DltHandler 메서드가 호출된다.

이 논블로킹 재시도를 쓰려면 설정 클래스에 @EnableKafkaRetryTopic을 붙여 기능을 활성화해야 한다. 재시도 방식은 두 가지가 있는데, 상황에 따라 골라 쓴다.

항목블로킹 재시도 (DefaultErrorHandler)논블로킹 재시도 (@RetryableTopic)
동작 방식같은 스레드에서 즉시 재시도재시도 토픽으로 넘겨 지연 후 처리
순서 보장파티션 순서 유지재시도된 메시지는 순서 흐트러짐
컨슈머 블로킹재시도 동안 파티션 소비 멈춤원본 파티션은 계속 진행
지연 재시도짧은 지연만 현실적분·시간 단위 지연도 가능
적합한 경우일시적 오류(짧은 재시도)외부 시스템 장애 등 긴 재시도

표에서 보듯 순서가 중요하면 블로킹 방식을, 처리량과 긴 지연이 중요하면 논블로킹 방식을 택한다. 참고로 @RetryableTopic 방식은 배치 리스너나 컨테이너 트랜잭션과는 함께 쓸 수 없다는 제약이 있으니, 배치 소비를 한다면 DefaultErrorHandlerDeadLetterPublishingRecoverer를 조합하는 방식을 쓴다.

오프셋을 언제 커밋할까: at-least-once와 수동 커밋

기본적으로 Spring Boot Kafka는 리스너 메서드가 정상 리턴하면 오프셋을 커밋한다. 이 자동 커밋은 “적어도 한 번(at-least-once)” 전달을 보장하지만, 처리 도중 장애가 나면 같은 메시지가 다시 배달될 수 있다. 정밀한 제어가 필요하면 ack-modeMANUAL로 바꾸고 Acknowledgment로 직접 커밋한다.

@KafkaListener(topics = "orders", groupId = "order-service", ackMode = "MANUAL")
public void listen(OrderEvent event, Acknowledgment ack) {
    try {
        process(event);   // 비즈니스 처리
        ack.acknowledge(); // 처리가 끝난 뒤에만 커밋
    } catch (Exception e) {
        // 커밋하지 않음 → 재조정 후 재배달
        throw e;
    }
}
Java

ackMode = "MANUAL"을 지정하면 자동 커밋이 꺼지고, ack.acknowledge()를 호출하는 시점에만 오프셋이 커밋된다. 위 코드는 비즈니스 처리가 성공적으로 끝난 뒤에만 커밋하므로, 처리 중 장애가 나면 커밋되지 않아 메시지가 다시 배달된다. 데이터를 절대 잃으면 안 되는 흐름에서 유용하다.

여기서 중요한 실무 원칙이 나온다. at-least-once는 “중복 배달”을 전제로 하므로, 컨슈머 로직은 같은 메시지를 두 번 받아도 문제없도록 멱등(idempotent)하게 짜야 한다. 예를 들어 주문 ID를 유니크 키로 두고 이미 처리한 주문이면 건너뛰는 식이다. Kafka에서 “정확히 한 번”은 트랜잭션까지 동원해야 하는 별도 주제이고, 대부분의 서비스는 at-least-once + 멱등 소비 조합으로 충분하다.

브로커 없이 테스트하기

Spring Boot Kafka 연동 코드는 실제 브로커 없이도 테스트할 수 있다. spring-kafka가 제공하는 @EmbeddedKafka는 테스트 JVM 안에 인메모리 브로커를 띄워, Producer와 Consumer를 실제로 주고받게 하며 검증한다. 더 운영에 가까운 검증이 필요하면 Testcontainers로 진짜 Kafka 컨테이너를 띄우는 방법도 있다.

import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.kafka.test.context.EmbeddedKafka;

@SpringBootTest
@EmbeddedKafka(partitions = 1, topics = "orders")
class OrderKafkaTest {

    @org.springframework.beans.factory.annotation.Autowired
    private OrderProducer producer;

    @org.junit.jupiter.api.Test
    void 메시지를_보내면_컨슈머가_받는다() throws Exception {
        producer.send("order-1", "{\"orderId\":\"order-1\",\"product\":\"book\",\"quantity\":2}");
        // Awaitility 등으로 컨슈머가 처리했는지 검증
        Thread.sleep(1000);
    }
}
Java

@EmbeddedKafka(partitions = 1, topics = "orders")는 테스트 시작 시 인메모리 브로커를 띄우고 orders 토픽을 미리 만든다. @SpringBootTest와 함께 쓰면 실제 애플리케이션 컨텍스트에서 Producer/Consumer가 동작하므로 통합 흐름을 검증할 수 있다. 다만 임베디드 브로커는 실제 Kafka와 미묘하게 동작이 다를 수 있어, 재시도·DLT처럼 브로커 동작에 민감한 부분은 Testcontainers로 진짜 Kafka를 띄워 검증하는 편이 안전하다.

Testcontainers를 이용한 통합 테스트의 구성 방법은 Testcontainers로 Spring Boot 통합 테스트하기에서 자세히 다뤘으니, Kafka 컨테이너를 붙일 때 함께 참고하면 좋다. 테스트를 어디까지 실제 인프라로 검증할지는 팀의 CI 시간 예산과 상충하므로, 핵심 흐름만 Testcontainers로 두고 나머지는 임베디드로 가는 절충이 현실적이다.

직접 돌려본 결과: produce·consume와 파티션·컨슈머 그룹 상태

앞의 코드를 실제 프로젝트로 만들어 로컬 Kafka에 붙여 돌려봤다. Producer가 보낸 주문을 Consumer가 곧바로 받고, 같은 키의 메시지는 같은 파티션으로 들어가 순서가 유지되며, 컨슈머 그룹의 LAG는 0으로 수렴한다. 아래는 앞서 만든 OrderProducer·OrderListener·application.yml을 그대로 쓴 실행 결과다.

컨슈머그룹의 LAG는 ‘그 컨슈머가 아직 처리하지 못하고 밀려있는 메시지 개수’를 의미한다.
컨슈머가 프로듀서를 얼마나 못 따라잡고 있는지를 나타내는 지연 지표다.
계산식은 다음과 같다.
LAG = LOG-END-OFFSET – CURRENT-OFFSET
– CURRENT-OFFSET: 컨슈머 그룹이 여기까지 읽고 처리했다고 커밋한 위치
– LOG-END-OFFSET: 파티션에 쌓인 마지막 메시지의 다음 위치(즉 파티션의 현재 끝)

# 1) 브로커 기동 (compose.yaml)
docker compose up -d

# 2) 애플리케이션 실행 (orders 토픽이 파티션 3개로 자동 생성됨)
./mvnw spring-boot:run

# 3) 서로 다른 키로 주문 6건 전송
curl -X POST localhost:8080/orders -H 'Content-Type: application/json' \
  -d '{"orderId":"A-1001","product":"keyboard","quantity":2}'
ShellScript

위 순서로 브로커를 띄우고 앱을 실행한 뒤 주문을 전송한다. KafkaTopicConfigNewTopic 빈 덕분에 orders 토픽이 파티션 3개로 자동 생성되고, orderId를 키로 넘겼으므로 같은 주문은 같은 파티션으로 라우팅된다.

이 캡처를 그대로 재현하는 데 쓴 코드는 다음 다섯 파일이다. 문자열 대신 OrderEvent 객체를 JSON으로 주고받는 구성이며, application.yml의 직렬화 설정은 앞의 “문자열 말고 객체를 주고받기” 절의 것을 그대로 쓴다.

// OrderEvent.java — 주고받을 도메인 객체
public record OrderEvent(String orderId, String product, int quantity) {}
Java

OrderEvent는 주문 하나를 표현하는 record다. orderId를 메시지 키로, 객체 전체를 값으로 전송한다. 이 클래스가 JSON으로 직렬화되어 토픽에 저장되고, 컨슈머에서 다시 같은 타입으로 역직렬화된다.

// KafkaTopicConfig.java — orders 토픽을 파티션 3개로 생성
@Configuration
public class KafkaTopicConfig {

    @Bean
    public NewTopic ordersTopic() {
        return TopicBuilder.name("orders").partitions(3).replicas(1).build();
    }
}
Java

NewTopic 빈을 등록하면 애플리케이션 기동 시 orders 토픽이 파티션 3개로 자동 생성된다. 캡처에서 파티션이 0·1·2 세 개로 보인 이유가 바로 이 설정이다. 이 빈이 없으면 브로커 기본값(보통 1개)으로 만들어진다.

// OrderProducer.java — 키와 함께 전송하고 결과를 로그로 남긴다
@Service
public class OrderProducer {

    private final KafkaTemplate<String, OrderEvent> kafkaTemplate;

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

    public void send(OrderEvent event) {
        kafkaTemplate.send("orders", event.orderId(), event)
                .whenComplete((result, ex) -> {
                    if (ex == null) {
                        var meta = result.getRecordMetadata();
                        System.out.printf("[PRODUCED] key=%s partition=%d offset=%d %s%n",
                                event.orderId(), meta.partition(), meta.offset(), event);
                    }
                });
    }
}
Java

OrderProducerorderId를 키로 지정해 전송하고, 전송이 끝나면 콜백에서 [PRODUCED] 로그에 실제로 배정된 파티션과 오프셋을 찍는다. 캡처의 [PRODUCED] 줄이 이 System.out.printf에서 나온 것이다. 같은 키가 같은 파티션으로 가는 것도 이 로그로 확인된다.

// OrderListener.java — 수신하면 파티션·오프셋을 로그로 남긴다
@Component
public class OrderListener {

    @KafkaListener(topics = "orders", groupId = "order-service")
    public void listen(@Payload OrderEvent event,
                       @Header(KafkaHeaders.RECEIVED_KEY) String key,
                       @Header(KafkaHeaders.RECEIVED_PARTITION) int partition,
                       @Header(KafkaHeaders.OFFSET) long offset) {
        System.out.printf("[CONSUMED] partition=%d offset=%d key=%s -> %s%n",
                partition, offset, key, event);
    }
}
Java

OrderListenerorders 토픽을 구독해 메시지가 도착할 때마다 [CONSUMED] 로그를 찍는다. @Header로 받은 파티션·오프셋 덕분에 어느 파티션의 몇 번째 메시지를 처리했는지 그대로 드러난다. 참고로 오프셋 헤더 상수는 KafkaHeaders.OFFSET이다(RECEIVED_OFFSET이 아니다 — 실제로 컴파일하면 바로 걸린다).

// OrderController.java — POST /orders 로 주문을 전송하는 엔드포인트
@RestController
@RequestMapping("/orders")
public class OrderController {

    private final OrderProducer producer;

    public OrderController(OrderProducer producer) {
        this.producer = producer;
    }

    @PostMapping
    public String create(@RequestBody OrderEvent event) {
        producer.send(event);
        return "queued: " + event.orderId();
    }
}
Java

OrderController는 앞의 curl 명령이 호출한 엔드포인트다. POST /orders로 받은 JSON을 OrderEvent로 역직렬화한 뒤 OrderProducer.send()에 넘긴다. 이 다섯 파일과 application.yml만 있으면 위 실행 명령으로 캡처를 그대로 재현할 수 있다.

로그를 보면 [PRODUCED]로 6건이 전송된 직후 [CONSUMED]로 같은 6건이 소비된다. 핵심은 강조된 두 줄이다. 키가 A-1001로 같은 두 주문은 전송 시각이 달라도 모두 partition=2로 들어갔고(offset 0과 2), Consumer도 그 순서 그대로 받았다. 앞에서 설명한 “같은 키는 같은 파티션”이 실제로 동작하는 장면이다.

# 파티션 상태 확인
docker exec kafka-demo-broker \
  /opt/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092 --describe --topic orders

# 컨슈머 그룹 상태 확인 (offset / LAG)
docker exec kafka-demo-broker \
  /opt/kafka/bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group order-service
ShellScript

kafka-demo-broker는 로컬에서 띄운 docker container임을 참고하기 바란다.
두 명령으로 브로커 내부 상태를 직접 들여다볼 수 있다. kafka-topics --describe는 토픽의 파티션·리더·복제본을, kafka-consumer-groups --describe는 그룹이 파티션별로 어디까지 읽었는지(CURRENT-OFFSET)와 밀린 양(LAG)을 보여준다.

위쪽 출력은 orders 토픽이 파티션 3개로 만들어졌고 각 파티션의 리더가 브로커 1번임을 보여준다. 아래쪽 컨슈머 그룹 출력이 실무에서 가장 자주 들여다보는 화면이다. 파티션 1은 offset 1, 파티션 2는 offset 5까지 읽었고 LAG가 모두 0이다. LAG가 0이라는 건 컨슈머가 밀리지 않고 최신까지 따라잡았다는 뜻이다. 단일 컨슈머 하나(CONSUMER-ID가 동일)가 세 파티션을 모두 맡고 있는 것도 확인된다. 만약 이 그룹에 인스턴스를 하나 더 띄우면 파티션이 재분배되어 CONSUMER-ID가 둘로 나뉜다. 운영에서 “컨슈머가 메시지를 제때 처리하고 있나?”를 판단할 때 바로 이 LAG 값을 본다.

FAQ

Spring Boot에서 Kafka를 쓸 때 KafkaTemplate을 직접 빈으로 등록해야 하나요?

등록할 필요가 없다. spring-kafka 의존성을 추가하고 application.ymlspring.kafka.bootstrap-servers만 지정하면, Spring Boot의 auto-configuration이 KafkaTemplate과 리스너 컨테이너 팩토리를 자동으로 빈으로 만들어 준다. 직렬화기나 특수 설정을 바꿔야 할 때만 프로듀서/컨슈머 팩토리를 직접 정의하면 된다.

@KafkaListener의 groupId와 application.yml의 consumer.group-id는 뭐가 다른가요?

consumer.group-id는 전역 기본값이고, @KafkaListener(groupId = ...)는 해당 리스너에만 적용되는 개별 값으로 기본값을 덮어쓴다. 리스너마다 다른 그룹으로 같은 토픽을 독립적으로 소비하고 싶을 때 개별 groupId를 지정한다. 지정하지 않으면 application.yml의 값이 쓰인다.

메시지 소비 중 예외가 나면 그 메시지는 사라지나요?

기본 설정에서는 사라지지 않고, 에러 핸들러의 정책에 따라 재시도되거나 Dead Letter Topic으로 이동한다. 아무 설정도 하지 않으면 기본 DefaultErrorHandler가 몇 차례 재시도한 뒤 로그를 남기고 넘어간다. 실무에서는 @RetryableTopic으로 재시도 토픽과 DLT를 구성해, 끝내 실패한 메시지를 DLT에 모아 두고 나중에 수동으로 확인하는 방식을 권장한다.

같은 주문의 이벤트 순서를 보장하려면 어떻게 하나요?

메시지를 보낼 때 주문 ID를 키로 지정하면 된다. Kafka는 같은 키의 메시지를 항상 같은 파티션으로 보내고, 한 파티션 안에서는 순서가 보장된다. 따라서 kafkaTemplate.send("orders", orderId, event)처럼 키를 주면 같은 주문의 이벤트는 순서대로 처리된다. 단, @RetryableTopic으로 논블로킹 재시도를 하면 재시도된 메시지의 순서는 흐트러질 수 있으니 주의한다.

JSON 역직렬화에서 “trusted packages” 오류가 나요.

컨슈머에 spring.json.trusted.packages를 지정하지 않아서다. JacksonJsonDeserializer는 보안을 위해 신뢰하는 패키지에서만 클래스를 복원하므로, spring.kafka.consumer.properties.spring.json.trusted.packages에 도메인 객체가 있는 패키지(예: com.example.order)를 지정해야 한다. 개발 편의로 모든 패키지를 열려면 "*"를 주지만, 운영에서는 실제 패키지만 명시하는 게 안전하다.

마치며

지금까지 Spring Boot Kafka 연동을 의존성부터 DLT와 테스트까지 정리해 보았다. 개인적으로 Kafka를 처음 붙일 때 가장 오래 헤맸던 건 메시지를 보내고 받는 부분이 아니라, “실패한 메시지를 어떻게 할 것인가”였다. 처음엔 예외가 나면 그냥 로그만 남기고 넘어갔는데, 어느 날 결제 이벤트 하나가 조용히 유실된 걸 뒤늦게 발견하고 나서야 DLT의 필요성을 절감했다. @RetryableTopic 한 줄이면 되는 걸 몰라서 한동안 손으로 재시도 로직을 짰던 기억도 있다.

그래서 새로 Kafka를 도입하는 사람에게는 순서를 뒤집어 권하고 싶다. Producer/Consumer 기본기가 익으면 곧바로 에러 처리와 DLT부터 붙여 두라는 것이다. 정상 흐름은 어차피 잘 돌아간다. 서비스의 안정성을 가르는 건 언제나 실패한 메시지를 어디에 쌓아 두고 어떻게 다시 처리하느냐다. 이 글이 그 시행착오를 조금이라도 줄여 준다면 좋겠다.