서버리스 환경에서 2천만 유저에게 이메일 전송하기

Victor Kang
staygelabs
Published in
24 min readSep 18, 2023

안녕하세요. 스테이지랩스(STAYGE Labs) Back-End 팀 강은호입니다. 이번 아티클은 서버리스 환경에서 AWS Lambda와 AWS Step Functions를 활용하여 2천만 유저에게 순차적으로 이메일을 전송하는 시스템을 구축한 사례를 소개하려고 합니다.

목차

  • 요구 및 제약 사항
  • Architecture 소개
  • 몇가지 Tip
  • 결론

요구 및 제약 사항

서비스를 운영하다 보면 유저에게 알림을 보내야 할 상황들이 종종 있습니다. 특성에 따라서 앱의 푸시 기능 또는 이메일을 통해 유저에게 알림을 하게 됩니다. 이메일을 통한 알림은 유저 인증 코드 전송과 같이 1 건 일수도 있고 특정 유저를 대상으로 알림을 보내는 다량의 건 일수도 있습니다. 또는, 이용 약관 변경에 따른 고지 등을 위한 전체 유저 대상의 이메일 알림 일수도 있죠.

스테이지랩스에서 개발(운영)중인 Mnet Plus 에는 약 2천만명의 유저가 있습니다. 단 건 또는 소량의 이메일 전송은 문제가 되지 않겠지만, 이용 약관 변경에 따른 고지를 위한 전체 유저 대상의 대량의 이메일 전송을 하게 되면 문제가 발생 하게됩니다. 2천만건을 동시에 메일 서버에 전송 할 수는 없습니다. 적당한 동시 전송 횟수와 대기 시간을 가지고 이메일을 전송해야지 유저에게 안전하게 이메일이 전달 될 수 있습니다.

요구 사항

기존의 대량의 이메일 전송 프로세스는 간단하게 작성된 스크립트 형식으로 로컬 환경에서 수동으로 동작해 왔습니다. 그 당시 백만 단위의 유저 수로 비교적 감당 가능한 양 이였고, 전체 유저를 대상으로 이메일을 전송 해야하는 작업이 가끔씩 발생 했기 때문에 가능하고 합리적인 방법 이었습니다.

하지만, 어느 순간 천만 단위로 유저수가 넘어가고 특정 범위에 유저 대상으로 하루 단위 이메일 전송 작업이 필요해 졌기 때문에 저희 백엔드 팀에서는 개발자의 운영 피로 도와 시스템 안정성을 확보하기 위해 자동화 된 시스템을 구축 하기로 하였습니다. 아래의 항목은 시스템 구축을 위한 필요 요소들 입니다.

  • 천만 단위의 대량 이메일 전송 프로세스 자동화
  • 서버리스 환경에서 긴 시간 동안 순차적으로 전송
  • 예약된 시간에 이메일 전송 시작
  • 이메일 특성에 따라 대상 유저 선별
  • 블랙 리스트 도메인 이메일 필터링
  • 5초에 1000건 씩 전송
  • 이메일 전송 성공 즉시 후 처리
  • 이메일 전송 실패 시 3분 대기 후 재 시도
  • 이메일 전송 최종 실패 대상 기록 후 수동 처리
  • 이메일 전송 프로세스 완료 시 Slack을 통해 작업 결과 리포트

제약 사항

위의 요구 사항들을 보면 실패 시 재시도(Wait), 1000건 전송 후 5초 대기(Delay) 등이 있지만 서버리스 환경에서 가장 제약이 되는 사항은 긴 시간 동안 동작 하는 프로세스를 만드는 것 입니다.

AWS Serverless 환경에서 가장 널리 사용 되는 컴퓨팅 서비스는 Lambda 입니다. 한번의 실행으로 Lambda 가 최대 실행 가능한 시간은 15분이죠. 15분 안에 2천만 유저에게 이메일 전송을 완료하는 것은 불가능 합니다. 또한 대기 시간과 재시도에 대한 요구 사항도 있기 때문에 Lambda 하나 만으로 처리하기는 무리가 있습니다.

  • 15분 이상 진행 되는 이메일 전송 프로세스
  • 여러 단계의 작업 흐름
  • 재시도, 대기시간 등 단계 별 다양한 요구 조건

