Kafka 메시지가 처리 실패하면 어디로 가야 하는가 — Dead Letter Topic
Kafka 메시지가 처리 실패하면 어디로 가야 하는가 — Dead Letter Topic
2024-10-08 ~ 2025-01-24
문제 상황
알림 서비스는 Kafka Consumer로 메시지를 받아서 FCM 푸시를 보낸다. → Kafka는 대용량 메시지를 안정적으로 주고받기 위한 분산 메시지 큐 시스템이다. Producer가 메시지를 보내면 Kafka가 보관하고, Consumer가 꺼내서 처리하는 구조다. 그런데 메시지 처리 중에 예외가 발생하면 어떻게 될까?
Kafka → [Consumer] → 처리 중 예외 발생! → ???
기본 동작: Kafka는 실패한 메시지를 다시 큐에 넣는다. Consumer가 다시 받아서 처리하려고 한다. 또 실패한다. 또 다시 넣는다. 이게 무한 반복되면서 서버 로그가 에러로 도배된다.
ERROR AppPushListener - FCM 발송 실패
ERROR AppPushListener - FCM 발송 실패
ERROR AppPushListener - FCM 발송 실패
... (무한 반복)
정상 메시지도 밀려서 처리되지 못한다. 이걸 "Poison Pill" 문제라고 한다 — 독이 든 메시지 하나가 전체 Consumer를 마비시킨다. → Poison Pill(독약 메시지)은 어떻게 처리해도 항상 실패하는 메시지를 말한다. 이런 메시지가 큐에 계속 남아 있으면 정상 메시지까지 처리되지 못한다.
Dead Letter Topic이란?
이메일에서 "반송 우편함"이 있는 것처럼, Kafka에서도 처리 실패한 메시지를 별도 토픽으로 보내는 패턴이 있다. 이걸 Dead Letter Topic(DLT)이라고 한다. → Dead Letter는 "배달 불능 편지"라는 뜻이다. 실제 우체국에서 주소 불명의 편지를 모아두는 것처럼, 처리할 수 없는 메시지를 별도 공간에 격리하는 패턴이다.
정상 메시지: Kafka [push] → Consumer → FCM 발송 성공 ✓
실패 메시지: Kafka [push] → Consumer → 예외 발생 → Kafka [push.dlt] (격리)
실패한 메시지는 .dlt 접미사가 붙은 별도 토픽으로 이동한다.
원본 토픽은 깨끗해지고, 나중에 DLT를 확인해서 원인을 분석하거나 재처리할 수 있다.
구현
Spring Kafka에서는 DeadLetterPublishingRecoverer를 상속해서 커스텀 DLT 핸들러를 만든다.
→ Recoverer(복구기)는 메시지 처리가 최종적으로 실패했을 때 "이 메시지를 어떻게 할 것인가"를 결정하는 컴포넌트다.
public class CustomDeadLetterRecoverer extends DeadLetterPublishingRecoverer {
@Override
public void accept(ConsumerRecord<?,?> record, Consumer<?,?> consumer, Exception exception) {
// FCM 토큰 무효는 예상된 상황 — DLT에 안 보냄
if (exception.getCause() instanceof FirebaseInvalidTokenException) {
log.info("Invalid token - DLT 스킵: {}", record.topic());
return;
}
// 그 외 에러 — DLT로 전송
String dltTopic = record.topic() + ".dlt"; // push → push.dlt
DeadLetterData data = DeadLetterData.builder()
.originalTopic(record.topic())
.errorMessage(exception.getMessage())
.recordValue(record.value().toString()) // 원본 메시지 보존
.build();
kafkaTemplate.send(dltTopic, data);
log.error("DLT 전송: {} → {}", record.topic(), dltTopic, exception);
}
}
모든 에러를 DLT에 넣으면 안 되는 이유
처음에는 모든 에러를 DLT에 넣었다. 그랬더니 DLT에 메시지가 수천 개 쌓였는데, 대부분이 "앱 삭제로 인한 FCM 토큰 무효"였다.
push.dlt 메시지 10,000개:
- InvalidToken: 9,500개 (95%) ← 이건 에러가 아니라 정상적인 상황
- Firebase 내부 오류: 300개
- JSON 파싱 실패: 200개 ← 이것만 진짜 문제
앱 삭제는 일상적인 일이다. 사용자가 앱을 삭제하면 FCM 토큰이 무효화되는 건 당연하다. 이걸 "에러"로 분류하면 진짜 문제가 묻힌다.
그래서 FirebaseInvalidTokenException은 DLT에 안 보내고, 별도로 토큰 무효화 처리만 한다.
역직렬화 실패 — 더 위험한 경우
메시지 처리 실패보다 더 앞단에서 터질 수 있다. Kafka에서 메시지를 꺼내서 JSON을 객체로 변환(역직렬화)하는 단계에서 실패하면, Consumer 코드조차 실행되지 않는다. → 역직렬화(Deserialization)란 문자열이나 바이트 형태의 데이터를 프로그램에서 사용할 수 있는 객체로 복원하는 과정이다. 직렬화의 반대 방향이다.
Kafka 메시지: {"title": "이벤트", "body": 123} ← body가 String이어야 하는데 숫자
역직렬화: JSON → AppPushData 변환 실패!
→ Consumer 코드까지 도달하지 못함
→ 기본 동작: 무한 재시도
이걸 방지하기 위해 ErrorHandlingDeserializer를 사용한다.
public class PushDataErrorHandlingDeserializer extends ErrorHandlingDeserializer<PushData> {
public PushDataErrorHandlingDeserializer() {
super(new JsonDeserializer<>(PushData.class));
}
}
역직렬화 실패 시 예외를 잡아서 에러 핸들러로 전달한다. 에러 핸들러가 재시도 횟수를 초과하면 DLT로 보낸다.
DLT 메시지 구조
DLT에 보낼 때 원본 메시지를 그대로 보존한다. 나중에 원인 분석과 재처리를 위해서다.
DeadLetterData {
originalTopic: "push.notification", // 어디서 왔는지
errorMessage: "JsonParseException...", // 왜 실패했는지
recordValue: "{\"title\":\"이벤트\"}" // 원본 메시지 (문자열)
}
토픽별 DLT 매핑
push → push.dlt
noti.push → noti.push.dlt
lms → lms.dlt
push-test → push-test.dlt
각 토픽마다 DLT가 분리되어 있어서, "LMS 실패"와 "FCM 실패"를 구분해서 모니터링할 수 있다.
배운 것
- Poison Pill 문제: 처리 불가능한 메시지 하나가 전체 Consumer를 마비시킬 수 있다. DLT로 격리해야 한다.
- 모든 에러가 "에러"는 아니다: FCM 토큰 무효 같은 예상된 상황은 DLT에 넣지 말고 별도 처리해야 진짜 문제를 발견할 수 있다.
- 역직렬화 실패는 Consumer 코드보다 앞단에서 발생한다: ErrorHandlingDeserializer로 감싸야 한다.
- DLT 메시지에 원본을 보존해야 나중에 원인 분석과 재처리가 가능하다.