Step 01 — 환경 구축과 첫 메시지

학습 목표

  • spring-kafka 의존성 한 줄이 KafkaAutoConfiguration 을 통해 등록하는 빈 6종을 직접 조회해 확인한다
  • KafkaTemplate.send(topic, key, value)orders 토픽에 3건을 발행하고, 각 레코드가 어느 파티션·오프셋에 앉았는지 실측한다
  • @KafkaListener 로 수신하고, partitions assigned 로그가 나오기까지의 기동 순서를 한 줄씩 해독한다
  • @EnableKafka 가 왜 불필요한지를 KafkaAnnotationDrivenConfiguration 의 위임 구조로 설명한다
  • 발행한 메시지의 행방을 콘솔 컨슈머·컨슈머 그룹 랙·리스너 로그 세 가지 방법으로 교차 확인한다
  • auto-offset-reset 과 토픽 자동 생성이 "에러 없이 조용히" 만드는 두 가지 사고를 재현한다

선행 스텝: 실습 프로젝트 셋업 예상 소요: 90분


1-0. 실습 준비

브로커가 살아 있는지부터 확인합니다. 여기서 막히면 뒤가 전부 막힙니다.

docker compose ps

결과

NAME                IMAGE                COMMAND                  STATUS                   PORTS
learn-kafka         apache/kafka:3.7.0   "/__cacert_entrypoin…"   Up 2 minutes (healthy)   0.0.0.0:9092->9092/tcp
learn-kafka-mysql   mysql:8.0            "docker-entrypoint.s…"   Up 2 minutes (healthy)   0.0.0.0:3307->3306/tcp

두 컨테이너 모두 (healthy) 여야 합니다. (health: starting) 이면 10초쯤 더 기다리세요.

이제 토픽 목록을 봅니다. 실습 프로젝트 셋업 P-8 의 alias 를 등록했다고 가정합니다.

kt --list

결과 (앱을 한 번도 안 띄운 상태)

__consumer_offsets

__consumer_offsets 하나뿐입니다. 이것은 Kafka 가 컨슈머 그룹의 커밋된 오프셋을 저장하는 내부 토픽이며, 브로커가 자기 필요로 만든 것입니다. orders 는 아직 없습니다. KafkaTopicConfigNewTopic 빈이 만들 것이므로 지금 없는 게 정상입니다.


1-1. 의존성 하나가 하는 일

build.gradle 에서 Kafka 관련 줄은 딱 하나입니다.

implementation 'org.springframework.kafka:spring-kafka'

이 한 줄이 켜는 것은 KafkaAutoConfiguration 입니다. 자동 설정이 도는 조건은 두 가지입니다.

조건의미
@ConditionalOnClass(KafkaTemplate.class)클래스패스에 spring-kafka 가 있으면
@EnableConfigurationProperties(KafkaProperties.class)spring.kafka.*KafkaProperties 로 바인딩

그 결과 컨텍스트에 아래 빈들이 등록됩니다. 여러분이 한 줄도 쓰지 않았는데 생기는 것들입니다.

빈 이름타입하는 일등록 조건
kafkaTemplateKafkaTemplate발행 진입점. 내부적으로 Producer 를 빌려 씀항상
kafkaProducerFactoryDefaultKafkaProducerFactoryProducer 인스턴스를 만들고 캐싱항상
kafkaConsumerFactoryDefaultKafkaConsumerFactoryConsumer 인스턴스를 요청마다 새로 생성항상
kafkaListenerContainerFactoryConcurrentKafkaListenerContainerFactory@KafkaListener 하나당 컨테이너를 찍어내는 틀항상
kafkaAdminKafkaAdmin기동 시 NewTopic 빈들을 브로커에 생성항상
kafkaListenerEndpointRegistryKafkaListenerEndpointRegistry만들어진 컨테이너들을 보관·시작·정지항상
kafkaProducerListenerLoggingProducerListener발행 실패를 ERROR 로그로 남김항상
kafkaTransactionManagerKafkaTransactionManagerKafka 트랜잭션transaction-id-prefix 가 있을 때만

마지막 줄이 중요합니다. 조건이 안 맞으면 자동 설정은 예외를 던지지 않고 그냥 빈을 안 만듭니다. @Autowired KafkaTransactionManager 를 썼는데 NoSuchBeanDefinitionException 이 나면, 버그가 아니라 설정을 안 한 것입니다. 1-4 에서 이 부재를 직접 눈으로 확인합니다.

💡 KafkaProducerFactory 는 캐싱하고 KafkaConsumerFactory 는 캐싱하지 않습니다. Kafka Producer 는 스레드 안전해서 하나를 공유하는 게 정석이고, Consumer 는 스레드 안전하지 않아 스레드마다 하나씩 필요합니다. 이 비대칭이 뒤 스텝의 concurrency(Step 03), 트랜잭션(Step 09) 설계를 전부 지배합니다.


1-2. 첫 발행

KafkaTemplate 을 주입받아 3건을 보냅니다.

@Component
@Profile("step01")
public class FirstPublisher implements ApplicationRunner {

    private final KafkaTemplate<String, OrderCreated> kafkaTemplate;

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

    @Override
    public void run(ApplicationArguments args) {
        for (int seq = 1; seq <= 3; seq++) {
            OrderCreated event = OrderCreated.of(seq);
            kafkaTemplate.send("orders", event.orderId(), event)
                .whenComplete((result, ex) -> {
                    if (ex != null) { log.error("발행 실패", ex); return; }
                    log.info("발행 성공 key={} -> {}-{}@{}",
                        event.orderId(),
                        result.getRecordMetadata().topic(),
                        result.getRecordMetadata().partition(),
                        result.getRecordMetadata().offset());
                });
        }
        kafkaTemplate.flush();
    }
}

주입이 되는 이유

자동 설정이 만든 빈은 KafkaTemplate<?, ?> 로 선언되어 있습니다. 그런데 우리는 KafkaTemplate<String, OrderCreated> 로 받았습니다. 와일드카드는 어떤 구체 타입에도 대입 가능하므로 주입이 성립합니다.

단, 제네릭은 컴파일 타임 장식일 뿐입니다. 실제 직렬화기는 application.ymlkey-serializer/value-serializer 가 정합니다. KafkaTemplate<String, Integer> 로 주입받아 Integer 를 보내도 컴파일은 통과하고, JsonSerializer 가 그것을 그냥 JSON 으로 직렬화해 버립니다. 타입 안전성은 여기까지입니다. Step 04 에서 이 틈이 어떻게 사고로 이어지는지 봅니다.

실행

./gradlew bootRun --args='--spring.profiles.active=step01'

결과

INFO 12841 --- [           main] c.e.o.OrderServiceApplication            : The following 1 profile is active: "step01"
INFO 12841 --- [           main] o.a.k.c.u.AppInfoParser                  : Kafka version: 3.6.1
INFO 12841 --- [           main] c.e.o.s.Practice$FirstPublisher          : [1-2] orders 토픽으로 OrderCreated 3건 발행을 시작합니다
INFO 12841 --- [ad | producer-1] c.e.o.s.Practice$FirstPublisher          : [1-2] 발행 성공 key=ORD-0001 -> orders-1@0
INFO 12841 --- [ad | producer-1] c.e.o.s.Practice$FirstPublisher          : [1-2] 발행 성공 key=ORD-0002 -> orders-0@0
INFO 12841 --- [ad | producer-1] c.e.o.s.Practice$FirstPublisher          : [1-2] 발행 성공 key=ORD-0003 -> orders-2@0
INFO 12841 --- [           main] c.e.o.s.Practice$FirstPublisher          : [1-2] flush 완료

교재의 파티션 번호와 여러분 화면이 같아야 합니다. ORD-0001orders-1, ORD-0002orders-0, ORD-0003orders-2. 키가 결정적이고 기본 파티셔너가 murmur2(key) % 3 이므로 누가 실행해도 같습니다. 다르다면 파티션 수가 3이 아닌 것입니다. kt --describe --topic orders 로 확인하세요.

스레드 이름을 보세요

발행 시작 로그는 [main], 발행 성공 로그 세 줄은 [ad | producer-1] 입니다. 이 축약은 kafka-producer-network-thread | producer-1 이 잘린 것입니다.

send() 를 부른 스레드와 결과를 받는 스레드가 다릅니다. send() 는 레코드를 버퍼에 넣고 즉시 돌아오고, 실제 전송과 응답 처리는 네트워크 스레드가 합니다. whenComplete 콜백도 그 스레드에서 실행됩니다.

여기서 두 가지가 따라옵니다.

  • 콜백 안에서 무거운 작업을 하면 프로듀서의 전송 스레드를 붙잡아 전체 발행이 느려집니다.
  • 콜백을 안 붙이면 실패해도 아무 일도 안 일어납니다. kafkaProducerListener(=LoggingProducerListener)가 ERROR 로그 하나는 남겨 주지만, 애플리케이션 코드는 실패를 모릅니다.

💡 flush() 는 실습에서 로그 순서를 안정시키려고 넣었습니다. 운영 코드에서 매 건 flush() 하면 linger.ms 배치가 통째로 무력화됩니다. 이 비용을 Step 02 에서 숫자로 측정합니다.

콘솔 컨슈머로 확인

터미널을 하나 더 띄웁니다.

kcc --topic orders --from-beginning --property print.key=true --property print.partition=true

결과

Partition:1	ORD-0001	{"orderId":"ORD-0001","customerId":1001,"sku":"SKU-002","quantity":2,"amount":11000,"createdAt":"2025-01-01T00:01:00Z"}
Partition:0	ORD-0002	{"orderId":"ORD-0002","customerId":1002,"sku":"SKU-003","quantity":3,"amount":12000,"createdAt":"2025-01-01T00:02:00Z"}
Partition:2	ORD-0003	{"orderId":"ORD-0003","customerId":1003,"sku":"SKU-001","quantity":4,"amount":13000,"createdAt":"2025-01-01T00:03:00Z"}

파티션 번호가 발행 로그와 일치합니다. 애플리케이션 밖에서 확인했다는 점이 중요합니다. 내 코드의 로그는 내 코드가 거짓말할 수 있지만, 콘솔 컨슈머는 브로커에 실제로 저장된 것만 보여 줍니다.


1-3. 첫 수신

@Component
@Profile("step01")
public class InventoryListener {

    @KafkaListener(
            id = "s01-inventory-listener",
            topics = "${app.topic.orders}",
            groupId = "s01-inventory",
            concurrency = "1")
    public void onOrderCreated(ConsumerRecord<String, OrderCreated> record) {
        log.info("[1-3] 재고 차감 {}-{}@{} key={} sku={} qty={}",
                record.topic(), record.partition(), record.offset(),
                record.key(), record.value().sku(), record.value().quantity());
    }
}

concurrency = "1" 을 명시한 이유가 있습니다. application.ymllistener.concurrency: 3 을 그대로 쓰면 컨테이너 3개가 파티션을 하나씩 나눠 갖고, partitions assigned 로그가 세 줄로 쪼개집니다. Step 01 에서는 한 줄에 세 파티션이 나열되는 모습을 눈에 익히는 게 목적이므로 스레드를 하나로 묶었습니다. concurrency 와 파티션 수의 관계는 Step 03 의 주제입니다.

결과

