DLQ이용 외부 api 장애 알람 구축기

찬디·2025년 10월 3일

우테코

목록 보기
17/18
post-thumbnail

개요

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

DLQ 이미지

예상 독자

  • 트랜잭션 아웃박스 패턴과 DLQ의 차이를 알고 싶은 개발자
  • 최종적 일관성(final consistency)에 관심 있는 개발자
  • 외부 API 호출이나 MSA 환경에서 데이터 정합성을 고민하는 개발자

이제 DLQ에 대해서 알아보자.

DLQ의 목적

DLQ는 일반적으로 실패한 요청에 대한 안전망으로 사용된다.
주요 활용 케이스는 다음과 같다.

상황설명DLQ 역할
메시지 처리 실패 가능성이 있는 시스템외부 API 호출, 결제, 이메일 발송 등 실패 확률 존재실패 메시지 격리 후 재처리/분석
실패 시 즉시 재처리 불가능한 경우의존 서비스 다운, DB 장애 등DLQ에 저장 후 시스템 안정화 후 재처리
무한 재시도를 방지해야 하는 경우재시도 반복 시 시스템 과부하 발생실패 카운트 기반 메시지 폐기 또는 알람
문제 원인 추적 필요메시지 payload, 오류 이유 기록 필요DLQ를 통한 문제 재현 및 디버깅 용이

정리: DLQ는 실패 메시지를 단순히 버리지 않고 격리, 분석, 재처리를 가능하게 해주는 안전 장치다.


비슷한 패턴

트랜잭션 아웃박스

  • 트랜잭션과 데이터 일관성 보장을 위한 패턴
  • 한 트랜잭션 내에서 원자적(atomic) 처리를 위해서 저장장소(DB,메시지 등)를 통해 보장하려는 패턴
  • 간단하게는 저장소에 저장하고 즉각적으로는 아니어도 언젠가는 성공할 수 있게 하는 패턴이다
    • 다음 그림에서 메시지 브로커가 나온 이유는 메시지 브로커가 실패하는 경우에 내부적으로 일관성을 제공해주기 때문이다.

이해를 돕기위한 다이어그램 aws documentation
일단 DB에 저장하고 로직을 처리한다

주의점

꼭 트랜잭션 아웃박스가 메시지 아키텍쳐와 연관있을 필요는 없음

  • 많은 자료에서 DB -> 메시지 브로커 흐름 예시를 드는 이유는 비동기 시스템과의 통합이 흔하기 때문임.
  • 하지만 트랜잭션 아웃박스는 단순히 외부 상태와 일관성을 맞추고 싶을 때도 적용 가능.

트랜잭션 아웃박스와 DLQ 차이 정리

항목트랜잭션 아웃박스DLQ
목적메시지/DB 일관성 보장실패 메시지 처리 안전망
메시지 처리 실패별도 처리 필요 없음메시지 격리 후 재처리 가능
사용 시점정상 흐름예외, 실패 발생 시
구현 위치애플리케이션 내부 DB메시지 브로커 혹은 전용 큐

서킷 브레이커

  • 외부 서비스 호출 실패 시 즉시 실패(fail fast) 처리
  • 연속 실패 시 호출 차단 → 시스템 보호
  • DLQ와 유사하게 실패 메시지를 처리할 수 있지만, 목적이 다르다
    만약 서킷 브레이커가 익숙하지 않다면, "외부 api가 여러번 실패하면 open 상태가 되며 일정시간동안 요청을 차단하는 패턴" 정도로만 이해하자

이해를 돕기위한 다이어그램 출처 망나니개발자

서킷 브레이커는 시스템 보호에 목적이 있다

서킷 브레이커와 DLQ 차이

항목서킷 브레이커DLQ
목적시스템 과부하 방지, 빠른 실패메시지 재처리, 분석, 문제 원인 추적
메시지 저장XO
사용 시점외부 서비스/의존 시스템 호출메시지 처리 실패

요약

  • DLQ: 실패 메시지 안전하게 격리 → 재처리/분석/알람
  • 트랜잭션 아웃박스: 처리(트랜잭션) 일관성 보장
  • 서킷 브레이커: 외부 서비스 장애 시 시스템 보호

DLQ 적용한 예

외부 api를 사용하는 예제

  1. 외부 API(OpenAI) 호출
  2. 실패하면 failover API(Claude) 호출
  3. 그마저도 실패하면 트랜잭션 아웃박스 패턴으로 스케줄링 재시도

위에서 설명한 내용을 그린 플로우 다이어그램

