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시간이었던 그 경험을 공유합니다.
환경
| 항목 | 값 |
|---|---|
| Java | 17 |
| Spring Boot | 2.7.x |
| Message Queue | AWS SQS |
| DB | MySQL 8.0 |
| 격리 수준 | REPEATABLE_READ (MySQL 기본값) |
| 트래픽 | DAU 약 10만, 예약 발송 일 약 5,000건 |
목차
- 문제 상황
- 원인 분석
- 해결 방법
- 재발 방지
- 시스템 점검 체크리스트
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:#fffConsumer에서 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_READ | Phantom 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:#fff4. 재발 방지
적용 결과
| 지표 | Before | After |
|---|---|---|
| 메시지 실패율 | 약 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가 임계값을 넘으면 최근 에러 패턴과 함께 알림이 오니, “어제 밤부터 쌓였네”가 아니라 발생 즉시 대응할 수 있습니다.