INFO 12841 --- [ntainer#0-0-C-1] o.a.k.c.c.i.ConsumerCoordinator          : [Consumer clientId=consumer-s01-inventory-1, groupId=s01-inventory] Discovered group coordinator 127.0.0.1:9092 (id: 2147483646 rack: null)
INFO 12841 --- [ntainer#0-0-C-1] o.a.k.c.c.i.ConsumerCoordinator          : [Consumer clientId=consumer-s01-inventory-1, groupId=s01-inventory] (Re-)joining group
INFO 12841 --- [ntainer#0-0-C-1] o.a.k.c.c.i.ConsumerCoordinator          : [Consumer clientId=consumer-s01-inventory-1, groupId=s01-inventory] Successfully joined group with generation Generation{generationId=1, memberId='consumer-s01-inventory-1-9f2c1e77-...', protocol='range'}
INFO 12841 --- [ntainer#0-0-C-1] o.a.k.c.c.i.ConsumerCoordinator          : [Consumer clientId=consumer-s01-inventory-1, groupId=s01-inventory] Notifying assignor about the new Assignment(partitions=[orders-0, orders-1, orders-2])
INFO 12841 --- [ntainer#0-0-C-1] o.a.k.c.c.i.ConsumerCoordinator          : [Consumer clientId=consumer-s01-inventory-1, groupId=s01-inventory] Found no committed offset for partition orders-0
INFO 12841 --- [ntainer#0-0-C-1] o.a.k.c.c.i.SubscriptionState            : [Consumer clientId=consumer-s01-inventory-1, groupId=s01-inventory] Resetting offset for partition orders-0 to position FetchPosition{offset=0, ...}
INFO 12841 --- [ntainer#0-0-C-1] o.s.k.l.KafkaMessageListenerContainer     : s01-inventory: partitions assigned: [orders-0, orders-1, orders-2]
INFO 12841 --- [ntainer#0-0-C-1] c.e.o.s.Practice$InventoryListener        : [1-3] 재고 차감 orders-0@0 key=ORD-0002 sku=SKU-003 qty=3
INFO 12841 --- [ntainer#0-0-C-1] c.e.o.s.Practice$InventoryListener        : [1-3] 재고 차감 orders-1@0 key=ORD-0001 sku=SKU-002 qty=2
INFO 12841 --- [ntainer#0-0-C-1] c.e.o.s.Practice$InventoryListener        : [1-3] 재고 차감 orders-2@0 key=ORD-0003 sku=SKU-001 qty=4

partitions assigned 한 줄 해설

이 코스에서 가장 많이 보게 될 로그입니다. 앞의 다섯 줄이 여기까지 오는 과정입니다.

로그의미
Discovered group coordinator이 그룹을 관리할 브로커를 찾았다. 단일 노드라 자기 자신
(Re-)joining group그룹 가입 요청을 보냈다
Successfully joined group with generation가입 성공. generationId=1 = 이 그룹의 1세대
Notifying assignor about the new Assignment어떤 파티션을 맡을지 결정됐다
Found no committed offset for partition이 그룹은 이 파티션을 한 번도 커밋한 적이 없다
Resetting offset ... to position offset=0그래서 auto.offset.reset 규칙을 적용해 0번부터
partitions assigned: [...]Spring 컨테이너가 폴링을 시작한다

generationId 는 그룹의 멤버 구성이 바뀔 때마다 1씩 오릅니다. 이 숫자가 실습 중에 계속 올라간다면 리밸런스가 반복되고 있다는 뜻입니다.

주목할 것은 Found no committed offsetResetting offset 두 줄이 짝이라는 점입니다. 이 두 줄이 나온다는 건 auto.offset.reset실제로 개입했다는 뜻입니다. 커밋된 오프셋이 있으면 이 두 줄은 아예 안 나옵니다. 바로 다음 함정의 핵심입니다.

⚠️ 함정 — auto-offset-reset 기본값 latest 때문에 "앱을 켜기 전에 보낸 메시지"는 영원히 안 보인다

Kafka 클라이언트의 auto.offset.reset 기본값은 latest 입니다. 우리 application.ymlearliest 로 덮어썼기 때문에 위 실습이 동작한 것입니다.

증상. latest 인 채로 새 컨슈머 그룹을 띄우면, 컨슈머는 "붙은 순간의 마지막 오프셋"부터 읽습니다. 그 전에 쌓여 있던 메시지는 한 건도 안 들어옵니다. 그런데 로그는 완벽하게 깨끗합니다. 예외도 없고 WARN 도 없습니다. partitions assigned 도 정상적으로 찍힙니다. 리스너 메서드만 안 불릴 뿐입니다. 그래서 사람들은 리스너 코드, 역직렬화, 토픽명, 방화벽을 차례로 의심하며 몇 시간을 씁니다.

재현. spring.kafka.consumer.auto-offset-reset: latest 로 바꾼 뒤, ① 앱을 끈 상태에서 3건을 발행하고 ② 한 번도 쓴 적 없는 그룹(s01-latest)으로 리스너를 켭니다.

결과 (latest + 새 그룹)

INFO 12841 --- [ntainer#0-0-C-1] o.a.k.c.c.i.ConsumerCoordinator          : [Consumer clientId=consumer-s01-latest-1, groupId=s01-latest] Found no committed offset for partition orders-0
INFO 12841 --- [ntainer#0-0-C-1] o.a.k.c.c.i.SubscriptionState            : [Consumer clientId=consumer-s01-latest-1, groupId=s01-latest] Resetting offset for partition orders-0 to position FetchPosition{offset=1, ...}
INFO 12841 --- [ntainer#0-0-C-1] o.s.k.l.KafkaMessageListenerContainer     : s01-latest: partitions assigned: [orders-0, orders-1, orders-2]

offset=1 입니다. 0번 레코드를 건너뛰었습니다. 이 한 글자가 유일한 단서입니다.

가장 헷갈리는 부분 — 이 설정은 항상 적용되는 게 아닙니다. auto.offset.reset커밋된 오프셋이 없을 때만 쓰입니다. 한 번이라도 커밋한 그룹은 이 값을 완전히 무시하고 커밋 지점부터 이어 읽습니다. 그래서 "처음 한 번은 메시지가 안 보이다가, 이후엔 잘 되는" 이상한 재현률이 나옵니다. 그룹 이름을 바꾸는 순간 다시 재현됩니다.

진단. kcg --describe --group s01-latestConsumer group 's01-latest' has no active members. 를 내거나 그룹 자체가 없다고 하면, 한 번도 커밋한 적 없다는 뜻이고 = 지금 auto.offset.reset 이 작동 중입니다.

해결. 실습은 earliest. 운영은 서비스 성격에 따라 고르되, 반드시 의식하고 고르세요. 새 서비스를 붙일 때 earliest 면 몇 달치 과거 이벤트가 한꺼번에 쏟아집니다. 그것도 사고입니다.


1-4. 자동 설정이 만들어 준 빈들 실제로 꺼내 보기

말로만 듣지 말고 컨텍스트에서 직접 꺼냅니다.

Map<String, Class<?>> targets = new LinkedHashMap<>();
targets.put("KafkaTemplate",                 KafkaTemplate.class);
targets.put("ProducerFactory",               ProducerFactory.class);
targets.put("ConsumerFactory",               ConsumerFactory.class);
targets.put("KafkaListenerContainerFactory", KafkaListenerContainerFactory.class);
targets.put("KafkaAdmin",                    KafkaAdmin.class);
targets.put("KafkaListenerEndpointRegistry", KafkaListenerEndpointRegistry.class);
targets.put("KafkaTransactionManager",       KafkaTransactionManager.class);

targets.forEach((label, type) -> {
    String[] names = ctx.getBeanNamesForType(type);   // ← 구현체가 아니라 인터페이스로 조회
    ...
});

결과

INFO 12841 --- [           main] c.e.o.s.P$AutoConfiguredBeanReport        : [1-4] ---- spring-kafka 자동 설정 빈 목록 ----
INFO 12841 --- [           main] c.e.o.s.P$AutoConfiguredBeanReport        : [1-4] KafkaTemplate                    kafkaTemplate -> org.springframework.kafka.core.KafkaTemplate
INFO 12841 --- [           main] c.e.o.s.P$AutoConfiguredBeanReport        : [1-4] ProducerFactory                  kafkaProducerFactory -> org.springframework.kafka.core.DefaultKafkaProducerFactory
INFO 12841 --- [           main] c.e.o.s.P$AutoConfiguredBeanReport        : [1-4] ConsumerFactory                  kafkaConsumerFactory -> org.springframework.kafka.core.DefaultKafkaConsumerFactory
INFO 12841 --- [           main] c.e.o.s.P$AutoConfiguredBeanReport        : [1-4] KafkaListenerContainerFactory    kafkaListenerContainerFactory -> org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory
INFO 12841 --- [           main] c.e.o.s.P$AutoConfiguredBeanReport        : [1-4] KafkaAdmin                       kafkaAdmin -> org.springframework.kafka.core.KafkaAdmin
INFO 12841 --- [           main] c.e.o.s.P$AutoConfiguredBeanReport        : [1-4] KafkaListenerEndpointRegistry    org.springframework.kafka.config.internalKafkaListenerEndpointRegistry -> org.springframework.kafka.config.KafkaListenerEndpointRegistry
INFO 12841 --- [           main] c.e.o.s.P$AutoConfiguredBeanReport        : [1-4] KafkaTransactionManager          (없음 — 조건 불충족)
INFO 12841 --- [           main] c.e.o.s.P$AutoConfiguredBeanReport        : [1-4] 내부 인프라                      존재=true org.springframework.kafka.config.internalKafkaListenerAnnotationProcessor
INFO 12841 --- [           main] c.e.o.s.P$AutoConfiguredBeanReport        : [1-4] 리스너 컨테이너 1개: [s01-inventory-listener]

읽을 것이 여럿 있습니다.

빈 이름이 두 종류입니다. kafkaTemplate, kafkaAdmin 처럼 짧은 이름은 KafkaAutoConfiguration@Bean 메서드 이름입니다. 반면 org.springframework.kafka.config.internalKafkaListenerEndpointRegistry 처럼 긴 이름은 @EnableKafka 가 등록하는 인프라 빈이며, 이름이 상수로 고정되어 있습니다. 이 사실이 1-5 의 근거입니다.

역할 한 줄
kafkaTemplatesend() 진입점. ProducerFactory 에서 Producer 를 빌려 레코드를 버퍼에 넣는다
kafkaProducerFactoryProducer 를 만들고 캐싱. 기본적으로 앱 전체가 하나를 공유
kafkaConsumerFactoryConsumer 를 만든다. 캐싱하지 않음. 컨테이너 스레드마다 하나씩
kafkaListenerContainerFactory@KafkaListener 하나를 ConcurrentMessageListenerContainer 로 찍어낸다
kafkaAdmin기동 시 NewTopic 빈을 모아 AdminClient 로 토픽 생성. 없으면 만들고 있으면 둔다
internalKafkaListenerAnnotationProcessor@KafkaListener 애노테이션을 스캔해 엔드포인트로 등록하는 BeanPostProcessor
internalKafkaListenerEndpointRegistry컨테이너 보관소. start()/stop()/pause() 의 조작 대상 (Step 10)

KafkaTransactionManager 가 없습니다. spring.kafka.producer.transaction-id-prefix 를 설정하지 않았기 때문입니다. 자동 설정은 실패하지 않았습니다. 조건이 안 맞아서 아무것도 안 한 것입니다. Step 09 에서 그 프로퍼티를 넣으면 이 자리에 빈이 나타납니다.

리스너 컨테이너가 1개입니다. @KafkaListener 를 하나 붙였으니 하나입니다. concurrency 를 3으로 올려도 이 숫자는 1입니다. ConcurrentMessageListenerContainer 하나가 내부에 KafkaMessageListenerContainer 3개를 품는 구조이기 때문입니다.

💡 실무 팁 — 어떤 자동 설정이 켜지고 꺼졌는지 통째로 보고 싶다면

./gradlew bootRun --args='--spring.profiles.active=step01 --debug'

CONDITIONS EVALUATION REPORT 가 출력되고, KafkaAutoConfiguration 항목에서 어떤 @Beanmatched 이고 어떤 것이 did not match 인지 이유까지 보여 줍니다. KafkaAutoConfiguration#kafkaTransactionManager: @ConditionalOnProperty (spring.kafka.producer.transaction-id-prefix) did not find property 라는 줄을 찾아 보세요.


1-5. @EnableKafka 는 왜 필요 없는가

Spring Kafka 문서와 예전 블로그 글에는 대부분 @EnableKafka 가 붙어 있습니다. 그런데 우리 OrderServiceApplication 에는 없습니다. 그래도 @KafkaListener 가 동작합니다.

이유는 자동 설정의 구조입니다.

KafkaAutoConfiguration
  └─ @Import(KafkaAnnotationDrivenConfiguration.class)
        └─ @ConditionalOnClass(EnableKafka.class)
             └─ EnableKafkaConfiguration (nested)
                   └─ @EnableKafka          ← 여기서 대신 붙여 준다
                        └─ @Import(KafkaListenerConfigurationSelector.class)
                             └─ KafkaBootstrapConfiguration
                                  ├─ internalKafkaListenerAnnotationProcessor
                                  └─ internalKafkaListenerEndpointRegistry

KafkaAnnotationDrivenConfiguration 안의 중첩 설정 클래스에 @EnableKafka 가 이미 붙어 있고, 그것이 KafkaBootstrapConfiguration 을 끌어와 @KafkaListener 를 스캔하는 후처리기를 등록합니다. 1-4 에서 본 그 긴 이름의 빈 두 개가 이것입니다.

그리고 이 중첩 설정에는 조건이 하나 더 붙어 있습니다.

@ConditionalOnMissingBean(name = KafkaListenerConfigUtils.KAFKA_LISTENER_ANNOTATION_PROCESSOR_BEAN_NAME)

"사용자가 이미 @EnableKafka 를 붙였으면 나는 빠지겠다" 는 뜻입니다.

직접 붙이면 어떻게 되나

@Configuration
@EnableKafka
public class RedundantEnableKafkaConfig { }

결과

INFO 12841 --- [           main] c.e.o.OrderServiceApplication            : The following 2 profiles are active: "step01", "step01-enable-kafka"
INFO 12841 --- [ntainer#0-0-C-1] o.s.k.l.KafkaMessageListenerContainer     : s01-inventory: partitions assigned: [orders-0, orders-1, orders-2]

아무 일도 안 일어납니다. 에러도 없고 리스너가 두 번 등록되지도 않습니다. 후처리기 빈 이름이 상수로 고정되어 있어, Boot 쪽이 @ConditionalOnMissingBean(name = ...) 로 물러나기 때문입니다. 결과적으로 정확히 하나만 등록됩니다.

정리하면 이렇습니다.

상황@EnableKafka
Spring Boot + spring-kafka 스타터불필요. 붙여도 무해하지만 노이즈
Boot 없이 순수 Spring Framework필수. 안 붙이면 @KafkaListener 가 그냥 평범한 메서드
KafkaAutoConfigurationexclude 한 경우필수

💡 실무 팁 — "붙여도 무해하니 그냥 붙이자"를 권하지 않는 이유 @EnableKafka 를 직접 붙이는 순간, 위 조건식에 의해 Boot 의 EnableKafkaConfiguration 이 통째로 빠집니다. 지금은 그 안에 후처리기 등록밖에 없어서 차이가 없지만, Boot 버전이 올라가 그 클래스에 무언가가 추가되면 여러분만 그 개선을 못 받게 됩니다. 자동 설정이 해 주는 일은 자동 설정에 맡기는 편이 낫습니다.


1-6. 기동 로그 완전 해독

Step 01 의 목표 절반은 "이 로그가 정상인지 아는 것"입니다. 한 줄씩 읽습니다.

  .   ____          _            __ _ _
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v3.2.5)

INFO 12841 --- [           main] c.e.o.OrderServiceApplication            : Starting OrderServiceApplication using Java 21.0.2 with PID 12841
INFO 12841 --- [           main] c.e.o.OrderServiceApplication            : The following 1 profile is active: "step01"
INFO 12841 --- [           main] o.a.k.c.a.AdminClientConfig              : AdminClientConfig values:
	bootstrap.servers = [127.0.0.1:9092]
	request.timeout.ms = 30000
	...
INFO 12841 --- [           main] o.a.k.c.u.AppInfoParser                  : Kafka version: 3.6.1
INFO 12841 --- [           main] o.a.k.c.u.AppInfoParser                  : Kafka commitId: 5e3c2b738d253ff5
INFO 12841 --- [           main] o.s.k.c.KafkaAdmin                       : Created topics: [orders, orders.DLT, payments]
INFO 12841 --- [           main] o.a.k.c.c.ConsumerConfig                 : ConsumerConfig values:
	auto.offset.reset = earliest
	enable.auto.commit = false
	group.id = s01-inventory
	max.poll.records = 500
	value.deserializer = class org.springframework.kafka.support.serializer.ErrorHandlingDeserializer
	...
INFO 12841 --- [ntainer#0-0-C-1] o.a.k.c.c.i.ConsumerCoordinator          : [Consumer clientId=consumer-s01-inventory-1, groupId=s01-inventory] Discovered group coordinator 127.0.0.1:9092 (id: 2147483646 rack: null)
INFO 12841 --- [ntainer#0-0-C-1] o.a.k.c.c.i.ConsumerCoordinator          : [Consumer clientId=consumer-s01-inventory-1, groupId=s01-inventory] Successfully joined group with generation Generation{generationId=1, ..., protocol='range'}
INFO 12841 --- [ntainer#0-0-C-1] o.s.k.l.KafkaMessageListenerContainer     : s01-inventory: partitions assigned: [orders-0, orders-1, orders-2]
INFO 12841 --- [           main] c.e.o.OrderServiceApplication            : Started OrderServiceApplication in 2.418 seconds (process running for 2.771)
INFO 12841 --- [           main] o.a.k.c.p.ProducerConfig                 : ProducerConfig values:
	acks = all
	enable.idempotence = true
	linger.ms = 5
	...
INFO 12841 --- [ad | producer-1] c.e.o.s.Practice$FirstPublisher          : [1-2] 발행 성공 key=ORD-0001 -> orders-1@0
로그읽는 법
AdminClientConfig values:가장 먼저 뜹니다. KafkaAdminNewTopic 빈을 처리하려고 AdminClient 를 엽니다
Kafka version: 3.6.1클라이언트 버전입니다. 브로커는 3.7.0. 헷갈리기 쉬운 지점
Created topics: [...]없던 토픽만 만듭니다. 이미 있으면 이 줄이 안 나옵니다
ConsumerConfig values:컨슈머 설정 덤프. group.idauto.offset.reset 만 봐도 절반은 진단됩니다
Discovered group coordinator ... (id: 2147483646)이 큰 숫자는 Integer.MAX_VALUE - nodeId 로 만든 가짜 ID 입니다. 정상
protocol='range'파티션 배정 전략. 기본 RangeAssignor
partitions assigned: [...]컨테이너가 폴링을 시작합니다. 이 줄이 없으면 소비는 절대 안 됩니다
Started OrderServiceApplication in 2.418 seconds컨텍스트 기동 완료
ProducerConfig values:기동 완료 뒤에 뜹니다. 프로듀서는 첫 send() 때 지연 생성되기 때문

순서에서 배울 것 두 가지

첫째, ProducerConfigStarted ... 뒤에 나옵니다. DefaultKafkaProducerFactory 는 실제 Producersend() 시점에 만듭니다. 그래서 "브로커 주소가 틀렸는데 기동은 멀쩡히 되고, 첫 발행에서야 터지는" 일이 생깁니다. 컨슈머는 반대입니다. 기동 중에 그룹에 가입하므로 브로커가 죽어 있으면 기동 단계에서 티가 납니다.

둘째, partitions assignedStarted ... 보다 앞에 나옵니다. 리스너 컨테이너는 SmartLifecycle 로 컨텍스트 리프레시 막바지에 시작됩니다. 즉 애플리케이션이 "시작됨"이라고 선언하기 전에 이미 메시지를 소비하고 있을 수 있습니다. ApplicationRunner 에서 초기화하는 자원을 리스너가 참조한다면 순서 사고가 납니다. 이럴 땐 autoStartup=false 로 컨테이너를 늦게 켭니다(Step 10).

💡 application.ymlConsumerConfig/ProducerConfig 로거를 WARN 으로 낮춰 두었습니다. 위 덤프를 보려면 잠시 INFO 로 올리세요.

logging.level.org.apache.kafka.clients.consumer.ConsumerConfig: INFO
logging.level.org.apache.kafka.clients.producer.ProducerConfig: INFO

처음 며칠은 켜 두는 것을 권합니다. 내가 준 설정이 정말 클라이언트에 전달됐는지 확인할 유일한 창구이고, 1-8 의 오타 경고도 이 덤프 근처에서 나옵니다.

💡 브로커가 파티션을 어떤 규칙으로 나눠 주는지(RangeAssignor vs CooperativeStickyAssignor)는 Kafka 코스 Step 03Step 05 에서 다룹니다. 이 코스는 그 결과를 Spring 이 어떻게 노출하는지에 집중합니다.


1-7. 메시지가 어디로 갔는지 확인하는 3가지 방법

"보냈는데 안 온다"는 이 코스에서 가장 자주 만나는 상황입니다. 세 방향에서 확인합니다. 셋 중 둘 이상이 일치해야 믿습니다.

① 콘솔 컨슈머 — 브로커에 정말 있는가

kcc --topic orders --from-beginning \
    --property print.key=true --property print.partition=true --property print.offset=true

결과

Partition:0	Offset:0	ORD-0002	{"orderId":"ORD-0002",...}
Partition:1	Offset:0	ORD-0001	{"orderId":"ORD-0001",...}
Partition:2	Offset:0	ORD-0003	{"orderId":"ORD-0003",...}

여기 안 보이면 발행 자체가 실패했거나 다른 토픽으로 갔습니다(1-8). 여기 보이는데 리스너가 안 받으면 컨슈머 쪽 문제입니다. 이 한 번의 확인으로 문제 공간이 절반으로 줄어듭니다. 콘솔 컨슈머는 매번 임시 그룹(console-consumer-49183)을 새로 만들므로 s01-inventory 의 오프셋을 오염시키지 않습니다.

② 컨슈머 그룹 랙 — 내 그룹이 어디까지 읽었는가

kcg --describe --group s01-inventory

결과

GROUP          TOPIC   PARTITION  CURRENT-OFFSET  LOG-END-OFFSET  LAG  CONSUMER-ID                       HOST         CLIENT-ID
s01-inventory  orders  0          1               1               0    consumer-s01-inventory-1-9f2c...  /172.19.0.1  consumer-s01-inventory-1
s01-inventory  orders  1          1               1               0    consumer-s01-inventory-1-9f2c...  /172.19.0.1  consumer-s01-inventory-1
s01-inventory  orders  2          1               1               0    consumer-s01-inventory-1-9f2c...  /172.19.0.1  consumer-s01-inventory-1

LOG-END-OFFSET=1 은 각 파티션에 1건씩 있다는 뜻이고, CURRENT-OFFSET=1 은 거기까지 커밋했다는 뜻입니다. LAG=0.

앱을 끈 상태에서 실행하면 CONSUMER-ID 자리가 - 로 바뀝니다. 커밋된 오프셋은 그대로 남아 있습니다. 오프셋은 컨슈머가 아니라 그룹에 붙어 있다는 것을 보여 줍니다.

③ 리스너 로그 — 내 코드가 정말 처리했는가

①②는 브로커의 관점입니다. "내 비즈니스 로직이 실행됐는가"는 알려 주지 않습니다.

log.info("[1-3] 재고 차감 {}-{}@{} key={} sku={} qty={}",
        record.topic(), record.partition(), record.offset(),
        record.key(), record.value().sku(), record.value().quantity());
INFO 12841 --- [ntainer#0-0-C-1] c.e.o.s.Practice$InventoryListener        : [1-3] 재고 차감 orders-0@0 key=ORD-0002 sku=SKU-003 qty=3

💡 실무 팁 — 리스너 로그에 topic-partition@offset 을 무조건 남기세요. 이 좌표 하나면 그 레코드를 언제든 다시 꺼낼 수 있습니다.

kcc --topic orders --partition 0 --offset 0 --max-messages 1

그리고 구분자를 -@ 로 고정하는 데는 이유가 있습니다. Spring Kafka 자신이 에러 로그에서 이 포맷을 씁니다.

WARN 12841 --- [ntainer#0-0-C-1] o.s.k.l.DefaultErrorHandler              : Backoff FixedBackOff{interval=1000, currentAttempts=3, maxAttempts=3} exhausted for orders-1@42

애플리케이션 로그와 프레임워크 로그의 좌표 포맷이 같으면 grep 'orders-1@42' 한 번으로 양쪽을 동시에 훑을 수 있습니다. 장애 대응 시간이 크게 줄어듭니다.

세 방법의 사각지대

방법알 수 있는 것알 수 없는 것
콘솔 컨슈머브로커에 레코드가 존재하는가내 컨슈머가 받았는가
kcg --describe내 그룹이 어디까지 커밋했는가커밋된 게 처리 성공을 뜻하는가
리스너 로그내 코드가 실행됐는가그 뒤 커밋이 됐는가

LAG=0 이 처리 성공을 뜻하지 않습니다. 기본 AckMode.BATCH 는 리스너가 예외를 던져도 에러 핸들러가 처리를 끝내면 커밋합니다. 즉 처리 실패 + 랙 0 조합이 얼마든지 가능합니다. 이 간극이 Step 06·07 의 주제입니다.


1-8. 설정 오타는 조용히 무시된다

Kafka 를 쓰다 보면 "설정을 바꿨는데 아무 변화가 없다"는 순간이 옵니다. 대개 두 가지 중 하나입니다.

① 토픽명 오타 — 새 토픽이 조용히 생긴다

orders 대신 order 로 보냅니다.

kafkaTemplate.send("order", event.orderId(), event)   // ← 's' 가 빠졌다
        .whenComplete((result, ex) -> { ... });
./gradlew bootRun --args='--spring.profiles.active=step01,step01-typo'

결과

WARN 12841 --- [ad | producer-1] c.e.o.s.Practice$TypoTopicPublisher       : [1-8] 오타 토픽에 발행 '성공' order-0@0
WARN 12841 --- [           main] c.e.o.s.Practice$TypoTopicPublisher       : [1-8] kt --list 로 'order' 토픽이 생겼는지 확인하세요

"성공"입니다. 예외도, 경고도 없습니다(WARN 은 우리가 일부러 찍은 것입니다).

kt --list

결과

__consumer_offsets
order
orders
orders.DLT
payments

order 가 새로 생겼습니다. kt --describe --topic order 로 보면 PartitionCount: 1 — 브로커 기본값입니다. 원래 orders 는 3입니다.

⚠️ 함정 — 오타 난 토픽명은 예외가 아니라 새 토픽을 만든다

원인. 브로커의 auto.create.topics.enabletrue 면(우리 docker-compose.yml 이 그렇습니다), 프로듀서가 없는 토픽의 메타데이터를 요청하는 순간 브로커가 그 자리에서 토픽을 만들어 줍니다. 그러니 발행은 정상적으로 성공합니다.

증상이 특히 고약한 이유.

  • 발행 쪽 로그는 전부 초록불입니다. 개발자는 프로듀서를 의심하지 않습니다.
  • 리스너는 orders 를 보고 있으므로 아무 일도 일어나지 않습니다. "컨슈머가 안 돈다"로 오진하고 컨슈머만 몇 시간 뒤집니다.
  • 자동 생성 토픽은 파티션 1개라 나중에 오타를 고쳐도 잘못된 토픽에 쌓인 데이터가 남습니다.

프로듀서 쪽은 애플리케이션 설정으로 막을 수 없습니다. 컨슈머에는 allow.auto.create.topics=false 가 있고 리스너 컨테이너에는 missing-topics-fatal 이 있지만, 프로듀서에는 대응하는 클라이언트 설정이 없습니다. 토픽을 만들지 말지는 전적으로 브로커가 결정합니다.

해결.

  1. 운영 브로커에서는 auto.create.topics.enable=false. 가장 확실한 방어선입니다.
  2. 토픽명을 문자열 리터럴로 쓰지 않습니다. @Value("${app.topic.orders}") 나 상수로 한 곳에서만 관리합니다. 그러면 프로듀서와 컨슈머가 물리적으로 같은 값을 봅니다.
  3. 리스너 쪽은 missing-topics-fatal: true 로 두어, 오타 난 토픽을 구독하면 기동이 실패하게 만듭니다. IllegalStateException: Topic(s) [order] is/are not present and missingTopicsFatal is true — 이건 에러가 나서 다행인 경우입니다.

정리. kt --delete --topic order

② 프로퍼티 이름 오타 — WARN 한 줄로 끝난다

linger.ms 를 주려다 점을 빠뜨렸다고 합시다.

spring:
  kafka:
    producer:
      properties:
        lingerms: 5        # ← linger.ms 여야 한다

결과

INFO 12841 --- [           main] o.a.k.c.p.ProducerConfig                 : ProducerConfig values:
	acks = all
	linger.ms = 0
	...
WARN 12841 --- [           main] o.a.k.c.p.ProducerConfig                 : The configuration 'lingerms' was supplied but isn't a known config.
INFO 12841 --- [           main] o.a.k.c.u.AppInfoParser                  : Kafka version: 3.6.1

linger.ms = 0 입니다. 우리가 준 5 는 어디에도 없습니다.

⚠️ 함정 — 알 수 없는 설정 프로퍼티는 WARN 만 내고 무시된다

원인. Kafka 클라이언트의 AbstractConfig 는 모르는 키를 받아도 예외를 던지지 않고, 사용되지 않은 키를 모아 WARN 한 줄로 알리고 끝냅니다. 설계 의도는 호환성입니다. 신버전 설정을 구버전 클라이언트에 줘도 앱이 죽으면 안 되고, 시리얼라이저·인터셉터가 자기 설정을 자유롭게 끼워 넣을 수 있어야 하니까요.

위험한 이유 세 가지.

  1. WARN 이라 알림이 안 갑니다. 대부분의 운영 로그 수집기는 ERROR 부터 통보합니다.
  2. 기동 시 딱 한 번, 그것도 수십 줄짜리 설정 덤프 한복판에서 나옵니다.
  3. 증상이 "안 됨"이 아니라 "기본값으로 동작함" 입니다. 위 예에서 linger.ms 는 5 가 아니라 0 이 됩니다. 배치가 사실상 꺼진 상태로 도는데, 에러가 없으니 아무도 설정을 의심하지 않습니다. 더 나쁜 경우도 있습니다. enableidempotence: true 처럼 오타를 내면 멱등성이 꺼진 채로 돌다가, 네트워크가 한 번 흔들려 재시도가 일어나는 날 중복 레코드가 생깁니다.

해결. 배포 체크리스트에 2>&1 | grep "isn't a known config" 를 넣으세요. 그리고 Boot 의 축약 프로퍼티(spring.kafka.producer.acks 등)는 오타를 Boot 가 잡아 주지만 properties: 아래 원본 키만은 무방비라는 것을 기억하세요. 중요한 설정은 ProducerConfig.LINGER_MS_CONFIG 같은 상수로 코드에서 지정하면 오타가 런타임 침묵 대신 컴파일 에러가 됩니다.


정리

개념핵심
spring-kafka 의존성KafkaAutoConfiguration 을 켠다. 빈 7종이 자동 등록
kafkaTemplateKafkaTemplate<?, ?> 로 등록. 어떤 제네릭으로도 주입 가능하지만 타입 안전하지 않다
ProducerFactory vs ConsumerFactory프로듀서는 캐싱·공유, 컨슈머는 스레드마다 새로 (스레드 안전성 차이)
조건부 빈KafkaTransactionManagertransaction-id-prefix 가 있어야 등록. 없으면 조용히 부재
send()즉시 리턴. 결과는 [ad | producer-1] 스레드의 콜백에서만 알 수 있다
@EnableKafkaBoot 에서는 불필요. KafkaAnnotationDrivenConfiguration 이 대신 붙여 준다
partitions assigned컨테이너가 폴링을 시작한 시점. 이 줄이 없으면 소비는 절대 안 된다
기동 순서AdminClient → 토픽 생성 → ConsumerConfig → 그룹 조인 → assigned → Started → ProducerConfig
ProducerConfig 가 늦는 이유프로듀서는 첫 send() 때 지연 생성. 그래서 브로커 오설정이 기동 시 안 잡힌다
auto.offset.reset커밋된 오프셋이 없을 때만 적용. latest + 새 그룹 = 이전 메시지 영구 미수신
토픽명 오타브로커가 파티션 1개짜리 새 토픽을 조용히 생성. 프로듀서 쪽은 앱 설정으로 못 막는다
프로퍼티 오타WARN 한 줄. isn't a known config 를 grep 하라. 증상은 "기본값으로 동작"
확인 3종콘솔 컨슈머 / kcg --describe / 리스너 로그. LAG=0 은 처리 성공을 뜻하지 않는다

연습문제

Exercise.java 에 7문제가 있습니다. 정답은 Solution.java. 반드시 콘솔 로그와 CLI 출력을 눈으로 대조하세요.

  1. payments 토픽으로 발행하고 s01-payment 그룹으로 수신하기 — 파티션 1개짜리 토픽의 partitions assigned 는 어떻게 다른가
  2. 컨슈머 그룹 두 개(s01-inventory, s01-notification)로 같은 메시지를 중복 소비시키기 — 그리고 그룹 이름을 같게 주면 무엇이 달라지는가
  3. ApplicationContext.getBeanNamesForType 으로 자동 설정 빈 목록 출력하기 — KafkaTransactionManager없는 이유 설명하기
  4. auto-offset-resetlatest 로 바꿔 메시지 유실 재현 — 그리고 두 번째 실행부터 왜 재현되지 않는지 설명하기
  5. 리스너 로그를 topic-partition@offset + key + timestamp 형태로 만들어 추적 가능하게 하기
  6. 오타 난 토픽으로 발행할 때 '조용히 성공'하지 않게 만들기 — 프로듀서 쪽은 왜 못 막는가
  7. application.yml 에 오타 난 프로퍼티(lingerms)를 넣고, 그것을 알려 주는 로그 한 줄 찾기

다음 단계

메시지를 보내고 받는 최소 경로를 확인했습니다. 하지만 1-2 에서 잠깐 언급한 것처럼, send()예외 없이 리턴해도 브로커에 안 갔을 수 있습니다. 다음 스텝에서는 KafkaTemplatesend 오버로드 전부와 CompletableFuture 결과 처리를 파고들고, "안전하게 하려고" send().get() 을 붙였을 때 처리량이 어떻게 무너지는지를 숫자로 측정합니다.

Step 02 — KafkaTemplate 과 프로듀서


실습 파일

이 스텝은 Java 파일 세 개로 진행합니다. 세 파일 모두 프로젝트의 src/main/java/com/example/order/step01/ 에 넣고 프로필로 골라서 실행합니다. 먼저 Practice.javastep01 프로필로 띄워 1-2 ~ 1-7 을 재현하고, 함정은 보조 프로필(step01-typo, step01-enable-kafka)로 따로 켭니다. 그다음 Exercise.java 의 7문제를 step01ex 로 풀어 본 뒤 Solution.javastep01sol 로 돌려 대조합니다. 세 파일 모두 단일 public final class 안에 nested static class 를 담는 구조이며, 각 nested class 가 @Component/@Configuration 이라 컴포넌트 스캔에 그대로 걸립니다.

Practice.java

본문 1-2 ~ 1-8 의 모든 예제를 절 번호 주석(// [1-3] ...)과 함께 한 파일에 모은 실행 파일입니다.

  • 파일 상단 주석에 실행 명령과 확인용 CLI 4개가 정리돼 있습니다. 보조 프로필을 콤마로 이어 붙이는 방식(--spring.profiles.active=step01,step01-typo)에 주의하세요. step01 을 빼면 발행자와 리스너가 안 뜨고 함정 재현만 돕니다.
  • [1-2] FirstPublisherApplicationRunner 이며 @Order(20) 입니다. @Order(10)AutoConfiguredBeanReport먼저 돌아 빈 목록을 출력한 뒤 발행이 시작되도록 순서를 고정했습니다. 순서를 바꾸면 로그가 뒤섞여 읽기 어려워집니다.
  • [1-3] InventoryListenerconcurrency = "1"명시합니다. application.ymllistener.concurrency: 3 을 그대로 두면 partitions assigned 가 세 줄로 쪼개져 본문 로그와 달라집니다. 이 값을 3으로 바꿔 보고 로그가 어떻게 갈라지는지 직접 확인해 보는 것도 좋은 실습입니다(Step 03 예고편).
  • [1-4] AutoConfiguredBeanReport인터페이스 타입으로 조회합니다(ProducerFactory.class, 구현체인 DefaultKafkaProducerFactory.class 가 아님). 구현을 갈아끼워도 조회가 깨지지 않게 하려는 습관입니다. ctx.getType(name)null 을 리턴할 수 있어 방어 코드가 들어 있습니다.
  • [1-5] RedundantEnableKafkaConfig의도적으로 비어 있는 @Configuration 입니다. 클래스 본문이 아니라 @EnableKafka 애노테이션 자체가 실험 대상입니다. 이 프로필을 켜고 리스너가 여전히 정확히 한 번만 메시지를 받는지 확인하세요.
  • [1-8] TypoTopicPublisherTYPO_TOPIC = "order" 상수 하나가 전부입니다. 실행 후 반드시 kt --delete --topic order 로 정리하세요. 방치하면 이후 스텝의 kt --list 출력이 교재와 달라집니다.
package com.example.order.step01;

/*
 * ============================================================================
 * Step 01 — 환경 구축과 첫 메시지 : Practice
 * ============================================================================
 *
 * 배치 위치
 *   spring-kafka-lab/src/main/java/com/example/order/step01/Practice.java
 *
 * 실행
 *   ./gradlew bootRun --args='--spring.profiles.active=step01'
 *
 * 함정 재현용 보조 프로필 (필요할 때만 함께 켭니다)
 *   ./gradlew bootRun --args='--spring.profiles.active=step01,step01-typo'
 *       → [1-8] 오타 토픽(order)으로 발행. 토픽이 조용히 새로 생깁니다.
 *   ./gradlew bootRun --args='--spring.profiles.active=step01,step01-enable-kafka'
 *       → [1-5] @EnableKafka 를 직접 붙였을 때 무슨 일이 생기는지 확인.
 *
 * 실행 전/후에 확인할 CLI (project/ 의 alias 를 등록해 두었다고 가정)
 *   kt  --list
 *   kt  --describe --topic orders
 *   kcg --describe --group s01-inventory
 *   kcc --topic orders --from-beginning --property print.key=true --property print.partition=true
 *
 * 오프셋을 처음부터 다시 읽고 싶다면 (앱을 먼저 종료할 것)
 *   kcg --group s01-inventory --topic orders --reset-offsets --to-earliest --execute
 * ============================================================================
 */

import com.example.order.domain.OrderCreated;

import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.core.annotation.Order;
import org.springframework.kafka.annotation.EnableKafka;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.config.KafkaListenerContainerFactory;
import org.springframework.kafka.config.KafkaListenerEndpointRegistry;
import org.springframework.kafka.core.ConsumerFactory;
import org.springframework.kafka.core.KafkaAdmin;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.core.ProducerFactory;
import org.springframework.kafka.listener.MessageListenerContainer;
import org.springframework.kafka.support.SendResult;
import org.springframework.kafka.transaction.KafkaTransactionManager;
import org.springframework.stereotype.Component;

import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.CompletableFuture;

/**
 * Step 01 의 모든 예제를 담은 단일 파일입니다.
 * 각 nested static class 는 본문 절 번호와 1:1 로 대응합니다.
 */
public final class Practice {

    private Practice() {
        // 유틸리티 홀더. 인스턴스화하지 않습니다.
    }

    // ========================================================================
    // [1-2] 첫 발행 — KafkaTemplate 을 주입받아 orders 토픽으로 3건 보낸다
    // ========================================================================
    //
    // KafkaAutoConfiguration 이 만들어 준 kafkaTemplate 빈은 KafkaTemplate<?, ?> 로
    // 선언되어 있지만, 와일드카드는 어떤 구체 제네릭에도 대입 가능하므로
    // KafkaTemplate<String, OrderCreated> 로 주입받을 수 있습니다.
    // 실제 직렬화기는 application.yml 의 key-serializer / value-serializer 가 정합니다.
    //
    @Component
    @Profile("step01")
    @Order(20)
    public static class FirstPublisher implements ApplicationRunner {

        private static final Logger log = LoggerFactory.getLogger(FirstPublisher.class);

        private final KafkaTemplate<String, OrderCreated> kafkaTemplate;
        private final String ordersTopic;

        public FirstPublisher(KafkaTemplate<String, OrderCreated> kafkaTemplate,
                              @Value("${app.topic.orders}") String ordersTopic) {
            this.kafkaTemplate = kafkaTemplate;
            this.ordersTopic = ordersTopic;
        }

        @Override
        public void run(ApplicationArguments args) {
            log.info("[1-2] {} 토픽으로 OrderCreated 3건 발행을 시작합니다", ordersTopic);

            for (int seq = 1; seq <= 3; seq++) {
                OrderCreated event = OrderCreated.of(seq);

                // 키를 orderId 로 줍니다. 같은 주문은 항상 같은 파티션으로 갑니다.
                CompletableFuture<SendResult<String, OrderCreated>> future =
                        kafkaTemplate.send(ordersTopic, event.orderId(), event);

                // send() 는 즉시 리턴합니다. 성공/실패는 이 콜백에서만 알 수 있습니다.
                // (콜백을 안 붙이면 실패해도 예외 없이 조용히 지나갑니다 — Step 02 의 주제)
                future.whenComplete((result, ex) -> {
                    if (ex != null) {
                        log.error("[1-2] 발행 실패 key={}", event.orderId(), ex);
                        return;
                    }
                    log.info("[1-2] 발행 성공 key={} -> {}-{}@{}",
                            event.orderId(),
                            result.getRecordMetadata().topic(),
                            result.getRecordMetadata().partition(),
                            result.getRecordMetadata().offset());
                });
            }

            // 버퍼에 남은 레코드를 즉시 밀어냅니다. 실습에서 로그 순서를 안정시키려는 목적이며,
            // 운영 코드에서 매 건 flush() 하면 배치가 무력화되어 처리량이 급락합니다(Step 02).
            kafkaTemplate.flush();
            log.info("[1-2] flush 완료");
        }
    }

    // ========================================================================
    // [1-3] 첫 수신 — @KafkaListener
    // [1-7] 리스너 로그에 topic-partition@offset 을 항상 남긴다
    // ========================================================================
    //
    // concurrency 를 "1" 로 고정했습니다. application.yml 의 listener.concurrency=3 을
    // 그대로 두면 컨테이너 3개가 파티션을 하나씩 나눠 갖고 로그가 세 줄로 쪼개집니다.
    // Step 01 에서는 "partitions assigned: [orders-0, orders-1, orders-2]" 한 줄을
    // 눈에 익히는 것이 목적이므로 스레드를 하나로 둡니다.
    // concurrency 와 파티션 수의 관계는 Step 03 에서 다룹니다.
    //
    @Component
    @Profile("step01")
    public static class InventoryListener {

        private static final Logger log = LoggerFactory.getLogger(InventoryListener.class);

        @KafkaListener(
                id = "s01-inventory-listener",
                topics = "${app.topic.orders}",
                groupId = "s01-inventory",
                concurrency = "1")
        public void onOrderCreated(ConsumerRecord<String, OrderCreated> record) {
            OrderCreated event = record.value();

            // [1-7] 위치 정보를 반드시 남깁니다. "메시지가 어디로 갔는가"를
            // 나중에 추적할 수 있는 가장 싼 방법입니다.
            log.info("[1-3] 재고 차감 {}-{}@{} key={} sku={} qty={}",
                    record.topic(), record.partition(), record.offset(),
                    record.key(), event.sku(), event.quantity());
        }
    }

    // ========================================================================
    // [1-4] 자동 설정이 만들어 준 빈들을 실제로 꺼내 본다
    // ========================================================================
    //
    // spring-kafka 의존성 한 줄이 컨텍스트에 무엇을 등록했는지 눈으로 확인합니다.
    // 등록되지 '않은' 빈(KafkaTransactionManager)도 함께 보여 주는 것이 핵심입니다.
    // 조건이 안 맞으면 자동 설정은 조용히 아무것도 만들지 않습니다.
    //
    @Component
    @Profile("step01")
    @Order(10)
    public static class AutoConfiguredBeanReport implements ApplicationRunner {

        private static final Logger log = LoggerFactory.getLogger(AutoConfiguredBeanReport.class);

        private final ApplicationContext ctx;

        public AutoConfiguredBeanReport(ApplicationContext ctx) {
            this.ctx = ctx;
        }

        @Override
        public void run(ApplicationArguments args) {
            Map<String, Class<?>> targets = new LinkedHashMap<>();
            targets.put("KafkaTemplate", KafkaTemplate.class);
            targets.put("ProducerFactory", ProducerFactory.class);
            targets.put("ConsumerFactory", ConsumerFactory.class);
            targets.put("KafkaListenerContainerFactory", KafkaListenerContainerFactory.class);
            targets.put("KafkaAdmin", KafkaAdmin.class);
            targets.put("KafkaListenerEndpointRegistry", KafkaListenerEndpointRegistry.class);
            // transaction-id-prefix 를 설정하지 않았으므로 등록되지 않습니다(Step 09).
            targets.put("KafkaTransactionManager", KafkaTransactionManager.class);

            log.info("[1-4] ---- spring-kafka 자동 설정 빈 목록 ----");
            targets.forEach((label, type) -> {
                String[] names = ctx.getBeanNamesForType(type);
                if (names.length == 0) {
                    // 자동 설정은 조건이 안 맞으면 '조용히' 빈을 안 만듭니다.
                    log.info("[1-4] {} (없음 — 조건 불충족)", pad(label));
                    return;
                }
                for (String name : names) {
                    Class<?> impl = ctx.getType(name);
                    log.info("[1-4] {} {} -> {}", pad(label), name,
                            impl == null ? "(타입 미상)" : impl.getName());
                }
            });

            // @KafkaListener 애노테이션을 실제로 스캔하는 후처리기의 빈 이름입니다.
            // 이름이 고정되어 있다는 점이 [1-5] 의 핵심 근거입니다.
            for (String infra : new String[]{
                    "org.springframework.kafka.config.internalKafkaListenerAnnotationProcessor",
                    "org.springframework.kafka.config.internalKafkaListenerEndpointRegistry"}) {
                log.info("[1-4] {} 존재={}", pad("내부 인프라"), ctx.containsBean(infra) + " " + infra);
            }

            // 리스너 컨테이너가 몇 개 떴는지도 여기서 셀 수 있습니다.
            KafkaListenerEndpointRegistry registry =
                    ctx.getBean(KafkaListenerEndpointRegistry.class);
            Collection<MessageListenerContainer> containers = registry.getListenerContainers();
            log.info("[1-4] 리스너 컨테이너 {}개: {}", containers.size(),
                    containers.stream().map(MessageListenerContainer::getListenerId).toList());
        }

        /** 로그 열을 맞추기 위한 좌측 정렬 패딩. */
        private static String pad(String s) {
            return "%-32s".formatted(s);
        }
    }

    // ========================================================================
    // [1-5] @EnableKafka 는 왜 필요 없는가
    // ========================================================================
    //
    // Boot 를 쓰면 KafkaAnnotationDrivenConfiguration 이 이미 @EnableKafka 를 붙여 줍니다.
    // 직접 한 번 더 붙여도 KafkaListenerAnnotationBeanPostProcessor 는
    // 고정된 빈 이름(internalKafkaListenerAnnotationProcessor)으로 등록되므로
    // 중복 등록되지 않고 그냥 무시됩니다. 즉 "해롭지는 않지만 불필요"합니다.
    //
    // 이 프로필을 켜고 기동해 리스너가 여전히 정확히 한 번만 메시지를 받는지 확인하세요.
    //
    @Configuration
    @Profile("step01-enable-kafka")
    @EnableKafka
    public static class RedundantEnableKafkaConfig {
        // 의도적으로 비어 있습니다. 애노테이션만이 실험 대상입니다.
    }

    // ========================================================================
    // [1-8] 함정 재현 — 오타 난 토픽명은 조용히 새 토픽을 만든다
    // ========================================================================
    //
    // "orders" 대신 "order" 로 보냅니다. 브로커의 auto.create.topics.enable=true 때문에
    // 예외 없이 성공하고, 파티션 1개짜리 새 토픽 order 가 생깁니다.
    // 리스너는 orders 만 보고 있으므로 아무 일도 일어나지 않습니다.
    //
    // 실행 후 확인:
    //   kt --list                     → order 가 새로 생겼습니다
    //   kt --describe --topic order   → PartitionCount: 1 (orders 는 3)
    // 정리:
    //   kt --delete --topic order
    //
    @Component
    @Profile("step01-typo")
    @Order(30)
    public static class TypoTopicPublisher implements ApplicationRunner {

        private static final Logger log = LoggerFactory.getLogger(TypoTopicPublisher.class);

        private static final String TYPO_TOPIC = "order";   // ← 's' 가 빠졌습니다

        private final KafkaTemplate<String, OrderCreated> kafkaTemplate;

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

        @Override
        public void run(ApplicationArguments args) {
            OrderCreated event = OrderCreated.of(99);

            kafkaTemplate.send(TYPO_TOPIC, event.orderId(), event)
                    .whenComplete((result, ex) -> {
                        if (ex != null) {
                            log.error("[1-8] 발행 실패", ex);
                            return;
                        }
                        // 이 로그가 찍힌다는 사실 자체가 함정입니다.
                        // 오타를 냈는데 "성공"이라고 나옵니다.
                        log.warn("[1-8] 오타 토픽에 발행 '성공' {}-{}@{}",
                                result.getRecordMetadata().topic(),
                                result.getRecordMetadata().partition(),
                                result.getRecordMetadata().offset());
                    });

            kafkaTemplate.flush();
            log.warn("[1-8] kt --list 로 '{}' 토픽이 생겼는지 확인하세요", TYPO_TOPIC);
        }
    }
}

Exercise.java

7문제의 문제지입니다. "여기에 작성:" 아래를 채우는 구조이며, 비워 둔 상태로도 컴파일과 기동이 됩니다. 미구현 로그가 찍히는 것으로 자리를 확인할 수 있습니다.

  • 문제 1·2·3·5·7 은 기본 프로필 step01ex 하나로 돌아가고, 문제 4·6 은 각각 보조 프로필 step01ex-latest / step01ex-strict 가 필요합니다. 두 문제만 별도 ConsumerFactoryContainerFactory 를 새로 만들기 때문에, 기본 프로필과 섞이면 리스너가 중복으로 뜹니다.
  • 문제 4 의 Ex4LatestConfig 는 일부러 틀린 값("earliest")이 들어 있습니다. 그 줄을 "latest" 로 고치는 것이 문제의 절반이고, 나머지 절반은 (c)의 3단계 실행 절차를 그대로 밟아 "안 보임 → 보임"을 눈으로 확인하는 것입니다.
  • props.buildConsumerProperties()인자 없는 오버로드를 씁니다. Boot 3.4 부터 SslBundles 를 받는 시그니처가 추가되었으므로, 다른 버전의 예제를 복사해 오면 컴파일이 깨집니다. 이 프로젝트는 Boot 3.2.5 입니다.
  • 문제 6 의 힌트 3("프로듀서는 어떤가요?")이 이 문제의 진짜 목적입니다. 컨슈머는 막을 수 있고 프로듀서는 못 막는다는 비대칭을 스스로 발견하게 하려는 구성입니다. 답이 안 나와도 괜찮습니다. 못 막는 게 정답입니다.
  • 문제 7 은 코드를 거의 안 씁니다. application.yml 을 한 줄 고치고 기동 로그에서 한 줄을 찾아 상수에 붙여넣는 것이 전부입니다. 로그를 읽는 훈련이 목적입니다.
package com.example.order.step01;

/*
 * ============================================================================
 * Step 01 — 환경 구축과 첫 메시지 : Exercise (문제지)
 * ============================================================================
 *
 * 배치 위치
 *   spring-kafka-lab/src/main/java/com/example/order/step01/Exercise.java
 *
 * 실행
 *   ./gradlew bootRun --args='--spring.profiles.active=step01ex'
 *
 * 문제별 보조 프로필
 *   step01ex-latest   문제 4 (auto-offset-reset=latest 로 메시지 유실 재현)
 *   step01ex-strict   문제 6 (오타 토픽 자동 생성 막기)
 *   step01ex-typo     문제 7 (오타 난 설정 프로퍼티가 무시되는 것 확인)
 *
 * 규칙
 *   - "여기에 작성:" 아래를 채우세요. 그 밖의 시그니처는 바꾸지 않습니다.
 *   - 매 문제 후 반드시 콘솔 로그와 CLI 출력을 눈으로 확인합니다.
 *   - 컨슈머 그룹 이름은 전부 s01- 접두사를 씁니다.
 * ============================================================================
 */

import com.example.order.domain.OrderCreated;

import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.autoconfigure.kafka.KafkaProperties;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.core.annotation.Order;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory;
import org.springframework.kafka.core.ConsumerFactory;
import org.springframework.kafka.core.DefaultKafkaConsumerFactory;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Component;

import java.util.HashMap;
import java.util.Map;

public final class Exercise {

    private Exercise() {
    }

    // ========================================================================
    // 문제 1. payments 토픽으로 발행하고, 전용 컨슈머 그룹으로 받으세요.
    //
    //  요구사항
    //   (a) 기동 시 payments 토픽으로 OrderCreated.of(101) 한 건을 발행합니다.
    //       토픽명은 하드코딩하지 말고 application.yml 의 app.topic.payments 를 씁니다.
    //   (b) 발행 결과(topic-partition@offset)를 로그로 남깁니다.
    //   (c) groupId = "s01-payment" 인 @KafkaListener 로 받고,
    //       역시 topic-partition@offset 을 남깁니다.
    //   (d) payments 는 파티션이 1개입니다. 기동 로그의 partitions assigned 가
    //       orders 때와 어떻게 다른지 확인하세요.
    // ========================================================================
    @Component
    @Profile("step01ex")
    @Order(10)
    public static class Ex1PaymentPublisher implements ApplicationRunner {

        private static final Logger log = LoggerFactory.getLogger(Ex1PaymentPublisher.class);

        private final KafkaTemplate<String, OrderCreated> kafkaTemplate;
        private final String paymentsTopic;

        public Ex1PaymentPublisher(KafkaTemplate<String, OrderCreated> kafkaTemplate,
                                   @Value("${app.topic.payments}") String paymentsTopic) {
            this.kafkaTemplate = kafkaTemplate;
            this.paymentsTopic = paymentsTopic;
        }

        @Override
        public void run(ApplicationArguments args) {
            // 여기에 작성: payments 토픽으로 OrderCreated.of(101) 발행 + 결과 로깅
            log.info("문제 1 미구현 (topic={})", paymentsTopic);
        }
    }

    @Component
    @Profile("step01ex")
    public static class Ex1PaymentListener {

        private static final Logger log = LoggerFactory.getLogger(Ex1PaymentListener.class);

        // 여기에 작성: @KafkaListener 를 붙여 payments 토픽을 s01-payment 그룹으로 구독
        public void onPayment(ConsumerRecord<String, OrderCreated> record) {
            log.info("문제 1 미구현 record={}", record);
        }
    }

    // ========================================================================
    // 문제 2. 같은 토픽을 두 컨슈머 그룹이 각각 전부 소비하는 것을 확인하세요.
    //
    //  요구사항
    //   (a) 아래 리스너를 groupId = "s01-notification" 으로 orders 토픽에 붙입니다.
    //   (b) Practice 의 s01-inventory 리스너와 동시에 켜고, 발행한 3건을
    //       두 그룹이 각각 3건씩 = 총 6줄 로그로 받는지 확인합니다.
    //   (c) kcg --list 로 두 그룹이 모두 보이는지, kcg --describe 로
    //       각 그룹의 CURRENT-OFFSET 이 독립적으로 관리되는지 확인합니다.
    //
    //  생각해 볼 것: 같은 그룹 이름을 쓰는 리스너를 하나 더 붙이면 결과가 어떻게 달라질까요?
    // ========================================================================
    @Component
    @Profile("step01ex")
    public static class Ex2NotificationListener {

        private static final Logger log = LoggerFactory.getLogger(Ex2NotificationListener.class);

        // 여기에 작성: @KafkaListener(topics = ..., groupId = "s01-notification", concurrency = "1")
        public void onOrderCreated(ConsumerRecord<String, OrderCreated> record) {
            log.info("문제 2 미구현 record={}", record);
        }
    }

    // ========================================================================
    // 문제 3. 자동 설정이 만들어 준 빈을 직접 조회해 출력하세요.
    //
    //  요구사항
    //   (a) ApplicationContext.getBeanNamesForType 으로 아래 빈 이름을 출력합니다.
    //       KafkaTemplate / ProducerFactory / ConsumerFactory /
    //       KafkaListenerContainerFactory / KafkaAdmin / KafkaListenerEndpointRegistry
    //   (b) KafkaTransactionManager 는 왜 목록에 없는지 주석으로 한 줄 적으세요.
    //   (c) ProducerFactory 의 실제 구현 클래스 이름(getType)까지 함께 출력합니다.
    // ========================================================================
    @Component
    @Profile("step01ex")
    @Order(20)
    public static class Ex3BeanReport implements ApplicationRunner {

        private static final Logger log = LoggerFactory.getLogger(Ex3BeanReport.class);

        private final ApplicationContext ctx;

        public Ex3BeanReport(ApplicationContext ctx) {
            this.ctx = ctx;
        }

        @Override
        public void run(ApplicationArguments args) {
            // 여기에 작성: 위 6종 + KafkaTransactionManager 를 조회해 출력
            log.info("문제 3 미구현 (빈 총 개수={})", ctx.getBeanDefinitionCount());
        }
    }

    // ========================================================================
    // 문제 4. auto-offset-reset = latest 로 "메시지 유실"을 재현하세요.
    //
    //  요구사항
    //   (a) 아래 팩토리에서 AUTO_OFFSET_RESET_CONFIG 를 "latest" 로 설정합니다.
    //       나머지 설정은 KafkaProperties(=application.yml)에서 그대로 가져옵니다.
    //   (b) 리스너에 containerFactory = "latestContainerFactory" 를 지정하고
    //       groupId 는 "s01-latest" 로 둡니다. (한 번도 커밋한 적 없는 새 그룹)
    //   (c) 실행 절차
    //        1. 앱을 끈 상태에서 콘솔 프로듀서로 orders 에 메시지를 3건 넣습니다.
    //        2. 앱을 켭니다.  → 리스너에 아무것도 안 들어옵니다.
    //        3. 앱을 켠 채로 다시 3건을 넣습니다. → 이번엔 들어옵니다.
    //   (d) 그 뒤 앱을 껐다 켜고 1번을 반복하면 이번엔 메시지가 들어옵니다.
    //       왜 그런지 주석으로 설명하세요. (힌트: 커밋된 오프셋의 유무)
    // ========================================================================
    @Configuration
    @Profile("step01ex-latest")
    public static class Ex4LatestConfig {

        @Bean
        public ConsumerFactory<String, OrderCreated> latestConsumerFactory(KafkaProperties props) {
            // Boot 3.2.x 는 인자 없는 buildConsumerProperties() 입니다.
            // (SslBundles 를 받는 오버로드는 Boot 3.4 부터입니다.)
            Map<String, Object> config = new HashMap<>(props.buildConsumerProperties());
            // 여기에 작성: ConsumerConfig.AUTO_OFFSET_RESET_CONFIG 를 "latest" 로
            config.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest"); // ← 고치세요
            return new DefaultKafkaConsumerFactory<>(config);
        }

        @Bean
        public ConcurrentKafkaListenerContainerFactory<String, OrderCreated> latestContainerFactory(
                ConsumerFactory<String, OrderCreated> latestConsumerFactory) {
            ConcurrentKafkaListenerContainerFactory<String, OrderCreated> factory =
                    new ConcurrentKafkaListenerContainerFactory<>();
            factory.setConsumerFactory(latestConsumerFactory);
            factory.setConcurrency(1);
            return factory;
        }
    }

    @Component
    @Profile("step01ex-latest")
    public static class Ex4LatestListener {

        private static final Logger log = LoggerFactory.getLogger(Ex4LatestListener.class);

        // 여기에 작성: containerFactory = "latestContainerFactory", groupId = "s01-latest"
        public void onOrderCreated(ConsumerRecord<String, OrderCreated> record) {
            log.info("문제 4 미구현 record={}", record);
        }
    }

    // ========================================================================
    // 문제 5. 리스너 로그를 "추적 가능한" 형태로 만드세요.
    //
    //  요구사항
    //   (a) topic-partition@offset, key, 그리고 레코드 타임스탬프를 한 줄에 남깁니다.
    //   (b) 포맷은 "orders-1@42 key=ORD-0001 ts=..." 처럼 사람이 grep 할 수 있게 합니다.
    //   (c) 로그 한 줄만 보고 kcc / kcg 로 같은 레코드를 찾아갈 수 있어야 합니다.
    // ========================================================================
    @Component
    @Profile("step01ex")
    public static class Ex5TraceableListener {

        private static final Logger log = LoggerFactory.getLogger(Ex5TraceableListener.class);

        @KafkaListener(
                id = "s01ex-trace",
                topics = "${app.topic.orders}",
                groupId = "s01-trace",
                concurrency = "1")
        public void onOrderCreated(ConsumerRecord<String, OrderCreated> record) {
            // 여기에 작성: topic-partition@offset + key + timestamp 를 한 줄로
            log.info("문제 5 미구현");
        }
    }

    // ========================================================================
    // 문제 6. 오타 난 토픽으로 보냈을 때 '조용히 성공'하지 않게 만드세요.
    //
    //  요구사항
    //   (a) 존재하지 않는 토픽 "order" 로 발행을 시도합니다.
    //   (b) 기본 상태에서는 토픽이 자동 생성되며 성공합니다. 먼저 그것을 확인하세요.
    //   (c) 브로커의 auto.create.topics.enable 을 끄지 않고, 애플리케이션 쪽 설정만으로
    //       "실패하게" 만드는 방법을 찾아 적용하세요.
    //       힌트 1: 컨슈머에는 allow.auto.create.topics 프로퍼티가 있습니다.
    //       힌트 2: 리스너 컨테이너에는 missing-topics-fatal 이 있습니다.
    //       힌트 3: 프로듀서는 어떤가요? 여기서 얻는 교훈이 이 문제의 핵심입니다.
    //   (d) 확인 후 kt --delete --topic order 로 정리합니다.
    // ========================================================================
    @Component
    @Profile("step01ex-strict")
    @Order(30)
    public static class Ex6TypoTopicPublisher implements ApplicationRunner {

        private static final Logger log = LoggerFactory.getLogger(Ex6TypoTopicPublisher.class);

        private final KafkaTemplate<String, OrderCreated> kafkaTemplate;

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

        @Override
        public void run(ApplicationArguments args) {
            // 여기에 작성: "order" 토픽으로 발행하고 결과를 로깅
            log.info("문제 6 미구현 template={}", kafkaTemplate.getDefaultTopic());
        }
    }

    // ========================================================================
    // 문제 7. 오타 난 설정 프로퍼티가 어떻게 처리되는지 확인하세요.
    //
    //  요구사항
    //   (a) application.yml 의 spring.kafka.producer.properties 아래에
    //       lingerms: 5   (점이 빠진 오타) 를 추가합니다.
    //   (b) 앱을 기동해 무슨 일이 생기는지 확인합니다. 예외가 납니까?
    //   (c) 기동 로그에서 그 오타를 알려 주는 단 한 줄을 찾아 아래 상수에 적으세요.
    //   (d) 그 한 줄이 WARN 레벨이라는 사실이 왜 위험한지 주석으로 적으세요.
    // ========================================================================
    @Component
    @Profile("step01ex-typo")
    @Order(40)
    public static class Ex7UnknownConfigProbe implements ApplicationRunner {

        private static final Logger log = LoggerFactory.getLogger(Ex7UnknownConfigProbe.class);

        // 여기에 작성: 기동 로그에서 찾은 경고 한 줄을 그대로 붙여넣으세요.
        private static final String EXPECTED_WARNING = "(여기에 작성)";

        @Override
        public void run(ApplicationArguments args) {
            log.info("문제 7 — 기동 로그에서 찾아야 할 경고: {}", EXPECTED_WARNING);
        }
    }
}

Solution.java

7문제의 정답 코드와, "왜 그 답인가"를 설명하는 긴 블록 주석이 함께 들어 있습니다. 문제를 풀어 본 뒤에 여세요.

  • 정답 1 은 코드 자체보다 주석이 본론입니다. 토픽명을 @Value 로 받는 이유가 취향이 아니라 1-8 함정의 구조적 방어책이라는 점, 그리고 파티션 1개짜리 토픽의 partitions assigned: [payments-0] 가 어떻게 다른지를 설명합니다.
  • 정답 2 는 "groupId 를 같게 주면 어떻게 되는가"까지 답합니다. 다른 그룹이면 3건씩 총 6줄, 같은 그룹이면 나눠 가져서 총 3줄입니다. "왜 절반만 처리되지?"라는 흔한 질문의 답이 여기 있습니다.
  • 정답 3KafkaTransactionManager 가 없는 이유를 @ConditionalOnProperty(spring.kafka.producer.transaction-id-prefix) 로 못 박습니다. 실패가 아니라 부재라는 것이 요지입니다.
  • 정답 4 가 이 스텝에서 가장 긴 주석입니다. auto.offset.reset 이 "커밋된 오프셋이 없을 때만" 쓰인다는 사실 하나로 (c)와 (d)의 서로 모순돼 보이는 결과가 전부 설명됩니다. kcg --describe 가 "그룹이 없다"고 답하는 것이 "지금 이 설정이 작동 중"의 신호라는 진단법까지 담았습니다.
  • 정답 6 의 결론은 "완전히는 못 막는다" 입니다. 컨슈머 쪽 allow.auto.create.topics=false + missingTopicsFatal=true 로 구독은 막지만, 프로듀서 쪽은 브로커의 auto.create.topics.enable=false 만이 유일한 방어선입니다. 코드가 그 비대칭을 로그로 직접 드러내도록 짜여 있습니다.
  • 정답 7The configuration 'lingerms' was supplied but isn't a known config. 한 줄을 상수로 박아 두고, 그것이 WARN 인 것이 왜 위험한지를 세 가지로 나눠 설명합니다.
package com.example.order.step01;

/*
 * ============================================================================
 * Step 01 — 환경 구축과 첫 메시지 : Solution (정답과 해설)
 * ============================================================================
 *
 * 배치 위치
 *   spring-kafka-lab/src/main/java/com/example/order/step01/Solution.java
 *
 * 실행
 *   ./gradlew bootRun --args='--spring.profiles.active=step01sol'
 *   ./gradlew bootRun --args='--spring.profiles.active=step01sol,step01sol-latest'
 *   ./gradlew bootRun --args='--spring.profiles.active=step01sol,step01sol-strict'
 *
 * Exercise 를 직접 풀어 본 뒤에 여세요.
 * ============================================================================
 */

import com.example.order.domain.OrderCreated;

import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.autoconfigure.kafka.KafkaProperties;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.core.annotation.Order;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory;
import org.springframework.kafka.config.KafkaListenerContainerFactory;
import org.springframework.kafka.config.KafkaListenerEndpointRegistry;
import org.springframework.kafka.core.ConsumerFactory;
import org.springframework.kafka.core.DefaultKafkaConsumerFactory;
import org.springframework.kafka.core.KafkaAdmin;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.core.ProducerFactory;
import org.springframework.kafka.transaction.KafkaTransactionManager;
import org.springframework.stereotype.Component;

import java.time.Instant;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;

public final class Solution {

    private Solution() {
    }

    // ========================================================================
    // 정답 1 — payments 토픽 발행 + s01-payment 그룹 수신
    // ========================================================================
    /*
     * 왜 이 답인가
     *
     *  - 토픽명을 코드에 하드코딩하지 않고 @Value("${app.topic.payments}") 로 받는 이유는
     *    단순한 취향이 아닙니다. Kafka 는 토픽명 오타를 예외로 알려 주지 않습니다.
     *    브로커의 auto.create.topics.enable 이 켜져 있으면 새 토픽이 조용히 생기고,
     *    발행은 '성공'합니다. 토픽명을 한 곳(application.yml)에서만 관리하면
     *    프로듀서와 컨슈머가 같은 문자열을 보게 되어 이 사고 유형이 원천 차단됩니다.
     *
     *  - send() 는 CompletableFuture 를 리턴하고 즉시 반환됩니다. whenComplete 를
     *    붙이지 않으면 브로커가 거절해도 애플리케이션은 아무것도 모릅니다.
     *    Step 01 단계에서부터 "발행 결과를 반드시 본다"를 습관으로 만드세요.
     *
     *  - payments 는 파티션이 1개입니다. 그래서 기동 로그가
     *      s01-payment: partitions assigned: [payments-0]
     *    한 줄로 끝납니다. orders(3파티션)는 세 개가 한 줄에 나열됩니다.
     *    partitions assigned 로그는 "무엇을 몇 개 할당받았는가"를 보여 주는 창이며,
     *    여기가 비어 있으면([]) 리스너는 살아 있어도 아무것도 받지 못합니다.
     */
    @Component
    @Profile("step01sol")
    @Order(10)
    public static class Sol1PaymentPublisher implements ApplicationRunner {

        private static final Logger log = LoggerFactory.getLogger(Sol1PaymentPublisher.class);

        private final KafkaTemplate<String, OrderCreated> kafkaTemplate;
        private final String paymentsTopic;

        public Sol1PaymentPublisher(KafkaTemplate<String, OrderCreated> kafkaTemplate,
                                    @Value("${app.topic.payments}") String paymentsTopic) {
            this.kafkaTemplate = kafkaTemplate;
            this.paymentsTopic = paymentsTopic;
        }

        @Override
        public void run(ApplicationArguments args) {
            OrderCreated event = OrderCreated.of(101);

            kafkaTemplate.send(paymentsTopic, event.orderId(), event)
                    .whenComplete((result, ex) -> {
                        if (ex != null) {
                            log.error("[정답1] 발행 실패 key={}", event.orderId(), ex);
                            return;
                        }
                        log.info("[정답1] 발행 성공 {}-{}@{} key={}",
                                result.getRecordMetadata().topic(),
                                result.getRecordMetadata().partition(),
                                result.getRecordMetadata().offset(),
                                event.orderId());
                    });
            kafkaTemplate.flush();
        }
    }

    @Component
    @Profile("step01sol")
    public static class Sol1PaymentListener {

        private static final Logger log = LoggerFactory.getLogger(Sol1PaymentListener.class);

        @KafkaListener(
                id = "s01sol-payment",
                topics = "${app.topic.payments}",
                groupId = "s01-payment",
                concurrency = "1")
        public void onPayment(ConsumerRecord<String, OrderCreated> record) {
            log.info("[정답1] 결제 수신 {}-{}@{} key={} amount={}",
                    record.topic(), record.partition(), record.offset(),
                    record.key(), record.value().amount());
        }
    }

    // ========================================================================
    // 정답 2 — 컨슈머 그룹 두 개는 같은 메시지를 각자 전부 받는다
    // ========================================================================
    /*
     * 왜 이 답인가
     *
     *  - groupId 만 다르게 준 리스너를 하나 더 붙이면 끝입니다. 코드에 특별한 것은 없습니다.
     *    중요한 건 결과입니다. 3건을 발행하면 로그가 6줄 나옵니다.
     *    s01-inventory 가 3건, s01-notification 이 3건. 이것이 Kafka 의 pub/sub 입니다.
     *
     *  - 오프셋은 (그룹, 토픽, 파티션) 조합마다 따로 관리됩니다. 그래서
     *      kcg --describe --group s01-inventory
     *      kcg --describe --group s01-notification
     *    의 CURRENT-OFFSET 은 서로 완전히 독립적입니다. 한쪽을 리셋해도
     *    다른 쪽은 영향을 받지 않습니다.
     *
     *  - 반대로 groupId 를 '같게' 주고 리스너를 둘 붙이면 어떻게 될까요?
     *    두 리스너가 한 그룹의 두 멤버가 되어 파티션을 나눠 갖습니다.
     *    각 메시지는 둘 중 하나에게만 갑니다. 총 로그는 6줄이 아니라 3줄입니다.
     *    "왜 절반만 처리되지?" 라는 질문의 절반은 여기서 나옵니다.
     *    파티션이 3개인데 같은 그룹 멤버가 4개면 한 명은 아무것도 못 받고 놉니다(Step 03).
     */
    @Component
    @Profile("step01sol")
    public static class Sol2NotificationListener {

        private static final Logger log = LoggerFactory.getLogger(Sol2NotificationListener.class);

        @KafkaListener(
                id = "s01sol-notification",
                topics = "${app.topic.orders}",
                groupId = "s01-notification",
                concurrency = "1")
        public void onOrderCreated(ConsumerRecord<String, OrderCreated> record) {
            log.info("[정답2] 알림 발송 {}-{}@{} key={} customerId={}",
                    record.topic(), record.partition(), record.offset(),
                    record.key(), record.value().customerId());
        }
    }

    // ========================================================================
    // 정답 3 — 자동 설정 빈 목록
    // ========================================================================
    /*
     * 왜 이 답인가
     *
     *  - getBeanNamesForType 은 인터페이스로 조회해야 합니다. ProducerFactory 는
     *    인터페이스이고 실제 구현은 DefaultKafkaProducerFactory 입니다.
     *    DefaultKafkaProducerFactory.class 로 조회해도 지금은 찾아지지만,
     *    커스텀 구현으로 바꾸는 순간 조회가 깨집니다. 인터페이스로 조회하세요.
     *
     *  - KafkaTransactionManager 가 목록에 없는 이유:
     *    KafkaAutoConfiguration 의 해당 @Bean 에는
     *      @ConditionalOnProperty(name = "spring.kafka.producer.transaction-id-prefix")
     *    가 붙어 있습니다. application.yml 에 transaction-id-prefix 를 안 적었으므로
     *    조건이 불충족이고, 빈은 '조용히' 만들어지지 않습니다.
     *    이것이 Spring Boot 자동 설정의 기본 성격입니다. 실패가 아니라 부재입니다.
     *    Step 09 에서 transaction-id-prefix 를 넣는 순간 이 빈이 나타납니다.
     *
     *  - ctx.getType(name) 이 null 을 리턴할 수 있다는 점에 유의하세요.
     *    빈 정의만 있고 아직 타입이 확정되지 않은 경우입니다. 방어 코드를 넣습니다.
     */
    @Component
    @Profile("step01sol")
    @Order(20)
    public static class Sol3BeanReport implements ApplicationRunner {

        private static final Logger log = LoggerFactory.getLogger(Sol3BeanReport.class);

        private final ApplicationContext ctx;

        public Sol3BeanReport(ApplicationContext ctx) {
            this.ctx = ctx;
        }

        @Override
        public void run(ApplicationArguments args) {
            Map<String, Class<?>> targets = new LinkedHashMap<>();
            targets.put("KafkaTemplate", KafkaTemplate.class);
            targets.put("ProducerFactory", ProducerFactory.class);
            targets.put("ConsumerFactory", ConsumerFactory.class);
            targets.put("KafkaListenerContainerFactory", KafkaListenerContainerFactory.class);
            targets.put("KafkaAdmin", KafkaAdmin.class);
            targets.put("KafkaListenerEndpointRegistry", KafkaListenerEndpointRegistry.class);
            targets.put("KafkaTransactionManager", KafkaTransactionManager.class);

            targets.forEach((label, type) -> {
                String[] names = ctx.getBeanNamesForType(type);
                if (names.length == 0) {
                    log.info("[정답3] {} → (없음) 조건이 충족되지 않아 등록되지 않았습니다",
                            "%-32s".formatted(label));
                    return;
                }
                for (String name : names) {
                    Class<?> impl = ctx.getType(name);
                    log.info("[정답3] {} → {} ({})", "%-32s".formatted(label), name,
                            impl == null ? "타입 미확정" : impl.getSimpleName());
                }
            });
        }
    }

    // ========================================================================
    // 정답 4 — auto-offset-reset = latest 로 메시지 유실 재현
    // ========================================================================
    /*
     * 왜 이 답인가 — 이 스텝에서 가장 중요한 정답입니다.
     *
     *  - auto.offset.reset 은 "커밋된 오프셋이 없을 때 어디서부터 읽을지"만 정합니다.
     *    항상 적용되는 값이 아닙니다. 이 한 문장을 놓치면 실습 결과가 뒤죽박죽이 됩니다.
     *
     *  - latest 로 두고 새 그룹(s01-latest)으로 처음 붙으면, 컨슈머는
     *    "지금 이 순간의 LOG-END-OFFSET" 부터 읽기 시작합니다.
     *    그러니 앱을 켜기 전에 넣어 둔 메시지는 영원히 안 보입니다.
     *    로그는 완벽하게 깨끗합니다. 예외도 없고 경고도 없습니다.
     *    partitions assigned 도 정상적으로 찍힙니다. 그래서 코드를 의심하게 됩니다.
     *
     *  - 앱을 켠 상태에서 넣은 메시지는 정상적으로 들어옵니다.
     *    그리고 그 순간 s01-latest 그룹에 오프셋이 '커밋'됩니다.
     *    이제부터는 auto.offset.reset 이 아예 개입하지 않습니다.
     *    앱을 껐다 켠 뒤 메시지를 넣으면, 커밋된 오프셋부터 이어 읽으므로
     *    latest 인데도 "앱이 꺼진 동안 들어온 메시지"가 보입니다.
     *    문제 4(d)의 답이 이것입니다. 값이 바뀐 게 아니라 조건이 사라진 것입니다.
     *
     *  - 진단 방법: kcg --describe --group s01-latest 를 실행해서
     *    그 그룹이 아예 없다고 나오면 '한 번도 커밋한 적 없는' 상태입니다.
     *    이 경우에만 auto.offset.reset 이 작동합니다.
     *
     *  - 운영 기본값이 latest 인 것은 이유가 있습니다. 새 서비스를 붙일 때
     *    earliest 면 몇 달치 과거 메시지가 한꺼번에 쏟아집니다.
     *    "학습에는 earliest, 운영에는 대개 latest, 단 반드시 의식하고 고를 것" 입니다.
     */
    @Configuration
    @Profile("step01sol-latest")
    public static class Sol4LatestConfig {

        @Bean
        public ConsumerFactory<String, OrderCreated> latestConsumerFactory(KafkaProperties props) {
            Map<String, Object> config = new HashMap<>(props.buildConsumerProperties());
            config.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "latest");
            return new DefaultKafkaConsumerFactory<>(config);
        }

        @Bean
        public ConcurrentKafkaListenerContainerFactory<String, OrderCreated> latestContainerFactory(
                ConsumerFactory<String, OrderCreated> latestConsumerFactory) {
            ConcurrentKafkaListenerContainerFactory<String, OrderCreated> factory =
                    new ConcurrentKafkaListenerContainerFactory<>();
            factory.setConsumerFactory(latestConsumerFactory);
            factory.setConcurrency(1);
            return factory;
        }
    }

    @Component
    @Profile("step01sol-latest")
    public static class Sol4LatestListener {

        private static final Logger log = LoggerFactory.getLogger(Sol4LatestListener.class);

        @KafkaListener(
                id = "s01sol-latest",
                topics = "${app.topic.orders}",
                groupId = "s01-latest",
                containerFactory = "latestContainerFactory")
        public void onOrderCreated(ConsumerRecord<String, OrderCreated> record) {
            log.info("[정답4] 수신 {}-{}@{} key={}",
                    record.topic(), record.partition(), record.offset(), record.key());
        }
    }

    // ========================================================================
    // 정답 5 — 추적 가능한 리스너 로그
    // ========================================================================
    /*
     * 왜 이 답인가
     *
     *  - "orders-1@42" 라는 좌표 하나가 있으면 그 레코드를 언제든 다시 찾아갈 수 있습니다.
     *      kcc --topic orders --partition 1 --offset 42 --max-messages 1
     *    반대로 좌표가 없으면 "그 메시지가 처리됐는지"를 사후에 증명할 방법이 없습니다.
     *
     *  - 구분자를 '-' 와 '@' 로 고정하는 것이 중요합니다. Spring Kafka 자신이
     *    에러 로그에서 이 포맷을 씁니다.
     *      Backoff ... exhausted for orders-1@42
     *    애플리케이션 로그와 프레임워크 로그의 좌표 포맷이 같으면
     *    한 번의 grep 으로 양쪽을 동시에 훑을 수 있습니다.
     *
     *  - record.timestamp() 는 기본적으로 프로듀서가 찍은 시각(CreateTime)입니다.
     *    브로커 도착 시각을 원하면 토픽 설정 message.timestamp.type 을 바꿔야 합니다.
     *    "내 로그 시각 - 레코드 타임스탬프" 가 그 레코드의 실제 지연(end-to-end lag)입니다.
     *    컨슈머 랙(건수)과 지연(시간)은 다른 지표이며, 둘 다 필요합니다(Step 12).
     */
    @Component
    @Profile("step01sol")
    public static class Sol5TraceableListener {

        private static final Logger log = LoggerFactory.getLogger(Sol5TraceableListener.class);

        @KafkaListener(
                id = "s01sol-trace",
                topics = "${app.topic.orders}",
                groupId = "s01-trace",
                concurrency = "1")
        public void onOrderCreated(ConsumerRecord<String, OrderCreated> record) {
            Instant produced = Instant.ofEpochMilli(record.timestamp());
            long lagMs = System.currentTimeMillis() - record.timestamp();

            log.info("[정답5] {}-{}@{} key={} ts={} lagMs={}",
                    record.topic(), record.partition(), record.offset(),
                    record.key(), produced, lagMs);
        }
    }

    // ========================================================================
    // 정답 6 — 오타 토픽을 '조용히 성공'하지 않게 만들기
    // ========================================================================
    /*
     * 왜 이 답인가 — 그리고 이 문제의 진짜 답은 "완전히 막을 수는 없다" 입니다.
     *
     *  (1) 컨슈머 쪽: allow.auto.create.topics = false
     *      컨슈머가 없는 토픽을 구독할 때 브로커에 토픽 생성을 요청하지 않게 합니다.
     *      여기에 listener.missing-topics-fatal = true 를 더하면,
     *      리스너가 구독하는 토픽이 없을 때 기동 자체가 실패합니다.
     *      "오타 난 토픽을 구독하는" 사고는 이걸로 잡힙니다.
     *      기동 로그:
     *        IllegalStateException: Topic(s) [order] is/are not present and missingTopicsFatal is true
     *
     *  (2) 프로듀서 쪽: 막을 수 있는 클라이언트 설정이 없습니다.
     *      프로듀서는 메타데이터를 조회할 뿐이고, 없는 토픽을 만들지 말지는
     *      전적으로 브로커의 auto.create.topics.enable 이 결정합니다.
     *      즉 "오타 난 토픽으로 send 하는" 사고는 애플리케이션 설정만으로는 못 막습니다.
     *
     *  (3) 그래서 실무의 정답은 셋입니다.
     *      - 운영 브로커에서는 auto.create.topics.enable = false 로 둔다 (가장 확실)
     *      - 토픽명을 문자열 리터럴로 쓰지 않는다. 설정값이나 상수 한 곳에서만 관리한다
     *      - NewTopic 빈으로 쓸 토픽을 선언해 두고, 그 목록 밖의 토픽은 코드 리뷰에서 잡는다
     *
     *  (4) 아래 코드는 (1)을 구현하고, 프로듀서 쪽은 여전히 성공한다는 사실을
     *      로그로 드러내 보여 줍니다. 이 비대칭이 이 문제의 학습 포인트입니다.
     */
    @Configuration
    @Profile("step01sol-strict")
    public static class Sol6StrictConfig {

        @Bean
        public ConsumerFactory<String, OrderCreated> strictConsumerFactory(KafkaProperties props) {
            Map<String, Object> config = new HashMap<>(props.buildConsumerProperties());
            config.put(ConsumerConfig.ALLOW_AUTO_CREATE_TOPICS_CONFIG, false);
            return new DefaultKafkaConsumerFactory<>(config);
        }

        @Bean
        public ConcurrentKafkaListenerContainerFactory<String, OrderCreated> strictContainerFactory(
                ConsumerFactory<String, OrderCreated> strictConsumerFactory) {
            ConcurrentKafkaListenerContainerFactory<String, OrderCreated> factory =
                    new ConcurrentKafkaListenerContainerFactory<>();
            factory.setConsumerFactory(strictConsumerFactory);
            factory.setConcurrency(1);
            // 토픽이 없으면 기동을 실패시킵니다. application.yml 의
            // spring.kafka.listener.missing-topics-fatal 은 기본 컨테이너 팩토리에만
            // 적용되므로, 직접 만든 팩토리에는 이렇게 코드로 지정합니다.
            factory.getContainerProperties().setMissingTopicsFatal(true);
            return factory;
        }
    }

    @Component
    @Profile("step01sol-strict")
    @Order(30)
    public static class Sol6TypoTopicPublisher implements ApplicationRunner {

        private static final Logger log = LoggerFactory.getLogger(Sol6TypoTopicPublisher.class);

        private static final String TYPO_TOPIC = "order";

        private final KafkaTemplate<String, OrderCreated> kafkaTemplate;

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

        @Override
        public void run(ApplicationArguments args) {
            OrderCreated event = OrderCreated.of(99);

            kafkaTemplate.send(TYPO_TOPIC, event.orderId(), event)
                    .whenComplete((result, ex) -> {
                        if (ex != null) {
                            log.error("[정답6] 발행 실패 — 브로커가 토픽 자동 생성을 거부했습니다", ex);
                            return;
                        }
                        log.warn("[정답6] 프로듀서는 여전히 성공합니다: {}-{}@{}",
                                result.getRecordMetadata().topic(),
                                result.getRecordMetadata().partition(),
                                result.getRecordMetadata().offset());
                        log.warn("[정답6] 프로듀서 쪽 오타는 클라이언트 설정으로 못 막습니다. "
                                + "브로커의 auto.create.topics.enable=false 가 유일한 방어선입니다");
                    });
            kafkaTemplate.flush();
        }
    }

    // ========================================================================
    // 정답 7 — 오타 난 설정 프로퍼티는 WARN 한 줄로 끝난다
    // ========================================================================
    /*
     * 왜 이 답인가
     *
     *  - 찾아야 할 로그 한 줄은 이것입니다.
     *      WARN  o.a.k.c.p.ProducerConfig :
     *            The configuration 'lingerms' was supplied but isn't a known config.
     *
     *  - Kafka 클라이언트는 자기가 모르는 설정 키를 받으면 예외를 던지지 않습니다.
     *    AbstractConfig 가 사용되지 않은 키를 모아 WARN 으로 한 번 알리고 끝입니다.
     *    설계 의도는 호환성입니다. 신버전 설정을 구버전 클라이언트에 줘도 죽지 않아야 하고,
     *    인터셉터·시리얼라이저가 자기 설정을 자유롭게 끼워 넣을 수 있어야 하니까요.
     *
     *  - 위험한 이유는 세 가지입니다.
     *      1. WARN 이라서 대부분의 운영 로그 수집기에서 알림이 안 갑니다.
     *      2. 기동 시 딱 한 번만 나오고, 그 앞뒤가 ProducerConfig 설정 덤프라
     *         수십 줄에 파묻힙니다.
     *      3. 증상이 "안 됨"이 아니라 "기본값으로 동작함"입니다.
     *         linger.ms 를 5 로 주려다 오타를 내면 기본값 0 으로 돕니다.
     *         에러가 없으니 성능이 이상해도 설정을 의심하지 않게 됩니다.
     *
     *  - 방어책
     *      - 기동 로그에서 "isn't a known config" 를 grep 하는 것을 배포 체크리스트에 넣습니다.
     *      - 가능하면 ProducerConfig.LINGER_MS_CONFIG 같은 상수를 코드에서 씁니다.
     *        오타가 컴파일 에러가 됩니다. yml 문자열은 오타가 런타임 침묵이 됩니다.
     *      - Boot 의 축약 프로퍼티(spring.kafka.producer.acks 등)는 오타 나면
     *        Boot 가 잡아 줍니다. properties: 아래 원본 키만 무방비입니다.
     */
    @Component
    @Profile("step01sol")
    @Order(40)
    public static class Sol7UnknownConfigProbe implements ApplicationRunner {

        private static final Logger log = LoggerFactory.getLogger(Sol7UnknownConfigProbe.class);

        private static final String EXPECTED_WARNING =
                "The configuration 'lingerms' was supplied but isn't a known config.";

        @Override
        public void run(ApplicationArguments args) {
            log.info("[정답7] 기동 로그에서 찾아야 할 한 줄: {}", EXPECTED_WARNING);
            log.info("[정답7] 확인 명령: ./gradlew bootRun ... 2>&1 | grep \"isn't a known config\"");
        }
    }
}