- 매일 최대 500건의 이메일 대량 발송 필요
- 이메일당 평균 크기는 100KB 예상
- 이메일 발송 지연 시간은 5분 이내
- 발송 실패 처리: 실패한 이메일에 대한 자동 재시도 로직 (최대 3회)
- 구독 관리: 사용자별 이메일 수신 설정 및 구독 해지 기능
- 스팸 방지: SES의 SPF를 통한 도메인 인증
- 무신사 마케팅 직원 A씨가 고객 200명에게 맞춤형 홍보 이메일 전송
박언선 | 김지원 | 박가영 | 이동준 | 정현석 | 황규리 |
---|---|---|---|---|---|
eonpark | JiwonKim08 | ParkIsComing | dongjune8931 | Junghs21 | gyuuuuri |
Lambda
- 15분의 Lambda 실행 시간의 제약 발생
- but, Serverless 환경의 장점을 사용하고 싶음
- StepFunction 도입
StepFunction
- Map을 사용해 동일한 워크플로우(예: Lambda 함수 호출)를 병렬로 실행
- 전체 실행시간을 단축 & 복잡한 반복 로직을 간결하게 처리
- 이메일이 늘어날수록 처리속도 상승
Email Verification(sandbox)
- SES Sandbox로 인해 '일일 최대 200개라는 한도', '인증된 이메일로만 전송' 들로 인해 프로젝트 목적 달성에 제약 발생
- 샌드박스 해제하여 문제 해결
- 프로덕션 환경으로 전환되어 수신자의 주소 또는 도메인이 확인되었는지 여부에 관계없이 모든 수신자에게 이메일을 보낼 수 있음
- 일일 최대 200개 리밋 벗어남.
- Request production access을 하기 위해 다음과 같은 내용을 작성해서 신청
- 메일 발송 목록을 어떻게 작성하거나 만들 계획
- 반송 메일과 수신 거부를 어떻게 처리할 계획
- 수신자가 귀하가 보내는 이메일을 수신 거부하는 방법
- 이 요청에서 귀하가 지정한 송신률 또는 발신 할당량
DynamoDB
- 팀원들 모두 RDS를 사용해본 경험이 있으므로 새로운 DB인 NoSQL을 사용하고자 선택함
- 완전 관리형 서비스이므로 운영 부담이 발생하지 않음
- key-value 형태이므로 READ 속도가 빠름
- 데이터 양이 늘어나면 서버를 증설하는 수평적 확장을 통해 성능 유지 가능
- 실제 이메일은 sandbox를 해제하지 않는 이상 AWS 계정당 메일 200개만을 보낼 수 있기에, 실행 예시는 200개를 기준으로 함
- DynamoDB에 있는 전체 유저 200명에게 이메일을 보낸다고 가정
- Lambda(1)에서 dynamoDB로부터 10명의 유저 정보를 가져옴
- 그 후 Lambda(2)를 호출해 10명의 유저 payload와 템플릿 정보를 SQS로 전달 (*SQS를 거치는 이유는 이메일 누락을 막기 위함)
- Lambda(3)에서 10명의 유저 payload를 가져와 템플릿에 삽입하여 이메일을 완성한 후, SES로 이메일을 전송
- 200개의 이메일을 전송하기 위해 위 과정을 20번 반복 (10*20 = 200개의 이메일)
AWS SES(Amazon Simple Email Service)는 대량으로 이메일을 전송할 수 있는 클라우드 이메일 서비스 공급자
- 비용 효율성: 사용한 만큼만 비용을 지불하여 경제적
- 자동 스케일링: 이메일 발송 크기가 자동으로 스케일링되어 유연하게 대응
- 상세한 로그와 보고서: 전송된 이메일에 대한 상세한 로그와 보고서를 제공하여, 다른 서비스의 기준값으로 활용 가능
-
도메인 구매: 가비아에서 도메인을 구매하여 사용
-
DKIM 인증: SES에서 제공하는 **DKIM(DomainKeys Identified Mail)**의 CNAME 레코드를 가비아의 도메인 DNS 설정에 추가
목적: 이메일 전송 시, 도메인이 실제로 내 것임을 증명하고, 이메일이 스팸으로 분류되는 것을 방지
StepFunction 내 Lambda 별 기능 및 Input/Output
Lambda(1) | Lambda(2) | Lambda(3) | |
---|---|---|---|
목적 | 유저리스트 n개씩 가져옴 | payload를 큐로 n개씩 전송 | SQS에서 SES로 이메일 전송 |
Input | 유저리스트 chunk (크기: n개) (DynamoDB에서 유저 데이터 몇 개 읽어올 지) | 유저리스트 데이터 (DynamoDB에서 꺼내온 정보) | 유저 payload 리스트 n개 배치 |
Output | 1. 유저리스트 chunk (DynamoDB에서 꺼내온 정보 = name, email, gender, isscribing) 2. 템플릿 정보 | 유저 payload 리스트 n개 배치 (name, email, subject, body) | 메일 완성본 (템플릿에 유저 payload 삽입 + 구독취소링크 삽입 + S3에서 가져온 이미지 삽입) |
Output
{
"result": {
"Payload": [
{
"Gender": "Male",
"SubscriptionStatus": true,
"Email": "jiwonkim0810@gmail.com",
"Name": "chulsu"
},
.
. (생략)
.
{
"Gender": "Female",
"SubscriptionStatus": "true",
"Email": "yuripark066@gmail.com",
"Name": "Karen Young"
},
],
"LastEvaluatedKey": {
"Email": "yuripark066@gmail.com"
}
}
- 10개의 유저데이터 output은 map#1~map#10에 해당
- 들고 온 마지막 데이터를 LastEvaluatedKey 로 저장
Input | Output |
{ "Gender": "Female", "SubscriptionStatus": "true", "Email": "jiwonkim0810@gmail.com", "Name": "chulsu" } |
{ "statusCode": 200, "body": { "template": { "subject": "환영합니다!", "body": "<title>{{Subject}}</title> |
- Lambda(2)의 위 포멧은 map#1만을 의미
- Step Functions의 Map 특성상, 나머지 map#2~map#10도 병렬 처리되고 있음
→ SES에 저장해 둔 템플릿을 가져와 Lambda(3)로 옮김
→ Lambda(3)로 이동 전 SQS 거침
- 메일 완성본을 SES로 전달
-> S3으로부터 이미지 삽입
-> 구독 취소 링크 삽입
{
"StartAt": "Lambda (1)",
"States": {
"Lambda (1)": {
"Type": "Task",
"Resource": "arn:aws:lambda:ap-northeast-2:008971651769:function:GetUserData10",
"ResultPath": "$.result",
"Next": "Map"
},
"Map": {
"Type": "Map",
"ItemsPath": "$.result.Payload",
"MaxConcurrency": 10,
"ItemProcessor": {
"ProcessorConfig": {
"Mode": "INLINE"
},
"StartAt": "Lambda(2)",
"States": {
"Lambda(2)": {
"Type": "Task",
"Resource": "arn:aws:lambda:ap-northeast-2:008971651769:function:EmailQueuer_update",
"Next": "Lambda (3)"
},
"Lambda (3)": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"OutputPath": "$.Payload",
"Parameters": {
"Payload.$": "$",
"FunctionName": "arn:aws:lambda:ap-northeast-2:008971651769:function:email_pra"
},
"Retry": [
{
"ErrorEquals": [
"Lambda.ServiceException",
"Lambda.AWSLambdaException",
"Lambda.SdkClientException",
"Lambda.TooManyRequestsException"
],
"IntervalSeconds": 1,
"MaxAttempts": 3,
"BackoffRate": 2
}
],
"End": true
}
}
},
"Next": "CheckForMoreData",
"ResultPath": "$.results_test"
},
"CheckForMoreData": {
"Type": "Choice",
"Choices": [
{
"Variable": "$.result.LastEvaluatedKey",
"IsPresent": true,
"Next": "UpdateLastEvaluatedKey"
}
],
"Default": "Finish"
},
"UpdateLastEvaluatedKey": {
"Type": "Pass",
"Parameters": {
"LastEvaluatedKey.$": "$.result.LastEvaluatedKey"
},
"ResultPath": "$.meta",
"Next": "Lambda (1)"
},
"Finish": {
"Type": "Succeed"
}
}
}
- 50개의 이메일을 보내는데 총 21초 걸림
- 이를 평균으로 계산하면 5분 동안 보낼 수 있는 이메일의 개수는 750개
- Lambda 함수에서 SES에 미리 올려놓은 템플릿을 가져오는 코드에서 너무 많은 API 요청으로 인해 Throttling이 발생 → 이를 해결하기 위해 처음에만 SES에서 템플릿을 가져와서 ElastiCache에 넣어 사용하는 형식으로 위의 문제를 해결하고자 함
- 유저 정보를 저장하기 위한 테이블
Partition key
- Email(String)
- 유저 이메일 주소를 저장하기 위해 사용
Attributes
- Name(String)
- 유저 이름을 저장하기 위해 사용
- Gender(String)
- 해당 유저의 성별을 저장
- CreateTime(숫자)
- 회원가입 시간 저장
- SubscriptionStatus(부울)
- 구독 상태를 저장
- 이메일 템플릿과 사용하는 이미지들의 메타데이터를 저장하기 위한 테이블
Partition key
- TemplateID(String)
- 이메일 템플릿을 고유하게 식별할 TemplateID
Attributes
- ImageURLs(List)
- 해당 템플릿이랑 매핑후, s3에 저장된 해당 이미지 주소
- TemplateName(String)
- 이메일 템플릿 사용 용도를 간략하게 저장하기 위한 용도
- 서비스 운영 DB로 관계형 데이터베이스를 사용하고 있다고 가정하고, 이메일 대량 발송을 위해서는 이메일 보낼 유저의 정보(이름, 이메일, 성별 등)를 관계형 데이터베이스에서 가져와야 함
- 다른 테이블과의 join 작업이 필요하지 않기 때문에 매번 운영 DB에 접속하여 메일을 발송할 유저 정보를 읽어오기 보다는 빠른 Read 작업이 가능한 DynamoDB에 유저 정보를 두고, 메일을 발송할 때 DynamoDB에서 유저 정보를 가져옴
- 주기적으로 운영 DB에서 변동된 유저 정보를 DynamoDB에 업데이트함으로써 데이터를 동일하게 유지함
- 참고) 운영 DB가 RDS에서 MySQL DB를 활용하고 있다고 가정
- 초기에는 RDS에 저장된 유저 정보를 S3를 거쳐 DynamoDB에 가져옴
- 유저 정보가 많은 경우에 대비해 RDS에서 DynamoDB로 한번에 데이터를 옮기지 않고, 데이터를 여러 작은 덩어리로 분할하여 나누어 저장
- 운영 DB에서 유저 정보가 추가, 변경되면서 DynamoDB의 데이터와 달라지면 주기적인 polling을 통해 이를 업데이트 해줘야 함
- 이메일 발송의 경우, 실시간 동기화가 필요하지 않고 발송 시간을 기준으로 동기화가 되어 있으면 되기 때문에 cronjob을 주기적인 동기화가 이루어지도록 설정
단계
- 배치 작업 설정
- 일정 시간 간격(예: 매 시간, 매일)으로 실행되는 배치 작업을 설정
- 배치 작업 실행
- EC2 인스턴스에서 주기적으로 배치 스크립트를 실행
- 변경된 데이터 추적
- MySQL의
TIMESTAMP
타입을 이용하면 데이터가 추가 및 수정될 때 자동으로TIMESTAMP
필드가 업데이트 됨 - 유저 테이블의
update_at
필드를TIMESTAMP
타입으로 설정하여 변경된 데이터를 추적함
- MySQL의
- 데이터 동기화
- 변경된 데이터를 읽어와 DynamoDB에 반영함. 이후에도 주기적으로 2~4번 과정이 실행됨