에디블로그
Engineer's Field Notes

AI 자동화로 매일
한 편씩 쓰는
엔지니어 운영 노트

Claude Code · 자동화 파이프라인 · 사고 회고까지. 잘 굴러간 기록 + 깨진 흔적도 같이 남깁니다.

사람이 할 수 있는 일은,
AI도 할 수 있어야 합니다.
매일 한 편 쓰면서 검증 중.
— 이번 주 가장 많이 읽힌 글 TOP 3
백엔드

[Kafka] 컨슈머 에러 핸들링과 DLQ: 재시도 BackOff, poison pill, 실패 메시지 재처리

반응형
[Kafka] 컨슈머 에러 핸들링과 DLQ: 재시도 BackOff, poison pill, 실패 메시지 재처리

[Kafka] 컨슈머 에러 핸들링과 DLQ: 재시도 BackOff, poison pill, 실패 메시지 재처리

카프카 리스너에서 예외가 터지면 그 메시지는 기본적으로 재시도되다가 건너뛰어져요. 그런데 계속 실패하는 메시지 하나가 파티션 전체를 막아버릴 수 있어요. 그걸 막는 게 재시도 한도와 DLQ예요.

예외가 났을 때 스프링 카프카가 무슨 일을 하는지를 먼저 따라가고, 재시도와 backoff, 재시도 가능한 예외와 불가능한 예외, poison pill 문제, DLQ로 빼는 법, 역직렬화 실패 처리, DLT 운영, 자주 만나는 문제까지 차례로 봐요. 컨슈머 기본 설정은 @KafkaListener 컨슈머 설정 글을 먼저 보면 좋아요.

01. 예외가 터지면 기본은 어떻게 되나요

리스너 메서드에서 예외가 나면 스프링 카프카의 DefaultErrorHandler가 받아요. 기본 동작은 짧은 간격으로 몇 번 재시도하고, 그래도 실패하면 그 메시지를 로그만 남기고 건너뛰어요(오프셋 커밋).

스프링 카프카 에러 핸들링 플로우. 리스너 예외를 DefaultErrorHandler가 받아 BackOff 정책대로 재시도하고, 소진되면 DeadLetterPublishingRecoverer가 원토픽.DLT로 발행해 파티션 막힘을 푼다

여기서 함정. 기본 설정의 "건너뛴다"는 그 메시지를 조용히 버린다는 뜻이에요. recoverer를 안 달면 재시도 소진 후 로그 한 줄 남기고 오프셋을 커밋해버려요. 결제 실패 이벤트 같은 게 알림도 없이 사라지는 거죠. 그래서 잃으면 안 되는 토픽엔 DLQ recoverer가 사실상 필수예요.

참고로 DefaultErrorHandler는 예전의 SeekToCurrentErrorHandler를 대체한 거예요. 스프링 카프카 2.8 이상에서는 이걸 써요. 동작 방식은, 실패한 레코드의 오프셋으로 다시 seek해서 같은 메시지를 재시도하는 구조예요. 그래서 재시도 중에는 그 파티션의 다음 메시지로 넘어가지 않아요.

02. 재시도와 backoff

재시도 간격과 횟수는 BackOff로 정해요. DefaultErrorHandler에 넘기면 돼요.

RetryTemplate 시절(2021)의 재처리 방식은 Consumer 실패 시 재처리 (Reply - @SendTo) 글과 비교해 보면 변화가 보여요.

// 1초 간격으로 3번까지 재시도
var backOff = new FixedBackOff(1000L, 3);
var handler = new DefaultErrorHandler(backOff);
factory.setCommonErrorHandler(handler);

간격을 일정하게 두려면 FixedBackOff를 써요. 실패가 누적될수록 간격을 늘리려면 ExponentialBackOff를 써요. 처음엔 짧게, 계속 실패하면 점점 길게 기다리는 거예요.

var backOff = new ExponentialBackOffWithMaxRetries(5);
backOff.setInitialInterval(1000L);   // 1초부터
backOff.setMultiplier(2.0);          // 2배씩 증가
backOff.setMaxInterval(10000L);      // 최대 10초
var handler = new DefaultErrorHandler(backOff);

외부 API 호출처럼 잠깐 뒤 회복될 수 있는 경우엔 지수 backoff가 효과적이에요. DB 일시 장애나 네트워크 순단을 재시도로 넘길 수 있거든요.

재시도해도 소용없는 예외도 있어요

