Notice
Recent Posts
Recent Comments
Link
관리 메뉴

look-forest

Kafka 메시지 처리 실패 시 대처 방법 본문

Middleware/Kafka (메시지 브로커)

Kafka 메시지 처리 실패 시 대처 방법

studyHub 2026. 2. 15. 19:18

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

 

정상 흐름은 아래와 같다.

 

  1. 원본 토픽에서 메시지 수신
  2. 실패하면 retry-1000 토픽으로 이동
  3. 1초 뒤 retry consumer가 처리
  4. 또 실패하면 retry-2000으로 이동
  5. 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를 사용하는 이유

  1. 실패 메시지 유실 방지
  2. 사후 실패 원인 분석
  3. 사후 수동 처리

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"
)

 

재시도에 실패한 메시지가 email.send.dlt 토픽에 저장되어 있는 걸 확인할 수 있다.

 

그림으로 이해하기

Conusmer 서버가 메시지를 처리하다가 실패하면 정해진 횟수까지 재시도를 한다. 여기서 끝까지 재시도 처리에 실패한 메시지는 DLT 토픽으로 옮겨서 보관하게 된다.


재시도조차 실패한 메시지 사후 처리하기

DLT에 저장된 메시지를 사후 처리하는 방식

현업에서는 DLT에 저장된 메시지를 아래와 같은 방식으로 주로 처리한다.

  1. DLT에 저장된 실패 메시지를 로그 시스템에 전송해 장애 원인을 추적할 수 있도록 한다.
  2. DLT에 메시지가 저장되자마자 수동으로 대처할 수 있게 알림을 설정한다.
  3. 알림을 받은 관리자는 로그에 쌓인 내용을 보고 장애 원인을 분석하고, 그에 맞게 메시지를 수동으로 처리한다.
    1. 일시적 장애(현재는 해결) -> 메시지를 원래 토픽으로 직접 다시 보내기
    2. 문제가 있는 메시지
      -> 폐기(단, 혹시 모를 상황에 대비해 로그로 남겨둔다)
      -> 잘못된 메시지 내용이 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 입문