Lambda에는 기본적으로 CloudWatch가 내장되어 있기 때문에 사용자들의 트래픽, 실행시간 등을 파악할 수 있습니다. Lambda 지표를 보는 와중 저는 Lambda의 고질적인 문제 Cold Start가 발생했다는 것을 확인했습니다.
Lambda의 Duration maximum을 확인해보니 7.4s로 Cold Start가 발생한 것을 확인할 수 있습니다.
Duration이란 Lambda함수의 총 실행 시간을 의미합니다. 간단히 설명하자면 제가 만든 handler들을 Lambda에 등록, 초기화, 코드 실행 등의 시간을 다 포함한 시간입니다.
Cold Start란 Lambda 함수가 처음 호출되거나, 오랜 시간동안 사용하지 않아 대기 상태에 있던 후 다시 호출될 때 발생하는 시간을 말합니다.
Lambda 함수가 실행되는 순서를 보면 더 이해가 쉽습니다.
크게 4가지의 순서인데 1~3번째를 Cold Start라고 부릅니다.
즉, Warm Start가 Cold Start보다 실행속도가 훨씬 빠릅니다. 당연하게도 Cold Start는 초기화 로직까지 포함하다보니 실행속도가 길어지는 건 당연하겠네요.
Slash Command로 해당 Lambda 트리거 시, Cold Start 문제로 인해 위와 같이 명령어가 실행되지 않는 상황이 발생했습니다.
그 이유는 명령어 실행 시, 슬랙 모달을 열기 위한 trigger_id
는 슬랙에 의해 발급된 후 3초간만 유효합니다. 하지만 Cold Start가 발생해 Lambda의 최대 실행 시간이 3초가 넘어가면 trigger_id는 만료됩니다.
즉 모달창을 열 수 있는 유효 시간 내에 람다 함수가 응답을 완료하지 못하면 모달창이 열리지 않고, 그 결과로 위의 사진과 같이 슬랙에 에러 메시지가 표시되는 상황입니다.
이 부분이 가장 고민이였습니다. Cold Start를 해결하는 방법은 너무나 많습니다. Lambda의 메모리를 늘리거나, 프로비전된 동시성을 사용하거나, 아니면 5분마다 한 번씩 Lambda를 호출 해 Warm Start로 만드는 방법이였습니다. 하지만 이 방법들은 공통적으로 많은 추가비용을 유도한다고 생각했습니다. 그래서 저는 다른 방법으로 시도해 보았습니다.
Aws의 공식 홈페이지에 Lambda의 Cold Start 해결방법을 찾을 수 있었습니다. 그래서 저는 다음과 같이 코드들을 최적화 해보았습니다.
가장 먼저 Lambda 함수의 핸들러 등록을 한 번만 수행하도록 전역 플래그를 사용하여 초기화 로직을 최적화하였습니다
Lambda에 핸들러 등록과 같은 초기화 작업은 비교적 리소스를 많이 사용하고 상당한 시간이 소요되기 때문에, 이 작업들을 한 번만 수행하고 이후 호출에서는 생략할 수 있도록 구현하여 Lambda의 Duration을 단축할 수 있도록 최적화 시켰습니다.
let handlerRegistered = false;
export const handler = async(
event: AwsEvent,
context: any,
callback: AwsCallback
): Promise<AwsResponse> => {
if(!handlerRegistered){
submitNotionCommandHandler(app);
uploadNotionBlogViewHandler(app, notion);
handlerRegistered = true;
console.log('lambda에 handler 등록');
}
const slackHandler = await awsLambdaReceiver.start();
console.log('awsLambdaReceiver 실행');
return slackHandler(event, context, callback);
}
두 번째로는 Dependency를 최소화 하였습니다.
Lambda가 시작될 때 모든 의존성이 메모리로 로드가 되는 데, 이때 Dependency가 많으면 많을수록 로드 시간이 길어지며 이는 Duration에 영향을 미치기 때문입니다. 그래서 불필요한 Dependency를 제거하였습니다.
최적화 결과 7.4s → 1.4s 로 시간을 단축시켰습니다. 그렇다면 이 상태에서 EventBridge를 활용한다면 Lambda의 Duration을 더 감소시킬 수 있지 않을까라는 생각을 해보았습니다.
최적화를 진행한 Lambda를 주기적으로 호출시킨다면 극단적으로 5분마다 Lambda를 호출시키지 않아도 되며이는 비용 절감으로 이어진다고 생각했습니다.
EventBridge를 scheduler로 활용하여 1시간만다 한 번씩 slackBotHandler Lambda를 호출하도록 Serverless 프레임워크를 활용하여 아래와 같이 수정하였습니다.
30분으로 EventBridge를 설정한 이유는 5분, 15분 으로 설정하기엔 주기가 너무 빨랐으며, Lambda의 Duration 지표를 확인해봤을 때 Cold Start가 발생하지 않았습니다. 그러나 30분이 넘어갔을때부터는 Duration이 급격히 증가하였기 때문에 그 중간 지점인 30분으로 스케쥴러를 설정하였습니다.
functions:
slackBotHandler:
handler: dist/utils/function/lambdaHandler/handler.handler
timeout: 15
events:
- httpApi:
method: POST
path: /slack/events
- httpApi:
method: POST
path: /slack/command
- httpApi:
method: POST
path: /slack/uploadNotion
- schedule: // 스케쥴러 추가
rate: rate(30 minutes) // 30분마다 Lambda함수 호출
enabled: true
input:
detail-type: "ScheduledEvent"
source: "aws.events"
detail: {}
EventBridge로 30분마다 Lambda를 실행하도록 Lambda 함수의 진입점 코드를 아래와 같이 수정했습니다.
let handlerRegistered = false;
export const handler = async(
event: CustomAwsEvent,
context: any,
callback: AwsCallback
): Promise<AwsResponse> => {
if(event.source === 'aws.events'){ // aws.events인 경우 Warm Start로 변경
console.log('Warm Start 설정');
return {statusCode:200, body:'Warm Start 완료'};
}
if(!handlerRegistered){
submitNotionCommandHandler(app);
uploadNotionBlogViewHandler(app, notion);
handlerRegistered = true;
console.log('lambda에 handler 등록');
}
const slackHandler = await awsLambdaReceiver.start();
console.log('awsLambdaReceiver 실행');
return slackHandler(event, context, callback);
}
Serverless 프레임워크로 EventBridge를 설정한 Lambda함수는 다음과 같습니다.
1.4s → 0.9s로 약 0.5초의 Duration을 단축시켰습니다.
슬랙 모달창의 제출 버튼을 누르면 위의 사진과 같이 모달창 단독 문제가 발생합니다. 그 이유는 모달창에서 문제가 발생했다 뜨지만, Notion에는 블로그 정보들이 제대로 등록되었기 때문입니다.
이 문제의 원인은 제출 버튼을 누르면 Slack에 3초 안에 Notion에 블로그 정보들을 업로드 했다고 Response를 해줘야 하는 데, 3초 안에 업로드를 하지 못해서 생긴 문제입니다.
이 문제를 해결하기 위해서 저는 제출 버튼을 누를 시에 블로그에 정보들을 업로드 하는 로직을 비동기 함수로 만들어 처리했습니다.
uploadNotionBlogViewHanlder의 로직을 handlerBlogUploadSubmission로 빼서 비동기 함수로 호출하도록 만들었습니다.
[uploadNotionBlogViewHanlder.ts]
import { App } from '@slack/bolt';
import { Client } from '@notionhq/client';
import { handlerBlogUploadSubmission } from '../notionPage/handleBlogUploadSubmission';
export const uploadNotionBlogViewHandler = (app: App, notion: Client) => {
app.view('uploadBlog', async({ack, body, view, client}) => {
await ack();
await handlerBlogUploadSubmission(body, view, client); // 비동기처리
});
}
[handlerBlogUploadSubmission.ts]
import { SlackViewAction, ViewOutput } from "@slack/bolt";
import { uploadBlogToNotion } from "./uploadBlogToNotion";
export async function handlerBlogUploadSubmission(body: SlackViewAction, view: ViewOutput, client: any){
const userId = body.user.id;
const slackUserInfo = await client.users.info({user: userId});
const slackUserName = slackUserInfo.user?.profile?.real_name ?? "";
console.log(slackUserName);
const notionUplodeValue = view.state.values;
const blogName = notionUplodeValue['blog_name_block']['blog_name_action'].value?? "";
const blogUrl = notionUplodeValue['blog_link_block']['blog_link_action'].value?? "";
try {
const uploadNotionUrl = await uploadBlogToNotion(blogName, blogUrl, slackUserName);
const channel = body.user.id;
const message = `블로그가 Notion에 업로드 되었습니다! ${uploadNotionUrl.notionUrl}`;
await client.chat.postMessage({
channel: channel,
text: message
});
} catch (error) {
console.error('Notion 업로드 실패:', error);
}
}
저는 처음에 Cold Start를 해결하기 위해서 무조건 Lambda를 5분마다 트리거 시켜서 항상 Warm Start인 상태를 유지하여 문제를 해결하려고 했습니다. 하지만 코드 최적화를 통하여 Lambda의 Duration을 최소한으로 줄였기때문에 비용을 절감할 수 있었습니다.
이번의 경험을 통하여 최적화의 중요성을 다시 한 번 깨달을 수 있었습니다.
저는 동기, 비동기의 개념은 알고 있었지만 정확하게는 이해를 하지 못했던 것 같습니다. 이번 문제를 직면하면서 처음에는 비동기로 처리해서 해결해야 하는 생각을 하지 못했기 때문입니다.
덕분에 저의 부족함을 알았고, 비동기에 대해서 공부하고 프로젝트에 적용할 수 있는 좋은 기회였던 것 같습니다.