Skip to content

SQS DLQ에 메시지가 쌓이고 있었다 - 예약 발송 장애 대응기

SQS DLQ에 메시지가 쌓이고 있었다 - 예약 발송 장애 대응기

TL;DR

  • 증상: 예약 메시지 발송이 간헐적으로 실패, DLQ에 메시지가 쌓이는데 모니터링이 없어서 뒤늦게 발견
  • 원인: Consumer에서 DB 업데이트 시 트랜잭션 격리 수준 문제로 데드락 발생
  • 해결: 격리 수준 READ_COMMITTED로 조정 + DLQ 모니터링 알림 + 재처리 로직 구현
  • 효과: 실패율 약 3% → 0.1% 미만, MTTD(Mean Time To Detect) 3일 → 5분
  • 한계: 재처리 로직이 멱등성을 보장해야 하고, DLQ 메시지 원인 분석은 여전히 수동

왜 이 글을 쓰게 됐나

이전 회사에서 메신저 SaaS 서비스의 예약 메시지 발송 기능을 담당했습니다.

예약 발송은 사용자가 미리 설정한 시간에 메시지가 발송되는 기능인데요. 처음에는 스케줄러로 구현했다가, 트래픽이 늘어나면서 SQS 비동기 방식으로 전환했습니다.

전환 후 잘 되는 줄 알았는데… 어느 날 CS팀에서 연락이 왔습니다.

“예약 메시지가 안 갔다고 하는 고객이 있어요. 확인 좀 해주세요.”

로그를 뒤져보니 SQS Consumer에서 간헐적으로 실패하고 있었고, DLQ(Dead Letter Queue)에 메시지가 수백 개 쌓여 있었습니다.

수정 3분, 원인 파악 3시간이었던 그 경험을 공유합니다.


환경

항목
Java17
Spring Boot2.7.x
Message QueueAWS SQS
DBMySQL 8.0
격리 수준REPEATABLE_READ (MySQL 기본값)
트래픽DAU 약 10만, 예약 발송 일 약 5,000건

목차

  1. 문제 상황
  2. 원인 분석
  3. 해결 방법
  4. 재발 방지
  5. 시스템 점검 체크리스트

1. 문제 상황

증상

예약 메시지 발송이 간헐적으로 실패하고 있었습니다. 특이한 점은 이랬습니다.

  • 평소에는 잘 되다가 특정 시간대에 집중적으로 실패
  • 같은 메시지를 재시도하면 성공하기도 함
  • 에러 로그에 DeadlockLoserDataAccessException이 찍혀 있음

더 큰 문제: 모니터링 부재

사실 진짜 문제는 따로 있었습니다.

DLQ에 메시지가 쌓이고 있는지 모니터링이 없었어요. CS팀에서 고객 문의가 들어오고 나서야 알게 됐습니다.

DLQ를 콘솔에서 직접 확인해보니 3일치 메시지가 쌓여 있었습니다. 약 150건 정도.

// AWS SQS 콘솔에서 확인한 DLQ 상태
Messages Available: 147
Messages in Flight: 0

기존 아키텍처

기존 구조는 이랬습니다.

flowchart LR
    subgraph Producer
        A[예약 API] --> B[SQS 발송]
    end

    subgraph Consumer
        C[SQS Listener] --> D[메시지 발송]
        D --> E[DB 상태 업데이트]
    end

    subgraph DLQ
        F[Dead Letter Queue]
    end

    B --> C
    C -->|3회 실패| F

    style F fill:#f66,stroke:#333,color:#fff

Consumer에서 3회 재시도 후 실패하면 DLQ로 이동하는 구조였는데, DLQ에 메시지가 쌓여도 아무도 몰랐습니다.


2. 원인 분석

데드락 발생 지점 추적

에러 로그를 보니 DeadlockLoserDataAccessException이 찍혀 있었습니다.

org.springframework.dao.DeadlockLoserDataAccessException:
Deadlock found when trying to get lock;
try restarting transaction

데드락이 어디서 발생하는지 확인하기 위해 MySQL의 SHOW ENGINE INNODB STATUS를 실행했습니다.

SHOW ENGINE INNODB STATUS;

-- 결과 (일부 발췌)
LATEST DETECTED DEADLOCK
------------------------
*** (1) TRANSACTION:
TRANSACTION 12345, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1136
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 100 page no 50 n bits 72 index PRIMARY

*** (2) TRANSACTION:
TRANSACTION 12346, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1

문제의 코드

Consumer 코드를 살펴보니 이런 구조였습니다.