사실, 긴 시간의 작업은 AWS EC2와 같은 컴퓨팅 서비스를 사용 하면 제약이 되지 않습니다. 하지만, 스테이지랩스에서는 Serverless 가 주는 설치 없는 빠른 시작, 낮은 운영 비용, 고가용성 등의 이점을 알고 있고 비스니스 로직 개발에 집중하고자 하는 Serverless 의 철학에 공감하고 있기 때문에 Serverless환경에서 해결하기로 하였습니다.

Architecture 소개

긴 작업을 Lambda 를 사용 해서 해결 할 수 있는 방법의 열쇠는 Step Functions 에 있습니다. AWS 에서는 Step Functions 를 다음과 같이 소개 합니다.

AWS Step Functions는 개발자가 AWS 서비스를 사용하여 분산 애플리케이션을 구축하고, 프로세스를 자동화하며, 마이크로서비스를 오케스트레이션하고, 데이터 및 기계 학습(ML) 파이프라인을 생성할 수 있도록 지원하는 시각적 워크플로 서비스입니다.

Step Functions 의 사용 사례로는 추출, 변환, 적재(ETL) 프로세스 자동화, 대규모 병렬 워크플로 오케스트레이션 등 긴 작업을 처리 할 수 있는 서비스로 소개 됩니다. Step Functions에 자세한 설명은 다른 자료 들로 대체 하고 이번 아티클에서는 사용 경험에 대해 더 소개 하겠습니다.

아래 그림은 EventBridge Scheduler에서 등록된 예약 이벤트에서 트리거 되는 Step Functions 의 시스템 구성도 입니다. 각 단계 별로 간략하게 설명 드리겠습니다.

  • Event Bridge Scheduler: 미리 정의 된 이벤트(JSON 형식)를 예약된 시간에 Step Functinos 에 전달하고 동작 시킴
  • Extract User List: 이벤트에 맞는 SQL 실행 시켜 유저를 선별하고 유저 식별자를 CSV 파일로 변환해 S3에 저장
  • Map: CSV 파일을 읽고 1000개 씩 나누어 하위 작업에 전달
  • Set Email: 유저 식별자로 이메일을 매칭 시키고, 블랙 리스트에 포함 되는 유저는 제외 시킴
  • Send Email: mailgun 서비스를 사용해 유저에게 이메일 전송 (mailgun API를 사용해 이메일을 전송하고 추적 및 분석 할 수 있는 서비스입니다. + 최대 1000개 묶음 전송 기능 지원)
  • After Fail: 이메일 전송 최종 실패 시 로그를 저장 및 처리
  • After Success: 이메일 전송 성공 시 후속 작업이 필요 하면 SNS 에 이벤트 게시 후 비동기로 처리
  • Report Work: 이메일 전송의 작업 결과를 SNS 에 이벤트 게시 후 Slack 채널에 알림

Work Flow 소개

왼쪽 그림은 위에서 소개한 아키텍쳐 중 Step Functions 부분을 나태낸 작업 흐름도입니다. 오른쪽 그림은 Step Functions 의 상태들인 Action, Map, Wait, Choice, Fail, Pass 로 표현된 작업 흐름도 입니다. AWS Web Console 에서는 작업의 흐름을 시각적으로 제공해서 가시성이 좋습니다.

