AWS Lambda 콜드 스타트를 겪고 나서: 언제 쓰고 언제 피하는지

이 글의 핵심

Lambda는 가볍다는 인식과 달리 트래픽이 끊겼다가 몰리는 시간대에 콜드 스타트가 연달아 터질 수 있고, 사용량 과금이 항상 저렴한 것도 아닙니다. 이벤트 중복 전달에 대비한 멱등 키와 DLQ, request id를 남기는 로그처럼 운영에서 먼저 챙길 것들을 짚어 어떤 워크로드를 Lambda에 맡길지 판단하도록 돕습니다.

예전에 새벽에 배포해 두고 잠들었다가, 아침에 대시보드를 열었더니 p99가 갑자기 2초까지 튄 적이 있습니다. 원인을 확인해 보니 앱이 느려진 것이 아니라, 그 시간대에 트래픽이 끊겼다가 다시 몰리면서 콜드 스타트가 연속으로 발생한 것이었습니다. 런타임이 뜨고, 패키지가 로드되고, 초기화 코드에서 DB 연결과 설정 로딩까지 하고 나면 체감 지연이 크게 올라갑니다. 그날 Lambda가 “항상 가볍다”는 말이 왜 위험한지 실감했습니다.

콜드 스타트는 언제, 왜 생기는가

첫 요청이 와서 새 실행 환경이 열릴 때는 런타임 Init 단계에서 모듈을 로드하는 시간이 먼저 들고, 그다음에야 handler가 실행됩니다. 한 번 열린 환경은 잠시 쉬는 동안 웜 상태로 남아 빠르게 응답하지만, 트래픽이 흩어지거나 새 버전이 배포될 때마다 이 과정이 반복됩니다.

여기서 자주 오해하는 점은 “콜드 스타트는 첫 요청 한 번만”이라는 생각입니다. 실행 환경 하나는 한 번에 요청 하나만 처리합니다. 그래서 웜 환경이 두 개 있는 상태에서 요청 열 개가 동시에 들어오면, 나머지 여덟 개는 새 환경을 여는 콜드 스타트를 겪습니다. 트래픽이 끊겼다가 한꺼번에 몰리는 시간대에 p99가 튀는 이유가 이것입니다. 웜 환경이 얼마나 오래 유지되는지는 AWS가 공개적으로 보장하지 않으므로, “5분마다 핑을 보내 웜 상태를 유지한다” 같은 요령은 동시 요청이 몰리는 상황에는 거의 도움이 되지 않습니다.

콜드 스타트의 길이를 결정하는 것은 대부분 우리 코드의 초기화 시간입니다. handler 바깥(모듈 최상위)에 둔 코드는 Init 단계에서 한 번 실행되고 이후 호출에서 재사용되므로, SDK 클라이언트 생성이나 DB 연결은 바깥에 두는 것이 맞습니다. 대신 거대한 의존성을 통째로 import하면 Init 시간이 늘어납니다. AWS SDK v3처럼 필요한 클라이언트만 가져오는 패키지를 쓰고 esbuild로 번들링해 트리 셰이킹하면 패키지 크기와 로딩 시간을 크게 줄일 수 있습니다. Java는 JVM 시작과 클래스 로딩 때문에 콜드 스타트가 특히 긴데, 초기화된 실행 환경의 스냅샷을 떠 두었다가 복원하는 SnapStart가 이 문제를 겨냥한 기능입니다. 비용 측면에서도 Init 단계를 가볍게 해야 하는 이유가 생겼습니다. AWS는 2025년 8월부터 ZIP 패키지로 배포한 관리형 런타임 함수의 Init 단계도 과금 대상에 포함하기 시작했습니다.

“메모리만 올리면 되지 않나”라고 말하는 사람도 있습니다. 틀린 말은 아니지만 비용과 함께 봐야 합니다. 더 애매한 선택은 Provisioned Concurrency로 콜드 스타트를 줄이는 방법인데, 이는 사실상 “상시 띄워 둔다”에 가깝습니다. 그렇게 되면 서버리스가 항상 저렴한 것은 아니라는 결론으로 기웁니다. 사용한 만큼만 낸다는 문구만 믿고 들어갔다가 트래픽 패턴이 꼬이면, EC2를 운영하는 것보다 청구서가 이상하게 나올 수 있습니다.

Lambda가 잘 맞는 일과 피해야 할 일

저는 Lambda를 정답으로 여기고 쓰지는 않습니다. 대신 이벤트가 발생할 때만 돌리면 되는 배치, 업로드 후 이미지를 한 번 처리하는 워커, API Gateway 뒤의 얇은 CRUD 같은 작업에는 상당히 잘 맞습니다. 이벤트 기반 모델이라 S3·DynamoDB·EventBridge와 연결하기가 편하기 때문입니다.

