| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 |
- Web
- @ComponentScan
- Spring
- dockerhub
- JPA
- JWT
- AWS
- redis
- docker
- @Transactional
- 서블릿 컨테이너
- DI
- 지연 로딩
- kafka
- 컨테이너
- Routing Key
- Spring Container
- JdbcTemplate
- 스프링 부트
- CORS
- 페이징
- mybatis
- 쿠버네티스
- DLQ
- JPQL
- securitycontextholderfilter
- docker compose
- MSA
- Dead Letter Queue
- Spring Data JPA
- Today
- Total
look-forest
Kafka 메시지 처리 실패 시 대처 방법 본문
Consumer가 실제 작업에 실패했을 때 사용자에게 실패 여부를 전달할 수 없다는 단점이 있었다.
이번 시간에는 그 단점을 재시도(Retry) 방식을 활용해 해결해보자.
실패한 메시지 Retry 설정
목적: 일시적 오류 극복
메시지 재시도 메커니즘은 일시적인 네트워크 문제나 서비스 불안정성 같은 오류를 자동으로 극복하여 메시지 처리를 완료하는 데 기여한다. 이는 시스템의 견고성과 신뢰성을 높이는 핵심적인 방법이다.
의도적으로 실패 상황을 만들기 위해 Consumer 코드에 아래 코드를 삽입하고 요청을 보내봤다.
// 잘못된 이메일 주소일 경우 실패 가정
if (emailSendMessage.getTo().equals("fail@naver.com")) {
throw new RuntimeException("잘못된 이메일 주소로 인해 발송 실패");
}

- interval : 재시도를 하는 시간 간격 (ms)
- maxAttempts : 최대 재시도 횟수
- currentAttempts : 지금까지 시도한 횟수 (최초 시도 횟수 + 재시도 횟수)
별도의 설정을 하지 않았는데 이미 기본값으로 재시도(retry) 전략이 설정되어 있다. 이 설정을 변경해서 적용시켜보자.
@RetryableTopic
- 현업에서 보통 재시도 횟수는 3~5회 사이로 정하는 편이다. 왜냐하면 재시도를 너무 많이 할 경우 시스템 부하가 커질 수 있고, 너무 적으면 일시적인 장애에 대응하기 어렵기 때문이다.
- 첫 재시도 간격은 짧게 설정하는 편이고, 그 이후 재시도 간격은 지수적(exponential)으로 증가하도록 설정하는 편이다. 그래야 일시적인 장애에 대해서는 첫 빠른 재시도로 대응이 가능하고, 장애가 조금 길어지는 경우라도 무의미하게 재시도하는 걸 방지하기 위함이다.
지수 백오프는 재시도 간격을 점진적으로 늘려 일시적인 장애가 복구될 시간을 주며, 동시에 실패한 서비스에 대한 과도한 요청으로 인한 추가 부하를 방지하여 시스템 안정성에 도움이 된다.
@Service
public class EmailSendConsumer {
@KafkaListener(topics = "email.send", groupId = "email-send-group")
@RetryableTopic(
//총 시도 횟수 (최초 시도 1회 + 재시도 4회)
attempts = "5",
//backOff: 재시도 간격 (1000ms -> 2000ms -> 4000ms -> 8000ms 순으로 재시도 시간이 증가)
backOff = @BackOff(delay = 1000, multiplier = 2)
)
public void consume(String message) {
System.out.println("Kafka로부터 받아온 메시지 = " + message);
EmailSendMessage emailSendMessage = EmailSendMessage.fromJson(message);
if (emailSendMessage.to().equals("fail@naver.com")) {
System.out.println("잘못된 이메일 주소로 인해 발송 실패");
throw new RuntimeException("잘못된 이메일 주소로 인해 발송 실패");
}
// ... 실제 이메일 발송 로직은 생략 ...
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
throw new RuntimeException("이메일 발송 실패");
}
System.out.println("이메일 발송 완료");
}
}
이슈
retry는 설정한 대로 이뤄졌는데, 에러로그가 무한으로 찍히는 이슈가 발생했다.
IllegalStateException:
No Acknowledgment available ... the listener container must have a MANUAL AckMode
- acknowledge(인정,확인): “이 메시지 처리 완료했으니 offset을 커밋해도 된다”는 신호
- AckMode.MANUAL: offset 커밋을 자동으로 할지, 개발자가 직접 할지 정하는 설정
offset 커밋이 제대로 안 되면 같은 메시지를 계속 읽는다
컨테이너의 default 설정이 MANUAL 모드가 아니어서, Acknowledge 를 주입할 수 없어 offset을 커밋하지 못해 실패가 반복된 것이다.
좀 더 자세히 알아보자면, @RetryableTopic은 내부적으로 retry 토픽을 만들고 각각 별도의 consumer group을 사용하기 때문에 아래와 같이 consumer group이 생성된다.
ubuntu@ip-172-31-44-228:~/kafka_2.13-4.0.0$ bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --list
email-send-group
email-send-group-dlt
email-send-group-retry-1000
email-send-group-retry-2000
email-send-group-retry-4000
email-send-group-retry-8000
정상 흐름은 아래와 같다.
- 원본 토픽에서 메시지 수신
- 실패하면 retry-1000 토픽으로 이동
- 1초 뒤 retry consumer가 처리
- 또 실패하면 retry-2000으로 이동
- …
- 5번 실패하면 DLT로 이동
retry 구조는 만들어졌는데 offset 커밋 흐름이 깨져서 원본에서 반복 재처리 중인 것이다.
해결법
지금 같은 “재시도(@RetryableTopic) + 실패 처리” 시나리오에서는 자동 커밋을 권장하지 않는다.
실패하면 커밋하지말고, 성공했을 때만 커밋하는게 가장 명확하다.
따라서 아래와 같이 설정한다.
설정 수정
spring:
kafka:
consumer:
enable-auto-commit: false
listener:
ack-mode: manual
코드 수정
public void consume(String message, Acknowledgment ack) {
...
// 성공 시에만 커밋
ack.acknowledge();
}
그런데 재시도(retry)를 여러번 했음에도 불구하고 작업이 실패하면 어떻게 해야 할까?
사용자는 작업이 정상 처리된 것으로 알고 있다. 어떻게 대처해야할 지 알아보자.
재시도조차 실패한 메시지를 따로 보관하기 (DLT, Dead Letter Topic)
실패한 메시지는 Dead Letter Topic(DLT)라는 별도 토픽을 활용하여, 재시도까지 실패한 메시지를 안전하게 보관하고 나중에 관리자가 확인해서 수동으로라도 처리할 수 있도록 구성할 수 있도록 해야 한다.
Dead Letter Topic이란?
오류로 인해 처리할 수 없는 메시지를 임시로 저장하는 토픽
DTL를 사용하는 이유
- 실패 메시지 유실 방지
- 사후 실패 원인 분석
- 사후 수동 처리
DLT를 활용해 재시도에 실패한 메시지 따로 보관하기
사실 Spring Kafka는 @RetryableTopic을 사용하면 자동으로 DLT 토픽을 생성하고 메시지를 전송해준다.
기본적으로 만드는 DLT 토픽 이름은 {기존 토픽명}-dlt 형태로 지어지는데, 일관적인 DLT 토픽 이름을 위해 직접 DLT 토픽명을 별도로 다시 설정할 수 있다.
@RetryableTopic(
// 총 시도 횟수 (최초 시도 1회 + 재시도 4회)
attempts = "5",
// 재시도 간격 (1000ms -> 2000ms -> 4000ms -> 8000ms 순으로 재시도 시간이 증가한다.)
backoff = @Backoff(delay = 1000, multiplier = 2),
// DLT 토픽 이름에 붙일 접미사
dltTopicSuffix = ".dlt"
)