작업 흐름은 아마존 상태 언어(Amazon States Language)로 작성 되어 코드로 관리 됩니다. 언어라고 하지만 사실 JSON 입니다. 아래의 코드는 Serverless Framework 환경에서 작성된 Step Functions 상태 코드입니다. 주석(#) 을 참고해서 보시면 각 상태별 설정 값들을 이해 하실 수 있습니다.

definition:
Comment: A description of my state machine
StartAt: ExtractUserList
States:
ExtractUserList: # 이벤트 종류 별로 이메일을 보내야 하는 유저를 선별
Type: Task
Resource: arn:aws:states:::lambda:invoke
OutputPath: $.Payload
Parameters:
FunctionName:
Fn::GetAtt: [bulkEmailSendWorkFlow, Arn]
Payload:
handlerName: ExtractUserList
input.$: $.result
Retry:
- ErrorEquals:
- Lambda.ServiceException
- Lambda.AWSLambdaException
- Lambda.SdkClientException
- Lambda.TooManyRequestsException
IntervalSeconds: 2
MaxAttempts: 6
BackoffRate: 2
Next: LoopUserList

LoopUserList: # 1000개씩 유저 식별자를 하위 작업에 전달
Type: Map
ItemProcessor:
ProcessorConfig:
Mode: DISTRIBUTED
ExecutionType: STANDARD
StartAt: SetEmail
States:

SetEmail: # 이메일을 전송 할 수 있게 세팅
Type: Task
Resource: arn:aws:states:::lambda:invoke
OutputPath: $.Payload
Parameters:
FunctionName:
Fn::GetAtt: [bulkEmailSendWorkFlow, Arn]
Payload:
handlerName: SetEmail
input.$: $.BatchInput.result
items.$: $.Items
Retry:
- ErrorEquals:
- Lambda.ServiceException
- Lambda.AWSLambdaException
- Lambda.SdkClientException
- Lambda.TooManyRequestsException
IntervalSeconds: 2
MaxAttempts: 6
BackoffRate: 2
Next: SendEmail

SendEmail: # mailgun에 이메일 전송
Type: Task
Resource: arn:aws:states:::lambda:invoke
OutputPath: $.Payload
Parameters:
FunctionName:
Fn::GetAtt: [bulkEmailSendWorkFlow, Arn]
Payload:
handlerName: SendEmail
input.$: $.result
Retry:
- ErrorEquals:
- Lambda.ServiceException
- Lambda.AWSLambdaException
- Lambda.SdkClientException
- Lambda.TooManyRequestsException
IntervalSeconds: 2
MaxAttempts: 6
BackoffRate: 2
Next: Delay

Delay: # 이메일 전송 후 10초 대기
Type: Wait
Seconds: 5
Next: IsSuccess

IsSuccess:
Type: Choice
Choices:
- Variable: $.result.retryCnt
NumericGreaterThan: 0 # 재시도 횟수가 0이 될 때 까지 재시도
Next: RetryWait
- And: # 전송 생태가 fail이고 재시도 횟수가 0이면 최종 실패로 처리
- Variable: $.result.status
StringEquals: fail
- Variable: $.result.retryCnt
NumericLessThanEquals: 0
Next: AfterFail
Default: AfterSuccess

RetryWait:
Type: Wait
Seconds: 180 # 실패시 3분 동안 대기
Next: SendEmail

AfterSuccess: # 이메일 전송 성공 시 후속 작업 처리
Type: Task
Resource: arn:aws:states:::lambda:invoke
OutputPath: $.Payload
Parameters:
FunctionName:
Fn::GetAtt: [bulkEmailSendWorkFlow, Arn]
Payload:
handlerName: AfterSuccess
input.$: $.result
Retry:
- ErrorEquals:
- Lambda.ServiceException
- Lambda.AWSLambdaException
- Lambda.SdkClientException
- Lambda.TooManyRequestsException
IntervalSeconds: 2
MaxAttempts: 6
BackoffRate: 2
Next: Success

AfterFail: # 이메일 최종 전송 실패 시 핸들링
Type: Task
Resource: arn:aws:states:::lambda:invoke
OutputPath: $.Payload
Parameters:
FunctionName:
Fn::GetAtt: [bulkEmailSendWorkFlow, Arn]
Payload:
handlerName: AfterFail
input.$: $.result
Retry:
- ErrorEquals:
- Lambda.ServiceException
- Lambda.AWSLambdaException
- Lambda.SdkClientException
- Lambda.TooManyRequestsException
IntervalSeconds: 2
MaxAttempts: 6
BackoffRate: 2
Next: Fail

Success: # 성공
Type: Pass
End: true

Fail: # 최종 실패
Type: Fail
Error: LimitMaxRetry

Next: ReportWork
MaxConcurrency: 1
Label: LoopUserList
ItemReader:
Resource: arn:aws:states:::s3:getObject
ReaderConfig:
InputType: CSV
CSVHeaderLocation: FIRST_ROW
Parameters:
Bucket.$: $.result.map.bucket
Key.$: $.result.map.objectKey
ItemBatcher:
# 1000개 씩 끊어서 처리
MaxItemsPerBatch: 1000
MaxInputBytesPerBatch: 262144
BatchInput:
result.$: $.result
ResultWriter:
Resource: arn:aws:states:::s3:putObject
Parameters:
Bucket.$: $.result.map.bucket
Prefix.$: $.result.map.prefix
ToleratedFailurePercentage: 100
Catch:
- ErrorEquals:
- States.ItemReaderFailed
Next: ReportWork
ResultPath: $.result.mapResult.error
ResultPath: $.result.mapResult

ReportWork: # 작업 결과 보고
Type: Task
Resource: arn:aws:states:::lambda:invoke
OutputPath: $.Payload
Parameters:
FunctionName:
Fn::GetAtt: [bulkEmailSendWorkFlow, Arn]
Payload:
handlerName: ReportWork
input.$: $.result
Retry:
- ErrorEquals:
- Lambda.ServiceException
- Lambda.AWSLambdaException
- Lambda.SdkClientException
- Lambda.TooManyRequestsException
IntervalSeconds: 2
MaxAttempts: 6
BackoffRate: 2
End: true

시나리오

전체 유저 대상의 이메일을 전송하는 시나리오로 각 상태 별로 조금 더 상세하게 소개 하겠습니다.

ExtractUserList

ExtractUserList에서 이메일을 받아야 하는 유저를 선별하는 기능을 담당 합니다.

아래의 샘플 쿼리를 사용해서 유저 테이블에서 전체 유저의 식별자 추출 하고 CSV 파일로 변형해서 S3 에 저장합니다. 그 후 CSV 파일의 S3 정보를 Map 상태에 넘겨 줍니다. Aurora DB의 query_export_to_s3 기능을 사용 하면 SQL 의 결과를 손쉽게 CSV 로 변형해서 S3에 저장 할 수 있습니다.

SELECT * 
from aws_s3.query_export_to_s3(
'
select user_id
from user_table.user
;
',
's3-bucket-name',
's3-object-key',
's3-region',
'format csv, delimiter $$ $$,header true, quote "''"'
);

MAP

Map 상태는 Javascript 의 Array.map 문법과 유하사게 동작하며, 루프의 역할을 합니다. 배열을 입력 값으로 받고 배열을 순회 하면서 지정한 개수 만큼 처리합니다. 런타임 중에 배열을 입력 값으로 받을 수 있고 또는, S3에 있는 CSV 파일을 입력값으로 받아서 미리 지정한 라인 수 만큼 씩 가져올 수 있습니다.

추가 설정으로 Map 에 대한 결과를 성공, 실패, 보류 케이스로 구분해서 S3 에 저장하고, 작업 성공 상태에 대한 알림이나 실패한 케이스에 대한 수동 처리를 위한 로그로 사용 할 수 있습니다.

Map에서 CSV 파일을 읽고 하위 작업에 넘겨주는 입력 값은 아래 예시와 같은 포맷 입니다. CSV에 있는 전체 유저의 식별자를 1000개 씩 순차적으로 가져오게 됩니다.

// Map에서 CSV 파일을 읽어 왔을 때 예시
{
"Items": [
{
"user_id": "abcd-1"
},
........
{
"user_id": "abcd-2"
},
]
}

SetEmail

SetEmail에서 이메일 전송하기 전에 필요한 준비를 담당합니다.

유저 식별자를 사용해서 이메일 테이블에서 이메일 주소를 가져오고, Redis 에 등록된 블랙 도매인 정보를 사용해서 블랙 리스트에 포함되어 있는 이메일 주소를 필터 합니다. 그 후, mailgun의 묶음 이메일 전송 API 호출을 위한 설정값들을 준비 합니다.

// SendEmail 에게 전달하는 output 값 예시
{
"result": {
"typeName": "sendEmailAllUser",
"templateName": "email-template-name",
"whiteUserIds": [
"whiteUserId-abcd-1",
"whiteUserId-abcd-2"
],
"blackUserIds": [
"blackUserId-abcd-1"
],
"to": [
"white-user1@gmail.com",
"white-user2@gmail.com"
],
"from": "no-reply@mnet.world",
"subject": "전체 유저 대상 공지 이메일"
}
}

SendEmail, Delay

SendEmail 에서는 mailgun 서버스에 이메일을 배치(1000개)로 묶어서 전송하고 실패 시 재시도 횟수를 관리 합니다.

Delay에서는 이메일 전송 후 5초 동안 대기하게 합니다.

IsSuccess, RetryWait

IsSuccess 에서는 이메일 전송 성공 여부에 따라서 재시도 처리하거나 최종 실패, 성공에 대한 분기 처리를 합니다.

분기의 조건

  • 재시도: $.result.retryCnt > 0
  • 최종 실패: $.result.status == "fail" and $.result.retryCnt <= 0
  • 성공: Default state

RetryWait 에서는 재시도 전에 3분간 대기하게 합니다.

AfterSuccess, AfterFail

AfterSuccess 에서는 메일 전송에 성공한 유저 목록을 후속 작업을 위해 SNS에 개시합니다. 별도로 동작하는 Lambda 에서 SNS 에 구독해서 가져온 유저 목록으로 후속 작업을 비동기 적으로 처리하게 됩니다. (후속 작업이란, 이메일 전송과 무관한 작업 성공 이후 필요한 DB 작업 등을 의미합니다.)

AfterFail 에서는 최종 실패한 유저 목록을 수동 처리하기 위해 로그를 남깁니다.

ReportWork

ReportWork 에서는 Map의 작업 내용중 실패한 건을 포함하고 있으면 Slack에 알림을 줍니다. 아래의 코드는 Map이 종료 후 S3에 생성해주는 manifest.json 파일의 예시입니다. 실패, 성공 건의 구분해서 입력 값을 기록해 둡니다.

// manifest.json
{
"DestinationBucket": "bucket-name",
"MapRunArn": "arn{중략}/LoopUserList:82167d83-f8ec-349a-bb81-7d861493b6b3",
"ResultFiles": {
"FAILED": [
{
"Key": "82167d83-f8ec-349a-bb81-7d861493b6b3/FAILED_0.json",
"Size": 10966
}
],
"PENDING": [],
"SUCCEEDED": [
{
"Key": "82167d83-f8ec-349a-bb81-7d861493b6b3/SUCCEEDED_0.json",
"Size": 30966
}
]
}
}

중간 정리

아티클 초반으로 돌아가보면 다양하고 많은 요구사항 있던 것을 기억 하실 겁니다. 저희 팀에서는 Step Functions의 기능들과 Event Bridge Scheduler, Lambda, SNS, S3 등의 서버리스 서비스를 조합해서 모든 요구사항을 충족하는 2천만 유저에게 이메일을 전송 하는 시스템을 구축 할 수 있었습니다.

몇가지 Tip

Long Term Process(긴 작업)을 수행 시키기 위해서 EC2등의 컴퓨팅 서비스를 사용하지 않고 Step Functions 와 서버리스 서비스 들을 선택한 이유로 서버리스를 사용 함으로서 얻게 되는 이점만 소개 했지만, Step Functions 의 모니터링과 Work Flow Studio 기능도 있습니다. Step Functions 를 사용 하면서 도움이 될 만한 몇가지 팁을 소개하겠습니다.

디버깅 및 모니터링

Step Functions의 AWS Web Console 에는 각 State(상태) 가 전환 되면서 발생한 입력 값, 출력 값에 대한 상세한 기록과, 상태 별 소요한 작업 시간을 시각적으로 제공 합니다. 해당 기능들은 시스템 개발 단계에서 풍부한 정보를 제공 받음으로 개발 속도를 촉진 해주는 효과가 있고, 시스템 운영 단계에서 오류나 이상 감지 시 훌륭한 디비깅 도구로 사용 됩니다.

또한, Map 상태의 작업 진행 상황을 실시간 제공 합니다. Map 상태가 1천만 건의 Array를 입력 받아 1000건 씩 처리 하는데 10초가 걸린다고 가정 해봅시다. 약 28시간 이라는 긴 작업 시간 동안 처리, 성공, 실패 횟수에 대해 모니터링 할 수 있습니다.

쉬운 작업

21년에 Step Functions에 시각적 편집 도구인 Workflow Studio 가 출시 되었습니다. 기존에는 Amazon States Language 를 직접 작성 해야 하는 높은 진입 장벽이 있었다고 한다면 Workflow Studio 출시 이후는 상태 블럭을 Drag and Drop 하여 작업의 흐름을 빠르게 설계하고 상세 정의 UI 를 사용해서 상태 머신을 완성 할 수 있어 진입 난이도가 매우 쉬워졌습니다. 또한, Workflow Studio 로 빠르게 설계하고 yaml 파일이로 export 시켜서 Serverless Framework 나 AWS SAM 을 사용해서 코드로 관리 할 수도 있습니다.

단일 Lambda 사용

이메일 전송 상태 머신(Step Functions)에서 사용 된 상태(Lambda) 는 총 6개 입니다. 1개의 Lambda 만 배포해서 사용 하거나 6개의 Lambda를 각각 배포 해서 사용 할 있지만, 6개의 Lambda 를 사용하면 운영 및 관리 난이도와 구조의 복잡도가 높아지기 때문에 1개의 Lambda 를 사용 하는 것을 추천합니다.

.yml→Payload→handlerName 에 상태(Lambda) 이름을 입력 값으로 주입 시키고 handler 함수에서 상태 이름과 메소드를 매칭 시키면 쉽고 깔끔하게 1개의 Lambda로 여러개의 상태를 처리 할 수 있습니다.

하지만, 해결 해야할 문제점이 있습니다. Lambda에서 console.log를 사용하면 로그가 Cloud Watch Log 에 남게 됩니다. 6개의 상태에서 로그를 남기게 되면 한개의 Log Group 에서 섞여 보이기 때문에 디버깅에 어려움이 있을 수 있습니다. 그렇기 때문에 로그를 남 길 때는 상태의 이름을 포함해서 남기고 Cloud Watch에서 검색해서 디버깅 하는 것을 권장합니다.

ex). console.log('ExtractUserList ::: ', Message);

