[Slackbot 개발] Lambda 성능 개선기(with Cold Start)

Jeong Choi(최현정)·2024년 2월 11일
0

Lambda에는 기본적으로 CloudWatch가 내장되어 있기 때문에 사용자들의 트래픽, 실행시간 등을 파악할 수 있습니다. Lambda 지표를 보는 와중 저는 Lambda의 고질적인 문제 Cold Start가 발생했다는 것을 확인했습니다.

1. Cold Start 이슈

Lambda의 Duration maximum을 확인해보니 7.4sCold Start가 발생한 것을 확인할 수 있습니다.

Duration이란?

Duration이란 Lambda함수의 총 실행 시간을 의미합니다. 간단히 설명하자면 제가 만든 handler들을 Lambda에 등록, 초기화, 코드 실행 등의 시간을 다 포함한 시간입니다.

2. Cold Start

Cold Start란 Lambda 함수가 처음 호출되거나, 오랜 시간동안 사용하지 않아 대기 상태에 있던 후 다시 호출될 때 발생하는 시간을 말합니다.

Lambda 함수가 실행되는 순서를 보면 더 이해가 쉽습니다.

Lambda 실행 순서

  1. Lambda 함수를 실행시키 위해 작성된 code들을 Aws에 업로드(다운로드) 합니다.
  2. Lambda 함수가 실행될 환경들을 구축합니다. (Lambda의 실행시간, 메모리, 런타임 등등)
  3. 전역 코드를 실행합니다. (Lambda가 처음 실행될 때 딱 한 번만 실행되는 코드: DB연결, handler Lambda에 등록 등등)
  4. 이벤트 트리거 시 Lambda함수의 handler가 실행됩니다.

크게 4가지의 순서인데 1~3번째를 Cold Start라고 부릅니다.

즉, Warm Start가 Cold Start보다 실행속도가 훨씬 빠릅니다. 당연하게도 Cold Start는 초기화 로직까지 포함하다보니 실행속도가 길어지는 건 당연하겠네요.

3. 첫 번째 문제: Slash Command 실패

Slash Command로 해당 Lambda 트리거 시, Cold Start 문제로 인해 위와 같이 명령어가 실행되지 않는 상황이 발생했습니다.

그 이유는 명령어 실행 시, 슬랙 모달을 열기 위한 trigger_id는 슬랙에 의해 발급된 후 3초간만 유효합니다. 하지만 Cold Start가 발생해 Lambda의 최대 실행 시간이 3초가 넘어가면 trigger_id는 만료됩니다.

즉 모달창을 열 수 있는 유효 시간 내에 람다 함수가 응답을 완료하지 못하면 모달창이 열리지 않고, 그 결과로 위의 사진과 같이 슬랙에 에러 메시지가 표시되는 상황입니다.

해결방법: 최적화 vs 주기적으로 호출

이 부분이 가장 고민이였습니다. Cold Start를 해결하는 방법은 너무나 많습니다. Lambda의 메모리를 늘리거나, 프로비전된 동시성을 사용하거나, 아니면 5분마다 한 번씩 Lambda를 호출 해 Warm Start로 만드는 방법이였습니다. 하지만 이 방법들은 공통적으로 많은 추가비용을 유도한다고 생각했습니다. 그래서 저는 다른 방법으로 시도해 보았습니다.

1) 최적화

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를 제거하였습니다.

최적화 후 Lambda Duration

최적화 결과 7.4s → 1.4s 로 시간을 단축시켰습니다. 그렇다면 이 상태에서 EventBridge를 활용한다면 Lambda의 Duration을 더 감소시킬 수 있지 않을까라는 생각을 해보았습니다.

2) EventBridge를 활용해 주기적으로 Lambda를 실행시키자

최적화를 진행한 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함수는 다음과 같습니다.

EventBridge 설정 후 Lambda Duration

1.4s → 0.9s로 약 0.5초의 Duration을 단축시켰습니다.

2. 두 번째 문제: 슬랙 모달창 이슈

슬랙 모달창의 제출 버튼을 누르면 위의 사진과 같이 모달창 단독 문제가 발생합니다. 그 이유는 모달창에서 문제가 발생했다 뜨지만, 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을 최소한으로 줄였기때문에 비용을 절감할 수 있었습니다.

이번의 경험을 통하여 최적화의 중요성을 다시 한 번 깨달을 수 있었습니다.

이론만 아는 동기, 비동기

저는 동기, 비동기의 개념은 알고 있었지만 정확하게는 이해를 하지 못했던 것 같습니다. 이번 문제를 직면하면서 처음에는 비동기로 처리해서 해결해야 하는 생각을 하지 못했기 때문입니다.

덕분에 저의 부족함을 알았고, 비동기에 대해서 공부하고 프로젝트에 적용할 수 있는 좋은 기회였던 것 같습니다.

profile
Node와 DB를 사랑하는 백엔드 개발자입니다:)

0개의 댓글