이 글은 이전 회사에서 운영 중이던 Lambda를 사용한 서비스의 초기 API 응답속도(콜드 스타트 이슈)를 개선한 과정을 작성한 글입니다.
난 이전 회사에서 운영하던 서비스의 백엔드를 AWS Lambda를 이용해서 구성했었다. 백엔드 인프라를 구성하기 위한 다른 여러가지 방법이 많았지만, AWS Lambda를 사용한 이유는 다음과 같다.
말 그대로다. 기존 서비스의 백엔드는 Azure Functions를 통해서 구성했었는데, AWS로 클라우드 서비스를 이전해야 하는 상황이 생겼다. 이 이유도 몇 가지가 있는데, 다음과 같다.
사실 맨 처음 서비스를 구성할 때 Azure를 선택했던 이유 중 하나가 타사(AWS)에 비해 저렴한 비용 때문이었다. 그런데, AWS 측에서 1년이 넘는 기간(당시 인프라 비용 기준으로) 동안 무료 크레딧을 제공해준다는 연락이 왔었다. AWS로 이전하지 않을 이유가 없었다.
또한, 최소한 국내에서는 AWS 관련 문서 가 훨씬 많았다. 백엔드를 서브로 하는 나에게, 영어로 된 Azure 공식문서를 읽고 이해하는 건 잘 정리 및 번역된 AWS 관련 문서를 읽는 것보다는 힘든 일이었다.
추가로, 팀원들 또한 AWS로 이전하는 것에 대해 매우 찬성했다. 모두들 아무래도 Azure보다는 AWS가 익숙할테니까 그랬지 않았을까 싶다. (Azure를 선택했을 때는 나 혼자서 서비스를 만들던 시기였다. 팀원들 동의도 안 구하고 마음대로 클라우드 서비스를 선택한게 아니다!)
따라서, 마이그레이션에 드는 시간과 수고를 줄이기 위해 Azure Functions와 거의 유사한 형태의 서비스인 AWS Lambda를 채택했다.
EC2로 서버를 관리해 본 사람이라면 공감할 것이다. 서버를 유지 및 관리하는데는 신경 써야 할 부분이 정말 많다. 오토 스케일링, 로드 밸런서 설정, CPU/메모리는 어떤 서비스를 사용해야 현재 서비스에 알맞는 수준일지, 혹시 사용자가 폭발적으로 늘어난다면 이에 대응할 수 있는 수준인지 등등..
심지어 프론트를 메인으로 하는 나는 백엔드/인프라 쪽 지식이 해당 분야를 전문적으로 공부한 분들보다는 부족하다고 생각했다. 그래서 실제 서비스를 운영해야 하는 환경에서 예기치 못한 상황이 생겼을 때, 빠른 대응이 어려울 거라고 생각해서 보다 인프라에 대한 관리가 덜한 Lambda를 선택하게 되었다.
그래서 최종적으로 AWS Lambda(Node.js) + Serverless Framework를 사용해서 백엔드 서비스를 마이그레이션하였다.
그런데, 배포를 하고 보니 각 API의 최초 응답속도가 1.5초~2초 정도나 걸리는 것이었다. 물론, 최초 요청 이후에는 0.1초 수준으로 빠른 응답을 확인했다. 하지만 일반적으로 사용자가 서비스를 이용할 때 GET 요청에 해당하는 API를 제외한 POST, PUT, DELETE 등의 API는 보통 한번 정도만 요청한다.
예를 들면, 사용자가 댓글을 작성한다고 가정해보자. 댓글을 모두 작성하고 확인 버튼을 눌렀을 때, 로딩 스피너가 2초나 돌아간다면 사용자는 ‘렉이 걸렸나..?’라고 생각할 것이다. 문제는, 이 사용자가 댓글을 수정하거나, 삭제할 때도 각 API를 최초 호출하게 된다. 마찬가지로 2초 동안 로딩 스피너가 돌아갈테고, 이 사용자는 사이트 자체가 느리다고 판단할 것이다. (나였으면 답답해서 진작 이탈했다)
이 문제를 해결하기 위해서는, 먼저 AWS Lambda에 올린 코드가 어떤 과정을 거쳐 실행되는지를 알아야 한다.
Lambda 함수가 실행되는 순서는 크게 4가지로 나뉜다. 아래 그림을 보자.
Battle of the Serverless — Part 2: AWS Lambda Cold Start Times
여기서 API를 호출했을 때, 1, 2, 3번 단계에 해당하는 과정이 진행되면 그걸 바로 콜드 스타트(Cold Start)라고 부른다. Lambda는 이미 생성된 컨테이너가 있다면(2번 과정), 함수 호출이 끝나고 해당 컨테이너를 바로 정리하는 것이 아니라 일정 기간 동안 유지하여 기존 컨테이너를 재사용한다.
이렇게 기존에 생성된 컨테이너를 재사용 하는 경우에는, 1~3번 과정을 건너뛰고, 4번 과정만 진행한다. 이를 웜 스타트(Warm Start)라고 부른다.
결국, 콜드 스타트는 EC2처럼 서버가 계속 켜져있는 상태가 아니기 때문에 발생한다. 그래서 AWS Lambda에서 EC2처럼 함수를 쭉 켜두면 되지 않을까? 해서 나온 기능이 아래에서 설명할 Provisioned Concurrency(프로비저닝된 동시성)이다.
Provisioned Concurrency는 AWS Lambda가 제공하는 기능으로, 특정 수의 컨테이너를 항상 웜 상태로 유지하도록 설정할 수 있다.
Provisioned Concurrency를 설정하면 AWS가 사용자의 함수에 대해 설정한 수만큼의 웜 상태 컨테이너를 미리 생성하고 유지한다.
사용자가 요청을 보내기 전에 컨테이너가 준비되므로, 요청이 들어오면 즉시 실행할 수 있다.
설정된 컨테이너는 함수가 실행되기 전부터 모든 초기화(코드 다운로드, 런타임 설정, 전역 코드 실행)를 완료한 상태이다.
함수 핸들러 코드 실행만 남아 있어 응답속도가 빠르다.
설정된 동시 실행 수 이상으로 요청이 들어오면, AWS는 기존 방식대로 추가 컨테이너를 생성하여 처리한다. (이때는 콜드 스타트가 발생할 수 있음)
이 방법으로 콜드 스타트를 해결할 수 있지만, 문제는 비용이다. Lambda 함수는 필요할때만 함수가 호출되어 처리한다는 점에서 항상 서버를 켜두고 있지 않아도 되므로 비용을 절약할 수 있다. Lambda 함수의 요청 수와 실행시간에 따라서 요금이 부과된다.
그런데, Provisioned Concurrency 활성화하면 EC2처럼 서버를 계속 켜놓게 되니, 지속적으로 비용이 발생하게 된다. 비용이 얼마나 되는지 확인해보자.
1,536MB 메모리 / 동시성 개수를 100 / 24시간동안 구성했을 때 54달러가 과금 되는 것을 알 수 있다. 한달 내내 켜놓았을때 54달러 * 30일 = 1620$가 과금된다. EC2의 m6g.12xlarge 인스턴스를 한달 켜 놓았을때와 거의 동일한 금액이다. 초기 서비스에서는 말도 안되는 상황이다. 콜드 스타트 해결하려다 인프라 비용이 말도 안되는 수준으로 높아지게 된다.
계속 서버를 켜놓을 수는 없으니, 아예 Provisoned Concurrency를 트래픽이 급증하는 시간대에 맞춰 오토 스케일링할 수 있다. 이렇게 하면 하루종일 동시성을 동일하게 유지하는것보다는 훨씬 비용을 절감할 수 있다.
그럼, 다시 예를 들어보자. 위의 과금 예시에서 하루 24시간이라는 조건을 트래픽이 많은 시간대인 오전 10시~오후 10시까지라고 설정해보자. 그럼, 하루 12시간 동안 활성화하니 810$가 과금된다. 여전히 굉장히 비싸다.
결국, Provisioned Concurrency를 사용하는 것은 비용 문제 때문에 힘들다고 판단하였다. 이후 다른 방법을 찾아보았다.
Event Bridge는 일종의 이벤트 버스다. AWS에서 발생한 이벤트를 어딘가로 전달해서 연결하는 용도로 사용한다. 이 서비스를 이용해서 주기적으로 이벤트를 발행하고 이걸 Lambda로 연결하면 발행된 이벤트에 의해 람다가 주기적으로 실행될 것으로 예상했다.
좀 더 자세히 말해서, 5분마다 이벤트를 발행해서 Lambda 함수를 주기적으로 호출하면, Lambda의 컨테이너가 계속 살아있을 것 같았다.
여기서, 한 가지 해결해야 할 문제가 있었다. 난 백엔드를 Lambda + Serverless Framework를 사용해서 구성했다. 대부분의 AWS 관련 설정을 이 프레임워크를 통해서 진행했는데, 각 함수에 EventBridge를 설정하는 방법 또한 이 프레임워크를 이용할 수 있을 거라고 판단했다.
만약 이게 안된다면, 함수를 배포하고 직접 AWS 콘솔에서 EventBridge를 연결해줘야 하는 상황이 발생 했을텐데, 이렇게 불편한 작업이 추가되는 상황은 피하고 싶었다.
위 글을 참고해서, 아래와 같이 createMeeting 함수에 EventBridge를 적용하였다.
## meeting.yml
functions:
createMeeting:
handler: src/meeting/createMeeting/index.createMeeting
events:
- http:
method: post
path: /project/{projectId}/meeting
cors: true
## EventBridge가 적용된 부분
- eventBridge:
name: 'createMeeting-${self:provider.stage}'
schedule: rate(5 minutes)
layers:
- arn:aws:lambda:ap-northeast-2:722419581403:layer:test-backend-layer:latest
package:
patterns:
- 'src/meeting/createMeeting/**'
이벤트 이름은 함수이름-dev(또는 prd)
라고 작성했고, 주기는 5분마다로 설정했다. 위 코드를 작성하고 배포하니, createMeeting 함수를 최초로 호출했을 때 API 응답 시간이 0.15초가 걸렸다. (해당 함수가 이미 Warm 상태였기 때문)
이후 추가적으로 상대적으로 콜드 스타트가 발생할 빈도가 높을 것 같은 함수들에 대해 EventBridge를 적용하였다. 이벤트를 통해 함수를 주기적으로 호출하면 결국 모두 비용이기 때문에, 거의 사용되지 않는 함수에는 적용하지 않았다.
결국 EventBridge 적용을 통해 콜드 스타트 이슈를 해결하게 되었다.
EventBridge를 사용해 일부 함수를 5분 주기로 호출하게 되면서 콜드 스타트 이슈는 해결했지만, 비용이 조금 더 발생하는 한계점도 있었다. 그러나, Provisioned Concurrency만큼 너무 비싼 비용이 아니라, 200개 함수 기준으로 기존 비용보다 고작 월에 3만원 정도 늘어난 수준이었다.