
AWS SQS·SNS 완전 정복: 시스템 사이에 '메시지'를 보내는 기술
배달앱에서 주문 버튼을 누르면 뒤에서 수십 개의 시스템이 동시에 움직인다. 이 시스템들을 연결하는 '메시지 큐'와 '이벤트 알림'의 세계 — SQS와 SNS를 카페 주문 비유부터 실전 아키텍처까지 완전히 풀어본다.

배달앱에서 주문 버튼을 누르면 뒤에서 수십 개의 시스템이 동시에 움직인다. 이 시스템들을 연결하는 '메시지 큐'와 '이벤트 알림'의 세계 — SQS와 SNS를 카페 주문 비유부터 실전 아키텍처까지 완전히 풀어본다.
배달 앱에서 "주문하기" 버튼을 누르는 건 0.3초의 일이다. 하지만 그 뒤에서는 수십 개의 시스템이 동시에 움직여야 한다.
자, 여기서 질문이 생긴다. 주문 서비스가 나머지 4개 서비스를 직접 하나하나 호출하면 어떻게 될까?
"동기"란 전화 통화와 같다. 내가 상대방을 부르면, 상대방이 응답할 때까지 기다려야 한다.
총 5초. 사용자는 주문 버튼을 누른 뒤 5초 동안 화면이 멈춘 채로 기다려야 한다. 그런데 결제 서비스가 장애를 일으키면? 뒤에 있는 재고·알림·배송 서비스는 호출조차 되지 않는다. 하나가 죽으면 전체가 죽는 구조다.
"비동기"는 문자 메시지와 같다. 내가 메시지를 보내놓고 내 할 일을 하면 된다. 상대방은 자기 시간에 맞춰 메시지를 읽고 처리한다.
주문 서비스가 "주문 완료됐어!"라는 메시지를 큐에 던져놓고 즉시 사용자에게 "주문이 접수되었습니다"를 보여준다. 결제, 재고, 알림, 배송 서비스는 각자 알아서 큐에서 메시지를 꺼내 처리한다. 사용자 응답 시간은 0.3초로 줄어든다. 결제 서비스가 잠시 멈춰도 다른 서비스에는 아무 영향이 없다.
이 "메시지를 중간에서 전달해 주는 시스템"이 바로 오늘의 주인공, 메시지 큐(Message Queue) 와 이벤트 알림(Pub/Sub) 이다.

메시지 큐를 가장 쉽게 이해하는 방법은 카페 주문 시스템을 떠올리는 것이다.
카페에 갔다고 생각해 보자.
핵심은 이것이다:
이 구조를 소프트웨어로 만든 것이 메시지 큐다.
이 패턴에서 Producer와 Consumer는 서로를 모른다. 그저 Queue만 알면 된다. 이것이 느슨한 결합(Loose Coupling) 이다. 마치 손님이 바리스타의 이름을 모르고, 바리스타도 손님의 이름을 모르지만 — 주문 대기판이라는 중간 매개체를 통해 완벽하게 소통하는 것과 같다.
SQS(Simple Queue Service) 는 AWS가 제공하는 완전관리형 메시지 큐 서비스다. 2004년에 출시된 AWS의 초창기 서비스 중 하나로, S3(2006)보다도 먼저 나왔다. 서버를 관리할 필요 없이 메시지를 보내고 받을 수 있다.
SQS에는 두 가지 타입의 큐가 있다. 이 둘의 차이를 이해하는 것이 SQS 사용의 첫걸음이다.
Standard Queue는 말 그대로 "일반" 큐다. 카페에서 주문이 많을 때 바리스타 여러 명이 동시에 주문표를 뽑아 처리하는 것과 같다. 처리 속도가 엄청나게 빠르지만, 가끔 순서가 뒤바뀔 수 있다. 아이스 아메리카노를 먼저 주문했는데 뒤에 주문한 에스프레소가 먼저 나올 수 있는 것이다.
FIFO Queue는 "선입선출(First-In, First-Out)"을 보장한다. 은행 번호표처럼 정확히 순서대로 처리된다. 대신 속도 제한이 있다.
| 특성 | Standard Queue | FIFO Queue |
|---|---|---|
| 순서 보장 | 최선 노력 (best-effort) | 엄격한 순서 보장 |
| 중복 배달 | 가능 (at-least-once) | 정확히 한 번 (exactly-once) |
| 처리량 | 무제한 (초당 수만~수백만) | 초당 3,000건 (배치 시 최대 30,000) |
| 가격 | 요청 백만 건당 $0.40 | 요청 백만 건당 $0.50 |
| 사용 사례 | 로그 수집, 이미지 처리, 이메일 발송 | 결제 처리, 주문 순서, 티켓 예매 |
SQS의 동작 방식에서 가장 중요한 개념이 Visibility Timeout이다.
카페 비유로 돌아가 보자. 바리스타가 주문표를 뽑으면, 그 주문표는 대기판에서 잠시 사라진다. 다른 바리스타가 같은 주문을 또 뽑지 않도록 하기 위해서다. 커피를 다 만들면 주문표를 버린다(삭제). 하지만 바리스타가 10분 안에 커피를 못 만들면? 주문표가 다시 대기판에 나타나서 다른 바리스타가 처리할 수 있게 된다.
만약 Consumer가 Visibility Timeout 안에 메시지를 삭제하지 못하면? 메시지가 다시 큐에 나타나서 다른 Consumer가 가져갈 수 있다. 이것이 SQS의 자동 재시도(retry) 메커니즘이다.
어떤 메시지는 아무리 재시도해도 처리할 수 없다. JSON 형식이 깨졌다거나, 참조하는 데이터가 존재하지 않는다거나. 이런 메시지가 큐에 계속 남아서 재시도되면, Consumer의 자원만 낭비하게 된다.
이때 사용하는 것이 DLQ(Dead Letter Queue) 다.
DLQ 설정은 간단하다:
{
"RedrivePolicy": {
"deadLetterTargetArn": "arn:aws:sqs:ap-northeast-2:123456789:order-dlq",
"maxReceiveCount": 3
}
}
maxReceiveCount: 3은 "3번 수신(=3번 처리 실패)하면 DLQ로 보내라"는 뜻이다.