그림으로 이해하기

재시도조차 실패한 메시지 사후 처리하기
DLT에 저장된 메시지를 사후 처리하는 방식
현업에서는 DLT에 저장된 메시지를 아래와 같은 방식으로 주로 처리한다.
- DLT에 저장된 실패 메시지를 로그 시스템에 전송해 장애 원인을 추적할 수 있도록 한다.
- DLT에 메시지가 저장되자마자 수동으로 대처할 수 있게 알림을 설정한다.
- 알림을 받은 관리자는 로그에 쌓인 내용을 보고 장애 원인을 분석하고, 그에 맞게 메시지를 수동으로 처리한다.
- 일시적 장애(현재는 해결) -> 메시지를 원래 토픽으로 직접 다시 보내기
- 문제가 있는 메시지
-> 폐기(단, 혹시 모를 상황에 대비해 로그로 남겨둔다)
-> 잘못된 메시지 내용이 Kafka에 들어가지 않게 Producer의 검증 로직 보완
DLT에 저장된 메시지 처리하기
DLT도 메시지가 저장된 토픽이므로, Consumer group을 이용해 처리한다.
@Service
public class EmailSendDtlConsumer {
@KafkaListener(
topics = "email.send.dlt",
groupId = "email-send-dlt-group"
)
public void consume(String message) {
//로그 시스템에 전송
System.out.println("로그 시스템에 전송 = " + message);
// 알림 발송
System.out.println("Slack에 알림 발송");
}
}

Consumer가 토픽에 들어있는 메시지를 처리하다가 실패하면서, 재시도해서 메시지를 처리하려 했으나 결국 총 5번의 실패로 인해 DLT 토픽으로 메시지를 전달한다. 그래서 DLT 토픽을 처리하는 Consumer가 해당 실패 메시지를 받아 로그 시스템에 전송하고 Slack에 알림까지 발송한 걸 확인할 수 있다.
참고 자료 & 이미지 출처
실전에서 바로 써먹는 kafka 입문
'Middleware > Kafka (메시지 브로커)' 카테고리의 다른 글
| Kafka 장애 대비하기 (고가용성) (0) | 2026.02.16 |
|---|---|
| Kafka 메시지 처리 성능 높이기 (병렬 처리) (0) | 2026.02.16 |
| Kafka의 기본 구성 (0) | 2026.02.13 |
| Kafka 기본 개념 (0) | 2026.02.13 |