@Service
public class MessageConsumer {

    @SqsListener("${sqs.queue.name}")
    @Transactional
    public void processMessage(ScheduledMessage message) {
        // 1. 발송 상태를 PROCESSING으로 변경
        messageRepository.updateStatus(message.getId(), Status.PROCESSING);

        // 2. 실제 메시지 발송 (외부 API 호출)
        messagingService.send(message);

        // 3. 발송 상태를 COMPLETED로 변경
        messageRepository.updateStatus(message.getId(), Status.COMPLETED);
    }
}

겉보기엔 문제없어 보이는데요. 문제는 동시에 여러 Consumer가 같은 시간대의 예약 메시지를 처리할 때 발생했습니다.

왜 데드락이 발생했나

MySQL의 기본 격리 수준인 REPEATABLE_READ에서는 UPDATE 시 넥스트 키 락(Next-Key Lock)을 사용합니다.

쉽게 말하면, UPDATE할 때 해당 행뿐만 아니라 인접한 행까지 락을 거는 거예요.

예를 들어 이런 상황입니다.

시간: 09:00 (예약 발송 피크 타임)

Consumer A: UPDATE messages SET status='PROCESSING' WHERE id=100
Consumer B: UPDATE messages SET status='PROCESSING' WHERE id=101
Consumer A: UPDATE messages SET status='COMPLETED' WHERE id=100  -- 대기
Consumer B: UPDATE messages SET status='COMPLETED' WHERE id=101  -- 대기

→ 데드락 발생!

인접한 ID의 메시지를 동시에 처리하다 보니, 넥스트 키 락이 서로 충돌한 겁니다.


3. 해결 방법

3.1 격리 수준 조정

첫 번째로 트랜잭션 격리 수준을 READ_COMMITTED로 낮췄습니다.

READ_COMMITTED에서는 넥스트 키 락 대신 레코드 락만 사용하기 때문에 인접 행 간의 데드락을 피할 수 있습니다.

@Service
public class MessageConsumer {

    @SqsListener("${sqs.queue.name}")
    @Transactional(isolation = Isolation.READ_COMMITTED)  // 변경
    public void processMessage(ScheduledMessage message) {
        messageRepository.updateStatus(message.getId(), Status.PROCESSING);
        messagingService.send(message);
        messageRepository.updateStatus(message.getId(), Status.COMPLETED);
    }
}

격리 수준 Trade-off

격리 수준장점단점
REPEATABLE_READPhantom Read 방지, 데이터 일관성넥스트 키 락으로 데드락 가능성
READ_COMMITTED락 범위 최소화, 동시성 향상Phantom Read 가능 (우리 케이스에서는 괜찮음)

예약 메시지 발송 로직에서는 같은 트랜잭션 내에서 SELECT를 여러 번 하지 않기 때문에 Phantom Read가 문제되지 않았습니다.

3.2 DLQ 모니터링 알림 추가

두 번째로 DLQ에 메시지가 쌓이면 바로 알 수 있도록 CloudWatch 알람을 설정했습니다.

// CloudWatch 알람 설정 (Terraform)
resource "aws_cloudwatch_metric_alarm" "dlq_alarm" {
  alarm_name          = "scheduled-message-dlq-alarm"
  comparison_operator = "GreaterThanThreshold"
  evaluation_periods  = "1"
  metric_name         = "ApproximateNumberOfMessagesVisible"
  namespace           = "AWS/SQS"
  period              = "300"  // 5분
  statistic           = "Average"
  threshold           = "0"    // 1개라도 쌓이면 알림

  dimensions = {
    QueueName = "scheduled-message-dlq"
  }

  alarm_actions = [aws_sns_topic.alert.arn]
}

3.3 DLQ 재처리 로직 구현

세 번째로 DLQ에 쌓인 메시지를 재처리하는 로직을 만들었습니다.

@Service
@RequiredArgsConstructor
public class DlqReprocessor {

    private final SqsClient sqsClient;
    private final MessageConsumer messageConsumer;

