음악 스트리밍 파이프라인 만들기 - 05. DLQ와 poison pill, 그리고 세 번의 삽질
문제
깨진 JSON 메시지 하나가 파티션에 들어오면 무슨 일이 벌어지나.
무한 재시도 설정이라면 그 메시지가 파티션 전체를 막는다(head-of-line blocking). 뒤에 쌓인 정상 메시지 수만 건이 한 건 때문에 처리되지 않는다. 이게 poison pill 이다.
설계 — 실패를 두 종류로 가른다
M1 입수에서 “재시도 가능(DB 장애) vs 불가(검증 실패)“를 갈랐던 원칙을 그대로 재적용했다.
| 실패 유형 | 판단 | 처리 |
|---|---|---|
| 역직렬화 실패 | 백 번 다시 읽어도 같은 바이트는 같은 실패 | 재시도 없이 즉시 DLQ |
| 처리 실패 (DB 등) | 일시적일 수 있다 | 지수 backoff(0.5s→1s→2s) 3회 → DLQ |
ErrorHandlingDeserializer가 역직렬화 실패를 예외가 아니라 “값”(null + 원인 헤더)으로 바꿔준다 — 리스너까지 도달해야 우리가 제어권을 갖는다.- 고정 간격 재시도는 기각 — DB 가 과부하로 느려진 상황에서 같은 속도로 계속 두드리면 회복을 방해한다. 지수 backoff 는 시스템에 숨 쉴 틈을 준다.
- DLQ 토픽은 원본과 같은 파티션 수, 보관은 4배 — 재처리 판단에 시간 여유를 준다.
삽질 기록 — 문서엔 없고 구현에만 있는 것들
1. DLQ 메시지가 base64 로 변질
DLQ 발행에 기존 JsonSerializer 템플릿을 재사용했더니, 깨진 원본 byte[] 가
base64 문자열로 이중 인코딩되어 실렸다. 원본 보존이 DLQ 의 존재 이유인데 증거를
훼손한 셈. DeadLetterPublishingRecoverer 에 Map<Class<?>, KafkaOperations> 로
byte[] 전용 템플릿(ByteArraySerializer)을 따로 매핑해야 한다.
2. ClassCastException: String cannot be cast to [B
1번을 고치며 DLQ 템플릿의 key 직렬화까지 ByteArray 로 바꿨더니 이번엔 키에서 터졌다. 역직렬화가 실패한 것은 value 뿐이고 key 는 여전히 String 이다. 이 실패가 연쇄를 일으켰다 — DLQ 발행 실패 → 에러 핸들러 실패 → 배치 전체 재시도. DLQ 경로 자체가 poison pill 이 될 수 있다는 것을 몸으로 배웠다.
3. 배치 리스너의 예외 의미론
배치 리스너에서 예외를 그냥 던지면 배치 전체가 실패 처리된다.
BatchListenerFailedException 으로 실패 인덱스를 지정하지 않으면, 깨진 메시지
앞의 정상 메시지까지 도매금으로 묶인다. “깨진 메시지가 섞인 배치에서 정상 메시지는
살아남는다”를 테스트로 고정해 두었다.
검증
- poison pill 을 정상 메시지 사이에 주입 → DLQ 로 이동, 뒤 메시지 전부 처리됨
- DLQ 메시지에 원인 헤더(원본 토픽/파티션/오프셋/예외) 동봉 확인
— 참고로 Spring 은 발행 시점에 헤더를
KafkaHeaders.DLT_*로 바꿔 단다. 검증할 때 이걸 몰라서 또 한 번 헤맸다 - 재처리 runbook 문서화: DLQ 는 “버리는 곳”이 아니라 “고쳐서 되돌리는 대기열”
상세: ADR-0004