Dead Letter Queue로 장애를 설계 레벨에서 대비하기
클린 아키텍처로 실제 서비스 만들기
- 01멀티 모듈 프로젝트, 왜 클린 아키텍처로 시작했나
- 02상태 머신을 도메인 객체로 표현하는 방법
- 03Port & Adapter 패턴으로 외부 스토리지 연동하기
- 04Redis Streams로 비동기 분석 파이프라인 설계하기
- 05느리고 큰 외부 API 응답, 스트리밍으로 처리하기
- 06Dead Letter Queue로 장애를 설계 레벨에서 대비하기← 현재
- 07QueryDSL로 복잡한 동적 검색 쿼리 설계하기
- 08프로젝트 회고: 잘한 설계, 아쉬운 설계
"외부 API가 실패하면 어떻게 되나요?"
코드 리뷰 자리에서 선임 개발자가 던진 질문이었다. 문서 분석 시스템의 초기 버전은 외부 AI API 호출 실패 시 예외를 로그에 찍고 그냥 넘어가는 구조였다. 당시에는 "예외가 발생하면 로그를 보면 되지 않나"라고 생각했다.
하지만 실제 운영 관점에서 생각해보면 이 구조는 여러 문제를 내포하고 있다.
- 데이터 유실: 분석 요청이 실패했지만 사용자는 성공한 것처럼 보일 수 있다
- 운영팀 인지 불가: 로그 모니터링이 없으면 장애가 발생해도 누구도 모른다
- 재처리 불가: 실패한 요청의 데이터가 어디에도 남아있지 않으면 재시도할 방법이 없다
- 무음 장애: 시스템은 돌아가는 것처럼 보이지만 실제로는 데이터가 처리되지 않고 있다
이 문제를 해결하기 위해 Dead Letter Queue(DLQ) 패턴을 적용하기로 했다.
Dead Letter Queue 패턴이란
DLQ는 메시지 큐 시스템(RabbitMQ, Kafka 등)에서 처리 실패한 메시지를 별도 저장소로 보내는 패턴이다. 원래는 메시지 브로커와 함께 쓰이지만, 그 핵심 아이디어는 더 넓게 적용할 수 있다.
핵심 아이디어: 실패한 작업의 정보를 잃지 말자.
처리에 실패했을 때 단순히 예외를 던지고 끝내는 대신, 실패한 요청의 컨텍스트(무엇을, 언제, 왜 실패했는지)를 별도로 기록하고 알림을 보내는 것이다.
Dead Letter Queue 처리 흐름
이 프로젝트에서의 DLQ 구현
메시지 브로커를 별도로 운영하는 것은 인프라 비용과 운영 부담이 크다. 이 프로젝트는 소규모로 시작하는 시스템이었으므로, 인터페이스 추상화 + 알림 기반 DLQ를 선택했다.
DeadLetterSender 인터페이스
public interface DeadLetterSender {
void send(DeadLetter deadLetter);
}단순하다. 실패 정보(DeadLetter)를 받아서 전송한다. 이 인터페이스는 도메인 계층에 위치하며, "어떻게" 보낼 것인지는 구현체가 결정한다.
DeadLetter DTO
public record DeadLetter(
String source, // 어느 모듈에서 발생했는가 (api, synthy, analysis)
String operation, // 어떤 작업이 실패했는가
String errorMessage, // 에러 메시지
String payload, // 실패한 요청 데이터 (JSON)
LocalDateTime occurredAt
) {
public static DeadLetter of(String source, String operation,
String errorMessage, String payload) {
return new DeadLetter(source, operation, errorMessage, payload,
LocalDateTime.now());
}
}Record를 사용해서 불변성을 보장했다. payload에는 실패한 요청 데이터를 JSON 문자열로 담아서 나중에 재처리 시 활용할 수 있게 했다.
GoogleChatDeadLetterSender 구현체
@Component
@RequiredArgsConstructor
@Slf4j
public class GoogleChatDeadLetterSender implements DeadLetterSender {
private final GoogleChatWebhookClient webhookClient;
@Override
public void send(DeadLetter deadLetter) {
try {
String message = formatMessage(deadLetter);
webhookClient.send(message);
log.info("[DEAD-LETTER-SENT] source={}, operation={}",
deadLetter.source(), deadLetter.operation());
} catch (Exception e) {
// DLQ 전송 자체가 실패하면 최소한 로그는 남긴다
log.error("[DEAD-LETTER-SEND-FAILED] source={}, operation={}, error={}",
deadLetter.source(), deadLetter.operation(), e.getMessage());
}
}
private String formatMessage(DeadLetter deadLetter) {
return """
🚨 *Dead Letter Alert*
- Source: `%s`
- Operation: `%s`
- Error: `%s`
- Occurred At: `%s`
- Payload: ```%s```
""".formatted(
deadLetter.source(),
deadLetter.operation(),
deadLetter.errorMessage(),
deadLetter.occurredAt(),
deadLetter.payload()
);
}
}DLQ 전송 자체도 실패할 수 있다. Google Chat 웹훅이 일시적으로 다운되거나 네트워크 문제가 생길 수 있다. 이때 DLQ 전송 실패로 인해 원래 비즈니스 흐름이 중단되면 안 된다. 그래서 send() 내부에서 예외를 잡아 로그만 남기고 조용히 처리했다. "알림 전송 실패"가 "서비스 다운"이 되어서는 안 된다.
모듈을 왜 분리했나: deadletter/api, synthy, analysis
처음에는 DeadLetterSender 하나로 모든 실패를 처리하려 했다. 하지만 실제로 운영하다 보면 실패의 성격이 다르다는 것을 알게 됐다.
| 모듈 | 실패 유형 | 특이사항 |
|---|---|---|
deadletter/api | 일반 외부 API 호출 실패 | 동기 처리, 즉시 알림 |
deadletter/synthy | AI 분석 API 호출 실패 | 스트리밍 중단, 부분 결과 처리 필요 |
deadletter/analysis | 비동기 분석 작업 실패 | 배치 처리, 알림 집계 필요 |
각 모듈이 다른 payload 구조와 다른 알림 포맷을 가질 수 있다. 하나의 구현체에 모든 케이스를 if-else로 처리하는 것보다, 각 모듈에서 자신에게 맞는 DeadLetterSender 구현체 또는 DeadLetter 생성 방식을 가지는 것이 더 명확하다.
deadletter/
├── core/
│ ├── DeadLetterSender.java (인터페이스)
│ ├── DeadLetter.java (DTO)
│ └── GoogleChatDeadLetterSender.java (공통 구현체)
├── api/
│ └── ApiDeadLetterHandler.java (API 실패 특화 처리)
├── synthy/
│ └── SynthyDeadLetterHandler.java (AI API 실패 특화 처리)
└── analysis/
└── AnalysisDeadLetterHandler.java (비동기 분석 실패 특화 처리)
core의 GoogleChatDeadLetterSender는 공통으로 사용하고, 각 모듈의 핸들러는 자신의 컨텍스트에 맞는 DeadLetter를 구성해서 DeadLetterSender에 위임한다.
// synthy 모듈 예시
@Component
@RequiredArgsConstructor
public class SynthyDeadLetterHandler {
private final DeadLetterSender deadLetterSender;
public void handleStreamingFailure(String documentId, String errorMessage) {
DeadLetter deadLetter = DeadLetter.of(
"synthy",
"ai-streaming-analysis",
errorMessage,
"""
{"documentId": "%s"}
""".formatted(documentId)
);
deadLetterSender.send(deadLetter);
}
}Google Chat 알림: 운영 가시성 확보
왜 Slack이 아닌 Google Chat인가? 이 프로젝트의 팀이 Google Workspace를 사용했기 때문이다. 도구 선택은 팀의 현실을 따른다.
Google Chat 웹훅은 단순한 HTTP POST로 메시지를 보낼 수 있어서 별도 SDK 없이 WebClient로 직접 구현했다.
@Component
@RequiredArgsConstructor
public class GoogleChatWebhookClient {
private final WebClient webClient;
@Value("${google.chat.webhook-url}")
private String webhookUrl;
public void send(String text) {
Map<String, String> body = Map.of("text", text);
webClient.post()
.uri(webhookUrl)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(body)
.retrieve()
.toBodilessEntity()
.block();
}
}실제 운영에서 Google Chat DLQ 알림이 준 가시성은 생각보다 훨씬 컸다. AI API가 간헐적으로 타임아웃이 발생하는 패턴을 발견했고, 특정 시간대에 실패율이 높다는 것도 알 수 있었다. 로그만 봤다면 몰랐을 정보들이다.
"장애를 코드 레벨에서 어떻게 설계하는가"
이 설계에서 중요한 사고방식이 하나 있다. 장애는 예외적인 상황이 아니라 설계의 일부다.
외부 API는 반드시 실패한다. 네트워크가 불안정하고, 외부 서비스는 점검에 들어가고, 예상치 못한 응답 형식 변경이 생긴다. 이것을 "예외 상황"으로 처리하면 코드는 단순해지지만 운영은 고통스러워진다. 이것을 "설계의 일부"로 처리하면 코드는 조금 복잡해지지만 운영이 훨씬 안정적이다.
코드를 작성할 때 스스로에게 물어볼 것:
- "이 작업이 실패하면 누가 알 수 있는가?"
- "실패한 데이터를 나중에 복구할 수 있는가?"
- "장애가 다른 기능에 전파되지 않는가?"
- "장애의 패턴을 나중에 분석할 수 있는가?"
이 설계의 한계와 개선 방향
솔직하게 말하면, 현재 DLQ 구현은 알림까지는 되지만 재처리는 수동이다. 이것이 가장 큰 한계다.
현재 구조의 한계:
- 재시도 로직 없음: 실패하면 알림을 보내고 끝이다. 재시도는 운영팀이 수동으로 해야 한다
- 재처리 인터페이스 없음:
DeadLetter에 payload를 저장하지만, 이를 이용해 자동으로 재처리하는 메커니즘이 없다 - 알림 중복 가능: 동일한 오류가 반복 발생하면 동일한 알림이 계속 울린다
개선 방향:
현재: 실패 → 알림 → 수동 재처리
목표: 실패 → 알림 + DB 저장 → 자동 재시도 (Exponential Backoff) → 최종 실패 시 알림
구체적인 개선 방안:
- Spring Retry:
@Retryable을 사용한 자동 재시도와@Recover로 최종 실패 처리 - Dead Letter DB 테이블: 실패한 요청을 DB에 저장하고 별도 배치로 재처리
- 알림 쓰로틀링: 동일 오류가 N회 이상 발생 시에만 알림 (중복 알림 방지)
- Kafka/RabbitMQ: 트래픽이 충분히 커지면 실제 메시지 브로커 도입
지금 다시 설계한다면 Spring Retry와 DB 저장을 함께 적용하는 구조를 선택할 것이다. 운영 초기에는 수동 재처리도 감당할 수 있지만, 서비스가 성장하면 반드시 자동화가 필요해진다.
마무리
Dead Letter Queue는 화려한 기술이 아니다. 하지만 "외부 API가 실패하면 어떻게 되나요?"라는 질문에 자신 있게 답할 수 있게 해주는 설계다.
장애를 두려워하는 코드가 아니라, 장애를 예상하고 준비하는 코드를 작성하는 것. 그것이 프로덕션 레벨 코드와 토이 프로젝트의 차이라고 생각한다.