    /**
     * DLQ 메시지를 하나씩 꺼내서 재처리합니다.
     * 멱등성이 보장되어야 안전합니다.
     */
    @Scheduled(fixedDelay = 60000)  // 1분마다 실행
    public void reprocessDlqMessages() {
        ReceiveMessageRequest request = ReceiveMessageRequest.builder()
            .queueUrl(dlqUrl)
            .maxNumberOfMessages(10)
            .build();

        List<Message> messages = sqsClient.receiveMessage(request).messages();

        for (Message message : messages) {
            try {
                ScheduledMessage scheduledMessage = parseMessage(message);

                // 이미 처리된 메시지인지 확인 (멱등성)
                if (isAlreadyProcessed(scheduledMessage.getId())) {
                    deleteFromDlq(message);
                    continue;
                }

                // 재처리
                messageConsumer.processMessage(scheduledMessage);
                deleteFromDlq(message);

                log.info("DLQ 메시지 재처리 성공: {}", scheduledMessage.getId());

            } catch (Exception e) {
                log.error("DLQ 메시지 재처리 실패: {}", message.messageId(), e);
                // 재처리 실패 시 DLQ에 그대로 둠 → 수동 확인 필요
            }
        }
    }

    private boolean isAlreadyProcessed(Long messageId) {
        return messageRepository.findById(messageId)
            .map(m -> m.getStatus() == Status.COMPLETED)
            .orElse(false);
    }
}

개선된 아키텍처

flowchart LR
    subgraph Producer
        A[예약 API] --> B[SQS 발송]
    end

    subgraph Consumer
        C[SQS Listener] --> D[메시지 발송]
        D --> E[DB 상태 업데이트]
    end

    subgraph DLQ
        F[Dead Letter Queue]
        G[DLQ Reprocessor]
    end

    subgraph Monitoring
        H[CloudWatch Alarm]
        I[Slack 알림]
    end

    B --> C
    C -->|3회 실패| F
    F --> G
    G -->|재처리| D
    F --> H
    H --> I

    style F fill:#f96,stroke:#333,color:#fff
    style H fill:#4a9,stroke:#333,color:#fff
    style I fill:#4a9,stroke:#333,color:#fff

4. 재발 방지

적용 결과

지표BeforeAfter
메시지 실패율약 3%0.1% 미만
MTTD (문제 감지 시간)3일 (CS 문의 후)5분 (알람)
DLQ 평균 체류 시간무한 (방치)1시간 이내

교훈

이 경험에서 배운 점은 세 가지입니다.

첫째, DLQ 모니터링은 필수입니다.

DLQ는 “실패한 메시지를 잠시 보관하는 곳”이 아니라 “장애 징후를 알려주는 알람”입니다. 모니터링 없는 DLQ는 의미가 없어요.

둘째, 데드락은 격리 수준과 밀접합니다.

REPEATABLE_READ가 항상 좋은 건 아닙니다. 우리 서비스 특성에 맞는 격리 수준을 선택해야 합니다.

셋째, 멱등성 설계가 중요합니다.

재처리 로직을 만들면 같은 메시지가 여러 번 처리될 수 있습니다. 멱등성이 보장되지 않으면 재처리가 오히려 문제를 만들 수 있어요.


시스템 점검 체크리스트

내 시스템에 비슷한 문제가 없는지 확인해보세요.

  • DLQ에 메시지가 쌓이면 알림이 오는가?
  • Consumer에서 데드락이 발생할 수 있는 구간이 있는가?
  • 트랜잭션 격리 수준이 서비스 특성에 맞는가?
  • DLQ 메시지 재처리 방안이 있는가?
  • 메시지 처리 로직이 멱등성을 보장하는가?

마무리

SQS + DLQ 구조는 비동기 처리의 안정성을 높여주지만, 모니터링 없이는 무용지물입니다.

처음 SQS로 전환할 때 “DLQ가 있으니까 안심”이라고 생각했는데, 정작 DLQ에 메시지가 쌓이는지 확인을 안 하고 있었습니다.

비슷한 상황이라면 지금 바로 DLQ 모니터링부터 점검해보시길 권합니다.

읽어주셔서 감사합니다.🖐


🤖 AI라면 이 장애를 어떻게 다뤘을까?

DLQ 메시지가 쌓이기 시작하면 즉시 알아채는 게 핵심입니다. 지금은 CloudWatch 알림 → AI 에이전트 → 슬랙 경보 체계를 운용 중입니다. DLQ depth가 임계값을 넘으면 최근 에러 패턴과 함께 알림이 오니, “어제 밤부터 쌓였네”가 아니라 발생 즉시 대응할 수 있습니다.

백엔드 개발자의 AI 비서 만들기 시리즈


Ramsbaby
Written byRamsbaby
이 블로그는 직접 개발/운영하는 블로그이므로 당신을 불쾌하게 만드는 불필요한 광고가 없습니다.

#My Github#소개 페이지#Blog OpenSource Github#Blog OpenSource Demo Site