Step Functions 의 다양한 사용 사례

이번 아티클에서는 Step Functions 를 대량의 이메일을 순차적으로 전송하는 한가지 사례로 만 소개 했습니다. 위에서는 언급 하지 않았지만 Step Functions 의 Action 상태에는 Lambda:invoke 뿐 아니라 AWS SDK 에서 거의 모든 기능을 지원하기 때문에 다양한 방법으로 응용 할 수 있습니다. AWS 에서는 데이터 처리 및 ETL 오케스트레이션, 기계 학습 운영, 인프라 운영 자동화, SAGA 패턴, CQRS 디자인 패턴 등 정말 다양한 활용 사례를 제시 합니다.

Serverless Land 블로그에서 다양한 사용 사례를 학습하고 조직에서 해결 할 수 있는 문제가 있다면 고려 해보는 것을 제안 합니다.

Step Functions Use Case

결론

이번 아티클에서는 긴 시간이 소요되는 대량 이메일 전송 작업을 Serverless 환경에서 해결한 사례를 소개 했습니다.

스테이지랩스와 같이 Serverless 환경에서 Lambda 를 적극 활용 하는 개발 조직에서는 Lambda 의 15분 제한 시간 때문에 긴 시간이 소요되는 작업을 해결하기 위해 많은 어려움을 겪었을 것 입니다.

Step Functions 는 긴 시간이 소요 되는 작업 뿐 아니라 인프라 운영 자동화, 디자인 패턴 구성 등으로 사용 할 수 있는 좋은 도구 입니다. 스테이지랩스에서 Step Functions 를 사용해 문제를 해결 한 사례가 Serverless 를 사용하는 다른 개발 조직에 도움이 되었으면 합니다.

--

--