실습 예제 · 7
SQS + Lambda 비동기 처리
큐를 넣는 이유는 유입 속도와 처리 속도를 분리하기 위해서입니다. 처리가 늦어도 요청이 유실되지 않고, 처리 계층은 자기 속도로 소비합니다. 이 실습에서는 큐와 배달 못한 편지 큐(DLQ)를 만들고, 실패를 일부러 만들어 메시지가 어디로 가는지 확인합니다.
학습 목표
- 표준 큐와 FIFO 큐의 차이를 알고 상황에 맞게 고를 수 있습니다.
- 가시성 제한 시간과 함수 제한 시간의 관계를 설명할 수 있습니다.
- 재구동 정책으로 DLQ를 구성하고 넘어가는 조건을 정할 수 있습니다.
- 이벤트 소스 매핑의 배치 크기와 대기 시간이 무엇을 바꾸는지 이해할 수 있습니다.
- 중복 전달에 대비한 멱등 처리의 필요성을 설명할 수 있습니다.
시나리오
주문이 들어오면 영수증을 만들고 알림을 보내는 후처리를 합니다. 후처리는 몇 초가 걸리고 가끔 외부 서비스 오류로 실패합니다.
- 주문 접수는 즉시 응답해야 하므로 후처리를 동기로 두지 않습니다.
- 후처리가 실패해도 메시지가 유실되면 안 됩니다.
- 계속 실패하는 메시지가 큐를 막지 않아야 합니다.
- 순서 보장은 필요하지 않습니다.
마지막 조건 때문에 표준 큐를 고릅니다. 순서와 중복 제거가 필요했다면 FIFO 큐를 골라야 하고, 처리량 특성이 달라집니다.
아키텍처 개요
| 항목 | 표준 큐 | FIFO 큐 |
|---|---|---|
| 기본값 | 속성을 지정하지 않으면 표준 큐 | FifoQueue 속성을 켜야 한다 |
| 순서 | 최선 노력 — 보장 없음 | 메시지 그룹 안에서 엄격한 순서 |
| 중복 | 발생할 수 있다 | 중복 제거 |
| 처리량 | 거의 무제한 | 배치 없이 초당 300개, 배치 사용 시 초당 3,000개 |
| 유형 변경 | 생성 후 변경 불가 — 새로 만들어야 한다 | |
| 속성 | 범위 | 기본값 | 의미 |
|---|---|---|---|
VisibilityTimeout |
0초 – 12시간 | 30초 | 수신된 메시지가 다른 소비자에게 보이지 않는 시간 |
MessageRetentionPeriod |
60초 – 14일 | 4일 | 처리되지 않은 메시지를 큐에 보관하는 기간 |
DelaySeconds |
0 – 900초 | 0 | 메시지가 소비 가능해지기까지의 지연 |
MaximumMessageSize |
1 KiB – 1 MiB | 1 MiB | 메시지 본문의 최대 크기 |
구성 단계
1 · 큐와 DLQ 만들기
# DLQ 를 먼저 만듭니다. 주 큐가 이 ARN 을 참조하기 때문입니다.
DLQ_URL=$(aws sqs create-queue \
--queue-name lab07-orders-dlq \
--attributes '{"MessageRetentionPeriod":"1209600"}' \
--query 'QueueUrl' --output text)
DLQ_ARN=$(aws sqs get-queue-attributes \
--queue-url "$DLQ_URL" \
--attribute-names QueueArn \
--query 'Attributes.QueueArn' --output text)
QUEUE_URL=$(aws sqs create-queue \
--queue-name lab07-orders \
--attributes "{
\"VisibilityTimeout\": \"180\",
\"MessageRetentionPeriod\": \"345600\",
\"RedrivePolicy\": \"{\\\"deadLetterTargetArn\\\":\\\"$DLQ_ARN\\\",\\\"maxReceiveCount\\\":\\\"3\\\"}\"
}" \
--query 'QueueUrl' --output text)
aws sqs get-queue-attributes \
--queue-url "$QUEUE_URL" \
--attribute-names All \
--query 'Attributes.{Visibility:VisibilityTimeout,Retention:MessageRetentionPeriod,Redrive:RedrivePolicy}'
DLQ의 보존 기간은 주 큐보다 길게 잡습니다. 실패한 메시지를 조사할 시간이 필요한데, DLQ의 보존 기간이 짧으면 원인을 찾기 전에 사라집니다.
2 · 처리 함수
export const handler = async (event) => {
const failures = [];
for (const record of event.Records) {
try {
const body = JSON.parse(record.body);
if (body.simulateFailure) {
throw new Error("simulated downstream failure");
}
// 실제 후처리를 여기서 수행합니다. 멱등하게 작성합니다.
console.log("processed", body.orderId);
} catch (err) {
console.error("failed", record.messageId, err.message);
failures.push({ itemIdentifier: record.messageId });
}
}
// 실패한 항목만 다시 큐로 돌려보냅니다.
return { batchItemFailures: failures };
};
zip -q function.zip index.mjs
aws lambda create-function \
--function-name lab07-order-worker \
--runtime nodejs22.x \
--role arn:aws:iam::123456789012:role/Lab07WorkerRole \
--handler index.handler \
--zip-file fileb://function.zip \
--timeout 30 \
--memory-size 256
실행 역할에는 로그 권한과 함께
sqs:ReceiveMessage, sqs:DeleteMessage,
sqs:GetQueueAttributes 권한이 필요합니다.
이벤트 소스 매핑에서 Lambda 서비스가 함수의 역할로 큐를 폴링하기 때문입니다.
3 · 이벤트 소스 매핑
QUEUE_ARN=$(aws sqs get-queue-attributes \
--queue-url "$QUEUE_URL" \
--attribute-names QueueArn \
--query 'Attributes.QueueArn' --output text)
aws lambda create-event-source-mapping \
--function-name lab07-order-worker \
--event-source-arn "$QUEUE_ARN" \
--batch-size 10 \
--maximum-batching-window-in-seconds 5 \
--function-response-types ReportBatchItemFailures
aws lambda list-event-source-mappings \
--function-name lab07-order-worker \
--query 'EventSourceMappings[].[UUID,State,BatchSize,EventSourceArn]' \
--output table
검증 방법
# 정상 처리될 메시지
aws sqs send-message \
--queue-url "$QUEUE_URL" \
--message-body '{"orderId":"ord-1001"}'
# 항상 실패하도록 표시한 메시지
aws sqs send-message \
--queue-url "$QUEUE_URL" \
--message-body '{"orderId":"ord-9999","simulateFailure":true}'
# 큐에 남은 메시지와 처리 중인 메시지 수
aws sqs get-queue-attributes \
--queue-url "$QUEUE_URL" \
--attribute-names ApproximateNumberOfMessages ApproximateNumberOfMessagesNotVisible \
--query 'Attributes'
# 최대 수신 횟수를 넘긴 메시지는 DLQ 로 넘어갑니다.
aws sqs get-queue-attributes \
--queue-url "$DLQ_URL" \
--attribute-names ApproximateNumberOfMessages \
--query 'Attributes'
| 관찰 | 기대 결과 |
|---|---|
| 정상 메시지 | 한 번 처리되고 큐에서 사라진다 |
| 실패 메시지 | 가시성 제한 시간이 지날 때마다 재전달되고, 3회를 넘기면 DLQ로 이동한다 |
| 배치 부분 실패 | 같은 배치의 성공 건은 재처리되지 않는다 |
| 함수 로그 | 실패 메시지의 messageId가 반복해서 나타난다 |
| DLQ | 실패 메시지 본문이 원본 그대로 남아 있다 |
aws sqs receive-message \
--queue-url "$DLQ_URL" \
--max-number-of-messages 10 \
--query 'Messages[].[MessageId,Body]' --output table
# 원인을 고친 뒤에는 DLQ 의 메시지를 원래 큐로 되돌릴 수 있습니다.
aws sqs start-message-move-task \
--source-arn "$DLQ_ARN"
비용 고려
| 항목 | 성격 |
|---|---|
| SQS 요청 | 송신·수신·삭제 요청 수. 폴링도 요청이다 |
| Lambda 호출과 실행 시간 | 배치 크기를 키우면 호출 수가 줄어든다 |
| 긴 폴링 | 대기 시간을 두면 빈 응답 요청이 줄어 요청 수가 감소한다 |
| DLQ 보관 | 메시지가 오래 남을수록 저장 관련 항목이 늘어난다 |
| 재시도 | 실패가 반복되면 호출과 요청이 배로 늘어난다. DLQ가 이 비용을 끊는다 |
비용과 지연은 배치 크기와 대기 시간에서 맞바꿉니다. 배치를 키우고 대기 시간을 늘리면 호출 수가 줄지만 처리 지연이 커집니다. 지연 요구를 먼저 정하고 그 범위 안에서 배치를 키우는 순서가 맞습니다.
프로덕션 고려사항
- 처리를 멱등하게 만듭니다. 표준 큐는 중복 전달이 발생할 수 있으므로, 같은 메시지를 두 번 처리해도 결과가 같아야 합니다. 처리 완료를 기록하는 테이블을 두는 방식이 흔합니다.
- DLQ를 감시합니다. DLQ에 메시지가 쌓이는 것은 장애 신호입니다. 메시지 수에 알람을 걸어 둡니다(실습 12).
- 큐 적체를 기준으로 확장합니다. Lambda는 자동으로 확장하지만, 소비자가 컨테이너나 인스턴스라면 대기 메시지 수를 지표로 삼습니다.
- 동시성을 제한합니다. 하류 데이터베이스가 감당할 수 있는 수준으로 예약 동시성을 두지 않으면 큐가 완충한 부하가 그대로 데이터베이스로 전달됩니다.
- 메시지 크기 상한을 넘길 데이터는 Amazon S3에 두고 키만 보냅니다.
- 암호화와 접근 제어. 큐에 저장 데이터 암호화를 켜고, 큐 정책으로 송신자와 수신자를 한정합니다.
- 순서가 필요해지면 큐를 새로 만들어야 합니다. 표준 큐를 FIFO로 바꿀 수 없습니다.
정리 — 리소스 삭제
UUID=$(aws lambda list-event-source-mappings \
--function-name lab07-order-worker \
--query 'EventSourceMappings[0].UUID' --output text)
aws lambda delete-event-source-mapping --uuid "$UUID"
aws lambda delete-function --function-name lab07-order-worker
aws logs delete-log-group --log-group-name /aws/lambda/lab07-order-worker
aws sqs delete-queue --queue-url "$QUEUE_URL"
aws sqs delete-queue --queue-url "$DLQ_URL"
자주 하는 실수
| 실수 | 증상 | 고치는 법 |
|---|---|---|
| 가시성 제한 시간 < 함수 제한 시간 | 같은 메시지가 여러 번 처리된다 | 가시성 제한 시간을 충분히 크게 잡는다 |
| DLQ 없이 운영 | 실패 메시지가 무한히 재시도되며 큐를 막는다 | 재구동 정책으로 최대 수신 횟수를 정한다 |
| DLQ 보존 기간이 주 큐보다 짧음 | 조사하기 전에 실패 메시지가 사라진다 | DLQ 보존 기간을 길게 잡는다 |
| 실행 역할에 큐 권한 없음 | 매핑이 만들어지지만 메시지가 소비되지 않는다 | 수신·삭제·속성 조회 권한을 부여한다 |
| 배치 전체를 실패 처리 | 성공한 건까지 중복 처리된다 | 부분 배치 응답으로 실패 항목만 보고한다 |
| 비멱등 처리 | 중복 전달 시 결제나 알림이 두 번 나간다 | 처리 이력을 남기고 같은 키를 건너뛴다 |
| 큐만 넣고 하류 보호 없음 | 완충한 부하가 데이터베이스를 그대로 친다 | 소비자 동시성을 제한한다 |
이어서 볼 곳
- 12장 · 서버리스와 애플리케이션 통합 SQS 표준과 FIFO, 호출 모델 3종, Lambda 동시성을 정면으로 다룹니다.
- 케이스 7 · 트래픽 급증에 스케일아웃이 못 따라갔다 큐 완충이 부족 구간을 어떻게 견디게 하는지 설계 관점에서 다룹니다.
- 실습 6 · Lambda + API Gateway REST API 같은 함수를 동기로 호출할 때의 권한과 응답 구조를 비교합니다.
- 실습 8 · EventBridge 이벤트 아키텍처 큐가 아니라 이벤트 버스로 결합을 끊는 또 다른 방식입니다.
- 실습 12 · CloudWatch 알람 + SNS 알림 DLQ 적체와 큐 지연에 알람을 걸어 장애를 조기에 잡습니다.
공식 문서 출처
- Amazon SQS API 레퍼런스 — CreateQueue — 표준 큐가 기본이고 유형을 바꿀 수 없다는 서술, 큐 속성의 범위와 기본값, 삭제 후 같은 이름 재생성에 60초가 필요하다는 제약
- Amazon SQS API 레퍼런스 — ChangeMessageVisibility — 가시성 제한 시간의 기본 30초, 최소 0초, 최대 12시간
- Amazon SQS API 레퍼런스 — SendMessage
— FIFO 큐에서
MessageGroupId가 없으면 실패한다는 동작 - Amazon SQS 개발자 안내서 — 고처리량 FIFO — FIFO의 초당 300개, 배치 사용 시 3,000개 기준
- AWS Lambda API 레퍼런스 — CreateEventSourceMapping — 배치 크기, 배치 대기 시간, 함수 응답 유형 설정
- Amazon SQS API 레퍼런스 — StartMessageMoveTask — DLQ의 메시지를 원래 큐로 되돌리는 작업