반대로 항상 떠 있어야 하는 서비스를 전부 Lambda로 옮기면, 콜드 스타트·동시성 한도·다운스트림 DB 커넥션이 겹치면서 예상하지 못한 병목이 생기는 경우가 많습니다. 그럴 때는 차라리 컨테이너로 올리거나, Step Functions 같은 오케스트레이션으로 흐름을 쪼개는 편이 운영하기 수월합니다.

특히 다운스트림 DB 커넥션 문제는 Lambda를 처음 도입할 때 가장 흔히 터지는 장애입니다. 실행 환경마다 DB 연결을 하나씩 들고 있으므로, 트래픽이 몰려 환경이 300개로 늘어나면 RDS에는 연결이 300개 생깁니다. 작은 인스턴스의 max_connections는 이보다 훨씬 작아서 too many connections 에러가 나고, 그 에러 때문에 재시도가 늘어 상황이 더 나빠집니다. RDS Proxy로 연결을 모아 주거나, 함수의 예약 동시성(reserved concurrency)으로 최대 환경 수를 DB가 감당할 수 있는 선으로 묶는 것이 일반적인 대응입니다. 계정의 리전별 기본 동시 실행 한도(보통 1,000)를 여러 함수가 나눠 쓴다는 점도 알아 두어야 합니다. 한 함수가 폭주하면 같은 계정의 다른 함수까지 스로틀링(429)될 수 있습니다.

실행 시간 제약도 선택 기준입니다. Lambda 한 번의 최대 실행 시간은 15분이고, API Gateway 뒤에 붙이면 통합 타임아웃(기본 29초) 안에 응답해야 합니다. 몇 분씩 걸리는 보고서 생성이나 동영상 변환을 HTTP 요청 안에서 동기로 처리하려 하면 이 한계에 부딪히므로, 요청은 바로 접수 응답을 주고 실제 작업은 SQS나 Step Functions로 넘기는 구조가 필요합니다.

최소한의 핸들러 코드

코드는 최소한만 보겠습니다. Node.js라면 아래와 같은 형태이며, 핵심은 handler 시그니처와 반환 JSON 형식을 맞추는 것입니다.

// index.js
export const handler = async (event) => {
  console.log('Event:', JSON.stringify(event));
  return {
    statusCode: 200,
    body: JSON.stringify({ message: 'Hello from Lambda!', input: event }),
  };
};

export const handler처럼 ES 모듈 문법을 쓰려면 파일 확장자를 .mjs로 하거나 package.json에 "type": "module"을 넣어야 합니다. 그렇지 않으면 Node.js 런타임이 CommonJS로 해석해 SyntaxError: Cannot use import statement outside a module이나 Unexpected token 'export'로 실패합니다. 반환 객체의 statusCode와 body는 API Gateway의 Lambda 프록시 통합이 기대하는 형식이며, body는 반드시 문자열이어야 합니다. 객체를 그대로 넣으면 API Gateway가 502 Bad Gateway(“Malformed Lambda proxy response”)를 반환하는데, Lambda 로그에는 에러가 없어서 원인을 찾기 어려운 대표적인 실수입니다.

예제는 디버깅용으로 event 전체를 로그에 남기고 응답에도 그대로 돌려주는데, 운영에서는 둘 다 피해야 합니다. API Gateway 이벤트에는 Authorization 헤더와 쿠키가 들어 있어서, 전체를 로그에 찍으면 토큰이 CloudWatch Logs에 그대로 쌓입니다. 필요한 필드만 골라 기록하고, 응답에는 요청 내용을 반사하지 않는 것이 안전합니다.

API를 붙일 때는 Serverless Framework를 쓰는 팀도 많은데, 어떤 도구를 쓰든 events에 HTTP 이벤트만 제대로 연결하면 됩니다. REST API든 HTTP API든 “Lambda 프록시로 전부 넘기는” 방식을 어디까지 쓰느냐에서 팀마다 선택이 갈립니다. 제 경험상 응답 포맷과 에러 코드 규칙이 팀에서 합의되어 있지 않으면 나중에 골치가 아픕니다.

운영에서 먼저 챙길 것: 멱등 키, DLQ, 로그

DynamoDB·S3 트리거 예제는 공식 문서에 충분히 많으므로 여기서는 길게 다루지 않겠습니다. 대신 실전에서 꼭 챙겨야 한다고 느낀 것만 정리합니다. 이벤트는 최소 한 번 이상 전달될 수 있다고 가정하는 편이 안전하므로, 결제·포인트처럼 상태가 바뀌는 처리에는 멱등 키를 기본으로 두고, 실패한 메시지는 DLQ로 빼서 사람이 직접 확인할 수 있게 하는 것이 좋습니다. CloudWatch에 JSON 로그를 남길 때는 request id를 함께 기록하세요. 나중에 “왜 이 요청만 실패했는지”를 추적할 때 차이가 큽니다.