어떤 예외는 몇 번을 다시 해도 똑같이 실패해요. 형식이 틀렸거나 검증에 걸리는 메시지가 그래요. 이런 건 재시도 자체가 낭비예요.

카프카 예외 분류. 외부 API 타임아웃 같은 일시적 실패는 backoff 재시도로 흡수하고, 형식 오류나 검증 실패 같은 영구적 오류는 addNotRetryableExceptions로 재시도 없이 바로 DLT로 보낸다

DefaultErrorHandler에 재시도하지 않을 예외를 등록하면, 그런 예외는 재시도 없이 바로 실패 처리로 넘어가요.

handler.addNotRetryableExceptions(
    IllegalArgumentException.class,
    ValidationException.class);

반대로 "이 예외만 재시도하고 나머지는 바로 실패"로 두고 싶으면 retryable 예외만 지정할 수도 있어요. 일시적 장애(재시도 가치 있음)와 영구적 오류(재시도 무의미)를 구분하는 게 핵심이에요. 영구 오류를 재시도하면 어차피 실패할 일에 backoff 시간만큼 파티션 정체를 더하는 셈이거든요.

03. poison pill, 무한 재시도의 함정

재시도로 안 풀리는 메시지를 무한 재시도하면 그 파티션의 뒤 메시지가 전부 막혀요. 이런 메시지를 poison pill이라고 해요.

카프카 poison pill 문제. 파티션은 순서 보장 큐라서 계속 실패하는 메시지 하나가 뒤 메시지를 전부 막고, max.poll.interval.ms 초과로 리밸런싱까지 번진다. 해법은 재시도 한도와 DLT 격리

한 파티션은 순서대로 처리되니까, 맨 앞 메시지가 안 넘어가면 그 뒤는 전부 멈춰요. 카프카 컨슈머는 막힌 파티션을 건너뛰고 다른 메시지를 처리할 수 없어요. 게다가 처리가 계속 막히면 max.poll.interval.ms를 넘겨 컨슈머가 추방되고 리밸런싱까지 일어나요. 리밸런싱으로 다른 컨슈머가 그 파티션을 받아도 또 같은 메시지에 막히니, 한 건 때문에 그룹 전체가 계속 흔들리는 거죠. 그래서 재시도는 반드시 한도를 두고, 한도를 넘긴 메시지는 따로 빼내야 해요.

04. DLQ로 빼기

재시도를 소진한 메시지는 DLQ(Dead Letter Queue)로 보내요. 스프링 카프카는 DeadLetterPublishingRecoverer로 이걸 처리해요. 재시도가 다 끝난 뒤 호출되는 "복구 단계(recoverer)"예요.

@Bean
DefaultErrorHandler errorHandler(KafkaTemplate<?, ?> template) {
    var recoverer = new DeadLetterPublishingRecoverer(template);
    var backOff = new FixedBackOff(1000L, 3);
    return new DefaultErrorHandler(recoverer, backOff);  // 3번 실패 → DLT로
}

이러면 재시도를 다 쓴 메시지가 원토픽이름.DLT 토픽으로 발행돼요(기본 이름 규칙). 본래 파티션은 그 메시지를 건너뛰고 다음으로 넘어가니 막힘이 풀려요.

DLT 헤더, 왜 실패했는지가 담겨요

DLT로 보낼 때 원래 메시지의 정보가 헤더에 함께 담겨요. 어느 토픽·파티션·오프셋에서 왔는지, 어떤 예외가 났는지, 예외 메시지와 스택트레이스까지 들어가요. 그래서 DLT를 들여다보면 어떤 메시지가 왜 실패했는지 바로 추적할 수 있어요.

보낼 곳 커스터마이징

DLT 토픽 이름이나 파티션을 바꾸고 싶으면 destination resolver를 지정해요. 예를 들어 모든 실패 메시지를 토픽 하나로 모으는 식이에요.

var recoverer = new DeadLetterPublishingRecoverer(template,
    (record, ex) -> new TopicPartition(record.topic() + ".DLT", 0));
destination resolver의 함정. 기본 동작은 실패한 메시지를 DLT의 같은 파티션 번호로 보내요. 원토픽이 6파티션인데 DLT를 1파티션으로 만들었다면, 파티션 1~5에서 온 실패 메시지는 보낼 곳이 없어서 DLT 발행 자체가 실패해요. 실패 처리가 실패하는 거죠. DLT 파티션 수를 원토픽과 맞추거나, 위처럼 resolver에서 파티션을 0으로 고정해야 해요.

05. 역직렬화 실패는 다르게 잡아요

