DLQ(Dead Letter Queue)는 처리하지 못한 메시지를 별도로 저장하는 특수 큐다.
즉, 정상 처리되지 않은 메시지를 격리하여 시스템 안정성을 확보하고, 재처리나 분석이 가능하도록 한다.
이번글에서는 DLQ에 대해서 알아보고, 다른 방식들과의 차이점, 마지막으로 실제로 사용한 예제를 소개해보겠다.

이제 DLQ에 대해서 알아보자.
DLQ는 일반적으로 실패한 요청에 대한 안전망으로 사용된다.
주요 활용 케이스는 다음과 같다.
| 상황 | 설명 | DLQ 역할 |
|---|---|---|
| 메시지 처리 실패 가능성이 있는 시스템 | 외부 API 호출, 결제, 이메일 발송 등 실패 확률 존재 | 실패 메시지 격리 후 재처리/분석 |
| 실패 시 즉시 재처리 불가능한 경우 | 의존 서비스 다운, DB 장애 등 | DLQ에 저장 후 시스템 안정화 후 재처리 |
| 무한 재시도를 방지해야 하는 경우 | 재시도 반복 시 시스템 과부하 발생 | 실패 카운트 기반 메시지 폐기 또는 알람 |
| 문제 원인 추적 필요 | 메시지 payload, 오류 이유 기록 필요 | DLQ를 통한 문제 재현 및 디버깅 용이 |
정리: DLQ는 실패 메시지를 단순히 버리지 않고 격리, 분석, 재처리를 가능하게 해주는 안전 장치다.

이해를 돕기위한 다이어그램 aws documentation
일단 DB에 저장하고 로직을 처리한다
꼭 트랜잭션 아웃박스가 메시지 아키텍쳐와 연관있을 필요는 없음
| 항목 | 트랜잭션 아웃박스 | DLQ |
|---|---|---|
| 목적 | 메시지/DB 일관성 보장 | 실패 메시지 처리 안전망 |
| 메시지 처리 실패 | 별도 처리 필요 없음 | 메시지 격리 후 재처리 가능 |
| 사용 시점 | 정상 흐름 | 예외, 실패 발생 시 |
| 구현 위치 | 애플리케이션 내부 DB | 메시지 브로커 혹은 전용 큐 |

이해를 돕기위한 다이어그램 출처 망나니개발자
서킷 브레이커는 시스템 보호에 목적이 있다
| 항목 | 서킷 브레이커 | DLQ |
|---|---|---|
| 목적 | 시스템 과부하 방지, 빠른 실패 | 메시지 재처리, 분석, 문제 원인 추적 |
| 메시지 저장 | X | O |
| 사용 시점 | 외부 서비스/의존 시스템 호출 | 메시지 처리 실패 |

위에서 설명한 내용을 그린 플로우 다이어그램
여기서 쟁점은 다음과 같다
만약, 트랜잭션 아웃박스나 서킷 브레이커등 여러가지 방법으로도 실패하는 경우에는 어떻게 해야할까?
트랜잭션 아웃박스까지 실패하는 경우에는 해당 원인을 분석할 필요가 있다
지속적으로 장애가 발생하는 경우에는 개발자가 직접 해당 원인을 분석할 필요가 있음
준비된 외부 api들 마저도 모두 실패하는 경우에는 공지를 올리거나 어떻게든 대응할 필요가 있음
DLQ 방식으로 실패한 메시지를 저장하자.