SQS가 "한 명에게 메시지를 전달하는 우편함" 이라면, SNS는 "여러 명에게 동시에 알리는 방송 시스템" 이다.
카페 비유를 바꿔보자. SQS는 "바리스타에게 주문표를 전달"하는 것이다. 한 장의 주문표는 한 명의 바리스타가 처리한다.
SNS는 "카페 전체에 마이크를 잡고 공지"하는 것이다. "오늘 신메뉴 출시!" 하고 방송하면, 바리스타도 듣고, 캐셔도 듣고, 매니저도 듣는다. 같은 메시지를 여러 수신자가 동시에 받는다.
SNS는 Pub/Sub(Publish/Subscribe) 패턴을 구현한 서비스다.
유튜브 구독을 생각하면 쉽다. 유튜버(Publisher)가 영상을 업로드(Publish)하면, 구독자(Subscriber)들에게 자동으로 알림이 간다. 구독자가 100명이든 100만 명이든, 유튜버는 영상을 한 번만 올리면 된다.
SNS Topic에 구독할 수 있는 엔드포인트의 종류는 다양하다:
| 구독 유형 | 설명 | 사용 사례 |
|---|---|---|
| SQS | SQS 큐로 메시지 전달 | 비동기 처리 파이프라인 |
| Lambda | Lambda 함수 직접 호출 | 이벤트 기반 서버리스 처리 |
| HTTP/HTTPS | 웹훅 URL로 POST 요청 | 외부 서비스 연동 |
| 이메일 | 이메일 주소로 알림 발송 | 장애 알림, 보고서 |
| SMS | 휴대폰 문자 메시지 발송 | 긴급 알림, OTP 발송 |
| 모바일 푸시 | iOS/Android 푸시 알림 | 앱 알림 |
SNS의 강력한 기능 중 하나가 메시지 필터링(Message Filtering) 이다.
모든 구독자가 모든 메시지를 받을 필요는 없다. 예를 들어 이커머스에서 "주문 완료" 이벤트가 발생하면:
SNS 필터 정책을 사용하면, 구독자별로 원하는 메시지만 받을 수 있다:
{
"customer_type": ["vip"],
"order_total": [{"numeric": [">=", 100000]}]
}
이 필터는 "VIP 고객이고 주문 금액이 10만 원 이상인 경우에만 메시지를 받겠다"는 뜻이다. 필터링은 SNS 내부에서 처리되므로, 구독자가 불필요한 메시지를 받아서 버리는 비용이 사라진다.
SQS와 SNS를 각각 이해했으니, 이제 이 둘을 결합하는 강력한 패턴을 알아보자. 이것이 실전에서 가장 많이 쓰이는 Fan-out 패턴이다.
"Fan-out"은 부채가 펼쳐지는 모양에서 따온 이름이다. 하나의 메시지가 여러 갈래로 퍼져 나간다.
좋은 질문이다. SNS에서 Lambda를 직접 호출해도 되는데, 왜 굳이 중간에 SQS를 두는 걸까?
// 1. SNS에 메시지 발행 (주문 서비스)
import { SNSClient, PublishCommand } from "@aws-sdk/client-sns";
const sns = new SNSClient({ region: "ap-northeast-2" });
await sns.send(new PublishCommand({
TopicArn: "arn:aws:sns:ap-northeast-2:123456789:order-events",
Message: JSON.stringify({
orderId: "ORD-20260401-001",
userId: "user-123",
items: [{ name: "아이스 아메리카노", qty: 2, price: 4500 }],
totalAmount: 9000,
timestamp: "2026-04-01T10:30:00Z"
}),
MessageAttributes: {
orderType: { DataType: "String", StringValue: "food" },
region: { DataType: "String", StringValue: "seoul" }
}
}));
// 2. SQS에서 메시지 소비 (결제 서비스 — Lambda)
export const handler = async (event) => {
for (const record of event.Records) {
const message = JSON.parse(record.body);
const order = JSON.parse(message.Message); // SNS 메시지 unwrap
console.log(`결제 처리 시작: ${order.orderId}`);
await processPayment(order);
console.log(`결제 처리 완료: ${order.orderId}`);
}
};
주문 서비스는 SNS에 메시지를 한 번만 발행한다. 결제, 재고, 알림, 분석 — 각 서비스의 SQS 큐에 메시지가 자동으로 복사된다.
AWS에는 메시징/이벤트 서비스가 여러 개다. 처음에는 헷갈릴 수 있다. 언제 무엇을 써야 하는지 정리해 보자.
EventBridge는 2019년에 출시된 서비스로, SNS의 진화된 버전이라고 생각할 수 있다. 가장 큰 차이점은 이벤트 패턴 매칭이다.
SNS의 메시지 필터링은 메시지 속성(MessageAttributes)에 대한 단순 필터링만 지원한다. 반면 EventBridge는 메시지 본문(body) 안의 JSON 필드까지 패턴 매칭할 수 있다.
{
"source": ["com.myapp.orders"],
"detail-type": ["OrderCreated"],
"detail": {
"totalAmount": [{"numeric": [">", 50000]}],
"items": {
"category": ["electronics"]
}
}
}
"주문 생성 이벤트 중, 금액이 5만 원 이상이고 카테고리가 전자제품인 것만 라우팅해라." EventBridge는 이런 복잡한 조건을 지원한다.
| 특성 | SQS | SNS | EventBridge |
|---|---|---|---|
| 패턴 | Point-to-Point (1:1) | Pub/Sub (1:N) | Event Bus (N:N) |
| 메시지 보존 | 최대 14일 보관 | 보관 안 함 (즉시 전달) | 아카이브로 재생 가능 |
| 필터링 | 없음 | 메시지 속성 기반 | JSON 본문까지 패턴 매칭 |
| 처리량 | 무제한 | 무제한 | 초당 수천~수만 이벤트 |
| 외부 연동 | AWS 내부만 | HTTP/이메일/SMS | Salesforce, Zendesk 등 SaaS |
| 가격 | 가장 저렴 | 중간 | 이벤트당 $1/백만 건 |
| 스케줄링 | 없음 | 없음 | 크론 표현식 지원 |
실무에서는 이 세 가지를 함께 쓰는 경우가 많다. EventBridge가 이벤트를 라우팅하고, SNS가 Fan-out으로 퍼뜨리고, SQS가 각 서비스의 버퍼 역할을 한다.
지금까지 배운 것들을 모아서, 실제 이커머스 서비스의 주문 처리 파이프라인을 설계해 보자.
왜 결제만 FIFO Queue를 사용하고, 나머지는 Standard Queue를 사용할까?
핵심은 각 단계가 독립적이라는 것이다. 알림 서비스가 장애를 일으켜도 결제는 정상적으로 처리된다. 분석 서비스가 느려도 사용자에게는 아무 영향이 없다. 이것이 메시징 기반 비동기 아키텍처의 힘이다.

