포폴/일정관리앱

실시간 알림에 DLQ를 적용하기.

slown 2025. 5. 17. 15:36

목차

1.DLQ를 적용하기 위한 이유

2.적용

3.후기

 

1.DLQ를 적용하기 위한 이유

Kafka 기반의 알림 시스템을 운영하면서, 메시지 컨슈머에서 특정 상황에서 예외가 발생하는 문제를 마주했습니다.  
예를 들어 DB 저장 실패, WebSocket 전송 실패, 직렬화 오류 등 다양한 이유로 Consumer에서 예외가 발생할 수 있습니다.

Kafka에서는 기본적으로 Consumer가 예외를 던지면 해당 파티션의 메시지 소비가 멈춰버립니다.  
이런 구조에서는 일시적인 오류 하나로 인해 전체 알림 시스템이 영향을 받을 수 있습니다.

이를 해결하기 위해 DLQ(Dead Letter Queue) 를 도입했다. DLQ는 실패한 메시지를 별도의 토픽으로 격리시켜 메시지 유실을 방지하고, 이후에 재처리 로직을 통해 운영 안정성을 높이는 패턴입니다.

2.적용

그럼 Consumer에서 예외가 났을 경우에 어떻게 진행이 되는지를 적용한 코드를 보여드리겠습니다.

 

2-1. Kafka Consumer 예외 처리

@KafkaListener(
    topics = "member-signup-events",
    groupId = "member-group",
    containerFactory = "memberKafkaListenerFactory"
)
public void handle(MemberSignUpKafkaEvent event) {
    ...
    throw new RuntimeException("Kafka Consumer 실패 → DLQ로 전송", e);
}

 

맨 처음에 Consumer에서 예외가 발생을 하게 되면 Kafka는 설정 클래스에 있는 ErrorHandler로 넘깁니다.

 

2-2. Kafka 설정에서 ErrorHandler 구성

factory.setCommonErrorHandler(new DefaultErrorHandler(
    new DeadLetterPublishingRecoverer(
        kafkaTemplate,
        (record, ex) -> new TopicPartition(record.topic() + ".DLQ", record.partition())
    ),
    new FixedBackOff(0L, 3) // 즉시 재시도 3번
));

 

3번까지 재시도하고 그래도 실패하면 notification-events.DLQ 또는 member-signup-events.DLQ로 자동 전송을 합니다.

 

2-3. DLQ 컨슈머에서 실패 내역 저장

@KafkaListener(topics = "member-signup-events.DLQ", groupId = "dlq-retry-group")
public void handle(MemberSignUpKafkaEvent event) {
    ...
    실패 내역을 저장
    failedMessageService.createFailMessage(failMessageModel);
}

 

그리고 각 DlqConsumer에서는 실패내역을 FailedMessage에 저장을 합니다. 

 

2-4. 스케줄러 기반 재처리

 @Scheduled(fixedDelay = 10 * 60 * 1000)
    public void retryMemberSignUps() {
	.......
        for (FailMessageModel entity : list) {

            if(entity.getRetryCount() >= MAX_RETRY_COUNT) {
                entity.setDead();
                .....
                continue;
            }

            try {
                MemberSignUpKafkaEvent event = objectMapper.readValue(entity.getPayload(), MemberSignUpKafkaEvent.class);
                String retryTopic = getRetryTopicByCountForMember(entity.getRetryCount());
                kafkaTemplate.send(retryTopic, event);
                entity.setResolved();
                log.info(" DLQ 재처리 성공 - member signup: id={}", entity.getId());
            } catch (Exception ex) {
            .....
            }
            failedService.createFailMessage(entity);
        }
    }

 

스케줄러를 사용해서 재처리를 시도를 했고 retryCount에 따라 점진적인 재처리를 위해 Kafka Delay Queue 구조를 도입했습니다. 

 

retryCount는 retryCount = 0 → 5초 후 / 1 → 10초 / 2 → 30초 / 3 이상 → 60초 이렇게 구성이 되어있습니다. 

 

각 retryCount에 따라 member-signup.retry.{5s,10s,30s,60s,final} 토픽으로 전송한 후, 별도의 KafkaListener에서 일정 시간이 지난 후 다시 원래 토픽으로 재전송합니다. 이렇게 함으로써 일시적 장애일 경우에는 자동 회복되고, 지속 장애는 점진적으로 제어됩니다.

 

마지막으로 이렇게 만든 처리는 아래의 사진과 같습니다.

 

3.후기

DLQ를 적용하면서 가장 크게 느낀 점은 운영 중 알림 장애에 대한 대응이 빨라졌다는 것이다. 이전에는 메시지 유실이나 소비 중단으로 이어지던 문제가, DLQ를 통해 격리 및 추적 가능하게 바뀌었습니다. 특히 재처리 로직을 스케줄러 기반으로 구성하면서, 지속적인 자동 복구 흐름을 만들 수 있었고, 또한 실패 이력과 예외 내용을 DB에 저장하면서, 장애 원인 분석 및 시각화 기반도 마련할 수 있게 되었습니다.

 

추후에 구현할 부분은 

  • 슬랙 또는 이메일을 사용해서 재처리 알림을 구현
  • 벤트의 보장성을 위해서 outbox패턴을 구현 
  • 재처리 현황 모니터링 대시보드 (Prometheus + Grafana)
  • DLQ 처리 성능 분석을 위한 Kafka 지표 수집

입니다.