“최소 한 번 전달”은 추상적인 경고가 아니라 Lambda의 실제 동작입니다. S3나 SNS처럼 비동기로 호출되는 경우 함수가 에러를 던지면 Lambda가 기본적으로 최대 두 번 더 재시도하고, SQS 트리거는 메시지를 처리하는 도중 가시성 타임아웃이 지나면 다른 실행 환경이 같은 메시지를 다시 받습니다. SQS 배치 열 개 중 하나만 실패했는데 함수가 예외를 던지면 열 개 전부가 다시 처리된다는 점도 함정인데, 이벤트 소스 매핑에 ReportBatchItemFailures를 켜고 실패한 메시지 ID만 batchItemFailures로 돌려주면 실패한 것만 재시도됩니다.

멱등 키는 DynamoDB의 조건부 쓰기로 간단히 구현할 수 있습니다.

import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb';

const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); // Init 단계에서 한 번 생성

export const handler = async (event) => {
  for (const record of event.Records) {
    const msg = JSON.parse(record.body);
    try {
      await ddb.send(new PutCommand({
        TableName: 'processed-events',
        Item: { pk: msg.paymentId, ttl: Math.floor(Date.now() / 1000) + 7 * 86400 },
        ConditionExpression: 'attribute_not_exists(pk)',
      }));
    } catch (err) {
      if (err.name === 'ConditionalCheckFailedException') continue; // 이미 처리함
      throw err;
    }
    await applyPoints(msg);
  }
};

attribute_not_exists(pk) 조건 덕분에 같은 paymentId가 두 번 들어오면 두 번째 쓰기는 ConditionalCheckFailedException으로 실패하고, 그 메시지는 건너뜁니다. 비즈니스 이벤트의 ID(paymentId)를 키로 쓰는 것이 중요합니다. SQS 메시지 ID는 프로듀서가 재전송하면 달라지기 때문입니다. ttl 속성에 DynamoDB TTL을 걸어 두면 오래된 기록이 자동으로 지워집니다. 다만 이 단순한 버전은 “기록은 했는데 applyPoints가 실패한” 경우 재시도 때 처리를 건너뛰는 빈틈이 있습니다. 이 틈까지 메우려면 처리 상태(IN_PROGRESS/COMPLETED)를 함께 저장해야 하는데, Powertools for AWS Lambda의 idempotency 유틸리티가 이 로직을 이미 구현해 두었으므로 직접 짜기보다 가져다 쓰는 편이 낫습니다.

콜드 스타트를 줄이는 실용적인 방법

콜드 스타트를 줄이는 방법은 대략 다음과 같습니다. 런타임은 팀이 익숙한 것을 고르면 되고(저는 Node.js 최신 LTS를 선호합니다), 패키지는 덩치가 클수록 Init 단계가 길어진다고 생각하면 됩니다. 메모리를 올리면 CPU도 함께 늘어나므로 지연 시간과 청구 금액을 같이 보면서 튜닝합니다. 그리고 VPC는 꼭 필요할 때만 붙이세요. 예전에는 VPC에 연결된 함수가 콜드 스타트마다 ENI를 새로 만들어 수 초씩 늦어졌지만, 2019년에 공유 ENI(Hyperplane) 방식으로 바뀐 뒤로는 그 지연이 함수 생성·업데이트 시점으로 옮겨져 호출 지연은 거의 사라졌습니다. 그래도 “보안상”이라는 이유로 습관처럼 붙이면, 외부 API나 AWS 서비스를 호출하려고 NAT 게이트웨이를 두게 되어 고정 비용과 데이터 처리 비용이 붙고, 서브넷 IP 부족 같은 새로운 장애 지점이 생깁니다. RDS처럼 프라이빗 서브넷에 있는 자원에 접근해야 할 때만 붙이는 것이 여전히 좋은 기준입니다.

그래도 지연이 문제라면 측정부터 합니다. CloudWatch Logs의 REPORT 줄에는 콜드 스타트 때만 Init Duration이 찍히므로, Logs Insights에서 이 값의 분포와 전체 호출 중 비율을 보면 콜드 스타트가 실제로 p99를 끌어올리는지, 아니면 다운스트림 호출이 느린 것인지 구분할 수 있습니다. Provisioned Concurrency는 이 측정 결과를 보고 특정 시간대에만 예약 스케줄로 켜는 방식이 비용 대비 효과가 좋습니다.

현장에서 가장 자주 나오는 세 가지 질문

Firecracker 마이크로VM과 격리 구조 이야기는 흥미롭지만, 현장에서 가장 많이 나오는 질문은 “언제 콜드 스타트가 발생하는가”, “동시에 몇 개까지 뜨는가”, “청구서가 왜 이렇게 나왔는가” 세 가지입니다. 이 세 가지만 기억해도 Lambda를 운영하면서 크게 당황할 일은 줄어듭니다.

이벤트 기반 설계에 관심이 있다면 아래 Inngest 글과 Cloudflare Workers 글을 함께 읽어 보세요. 콜드 스타트 특성이 전혀 다른 엣지 런타임과의 비교는 엣지 컴퓨팅과 서버리스 글에서 다룹니다.

같이 보면 좋은 글