스노우 입사 전, 한 달 정도의 여유 기간 동안 영어 학습 뉴스레터 서비스를 만들어 운영을 시작했다. 친구 둘과 나를 포함해 총 세 명이 초기 사용자가 되어 서버 비용을 간신히 충당할 수 있었지만, 한 푼이 아쉬운 상황이라는 데에는 큰 차이가 없었다.

사이드 프로젝트라고 공부도 할 겸 처음 써보는 기술을 몇 개 쓰다 보니, 프론트엔드 개발에 생각보다 시간을 많이 쓰게 되어 백엔드는 익숙한 스택으로 빠르게 개발한 것이 문제라면 문제였다. 서버는 Ruby on Rails로 개발했고, 메일 발송을 백그라운드에서 처리하기 위해 Resque를 사용했다. Google Cloud Platform의 무료 크레딧을 사용하기 위해 App Engine, Cloud SQL, 그리고 Cloud Memorystore를 사용했다. 덕분에 시작부터 서비스 유지비로만 한 달에 $80 정도가 나가는 상황이 됐다.

Lambda

그러기를 몇 달, 무료 크레딧을 다 사용하고 본격적으로 돈이 빠져나가기 시작하자 머리가 아파졌다. 가장 작은 인스턴스를 사용해도 CPU나 메모리 사용량은 수%를 넘지 못하고 있었다. 서비스 특성상 API 호출이 많지 않기 때문이다. 바로 떠오른 게 Serverless였다. Lambda의 무료 사용량을 다 쓸 정도면 나는 백만장자가 될 게 분명했다. (왜 Cloud Functions가 아니라 Lambda냐고 묻는다면.. 나는 Ruby와 ActiveRecord, ActiveSupport를 사랑하는데, Cloud Functions는 Ruby 런타임을 지원하지 않는다.)

Rails 코드를 재작성하려고 보니, 일반적인 요청 - 응답은 간단했지만, 기존에 Resque와 Redis를 사용하여 백그라운드에서 처리하던 메일이나 슬랙 메시지 발송을 어떻게 수정할지가 문제였다. 구현을 위해 Resque의 대략적인 작동 방식을 생각해보면, 아마 작업을 처리하는데 필요한 인자들과 실행할 작업 이름을 직렬화해 Redis 등의 데이터베이스에 저장하고, 서버에서 일정 시간마다 질의해 작업 이름과 인자를 받아 작업을 실행하는 식일 것이다.

처음에는 Redis 대신 SQS를 사용하려 했지만, 메시지가 최대 14일까지만 보존되는 제한이 있었다. 구독 해지 작업을 예약하는 경우, 1년 이용권을 구입한 지 얼마 되지 않아 바로 해지한다면 약 1년 정도 뒤에 작업이 실행되어야 하기 때문에 SQS는 사용하기 어려웠다.

비용을 줄이는 게 목적이고 작업이 Redis를 사용해야 할 정도로 많거나 속도에 민감하지 않기 때문에 직렬화한 작업 데이터는 MySQL에 저장하기로 했다. 서버에서 일정 시간마다 질의하는 부분은 마침 Lambda가 Scheduling을 지원해서 5분마다 작업 대기열을 확인해 실행 시간이 지난 작업을 실행 후 삭제하도록 했다. 아래와 같은 구조로 작동한다고 생각하면 된다.

Architecture Diagram

여기까지 작업한 코드는 아래와 같다.

app/models/pending_job.rb

1
2
3
4
5
6
class PendingJob < ActiveRecord::Base
# klass : string
# params : text
# wait_until: datetime
scope :enqueued, -> { where(wait_until: 0...Time.now) }
end

실행할 작업 이름 (klass), 필요한 인자 (params), 그리고 실행 시간 (wait_until)을 저장하는 모델

functions/execute_pending_jobs.rb

1
2
3
4
5
6
7
8
9
10
def handler(event:, context:)
PendingJob.enqueued.each do |job|
params = JSON.parse(job.params).symbolize_keys
job.klass.constantize.execute(params)
job.destroy
end
rescue => e
logger = Logger.new(STDERR)
logger.error e.inspect
end

5분마다 작업 대기열을 확인해 실행 시간이 지난 작업을 실행 후 삭제하는 Lambda 함수

app/common/base_job.rb

1
2
3
4
5
6
7
8
9
10
11
module Jobs
class Base
def self.schedule(with:, wait_until:)
PendingJob.create(
klass: self.to_s,
params: with.to_json,
wait_until: wait_until,
)
end
end
end

app/jobs/send_email_job.rb

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Jobs::SendEmail < Jobs::Base
def execute(to:, subject:, body:)
client = Aws::SES::Client.new(region: 'us-east-1')
client.send_email({
destination: {
to_addresses: [to],
},
message: {
subject: {
charset: 'UTF-8',
data: subject,
},
body: {
html: {
charset: 'UTF-8',
data: body,
},
},
},
source: 'Mailenglish <service@mailenglish.co>',
})
end
end

