본문 바로가기

Java/Spring

Kafka Listener 예외 반복 실행되는 이유 그리고 해결

요즘 경매 기능 개발 중 Kafka로 입찰 메시지를 처리하고 있는데, 이상한 상황을 하나 마주했다.

프론트에서 입찰 금액을 현재가 이하로 보내면, 백엔드에서는 당연히 예외를 던지게 돼 있다.

 

문제는 이때 Kafka 컨슈머가 해당 메시지를 계속해서 반복 실행하면서 로그가 쌓이고, 결국 장애처럼 느껴질 정도로 실행하면서 터져버렸다.

로그를 보면 아래처럼 계속 같은 메시지를 읽고 실패하고, 다시 읽고 실패하는 로그가 찍힌다.

 

분명 예외가 발생했는데도, Kafka가 계속 같은 메시지를 소비하고 있었다.

Kafka 리스너가 예외를 던졌는데, 이걸 “정상 처리되지 않았다”고 인식하고 계속 재시도하고 있었던 거다.

Kafka는 메시지를 읽고, 예외 없이 끝나야 성공적으로 처리했다고 판단한다.

그런데 RuntimeException이 발생하면 Kafka 입장에선 “이 메시지는 아직 처리 못했네?“라고 생각하고, 같은 메시지를 계속 다시 읽는다.

이걸 해결하려면?

Kafka는 내가 처리 실패라고 말하지 않아도, 예외가 터지면 자동으로 재시도한다.

그래서 이건 실패 아니야~ 하고 직접 알려줘야 한다.

 

방법 1. try-catch로 감싸서 처리하기

@KafkaListener(topics = "auction-bid", groupId = "auction-bid-group")
public void handleBid(ChatEvent event) {
    try {
        auctionService.placeBid(event.roomId(), event.senderId(), Long.valueOf(event.content()));
    } catch (ResponseStatusException ex) {
        log.warn("입찰 실패: {}", ex.getReason());
        // 여기서 예외를 처리해서 Kafka가 성공으로 인식하게 함
    }
}

 

해결 방법 2: Kafka 공식 방식으로 실패 처리하기 (DLQ)

앞서 try-catch로 예외를 먹는 방식도 좋지만,

다른 방법도 있다.

 

바로 Kafka의 DefaultErrorHandler를 활용해서

정해진 횟수만큼 재시도 → 그래도 실패하면 Dead Letter Queue(DLQ)로 보내기다.

@Configuration
public class KafkaConfig {

    @Bean
    public DefaultErrorHandler errorHandler() {
        // 2번 재시도하고 실패하면 DLQ로 이동
        FixedBackOff backOff = new FixedBackOff(0L, 2);

        return new DefaultErrorHandler((record, exception) -> {
            log.error("최종 실패 메시지: {}", record);
            // 여기서 DLQ로 자동 이동됨
        }, backOff);
    }

    @Bean
    public ConcurrentKafkaListenerContainerFactory<?, ?> kafkaListenerContainerFactory(
            ConsumerFactory<Object, Object> cf) {
        ConcurrentKafkaListenerContainerFactory<Object, Object> factory =
                new ConcurrentKafkaListenerContainerFactory<>();
        factory.setConsumerFactory(cf);
        factory.setCommonErrorHandler(errorHandler()); // 에러 핸들러 주입
        return factory;
    }
}

 

DefaultErrorHandler 
@KafkaListener에서 예외가 발생했을 때 동작하는, Spring Kafka가 제공하는 기본 에러 처리 메커니즘이다.

Dead Letter Queue(DLQ) 

정상적으로 처리되지 못한 메시지를 따로 보관하기 위한 Kafka의 예비 토픽이다.
재시도 후에도 실패한 메시지를 DLQ로 보내 운영자가 추적하거나 나중에 재처리할 수 있다.

결론: 비즈니스 예외는 진짜 에러가 아니다

Kafka는 예외를 “진짜 에러”로 받아들이기 때문에, 우리가 생각하는 단순한 로직 실패도

직접 처리하지 않으면 계속해서 장애처럼 작동하게 된다.

 

이걸 겪고 나서야 Kafka 리스너 로직을 조금 더 명확히 설계할 필요가 있다는 걸 느꼈다.

“컨슈머는 반드시 예외가 터지지 않도록 끝나야 한다.” 이거를 잘 기억하고 나중에 쓰게되면 실천하자..