SQS로 비동기 처리를 붙이고 나면 거의 비슷한 순서로 질문이 들어옵니다. "같은 메시지가 두 번 처리됐어요", "처리에 실패한 메시지가 큐를 계속 맴돌아요". 저도 주문 처리 파이프라인을 SQS로 옮기면서 똑같은 지점에서 한 번씩 걸렸습니다. 이번 글은 그때 정리해 둔 대응 방법을 공식 문서 기준으로 다시 손봐서 올립니다.
결론부터 말하자면, 표준 큐는 "적어도 한 번(at-least-once)" 전달이라 중복은 막는 게 아니라 멱등 컨슈머로 흡수해야 합니다. 처리 실패는 가시성 타임아웃을 실제 처리 시간에 맞춰 재전달을 유도하고, 반복 실패하는 독성 메시지는 DLQ로 격리한 뒤 원인을 고쳐 redrive로 되돌립니다. 아래에서 가시성 타임아웃, 멱등 컨슈머, DLQ를 차례로 살펴봅니다.
1. 왜 중복과 실패가 생기나
SQS 표준 큐는 처리량이 높은 대신, 같은 메시지를 한 번 넘게 전달할 수 있습니다. 공식 문서도 표준 큐를 적어도 한 번 전달 모델로 설명하고, 가시성 타임아웃 안에서도 중복 전달이 일어나지 않는다고 보장하지 않는다고 못 박습니다. 즉 중복은 버그가 아니라 표준 큐의 전제입니다.
예를 들어 컨슈머가 주문 메시지를 받아 결제를 처리하고 메시지를 삭제하기 직전에 프로세스가 죽으면, 메시지는 삭제되지 않은 채 다시 보이게 되고 다른 컨슈머가 같은 주문을 또 처리합니다. 결제가 두 번 나갈 수 있는 상황입니다.
처리 실패도 같은 메커니즘입니다. 컨슈머가 메시지를 삭제하지 못하면(예외, 크래시, 네트워크 문제) 메시지는 가시성 타임아웃이 지난 뒤 큐로 돌아옵니다. 코드 버그 때문에 영원히 실패하는 메시지라면, 격리하지 않는 한 큐를 계속 맴돌며 자원을 갉아먹습니다. 이런 메시지를 보통 독성 메시지(poison message)라고 부릅니다.
2. 가시성 타임아웃 튜닝
가시성 타임아웃은 컨슈머가 메시지를 받은 순간부터 그 메시지를 다른 컨슈머에게 안 보이게 숨겨 두는 시간입니다. 이 시간 안에 처리하고 DeleteMessage로 지워야 합니다. 못 지우면 메시지가 다시 큐에 나타나 재처리 대상이 됩니다.
기본값은 30초, 최대는 메시지를 처음 받은 시점부터 12시간입니다. 큐 단위로 바꿀 수도 있고, 메시지 단위로 따로 줄 수도 있습니다.
2.1 큐 단위 설정
처리에 평균 40~50초가 걸리는 작업이라면 기본 30초로는 짧습니다. 처리 도중에 메시지가 다시 보이게 되어 다른 컨슈머가 같은 일을 또 시작하기 때문입니다. 처리 시간의 최댓값에 여유를 더해 잡습니다.
aws sqs set-queue-attributes \
--queue-url https://sqs.ap-northeast-2.amazonaws.com/123456789012/orders \
--attributes VisibilityTimeout=60
aws sqs get-queue-attributes \
--queue-url https://sqs.ap-northeast-2.amazonaws.com/123456789012/orders \
--attribute-names VisibilityTimeout
< 결과 >
{
"Attributes": {
"VisibilityTimeout": "60"
}
}
여기서 한 번 걸리기 쉽습니다. 타임아웃을 너무 길게 잡으면(예: 처리는 1분인데 15분으로) 컨슈머가 죽었을 때 메시지가 15분 동안 안 보입니다. 그만큼 재처리가 늦어집니다. 짧게 잡으면 중복 처리가 늘어납니다. 그래서 "실제 처리 시간에 맞춘다"가 핵심입니다.
2.2 긴 작업은 하트비트로 연장
처리 시간이 들쭉날쭉하거나 가끔 길어진다면, 처음부터 타임아웃을 크게 잡는 대신 처리하면서 ChangeMessageVisibility로 조금씩 연장하는 편을 권합니다. 공식 문서가 권하는 하트비트 방식입니다.
# 처리가 길어질 때 주기적으로 가시성 연장 (예: 120초로 재설정)
sqs.change_message_visibility(
QueueUrl=QUEUE_URL,
ReceiptHandle=msg["ReceiptHandle"],
VisibilityTimeout=120,
)
다만 연장을 해도 처음 받은 시점부터 12시간 한도는 늘어나지 않습니다. 12시간을 넘길 작업이면 Step Functions로 빼거나 작업을 잘게 쪼개야 합니다.
반대로 "이 메시지는 지금 처리 못 하겠다" 싶으면 VisibilityTimeout을 0으로 주어 즉시 큐로 되돌릴 수 있습니다.
3. 멱등 컨슈머로 중복 흡수
멱등(idempotent)은 같은 메시지를 몇 번 처리해도 결과가 한 번 처리한 것과 같다는 뜻입니다. 표준 큐에서 중복을 없앨 수는 없으니, 컨슈머가 중복을 받아도 부작용이 한 번만 나게 만드는 게 정답입니다.
처리 완료 여부를 외부 저장소(DynamoDB, Redis 등)에 기록해 두고, 이미 처리한 메시지면 건너뜁니다. 여기서 중요한 순서가 있습니다. 처리 완료 기록은 반드시 실제 처리가 성공한 뒤에 남겨야 하고, 메시지 삭제도 성공했을 때만 해야 합니다.
이 순서를 어기면 메시지 유실 사고가 납니다. 처리에 들어가기 전에 먼저 "처리함"으로 기록하고 메시지를 무조건 삭제하는 코드를 흔히 보는데, 이러면 handle()이 예외로 실패해도 기록은 이미 남습니다. 재전달됐을 때 "이미 처리함"으로 판정해 건너뛰고 메시지를 지워 버립니다. 실패한 메시지가 maxReceiveCount에 도달하지 못해 DLQ로 가지 못하고 조용히 사라집니다. 뒤에서 설명할 재처리 자체가 무력화됩니다.
성공한 뒤에 기록하고, 실패하면 아무것도 지우지 않는 버전은 이렇게 씁니다.
import boto3
import logging
log = logging.getLogger(__name__)
sqs = boto3.client("sqs")
QUEUE_URL = "https://sqs.ap-northeast-2.amazonaws.com/123456789012/orders"
def already_processed(key):
# DynamoDB 등에서 처리 완료 기록 조회 (있으면 True)
...
def mark_processed(key):
# 처리 완료를 멱등하게 저장. TTL을 걸어 두는 것을 권장
...
def poll():
resp = sqs.receive_message(
QueueUrl=QUEUE_URL,
MaxNumberOfMessages=10,
WaitTimeSeconds=20, # 롱 폴링
)
for msg in resp.get("Messages", []):
# 생산자 중복까지 막으려면 MessageId 대신 업무 키를 쓴다 (3.1 참고)
key = msg["MessageId"]
try:
if not already_processed(key):
handle(msg["Body"]) # 실제 처리
mark_processed(key) # 성공한 뒤에만 기록
# 처리 성공이거나 이미 처리된 중복일 때만 삭제
sqs.delete_message(
QueueUrl=QUEUE_URL,
ReceiptHandle=msg["ReceiptHandle"],
)
except Exception:
# 삭제하지 않는다. 가시성 타임아웃이 지나면 재전달되고
# maxReceiveCount를 넘으면 DLQ로 격리된다.
log.warning("처리 실패, 재전달 대기: %s", key)
핵심은 except 블록에서 delete_message를 호출하지 않는다는 점입니다. 실패한 메시지는 지우지 않아야 다시 큐로 돌아오고, 반복 실패하면 DLQ로 넘어갑니다.
3.1 중복 판정 키는 무엇으로 잡나
위 예제는 MessageId로 판정합니다. 이건 가시성 타임아웃이 지나 같은 메시지가 재전달되는 경우(동일 MessageId)를 막는 데는 맞습니다. 하지만 생산자가 같은 주문을 두 번 SendMessage하면 MessageId가 서로 달라 판정을 못 합니다.
그래서 실무에서는 업무 수준의 멱등 키를 씁니다. 주문 ID, 결제 요청 ID처럼 "같은 일"을 식별하는 값을 메시지 본문이나 속성에 담고, 그 키로 already_processed를 판정합니다. 동시에 들어온 중복까지 막으려면 DynamoDB 조건부 쓰기(attribute_not_exists) 같은 원자적 연산으로 기록합니다.
4. DLQ로 독성 메시지 격리
DLQ(Dead Letter Queue)는 처리에 끝내 실패한 메시지를 따로 모아 두는 큐입니다. 원본 큐에 redrive 정책을 걸어 두면, 메시지가 일정 횟수 넘게 전달됐는데도 삭제되지 않았을 때 SQS가 그 메시지를 DLQ로 옮깁니다. 독성 메시지가 원본 큐를 맴돌지 않게 하고, 모아 놓고 원인을 분석할 수 있습니다.
기준이 되는 값이 maxReceiveCount입니다. 컨슈머가 한 메시지를 받을 수 있는 최대 횟수이고, 이 횟수를 넘기면 DLQ로 이동합니다. 1처럼 낮게 잡으면 한 번만 실패해도 바로 DLQ로 가니, 재시도 여유를 두고 적당히(예: 5) 잡습니다.
4.1 DLQ 만들고 연결
DLQ는 먼저 일반 큐로 만든 뒤 원본 큐의 대상으로 지정합니다. DLQ는 원본 큐와 같은 계정, 같은 리전에 있어야 합니다.
# 1) DLQ 생성
aws sqs create-queue --queue-name orders-dlq
# 2) DLQ의 ARN 확인
aws sqs get-queue-attributes \
--queue-url https://sqs.ap-northeast-2.amazonaws.com/123456789012/orders-dlq \
--attribute-names QueueArn
< 결과 >
{
"Attributes": {
"QueueArn": "arn:aws:sqs:ap-northeast-2:123456789012:orders-dlq"
}
}
원본 큐에 redrive 정책을 겁니다. deadLetterTargetArn에 DLQ의 ARN을, maxReceiveCount에 재시도 한도를 넣습니다.
aws sqs set-queue-attributes \
--queue-url https://sqs.ap-northeast-2.amazonaws.com/123456789012/orders \
--attributes '{"RedrivePolicy":"{\"deadLetterTargetArn\":\"arn:aws:sqs:ap-northeast-2:123456789012:orders-dlq\",\"maxReceiveCount\":\"5\"}"}'
따옴표 이스케이프 때문에 여기서 자주 막힙니다. RedrivePolicy 값 자체가 JSON 문자열이라, 바깥 JSON 안에서 다시 이스케이프해야 합니다. 셸에서 깨지면 --attributes file://redrive.json처럼 파일로 넘기는 편이 편합니다.
4.2 DLQ 적재 알림
DLQ에 메시지가 쌓인다는 건 뭔가 고장 났다는 신호입니다. 그냥 두면 모르고 지나가니 CloudWatch에서 ApproximateNumberOfMessagesVisible에 알람을 거는 것을 권장합니다.
aws cloudwatch put-metric-alarm \ --alarm-name orders-dlq-not-empty \ --namespace AWS/SQS \ --metric-name ApproximateNumberOfMessagesVisible \ --dimensions Name=QueueName,Value=orders-dlq \ --statistic Maximum --period 300 \ --evaluation-periods 1 --threshold 0 \ --comparison-operator GreaterThanThreshold \ --alarm-actions arn:aws:sns:ap-northeast-2:123456789012:ops-alerts
💡 DLQ의 메시지 보존 기간은 원본 큐보다 길게 잡으세요. 표준 큐에서 메시지 만료는 처음 큐에 들어온 시각을 기준으로 계산되고, DLQ로 옮겨져도 이 타임스탬프가 바뀌지 않습니다. 보존 기간이 짧으면 분석하기도 전에 지워질 수 있습니다.
5. redrive로 되돌려 재처리
DLQ에 쌓인 메시지는 원인(코드 버그, 외부 의존성 장애 등)을 먼저 고친 다음 원본 큐로 되돌려 재처리합니다. 이 되돌리기를 redrive라고 합니다. 콘솔의 "Dead-letter queue redrive" 버튼으로도 되고, CLI로는 start-message-move-task를 씁니다.
# DLQ(source)에서 원래 소스 큐로 메시지 이동 시작
aws sqs start-message-move-task \
--source-arn arn:aws:sqs:ap-northeast-2:123456789012:orders-dlq
< 결과 >
{
"TaskHandle": "eyJ0YXNrSWQiOiI..."
}
--destination-arn을 생략하면 각 메시지가 원래 있던 소스 큐로 돌아갑니다. 이동 중인 작업은 list-message-move-tasks로 상태를 보고, 필요하면 cancel-message-move-task로 멈출 수 있습니다.
aws sqs list-message-move-tasks \ --source-arn arn:aws:sqs:ap-northeast-2:123456789012:orders-dlq
⚠️ 원인을 고치기 전에 redrive하면 메시지가 또 실패해서 maxReceiveCount를 넘기고 그대로 DLQ로 돌아옵니다. "고친 뒤에 되돌린다"는 순서를 지키세요. 그리고 재처리 역시 중복 처리이므로, 3번의 멱등 컨슈머가 전제되어야 안전합니다.
6. 마무리
정리하면 표준 큐의 중복과 실패는 세 겹으로 막습니다. 가시성 타임아웃을 실제 처리 시간에 맞춰 불필요한 재전달과 중복을 줄이고, 멱등 컨슈머로 그래도 들어오는 중복을 흡수하고, DLQ와 redrive로 반복 실패 메시지를 격리했다가 고쳐서 되돌립니다. 이 중에서 하나만 꼽으라면 멱등 컨슈머입니다. 중복은 표준 큐의 전제라서, 컨슈머가 멱등하지 않으면 나머지 설정을 아무리 잘해도 사고가 납니다.
FIFO 큐를 쓰면 중복 제거와 순서 보장을 큐 차원에서 받을 수 있지만 처리량 특성이 다르고, DLQ 사용 시 순서가 깨질 수 있다는 점은 따로 고려해야 합니다. 이 글은 표준 큐 기준입니다.