그리고 실제로 메일을 발송하는 작업 코드이다. 아래처럼 사용할 수 있다.

1
2
3
4
5
6
7
8
Jobs::SendEmail.schedule(
with: {
to: 'mu29@yeoubi.net',
subject: 'Lorem Ipsum',
body: 'dolor sit amet, consectetur adipiscing elit',
},
wait_until: 1.days.from_now,
)

Simple Notification Service

이렇게 문제가 해결되면 좋았겠지만 이 방법은 확장성이 떨어진다. 메일 한 건 발송에는 약 1~2초 정도가 소요되기 때문에 정해진 시간에 한꺼번에 뉴스레터를 발송하면 Lambda의 900초 실행 시간 제한에 걸리게 된다. (사실 개발할 때는 30초인 줄 알았다. ㅠㅠ) 그래서 작업을 대기열에서 빼내는 함수작업을 실행하는 함수를 분리하기로 했다.

작업을 대기열에서 빼내는 함수는 앞의 execute_pending_jobs와 내용이 비슷하지만, 작업을 바로 실행하는 대신 SNS 주제에 메시지를 게시한다. 작업을 실행하는 함수는 SNS 주제를 구독하고, 메시지가 들어오면 그로부터 작업 이름과 인자를 받아내어 작업을 실행한다. 아래와 같은 구조로 작동한다고 생각하면 된다.

Architecture Diagram

SNS 주제에는 메시지를 빠르게 게시할 수 있고 순간적으로 많은 메시지가 들어와도 그만큼 많은 Lambda 함수를 실행할 것이기 때문에 다량의 작업을 처리하기에도 무리가 없는 구조라고 판단했다. 변경된 코드는 다음과 같다.

app/common/base_job.rb

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
module Jobs
class Base
def self.schedule(with:, wait_until:)
PendingJob.create(
klass: self.to_s,
params: with.to_json,
wait_until: wait_until,
)
end

def self.perform(params, id = nil)
@client ||= Aws::SNS::Client.new
@client.publish(
topic_arn: 'SNS 주제',
message: {
id: id,
klass: self.to_s,
params: params.to_json,
}.to_json,
)
end
end
end

functions/enqueue_pending_jobs.rb

1
2
3
4
5
6
7
8
def handler(event:, context:)
PendingJob.enqueued.each do |job|
job.klass.constantize.perform(job.params, job.id)
end
rescue => e
logger = Logger.new(STDERR)
logger.error e.inspect
end

functions/execute_pending_jobs.rb

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def handler(event:, context:)
messages = event['Records'].map do |record|
JSON.parse(record['Sns']['Message']).symbolize_keys
end
messages.each do |message|
klass = message[:klass]
params = message[:params]
id = message[:id]&.to_i

while params.is_a? String
params = JSON.parse(params)
end

klass.constantize.new.execute(params.symbolize_keys)
PendingJob.destroy(id) unless id.nil?
end
rescue => e
logger = Logger.new(STDERR)
logger.error e.inspect
end

위의 아키텍처 다이어그램을 보면 알겠지만 SNS로 함수를 실행함으로써 얻는 또 하나의 이점이 있다. 이제 작업을 예약 없이 바로 백그라운드에서 처리할 수 있게 됐다. (API에서 바로 SNS 호출)

Lambda만 사용하는 버전에서 회원가입 후 환영 이메일을 발송하는 과정을 생각해 보자. 회원가입을 완료하면 Jobs::SendEmail.schedule()을 사용하여 작업 정보를 데이터베이스에 저장하고, 일정 시간 뒤(최대 5분)에 enqueue_pending_jobs 함수가 작업을 실행하여 메일을 보내게 된다. 물론 백그라운드에서 처리하지 않고 바로 Jobs::SendEmail.execute()를 할 수도 있지만 둘 다 권장하는 방법은 아니다.

SNS까지 함께 사용하는 버전에서는 Jobs::SendEmail.perform()을 실행하면 된다! 🎉

마무리

지금은 백엔드를 전부 Lambda + SNS로 변경했고, 무료 사용량 내에서 쓰고 있다. 아직 RDS에 매달 $30씩 지불하고 있지만, 이마저도 dynamodb를 사용하면 돈 한 푼 들이지 않고 서비스를 운영할 수 있다. 물론 앞서 말했듯 나는 ActiveRecord와 ActiveSupport를 사랑하기 때문에 (..) 당분간은 이대로 서비스할 것 같다. 가볍게 시작한 글이 배경 설명과 코드 예제까지 들어가 길어졌다. 실제로 작동하는 예시 프로젝트는 Github에서 확인할 수 있다.