비동기 시스템에서 가장 어려운 부분은 에러 처리다. 동기 시스템에서는 에러가 바로 호출한 쪽으로 돌아오지만, 비동기 시스템에서는 메시지가 큐에 있으므로 에러를 "누가, 언제, 어떻게" 처리할지 명확히 정해야 한다.
| 전략 | 설명 | 사용 사례 | 예시 |
|---|---|---|---|
| 즉시 재시도 | 실패 후 바로 다시 시도 | 네트워크 순간 끊김 | 0초 → 재시도 |
| 고정 간격 | 일정 시간 후 재시도 | 외부 API 일시 장애 | 5초 → 5초 → 5초 |
| 지수 백오프 | 간격을 점점 늘려 재시도 | 권장 기본 전략 | 1초 → 2초 → 4초 → 8초 |
| 지수 백오프 + 지터 | 간격에 랜덤 변동 추가 | 대규모 시스템 권장 | 1.3초 → 2.7초 → 5.1초 |
지수 백오프(Exponential Backoff) 가 권장되는 이유는 간단하다. 서버가 과부하로 실패했는데, 수천 개의 클라이언트가 동시에 즉시 재시도하면 서버 부하가 더 심해진다. 간격을 점점 늘리면 서버에 회복할 시간을 준다.
지터(Jitter) 는 여기에 약간의 랜덤성을 추가한다. 1,000개의 메시지가 정확히 같은 시간에 재시도하면 또 다시 과부하가 걸린다. 각각 조금씩 다른 시간에 재시도하게 하는 것이다.
SQS Standard Queue는 "최소 한 번(at-least-once)" 전달을 보장한다. 이 말은 같은 메시지가 두 번 배달될 수 있다는 뜻이다.
배달 앱으로 치면, 같은 주문이 두 번 결제되는 상황이다. 이건 큰 문제다.
멱등성 구현 코드 예시:
// DynamoDB를 사용한 멱등성 체크
import { DynamoDBClient, PutItemCommand } from "@aws-sdk/client-dynamodb";
const dynamodb = new DynamoDBClient({ region: "ap-northeast-2" });
async function processWithIdempotency(orderId, messageId, processFn) {
try {
// 1. 멱등성 키 저장 시도 (조건부 쓰기)
await dynamodb.send(new PutItemCommand({
TableName: "idempotency-keys",
Item: {
pk: { S: `payment#${orderId}` },
messageId: { S: messageId },
processedAt: { S: new Date().toISOString() },
ttl: { N: String(Math.floor(Date.now() / 1000) + 86400) } // 24시간 후 만료
},
ConditionExpression: "attribute_not_exists(pk)" // 이미 존재하면 실패
}));
// 2. 키가 새로 저장되었으면 → 처음 처리하는 메시지
await processFn();
} catch (error) {
if (error.name === "ConditionalCheckFailedException") {
console.log(`이미 처리된 메시지입니다: ${orderId}`);
return; // 중복이므로 무시
}
throw error; // 진짜 에러는 다시 던진다
}
}
DLQ에 메시지가 쌓이면 누군가 알아야 한다. 실전에서는 이런 파이프라인을 구축한다:
CloudWatch에서 DLQ의 ApproximateNumberOfMessagesVisible 지표를 모니터링하다가, 0보다 커지면 알람을 울리는 구조다. 여기서도 SNS가 쓰인다 — 알림을 여러 채널로 동시에 보내는 Fan-out 패턴이다.
SQS와 SNS의 가장 큰 장점 중 하나는 완전한 종량제 모델이다. 서버를 미리 띄워놓고 돈을 내는 게 아니라, 메시지를 보내고 받은 만큼만 비용이 발생한다.
실제 비용을 계산해 보자. 하루 10만 건의 주문을 처리하는 이커머스 서비스가 있다고 하자.
물론 실제로는 각 메시지당 ReceiveMessage, DeleteMessage 등 여러 API 호출이 발생하므로 실제 비용은 이보다 높다. 하지만 Long Polling을 사용하면 빈 응답을 줄여 비용을 최적화할 수 있다.
SQS에서 메시지를 가져오는 방식에는 두 가지가 있다.
Short Polling(기본값): Consumer가 "메시지 있어?" 하고 물어본다. 큐가 비어있으면 즉시 "없어"라고 응답한다. 이 빈 응답도 API 호출 1회로 과금된다.
Long Polling: Consumer가 "메시지 있어? 있을 때까지 최대 20초 기다릴게" 하고 물어본다. 20초 안에 메시지가 오면 바로 응답하고, 없으면 20초 후에 "없어"라고 응답한다.
| 방식 | Short Polling | Long Polling |
|---|---|---|
| 빈 응답 | 많음 (비용 낭비) | 거의 없음 |
| 응답 지연 | 즉시 (0ms) | 최대 WaitTimeSeconds |
| 비용 | 높음 | 낮음 |
| 설정 | WaitTimeSeconds = 0 | WaitTimeSeconds = 20 (권장) |
ReceiveMessageWaitTimeSeconds를 20으로 설정하면 된다.메시지 큐의 가장 강력한 장점은 Consumer를 독립적으로 확장할 수 있다는 것이다.
평소에는 Consumer 2개가 메시지를 처리한다. 블랙프라이데이에 트래픽이 10배가 되면? SQS에 메시지가 쌓이기 시작한다. CloudWatch가 큐의 ApproximateNumberOfMessagesVisible을 모니터링하다가, 특정 임계값을 넘으면 Auto Scaling이 Consumer를 20개로 늘린다. 트래픽이 줄어들면 다시 2개로 줄인다.
점심 시간(12시13시)과 저녁 시간(18시21시)에 주문이 몰린다. Consumer가 자동으로 확장되어 큐가 밀리지 않게 처리한다. 새벽에는 Consumer 1개로 충분하다. 이것이 메시지 큐 + Auto Scaling의 시너지다.
Producer(주문 서비스)는 트래픽이 몇 배가 되든 그냥 큐에 메시지를 넣기만 하면 된다. 처리 속도 조절은 Consumer 쪽에서 독립적으로 한다. 이 "관심사의 분리"가 대규모 시스템 설계의 핵심이다.
SQS의 메시지 크기 제한은 256KB다. 이미지나 대용량 데이터를 보내야 한다면?
이 패턴을 Claim-Check 패턴이라고 한다. 옷가게에서 코트를 맡기면 번호표(claim check)만 받는 것과 같다. 메시지에는 가벼운 "참조"만 넣고, 실제 데이터는 S3에 보관한다.
AWS는 이 패턴을 위해 Amazon SQS Extended Client Library를 제공한다. 메시지가 256KB를 넘으면 자동으로 S3에 저장하고, Consumer가 받을 때 자동으로 S3에서 다운로드한다.
FIFO Queue에서 순서를 보장하려면 MessageGroupId를 올바르게 설정해야 한다.
같은 MessageGroupId를 가진 메시지끼리만 순서가 보장된다. 예를 들어:
// 주문 ID를 MessageGroupId로 사용
await sqs.send(new SendMessageCommand({
QueueUrl: "https://sqs.ap-northeast-2.amazonaws.com/123456789/orders.fifo",
MessageBody: JSON.stringify(orderEvent),
MessageGroupId: "order-ORD-20260401-001", // 같은 주문의 이벤트끼리 순서 보장
MessageDeduplicationId: `${orderId}-${eventType}-${timestamp}` // 중복 방지 ID
}));
서로 다른 주문은 병렬로 처리될 수 있다. 주문 A의 이벤트와 주문 B의 이벤트 사이에는 순서가 없어도 된다. 하지만 주문 A 안에서는 "생성 → 결제 → 배송" 순서가 지켜져야 한다. MessageGroupId가 이것을 가능하게 한다.
SQS는 한 번에 최대 10개의 메시지를 보내거나 받을 수 있다. 개별 API 호출 대비 최대 10배 비용을 절감할 수 있다.
| 방식 | 개별 처리 | 배치 처리 |
|---|---|---|
| 10개 메시지 전송 | API 호출 10회 | API 호출 1회 |
| 100만 건 전송 비용 | $0.40 | $0.04 |
| 네트워크 오버헤드 | 높음 | 낮음 |
Lambda를 SQS Consumer로 사용할 때, Lambda는 자동으로 배치 처리를 한다. event.Records에 최대 10개(설정에 따라 최대 10,000개)의 메시지가 한꺼번에 들어온다.
지금까지 SQS, SNS, 그리고 이 둘을 결합하는 패턴을 살펴봤다. 핵심을 정리하면 이렇다.
비동기 메시징은 단순히 "빠르게 만드는 기술"이 아니다. 시스템의 생존력을 높이는 기술이다.
처음에는 동기 방식(직접 API 호출)이 단순하고 이해하기 쉽다. 하지만 서비스가 커지고, 트래픽이 늘고, 장애가 빈번해지면 — 그때서야 메시징의 가치를 깨닫게 된다. 그리고 그때는 이미 바꾸기 어렵다.
그래서 많은 시니어 엔지니어들이 말한다: "비동기로 할 수 있다면, 비동기로 해라."
다음 편에서는 이 메시징 시스템을 서버리스 환경에서 어떻게 운영하는지 — Lambda와 SQS의 이벤트 소스 매핑, 동시성 제어, 부분 배치 실패 처리 등을 다룰 예정이다.