slack api, dlq 저장 및 알람 부분이 추가됐다.
@Entity
@Table(name = "dlq_events")
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
public class DLQEvent extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "original_event_type", nullable = false)
private String originalEventType;
@Column(name = "original_event_id")
private Long originalEventId;
@Column(name = "payload", columnDefinition = "TEXT", nullable = false)
private String payload;
@Column(name = "error_message", columnDefinition = "TEXT")
private String errorMessage;
@Column(name = "stack_trace", columnDefinition = "TEXT")
private String stackTrace;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false)
private DLQEventStatus status;
@Column(name = "alarm_sent", nullable = false)
@Builder.Default
private Boolean alarmSent = false;
public void markAlarmSent() {
this.alarmSent = true;
}
public boolean shouldSendAlarm() {
return !this.alarmSent && this.status == DLQEventStatus.PERMANENTLY_FAILED;
}
}
/**
* 실패한 이벤트를 DLQ에 저장
*/
@Transactional
public void sendToDLQ(String originalEventType, Long originalEventId,
String payload, String errorMessage, String stackTrace) {
DLQEvent dlqEvent = DLQEvent.builder()
.originalEventType(originalEventType)
.originalEventId(originalEventId)
.payload(payload)
.errorMessage(errorMessage)
.stackTrace(stackTrace)
.status(DLQEventStatus.PERMANENTLY_FAILED)
.build();
dlqEventRepository.save(dlqEvent);
log.warn("Event sent to DLQ: {} (ID: {})", originalEventType, dlqEvent.getId());
// 즉시 알람 시도 (비동기)
sendAlarmIfNeeded(dlqEvent.getId());
}
@Async
public void sendAlarmIfNeeded(Long dlqEventId) {
try {
DLQEvent dlqEvent = dlqEventRepository.findById(dlqEventId)
.orElseThrow(() -> new IllegalArgumentException("DLQ event not found: " + dlqEventId));
if (dlqEvent.shouldSendAlarm()) {
dlqAlarmService.sendDLQAlarm(dlqEvent);
dlqEvent.markAlarmSent();
dlqEventRepository.save(dlqEvent);
}
} catch (Exception e) {
log.error("Failed to send alarm for DLQ event {}", dlqEventId, e);
}
}
dlqAlarmService에서는 위와 같이
위 코드는 dlq 이벤트가 수신되면 비동기로 전송 요청을 보내는 부분이다.
만약 Transactional이 종료되기 전에 전송 요청이 실행되어도 상관없다
어차피 다음의 스케줄링으로 전송되지 않는 알람을 전송하기 때문이다
/**
* 주기적으로 알람이 필요한 이벤트들 처리
*/
@Scheduled(fixedDelay = 60000) // 1분마다
@Transactional
public void processAlarmEvents() {
List<DLQEvent> alarmEvents = dlqEventRepository.findEventsNeedingAlarm();
if (!alarmEvents.isEmpty()) {
log.info("Processing {} DLQ events needing alarm", alarmEvents.size());
for (DLQEvent event : alarmEvents) {
try {
sendAlarmIfNeeded(event.getId());
} catch (Exception e) {
log.error("Failed to send alarm for DLQ event {}", event.getId(), e);
}
}
}
}
오래되어 사용되지 않을것으로 예상되는 dlq는 삭제해주자.
한달전 dlq들은 자동으로 삭제하도록 구현했다.
/**
* 주기적으로 대기 중인 이벤트들 처리
*/
@Scheduled(fixedDelay = 30000) // 30초마다
@Transactional
public void processPendingEvents() {
List<TagCreationOutboxEvent> pendingEvents =
outboxRepository.findPendingEvents(LocalDateTime.now());
if (!pendingEvents.isEmpty()) {
log.info("Processing {} pending tag creation events", pendingEvents.size());
for (TagCreationOutboxEvent event : pendingEvents) {
try {
processEvent(event.getId());
} catch (Exception e) {
log.error("Failed to process pending event {}", event.getId(), e);
}
}
}
}
🚨 *DLQ Alert - PROD*
*Event Details:*
• ID: `12345`
• Type: `TAG_CREATION_OUTBOX`
• Status: `PERMANENTLY_FAILED`
• Created: `2024-01-15 14:30:25`
*Error Message:* ...
*stackTrace:*
알람 메시지는 로그를 확인하지않고 충분히 디버깅할 수 있게 stackTrace,메시지, 시도한 이벤트 등을 포함하였다.
이렇듯 아무리 방어책을 두어도, 방어책 조차 외부 api라면 딜레마에 빠지게 된다
기능이 안전해야할 수록, 방어책을 위한 방어책을 두는 수 밖에는 없다
하지만 아무리 방어책을 두어도 실패할 가능성을 0퍼센트로 만들기는 불가능하다
항상 성공하는 서비스는 없고, 우리가 할 수 있는것은 빠르게 대응하는것뿐이다.
결국 위 딜레마는 다음의 내용으로 귀결된다
해당 기능의 안정성을 위해 얼마나 자원을 투자할 수 있는가?
얼마만큼의 가용성을 허용하는가?
이것이 여러 기업에서 가용성 100%를 내세우는 것이 아닌 SLA를 두는 이유이다.

sla는 약속이다
서비스가 고객에게 이정도의 가용성을 제공해준다는 약속이다
aws cloudfront는 다음과 같이 약속했다

클라우드프론트는 이번달 제공된 가용성에 따라서 요금을 깎아준다고 한다..!
이번 경험으로 현실적으로 가용성 100%는 불가능에 가깝다는 것을 체감한것같다.
대신 서비스의 SLA 기준을 세우고 SLA를 충족하도록 설계하는것이 좀 더 효율적인 시선이라고 생각하게 됐다.