역직렬화 단계에서 터진 예외는 리스너 메서드까지 도달하지도 못해요. 그래서 위의 에러 핸들러로는 평범하게 안 잡혀요. 메서드 안에서 try-catch를 해도 소용없죠.

카프카 역직렬화 실패 지점. 깨진 메시지는 Deserializer 단계에서 터져 리스너에 도달하지 못하므로 try-catch가 무용하고, ErrorHandlingDeserializer로 감싸야 에러 핸들러와 DLT 경로를 탈 수 있다

이건 ErrorHandlingDeserializer로 역직렬화를 감싸서 처리해요. 감싸두면 깨진 메시지를 정상 흐름에서 잡아 DLQ로 보내거나 건너뛸 수 있어요. @KafkaListener 컨슈머 설정 글에서 다룬 그 함정과 같은 맥락이에요.

spring:
  kafka:
    consumer:
      value-deserializer: org.springframework.kafka.support.serializer.ErrorHandlingDeserializer
    properties:
      spring.deserializer.value.delegate.class: org.springframework.kafka.support.serializer.JsonDeserializer

이렇게 감싸두면 역직렬화 실패 메시지도 DLT로 보내져요. 나중에 형식을 고쳐 재처리할 수 있게 보관되는 거죠.

06. DLT는 쌓아두고 끝이 아니에요

DLT로 보낸 메시지는 "처리 실패함"을 기록해둔 거지, 해결한 게 아니에요. 그래서 운영에서는 DLT를 모니터링해야 해요. DLT에 메시지가 쌓이기 시작하면 어딘가 문제가 생겼다는 신호거든요. lag 알림처럼 DLT 토픽의 메시지 유입에 알림을 걸어두면 좋아요.

카프카 DLT 운영 루프. DLT 적재를 알림으로 감지하고 헤더로 원인을 추적해 코드와 데이터를 수정한 뒤 원토픽으로 재발행하며, 재처리 중복은 멱등성으로 흡수한다

쌓인 메시지는 원인을 고친 뒤 다시 원래 토픽으로 흘려보내 재처리하거나, 수동으로 보정해요.

재처리에도 함정이 있어요. DLT에서 원토픽으로 다시 흘려보내는 순간, 그 메시지는 "두 번째 처리"가 돼요. 첫 시도에서 절반만 반영되고 실패한 경우라면(DB는 썼는데 후속 호출에서 실패) 재처리가 중복 반영을 만들 수 있어요. 그래서 멱등성은 DLT 재처리 경로에서도 여전히 전제 조건이에요.

07. 자주 만나는 문제

한 건 실패에 컨슈머 전체가 멈췄어요

poison pill이 재시도를 무한 반복하는 거예요. 재시도 한도(BackOff)와 DLQ를 설정하면, 한도를 넘긴 메시지가 DLT로 빠지면서 막힘이 풀려요.

try-catch를 했는데 안 잡혀요

역직렬화 단계 예외예요. 메서드 진입 전에 터지니 메서드 안 try-catch로는 못 잡아요. ErrorHandlingDeserializer로 감싸야 해요.

DLT가 자꾸 쌓여요

근본 원인을 안 고친 거예요. DLT 헤더의 예외 정보를 보고 무엇이 실패하는지 파악한 뒤, 코드나 데이터를 고치고 재처리해요. DLT는 임시 보관소지 해결책이 아니에요.

실패 처리, 한 줄로

실패 처리의 핵심은 한도 있는 재시도와 한도 초과 시 DLQ예요. 일시적 실패는 backoff 재시도로 흡수하고, 재시도해도 안 되는 poison pill은 DLT로 빼서 파티션을 막지 않게 해요. 영구적 오류는 addNotRetryableExceptions로 곧장 빼고, 역직렬화 실패는 ErrorHandlingDeserializer로 따로 잡고요. DLT는 쌓아두지 말고 모니터링과 재처리까지 설계해야 안전해요.

이로써 컨슈머의 안정성 그림이 채워졌어요. @KafkaListener 설정, 수동 커밋과 멱등성, 리밸런싱과 파티션 할당을 함께 보면 컨슈머 운영에서 만나는 문제 대부분을 커버할 수 있어요.

출처: Spring for Apache Kafka — Handling Exceptions (DefaultErrorHandler) · Publishing Dead-Letter Records

반응형

📚 같이 보면 좋은

"이 포스팅은 쿠팡 파트너스 활동의 일환으로, 일정액의 수수료를 제공받습니다."