여기서 쟁점은 다음과 같다

  • failover나 트랜잭션 아웃박스 방식으로 실패하는 경우, 개발자는 원인을 알 수 없음
    - 아 알아서 됐구나하면서 넘어가게됨
  • 만약, 트랜잭션 아웃박스 패턴으로도 실패한다면 어떻게 할것인가?

수동 대응을 해야한다

만약, 트랜잭션 아웃박스나 서킷 브레이커등 여러가지 방법으로도 실패하는 경우에는 어떻게 해야할까?

  • 트랜잭션 아웃박스까지 실패하는 경우에는 해당 원인을 분석할 필요가 있다

    • 최악의 경우에는
  • 지속적으로 장애가 발생하는 경우에는 개발자가 직접 해당 원인을 분석할 필요가 있음

  • 준비된 외부 api들 마저도 모두 실패하는 경우에는 공지를 올리거나 어떻게든 대응할 필요가 있음

모든 방식이 실패한다면 개발자에게 알리자

  • 모든 방식이 실패한다면 특별히 개발자에게 다이렉트로 알람을 보낼 필요가 있다

Transactional Outbox로 시도하다 실패하면 알람 보내는 방식

  • 가장 간단하게, 트랜잭션 아웃박스가 실패하면 알람을 보내면 안되는걸까?

알람이 휘발될 가능성

  • 알람만 의존하면 실패 메시지 자체가 소실될 위험이 존재한다고 판단했다
  • 알람까지 실패하면, 적어도 DB에서라도 확인할 수 있어야한다.

DLQ 방식으로 실패한 메시지를 저장하자.

설계

slack api, dlq 저장 및 알람 부분이 추가됐다.

고려할 점

  • DLQ를 다른 api들도 재사용가능하게 구축해보자

DLQ Entity 설계

  • elq entity에 기대하는것은 다음과 같다
    • 로깅 역할을 할 수 있을것
    • 기존 이벤트를 추적할 수 있을 것
      따라서 페이로드, 스택 트레이스, 에러메시지가 필요하다
      또한 알람이 전송된 경우에는 boolean으로 관리한다
      또한 기존의 이벤트 타입을 담고 있어야한다
  • ex) outbox event 등..
@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;
    }
}

1. dlq에 저장 및 전송

/**
     * 실패한 이벤트를 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);
                }
            }
        }
    }

2. 오래된 dlq 삭제

오래되어 사용되지 않을것으로 예상되는 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);
                }
            }
        }
    }

3. 결과 슬랙 알람 메시지

🚨 *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,메시지, 시도한 이벤트 등을 포함하였다.

3겹으로 방어하고, 이렇게까지했는데, 이젠 외부 api 걱정없이 써도 괜찮겠죠?

  • 아쉽게도 이조차도 방어책을 한개 더 추가한것에 불과하다.
    위 예에서도 슬랙 api가 실패하면 개발자는 알림을 받지 못하는 문제가 생기기 때문이다
    • 이경우 슬랙 api가 실패하면 500 에러가 모니터링에 잡히게 되기때문에 DLQ가 완전 쓸모가 없었던것은 아니다.

이렇듯 아무리 방어책을 두어도, 방어책 조차 외부 api라면 딜레마에 빠지게 된다

  • 외부 api를 사용하는 방어책의 외부 api가 실패하면 어떻게할것인가?

기능이 안전해야할 수록, 방어책을 위한 방어책을 두는 수 밖에는 없다
하지만 아무리 방어책을 두어도 실패할 가능성을 0퍼센트로 만들기는 불가능하다

항상 성공하는 서비스는 없고, 우리가 할 수 있는것은 빠르게 대응하는것뿐이다.
결국 위 딜레마는 다음의 내용으로 귀결된다

해당 기능의 안정성을 위해 얼마나 자원을 투자할 수 있는가?
얼마만큼의 가용성을 허용하는가?

이것이 여러 기업에서 가용성 100%를 내세우는 것이 아닌 SLA를 두는 이유이다.

결론 : SLA


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

aws cloudfront는 다음과 같이 약속했다

클라우드프론트는 이번달 제공된 가용성에 따라서 요금을 깎아준다고 한다..!

이번 경험으로 현실적으로 가용성 100%는 불가능에 가깝다는 것을 체감한것같다.
대신 서비스의 SLA 기준을 세우고 SLA를 충족하도록 설계하는것이 좀 더 효율적인 시선이라고 생각하게 됐다.

profile
깃허브에서 velog로 블로그를 이전했습니다.

0개의 댓글