Amazon SNS - 완전 관리형 메시징 서비스
SNS를 활용한 발행-구독 패턴과 멀티 채널 알림 시스템 구축
Amazon SNS란?
Amazon SNS(Simple Notification Service)는 완전 관리형 발행-구독(Pub-Sub) 메시징 서비스임.
하나의 메시지를 여러 구독자에게 동시에 전달할 수 있으며, 이메일, SMS, Lambda, SQS, HTTP 엔드포인트 등 다양한 프로토콜을 지원함.
왜 SNS를 사용해야 하는가?
전통적인 알림 시스템의 문제점
직접 구현 방식:
1
2
3
4
5
애플리케이션 서버
├─ SMTP 서버 연결 → 이메일 전송
├─ SMS 게이트웨이 호출 → SMS 전송
├─ Slack API 호출 → Slack 메시지
└─ Webhook 호출 → 외부 시스템
문제점:
- 복잡한 통합: 각 채널마다 다른 API 구현 필요
- 장애 처리: 특정 채널 장애 시 전체 프로세스 영향
- 확장 어려움: 새 채널 추가 시 코드 수정 필요
- 재시도 로직: 각 채널별로 재시도 구현해야 함
- 관리 부담: 인증 정보, 연결 풀, 타임아웃 등 직접 관리
SNS의 해결 방법
발행-구독 패턴:
1
2
3
4
5
6
7
8
애플리케이션
↓
SNS Topic (한 번만 발행)
├─ Email 구독 → 자동 이메일 전송
├─ SMS 구독 → 자동 SMS 전송
├─ Lambda 구독 → Slack 전송
├─ SQS 구독 → 비동기 처리
└─ HTTP 구독 → Webhook 호출
장점:
- 단일 발행: 한 번 메시지 발행으로 모든 구독자에게 전달
- 느슨한 결합: 발행자와 구독자가 독립적
- 자동 재시도: 전달 실패 시 자동 재시도
- 확장 용이: 구독자 추가/제거 간편
- 프로토콜 다양성: 이메일, SMS, HTTP, SQS, Lambda 등
SNS 핵심 개념
1. Topic (토픽)
메시지를 발행하는 논리적 채널. 구독자들이 메시지를 받기 위해 구독하는 대상.
토픽 생성:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { SNSClient, CreateTopicCommand } from '@aws-sdk/client-sns';
const snsClient = new SNSClient({});
const response = await snsClient.send(new CreateTopicCommand({
Name: 'order-notifications',
Attributes: {
DisplayName: '주문 알림',
DeliveryPolicy: JSON.stringify({
http: {
defaultHealthyRetryPolicy: {
minDelayTarget: 20,
maxDelayTarget: 20,
numRetries: 3,
backoffFunction: 'linear'
}
}
})
}
}));
const topicArn = response.TopicArn;
// arn:aws:sns:ap-northeast-2:123456789:order-notifications
2. Subscription (구독)
토픽에서 메시지를 수신할 엔드포인트 등록.
지원 프로토콜:
- Email: 이메일 주소로 전송
- Email-JSON: JSON 형식으로 이메일 전송
- SMS: 전화번호로 SMS 전송
- HTTP/HTTPS: 웹훅으로 POST 요청
- SQS: SQS 큐로 메시지 전달
- Lambda: Lambda 함수 트리거
- Application: 모바일 푸시 알림
- Firehose: Kinesis Data Firehose로 스트리밍
구독 생성 예시:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { SubscribeCommand } from '@aws-sdk/client-sns';
// 이메일 구독
await snsClient.send(new SubscribeCommand({
TopicArn: topicArn,
Protocol: 'email',
Endpoint: 'user@example.com'
}));
// 사용자에게 확인 이메일 전송됨 (구독 확인 필요)
// Lambda 구독
await snsClient.send(new SubscribeCommand({
TopicArn: topicArn,
Protocol: 'lambda',
Endpoint: 'arn:aws:lambda:ap-northeast-2:123456789:function:notification-handler'
}));
// SQS 구독
await snsClient.send(new SubscribeCommand({
TopicArn: topicArn,
Protocol: 'sqs',
Endpoint: 'arn:aws:sqs:ap-northeast-2:123456789:my-queue',
Attributes: {
RawMessageDelivery: 'true' // SNS 메타데이터 없이 원본 메시지만 전달
}
}));
3. Message Publishing (메시지 발행)
토픽에 메시지를 발행하면 모든 구독자에게 전달됨.
기본 발행:
1
2
3
4
5
6
7
import { PublishCommand } from '@aws-sdk/client-sns';
await snsClient.send(new PublishCommand({
TopicArn: topicArn,
Subject: '주문 완료 알림',
Message: '주문번호 12345가 성공적으로 처리되었습니다.'
}));
메시지 속성 포함:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
await snsClient.send(new PublishCommand({
TopicArn: topicArn,
Message: JSON.stringify({
orderId: 'order-123',
customerId: 'user-456',
totalAmount: 50000,
status: 'completed'
}),
MessageAttributes: {
orderType: {
DataType: 'String',
StringValue: 'express'
},
priority: {
DataType: 'Number',
StringValue: '1'
},
timestamp: {
DataType: 'String',
StringValue: new Date().toISOString()
}
}
}));
실전 프로젝트 활용 사례
사례 1: 멀티 채널 알림 시스템
아키텍처:
1
2
3
4
5
6
7
주문 완료 이벤트
↓
SNS Topic
├─ Email Queue → Lambda → SES 이메일 전송
├─ SMS Queue → Lambda → SNS SMS 전송
├─ Slack Queue → Lambda → Slack Webhook
└─ Webhook Queue → Lambda → 외부 시스템 호출
메시지 필터링 정책:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 이메일 구독 (email 또는 all 채널만)
await snsClient.send(new SubscribeCommand({
TopicArn: topicArn,
Protocol: 'sqs',
Endpoint: emailQueueArn,
Attributes: {
FilterPolicy: JSON.stringify({
channel: ['email', 'all']
}),
RawMessageDelivery: 'true'
}
}));
// SMS 구독 (sms 또는 all 채널만)
await snsClient.send(new SubscribeCommand({
TopicArn: topicArn,
Protocol: 'sqs',
Endpoint: smsQueueArn,
Attributes: {
FilterPolicy: JSON.stringify({
channel: ['sms', 'all']
}),
RawMessageDelivery: 'true'
}
}));
// Slack 구독 (slack 또는 all 채널만)
await snsClient.send(new SubscribeCommand({
TopicArn: topicArn,
Protocol: 'sqs',
Endpoint: slackQueueArn,
Attributes: {
FilterPolicy: JSON.stringify({
channel: ['slack', 'all']
}),
RawMessageDelivery: 'true'
}
}));
채널별 메시지 발행:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 이메일만 전송
await snsClient.send(new PublishCommand({
TopicArn: topicArn,
Message: JSON.stringify({
title: '주문 완료',
message: '주문이 성공적으로 처리되었습니다'
}),
MessageAttributes: {
channel: {
DataType: 'String',
StringValue: 'email'
}
}
}));
// 모든 채널로 전송
await snsClient.send(new PublishCommand({
TopicArn: topicArn,
Message: JSON.stringify({
title: '긴급 알림',
message: '시스템 점검 예정입니다'
}),
MessageAttributes: {
channel: {
DataType: 'String',
StringValue: 'all'
},
severity: {
DataType: 'String',
StringValue: 'high'
}
}
}));
이메일 발송 Lambda:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import { SESClient, SendEmailCommand } from '@aws-sdk/client-ses';
const sesClient = new SESClient({});
export const handler = async (event) => {
for (const record of event.Records) {
const message = JSON.parse(record.body);
// HTML 이메일 템플릿 생성
const htmlBody = `
<div style="font-family: Arial, sans-serif;">
<h2 style="color: #333;">${message.title}</h2>
<p>${message.message}</p>
<hr>
<p style="color: #999; font-size: 12px;">
발송 시간: ${new Date().toLocaleString('ko-KR')}
</p>
</div>
`;
await sesClient.send(new SendEmailCommand({
Source: 'noreply@example.com',
Destination: {
ToAddresses: [message.email || 'default@example.com']
},
Message: {
Subject: { Data: message.title },
Body: {
Html: { Data: htmlBody },
Text: { Data: message.message }
}
}
}));
console.log(`Email sent for: ${message.title}`);
}
};
SMS 발송 Lambda:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
export const handler = async (event) => {
for (const record of event.Records) {
const message = JSON.parse(record.body);
// SMS는 160자 제한
const smsMessage = `[${message.title}] ${message.message}`.substring(0, 160);
await snsClient.send(new PublishCommand({
PhoneNumber: message.phoneNumber || '+821012345678',
Message: smsMessage,
MessageAttributes: {
'AWS.SNS.SMS.SMSType': {
DataType: 'String',
StringValue: message.severity === 'high' ? 'Transactional' : 'Promotional'
}
}
}));
console.log(`SMS sent to: ${message.phoneNumber}`);
}
};
사례 2: Lambda 직접 트리거
CloudWatch Alarm → SNS → Lambda 패턴:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
resources:
Resources:
HighCPUAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: HighCPUUsage
MetricName: CPUUtilization
Namespace: AWS/EC2
Statistic: Average
Period: 300
EvaluationPeriods: 2
Threshold: 80
ComparisonOperator: GreaterThanThreshold
AlarmActions:
- !Ref AlertTopic
functions:
alertHandler:
handler: handler.processAlarm
events:
- sns:
arn: !Ref AlertTopic
topicName: system-alerts
Lambda에서 알람 처리:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
export const handler = async (event) => {
for (const record of event.Records) {
const snsMessage = JSON.parse(record.Sns.Message);
// CloudWatch Alarm 메시지 파싱
const alarmName = snsMessage.AlarmName;
const newState = snsMessage.NewStateValue;
const reason = snsMessage.NewStateReason;
console.log(`Alarm: ${alarmName} is now ${newState}`);
console.log(`Reason: ${reason}`);
if (newState === 'ALARM') {
// Slack으로 알림 전송
await sendSlackNotification({
title: `🚨 ${alarmName}`,
message: reason,
severity: 'critical'
});
// 자동 스케일링 트리거 등
await triggerAutoScaling();
}
}
};
사례 3: Fanout 패턴 (SNS → SQS)
한 이벤트를 여러 큐로 분산:
1
2
3
4
5
6
7
S3 Upload Event
↓
SNS Topic
├─ Thumbnail Queue → Lambda (썸네일 생성)
├─ Metadata Queue → Lambda (메타데이터 추출)
├─ Virus Scan Queue → Lambda (바이러스 검사)
└─ Analytics Queue → Lambda (통계 수집)
Serverless Framework 설정:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
resources:
Resources:
S3EventTopic:
Type: AWS::SNS::Topic
Properties:
TopicName: s3-upload-events
ThumbnailQueue:
Type: AWS::SQS::Queue
Properties:
QueueName: thumbnail-queue
ThumbnailQueueSubscription:
Type: AWS::SNS::Subscription
Properties:
TopicArn: !Ref S3EventTopic
Protocol: sqs
Endpoint: !GetAtt ThumbnailQueue.Arn
RawMessageDelivery: true
# SQS 정책 (SNS가 메시지 전송 허용)
ThumbnailQueuePolicy:
Type: AWS::SQS::QueuePolicy
Properties:
Queues:
- !Ref ThumbnailQueue
PolicyDocument:
Statement:
- Effect: Allow
Principal:
Service: sns.amazonaws.com
Action: sqs:SendMessage
Resource: !GetAtt ThumbnailQueue.Arn
Condition:
ArnEquals:
aws:SourceArn: !Ref S3EventTopic
functions:
thumbnailGenerator:
handler: thumbnail.handler
events:
- sqs:
arn: !GetAtt ThumbnailQueue.Arn
batchSize: 10
장점:
- 각 처리 단계가 독립적으로 실패/재시도
- 새로운 처리 로직 추가 시 구독만 추가하면 됨
- 각 큐가 독립적으로 스케일링
SMS 전송 (SNS Direct)
국가별 전화번호 형식:
1
2
3
4
5
6
7
8
9
10
11
// 한국
await snsClient.send(new PublishCommand({
PhoneNumber: '+821012345678', // +82 (국가 코드) + 10-1234-5678
Message: '인증 번호: 123456'
}));
// 미국
await snsClient.send(new PublishCommand({
PhoneNumber: '+12025551234', // +1 (국가 코드) + 202-555-1234
Message: 'Verification code: 123456'
}));
SMS 타입:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Transactional (거래형) - 중요한 메시지, 비용 높음, 전달률 높음
await snsClient.send(new PublishCommand({
PhoneNumber: '+821012345678',
Message: '계좌 이체가 완료되었습니다',
MessageAttributes: {
'AWS.SNS.SMS.SMSType': {
DataType: 'String',
StringValue: 'Transactional'
}
}
}));
// Promotional (홍보형) - 마케팅 메시지, 비용 낮음
await snsClient.send(new PublishCommand({
PhoneNumber: '+821012345678',
Message: '할인 이벤트를 확인하세요!',
MessageAttributes: {
'AWS.SNS.SMS.SMSType': {
DataType: 'String',
StringValue: 'Promotional'
}
}
}));
SMS 설정:
1
2
3
4
5
6
7
8
9
10
import { SetSMSAttributesCommand } from '@aws-sdk/client-sns';
await snsClient.send(new SetSMSAttributesCommand({
attributes: {
DefaultSMSType: 'Transactional', // 기본값 설정
MonthlySpendLimit: '100', // 월 지출 한도 ($100)
DeliveryStatusIAMRole: 'arn:aws:iam::123456789:role/SNSSMSRole', // 배달 상태 로깅
DeliveryStatusSuccessSamplingRate: '100' // 성공 샘플링 비율 100%
}
}));
메시지 필터링
복잡한 필터 정책:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 구독 생성 시 필터 정책 설정
await snsClient.send(new SubscribeCommand({
TopicArn: topicArn,
Protocol: 'sqs',
Endpoint: queueArn,
Attributes: {
FilterPolicy: JSON.stringify({
// 숫자 비교
priority: [{ numeric: ['>=', 1] }],
// 문자열 일치
orderType: ['express', 'overnight'],
// 문자열 접두사
region: [{ prefix: 'ap-' }],
// 존재 여부
customerId: [{ exists: true }],
// AND 조건 (모든 조건 만족)
// OR 조건 (배열 내 하나라도 만족)
severity: ['high', 'critical']
})
}
}));
필터 정책 예시:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 고액 주문만 처리
{
"totalAmount": [{ "numeric": [">=", 1000000] }]
}
// 특정 지역의 긴급 알림만
{
"region": ["ap-northeast-2", "us-east-1"],
"severity": ["critical"]
}
// 프리미엄 고객의 모든 주문
{
"customerTier": ["premium", "vip"]
}
SNS FIFO Topics
순서 보장이 필요한 경우:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// FIFO Topic 생성 (.fifo 접미사 필수)
const response = await snsClient.send(new CreateTopicCommand({
Name: 'order-events.fifo',
Attributes: {
FifoTopic: 'true',
ContentBasedDeduplication: 'true' // 메시지 내용으로 중복 제거
}
}));
// FIFO SQS 구독
await snsClient.send(new SubscribeCommand({
TopicArn: fifoTopicArn,
Protocol: 'sqs',
Endpoint: 'arn:aws:sqs:ap-northeast-2:123456789:orders.fifo'
}));
// 메시지 발행 (순서 보장)
await snsClient.send(new PublishCommand({
TopicArn: fifoTopicArn,
Message: JSON.stringify({
orderId: 'order-123',
status: 'created'
}),
MessageGroupId: 'order-123', // 그룹별 순서 보장
MessageDeduplicationId: `create-${Date.now()}` // 중복 제거 ID
}));
await snsClient.send(new PublishCommand({
TopicArn: fifoTopicArn,
Message: JSON.stringify({
orderId: 'order-123',
status: 'paid'
}),
MessageGroupId: 'order-123', // 같은 그룹 → 순서 보장
MessageDeduplicationId: `pay-${Date.now()}`
}));
Standard vs FIFO:
| 특성 | Standard Topic | FIFO Topic |
|---|---|---|
| 순서 | 보장 안 됨 | 완벽 보장 |
| 중복 | 가능 | 정확히 1회 전달 |
| 처리량 | 무제한 | 300 msg/s (배치 시 3,000) |
| 구독자 | 모든 프로토콜 | SQS FIFO만 |
SNS 모니터링
CloudWatch 메트릭:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
resources:
Resources:
FailedNotificationsAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: SNSFailedNotifications
MetricName: NumberOfNotificationsFailed
Namespace: AWS/SNS
Dimensions:
- Name: TopicName
Value: !GetAtt MyTopic.TopicName
Statistic: Sum
Period: 300
EvaluationPeriods: 1
Threshold: 10
ComparisonOperator: GreaterThanThreshold
AlarmActions:
- !Ref AlertTopic
SlowDeliveryAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: SNSSlowDelivery
MetricName: NumberOfNotificationsDelivered
Namespace: AWS/SNS
Dimensions:
- Name: TopicName
Value: !GetAtt MyTopic.TopicName
Statistic: Average
Period: 60
EvaluationPeriods: 5
Threshold: 100
ComparisonOperator: LessThanThreshold
전달 상태 로깅 (HTTP/Lambda):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
await snsClient.send(new CreateTopicCommand({
Name: 'my-topic',
Attributes: {
// Lambda 전달 상태
LambdaSuccessFeedbackRoleArn: 'arn:aws:iam::123456789:role/SNSFeedbackRole',
LambdaSuccessFeedbackSampleRate: '100',
LambdaFailureFeedbackRoleArn: 'arn:aws:iam::123456789:role/SNSFeedbackRole',
// HTTP 전달 상태
HTTPSuccessFeedbackRoleArn: 'arn:aws:iam::123456789:role/SNSFeedbackRole',
HTTPSuccessFeedbackSampleRate: '100',
HTTPFailureFeedbackRoleArn: 'arn:aws:iam::123456789:role/SNSFeedbackRole'
}
}));
// CloudWatch Logs에 전달 성공/실패 로그 기록됨
Best Practices
1. DLQ (Dead Letter Queue) 설정
1
2
3
4
5
6
7
8
9
10
11
// SNS 구독에 DLQ 설정
await snsClient.send(new SubscribeCommand({
TopicArn: topicArn,
Protocol: 'lambda',
Endpoint: lambdaArn,
Attributes: {
RedrivePolicy: JSON.stringify({
deadLetterTargetArn: dlqArn
})
}
}));
2. 재시도 정책 커스터마이징
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
await snsClient.send(new SetTopicAttributesCommand({
TopicArn: topicArn,
AttributeName: 'DeliveryPolicy',
AttributeValue: JSON.stringify({
http: {
defaultHealthyRetryPolicy: {
minDelayTarget: 20, // 최소 재시도 간격 (초)
maxDelayTarget: 600, // 최대 재시도 간격 (초)
numRetries: 5, // 재시도 횟수
backoffFunction: 'exponential' // 지수 백오프
},
disableSubscriptionOverrides: false
}
})
}));
3. 메시지 크기 최적화
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// ❌ 큰 데이터를 직접 전송 (256KB 제한)
await snsClient.send(new PublishCommand({
TopicArn: topicArn,
Message: JSON.stringify({
data: largeObject // 너무 큼
})
}));
// ✅ S3 참조만 전송
const s3Key = `notifications/${notificationId}.json`;
await s3Client.send(new PutObjectCommand({
Bucket: 'notification-data',
Key: s3Key,
Body: JSON.stringify(largeObject)
}));
await snsClient.send(new PublishCommand({
TopicArn: topicArn,
Message: JSON.stringify({
notificationId,
s3Bucket: 'notification-data',
s3Key: s3Key
})
}));
4. 이메일 구독 자동 확인
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 구독 확인 토큰 자동 처리
export const handler = async (event) => {
for (const record of event.Records) {
const message = JSON.parse(record.Sns.Message);
if (record.Sns.Type === 'SubscriptionConfirmation') {
// 구독 확인 URL 호출
const confirmUrl = message.SubscribeURL;
await axios.get(confirmUrl);
console.log('Subscription confirmed');
} else if (record.Sns.Type === 'Notification') {
// 실제 알림 처리
await processNotification(message);
}
}
};
SNS 비용 최적화
비용 구조:
1
2
3
4
5
6
7
8
9
10
11
12
발행:
- 처음 100만 요청/월: 무료
- 이후: $0.50 per million publishes
이메일/HTTP/Lambda/SQS 전달:
- 무료
SMS:
- 국가별 상이 (한국: ~$0.06/건)
모바일 푸시:
- 무료
최적화 전략:
- 필터 정책 활용: 불필요한 메시지 전달 방지
- 배치 발행: 여러 메시지를 하나로 묶어 발행
- SMS 대신 푸시/이메일: SMS는 비용 높음
- SQS Fanout: SNS → SQS → Lambda (재시도 제어 가능)
마치며
Amazon SNS는 확장 가능한 알림 시스템 구축의 핵심임.
SNS를 사용해야 할 때:
- 여러 구독자에게 동시 알림
- 다양한 채널로 메시지 전송
- 이벤트 기반 아키텍처
- Fanout 패턴 구현
- 시스템 간 느슨한 결합
핵심 패턴:
- Fanout: SNS → 여러 SQS → 독립적 처리
- 필터링: MessageAttributes + FilterPolicy
- 멀티 채널: 이메일, SMS, Lambda, HTTP 동시 지원
- 이벤트 라우팅: CloudWatch Alarm → SNS → Lambda
실전 프로젝트에서는 SNS를 SQS(큐잉), Lambda(처리), SES(이메일)와 조합하여 확장 가능한 알림 시스템을 구축할 수 있음.