🔥 Cold Start: 왜 첫 호출이 느린가

2019자
24분

첫 요청에서 지연이 생기는 Lambda 함수 개념도. 위쪽 가로 막대는 Extension init, Runtime init, Function init이 순서대로 길게 이어진 뒤 짧은 INVOKE가 붙어 있고, 아래쪽 가로 막대는 INVOKE만 짧게 놓여 있다.

CloudWatch Logs Insights에서 한 함수의 @duration을 30분치 시각화했다. 평탄한 띠 위에 가끔 산봉우리 한두 개가 솟아 있다. 같은 함수, 같은 입력, 같은 메모리. 그런데 전체 평균이 80ms인 곳에서 어떤 호출 하나만 600ms를 찍고 있었다. 그 봉우리에 마우스를 가져다 댔다. @type=REPORT 행에 Init Duration: 482ms가 한 줄 더 있었다. 평탄한 띠 위 다른 호출에는 그 줄이 없었다.

그 한 줄 차이가 콜드스타트다. AWS는 이 현상을 전체 호출의 1% 미만에서만 발생한다고 적는다(Operating Lambda: Performance optimization (AWS Compute Blog)). 1%는 작아 보이지만, p50 latency 대시보드만 보면 사라지는 그 봉우리가 p99에는 가장 높은 점으로 그대로 남는다. 사용자가 첫 클릭에서 마주치는 응답 시간이 거기다. Lambda란 무엇인가: 서버리스의 경계에서 적어 둔 관리형이 감추는 부담 네 갈래 중 둘째, 즉 청구서가 아닌 곳으로 옮겨가는 비용의 본격적인 무대가 바로 콜드스타트다.

이 글은 그 한 줄을 짚는다. INIT가 무엇을 더 한 시간이고, 왜 언어에 따라 길이가 다르며, 그 길이를 줄이는 길 셋이 각각 어떤 구조인지.

첫 호출과 두 번째 호출 사이에 무엇이 끼어 있나

같은 함수를 두 번 부르면 첫 호출만 느리다. 두 번째 호출부터는 핸들러 본체가 도는 시간만 남는다. 그 사이에서 사라진 비용은 두 번째 호출 시점에 이미 만들어져 있던 무언가에 들어간다. 그 무언가가 Lambda 실행 환경이다.

AWS 공식 문서는 이 환경이 만들어지는 과정을 Init phase라 부르고, 그 안에서 세 가지 작업을 한다고 적는다. Extension init, Runtime init, Function init이다 (SnapStart 함수만 네 번째로 before-checkpoint runtime hook이 추가로 돈다). 세 작업의 자세한 내막, 즉 컨테이너 확보가 먼저인지 런타임 부팅이 어디서 나뉘는지는 Lambda란 무엇인가: 서버리스의 경계에서 한 번 짚어 두었다. 이 글은 그 위에 시간 한도라는 조건을 더 얹는다.

Init phase 전체에 걸린 한도는 10초다. 함수 코드 import가 무거워 10초를 넘기면 Lambda는 그 호출을 실패 처리하지 않고, 첫 invocation 시점에 다시 처음부터 INIT을 시도한다. 다만 이번에는 함수의 configured timeout(예: 6초) 안에서 마무리해야 한다. 그 6초도 넘기면 그제야 timeout error가 사용자에게 도착한다. 처음 함수를 만들 때 import 트리가 100MB 넘어가는 ML 모델을 핸들러 바깥에 그대로 둔 적이 있는데, 첫 호출이 5초 timeout을 두 번 깜빡이고서야 success로 넘어갔다. 그날 처음으로 핸들러 바깥에 무엇을 둘지가 latency 결정 변수라는 걸 체감했다.

10초 한도는 표준 Lambda에만 적용한다. Provisioned Concurrency·SnapStart·Managed Instances 함수는 같은 곳에서 130초 또는 함수 timeout(최대 900초) 중 더 큰 값까지 허용한다. 이쪽은 init 시간이 호출 응답에 안 보이는 곳에서 동작하므로 AWS가 한도를 길게 잡아 두었다. 같은 INIT인데 동작 시점만 다르게 정해 둔 셈이다.

그리고 INIT 시간은 청구서에 들어간다. 함수 메모리 할당량 기준으로 GB-초 단위로 돈다. 2025년 4월 29일에 발표돼 2025-08-01부터 시행된 INIT billing 표준화가 그 곳이고, 표준 Lambda·PC·SnapStart 모두 같은 룰이다. 콜드스타트는 latency에만 보이는 게 아니라 청구서에도 한 줄로 들어온다.

언어가 결정한다: 어디까지 줄일 수 있나

같은 코드라도 어떤 언어 런타임 위에 올렸느냐에 따라 INIT 길이가 다르다. AWS 공식 문서는 정성적으로 한 문장만 적어 둔다. Python·Node.js 같은 인터프리터 런타임은 보통 더 빠르게 시작하고, Java·.NET 같은 컴파일 런타임은 시작 시간이 더 길다는 진술이다. 정량 숫자는 안 적혀 있는데, 함수 코드·import 트리·메모리 설정이 사용자마다 다르기 때문이다. 대신 외부 측정으로 정리한 Mikhail Shilkov의 cold start 벤치마크 같은 자료가 그 정성적 진술을 채워 준다.

외부 측정 자료는 범위를 가늠하는 참고치로만 쓴다. 실제 판단은 내가 운영한 함수 몇 개에서 나온 INIT 시간과 사용자 응답 시간을 같이 두고 결정한다. 짧은 함수(import 한두 개) 기준으로 Node.js 22와 Python 3.13은 INIT가 보통 100~250ms 안쪽으로 들어왔다. Java 21은 같은 짧은 함수에서도 800ms~1.5초 사이가 흔했고, JIT warm-up이 필요한 코드에서는 2~3초까지 늘어난 경우도 직접 확인했다. .NET 8도 비슷한 갈래다(외부 측정 자료는 Mikhail Shilkov 벤치마크 참고). Java·.NET 런타임은 startup 시점에 클래스로더와 JIT가 동작할 환경을 추가로 준비하므로 시간이 더 든다. 코드 한 줄 차이가 아니라 런타임이 "시작"이라는 행위를 정의하는 방식 자체가 다르다.

실행 환경을 만든 뒤 핸들러를 호출하는 순서. 위쪽 가로 막대는 Extension init, Runtime init, Function init이 길게 이어진 뒤 짧은 INVOKE가 붙어 있는 cold start 흐름이고, 아래쪽 가로 막대는 INVOKE만 있는 warm 흐름이다.

이 차이를 언어 선택의 비용으로 보면 한 가지 맞바꿈이 또렷하게 드러난다. 짧은 응답 latency가 결정 변수인 워크로드(예: API Gateway + Lambda 동기 호출, 사용자가 클릭해서 기다리는 곳)에는 Node.js나 Python이 default다. 같은 비즈니스 로직을 Java로 짜면 사용자 첫 호출 응답에 800ms+가 그냥 새는 셈이다. 반대로 비동기·polling 호출(SQS·EventBridge·Kinesis)에는 콜드스타트가 사용자에게 안 닿는 영역에서 동작하므로 Java/.NET을 골라도 비용 표면이 다른 모습으로 나온다. 같은 Lambda인데 어느 호출 방식인지에 따라 같은 INIT가 다른 의미를 갖는다. 호출 방식 셋의 분기는 Handler와 실행 모델: 이벤트가 들어오면에서 한 번 짚어 두었다.

VPC 부착도 한때 콜드스타트의 큰 부담이었다. 2019년 9월 이전에는 함수가 VPC subnet에 붙을 때 ENI(Elastic Network Interface)를 매 cold start마다 새로 만드느라 그곳에서만 여러 초가 더 들었다. 같은 해 9월 AWS가 도입한 Hyperplane ENI는 ENI를 함수 생성 시점·VPC 설정 변경 시점에 미리 만들어 두고, cold start에는 그저 그 ENI 위로 터널을 잇기만 한다. AWS는 이 변경이 cold start latency를 "극적으로 단축했다(dramatically reduces)"고만 적었고 정확한 ms 숫자는 공식 문서에 안 적혀 있는데, 외부 측정으로는 한 자릿수 ms대까지 줄어든 결과를 보고한다. VPC connectivity는 Lambda란 무엇인가: 서버리스의 경계에서 짚은 부담 네 갈래 중 셋째였는데, 그쪽은 이미 2019년에 한 번 크게 작아진 상태다. 지금 운영에서 다루는 콜드스타트는 거의 INIT 3작업 안의 비용이다(SnapStart 함수에는 before-checkpoint hook 한 단계가 추가로 동작한다).

Provisioned Concurrency: 환경을 미리 데워 둔다

콜드스타트를 없애는 첫 번째 길은 환경 자체를 미리 만들어 두는 것이다. Provisioned Concurrency(PC)는 함수마다 N개의 실행 환경을 미리 INIT까지 끝낸 상태로 보관해 두는 기능이다. 호출이 들어오면 그 N개 안에서 freeze된 환경을 thaw해 핸들러로 바로 들어간다. 표면적으로는 매 호출이 warm처럼 동작한다.

청구서에 들어오는 비용은 두 줄로 나뉜다. 첫 줄은 데워 두는 비용으로, 함수에 PC를 100개 걸어 둔 시간만큼 GB-초 단가가 별도로 동작한다. 미국 동부(버지니아) x86 기준으로 한 환경이 GB-초당 $0.0000041667다. 둘째 줄은 호출이 실제로 도는 비용으로, PC가 응답한 호출은 normal duration보다 약간 낮은 별도 GB-초 단가로 계산한다. Arm/Graviton2 함수는 두 단가가 모두 약 20% 더 낮다(공식 가격 표 (Lambda Pricing)). 서울 리전(ap-northeast-2)은 동일 표 위에서 살짝 더 비싼 가격대에 위치하는 게 일반적인데, 정확한 숫자는 운영 시점에 AWS Pricing Calculator에서 region을 직접 골라 받아 두는 게 정확하다.

worked example 한 개로 감각을 가져가 보자. 1024MB(=1 GB) 메모리 함수에 PC=10을 24시간 걸어 둔다고 하자. 데워 두는 비용은 10 환경 × 1 GB × 86,400초 × $0.0000041667 ≈ $3.6/일, 한 달이면 약 $108이 base 비용으로 추가로 든다. 함수가 받는 트래픽이 평소 5 동시성 수준이라면 이 비용 절반이 그저 idle 환경에 들어가는 셈이다. PC 사이즈를 baseline 동시성에 맞춰 정확히 잡는 게 절약의 첫 단계고, 거기 못 미치면 청구서에 idle 비용이 그대로 새서 늘어난다.

Provisioned Concurrency가 작용하는 지점을 보여 주는 그림. 시간축 위에 트래픽 곡선이 그려져 있고, baseline 구간에는 미리 데워 둔 PC 박스 N개가 누워 있다. 그 위로 솟는 peak 부분에는 일반 cold start가 동작한다.

여기서 1인칭 판단이 한 번 들어간다. PC를 100개 걸어 두면 트래픽이 늘 100 동시성 안에 머무는 한 콜드스타트는 0이지만, 트래픽이 110이 되는 순간 11개가 일반 콜드스타트로 흘러간다. 100개를 데우는 비용은 24시간 그대로 돌고, 야간에 트래픽이 0이어도 100 환경의 GB-초가 청구서에 그대로 올라간다. 그래서 PC가 잘 맞는 곳은 baseline 트래픽이 안정적이고, 매 호출이 사용자 응답에 노출되는 경우다. 회사 internal API, 결제 성공 페이지, 첫 페이지 데이터 fetch 같은 호출들. 야간에 0으로 가라앉았다 아침에 한 번 솟아오르는 트래픽 곡선에는 Application Auto Scaling으로 PC 개수를 시간대별로 흔드는 변형이 default 운영 패턴이다.

내가 처음 PC를 적용했을 때 헷갈렸던 건 Reserved Concurrency와 어떤 점이 다른가다. 두 손잡이 자체의 정의는 Handler와 실행 모델: 이벤트가 들어오면에서 짚어 두었으니 여기서는 차이만 짧게 짚는다. Reserved는 함수당 동시 실행 한도를 정하고 무료다. cold start 자체에는 손대지 않는다. Provisioned는 미리 데워 둔 환경 수를 정하고 추가 비용이 든다. cold start를 N개의 슬롯 안에서 0으로 만든다. 같은 동시성 100이라는 숫자도 Reserved=100과 Provisioned=100은 청구서를 서로 다른 형식으로 찍는다.

SnapStart: 메모리 스냅샷이라는 다른 길

PC가 환경을 통째로 살려 두는 길이라면, SnapStart는 INIT이 끝난 직후의 메모리 상태를 한 번 찍어 두고 매 콜드스타트를 그 스냅샷으로부터 재개하는 길이다. 같은 함수가 콜드로 진입해도 INIT 단계를 처음부터 다시 도는 게 아니라 Restore 한 단계로 넘어간다.

지원 런타임은 공식 문서 기준 Java 11+, Python 3.12+, .NET 8+ 셋이다. 컨테이너 이미지로 배포한 함수는 SnapStart를 못 켠다. ZIP 패키지만 지원한다. 한 가지 더, Java 함수의 스냅샷은 14일 동안 invoke가 한 번도 없으면 AWS가 자동으로 회수한다(공식 가이드). 그 뒤 첫 호출은 일반 cold start로 진입하는 게 아니라 SnapStartNotReadyException을 받고, Lambda가 백그라운드에서 새 스냅샷을 만드는 동안 잠시 대기해야 한다. 호출이 띄엄띄엄한 admin·batch 함수에서 한 번씩 신경 쓰게 되는 부분이다.

SnapStart 라이프사이클 도식. 위쪽은 표준 cold start로 Extension init, Runtime init, Function init, INVOKE가 순서대로 이어지고, 아래쪽은 SnapStart로 INIT을 한 번만 하고 그 뒤로는 Restore 단계만 거쳐 INVOKE로 진입한다.

청구서 형식은 PC와 다르다. SnapStart는 데워 둔 시간만큼 발생하는 비용이 없다. 대신 매 cold start의 Restore 단계에 대해 복원된 메모리 GB당 한 번씩 AWS가 비용을 부과한다. GB-초가 아니라 GB-restore 단위라는 점을 헷갈리지 말 것. 미국 동부(버지니아) 기준 단가가 GB-restore당 $0.0001397998고, 한 가지 단서가 더 따라온다. Java 관리형 런타임은 SnapStart Restore 요금에서 빠져 있어 무료고, Python·.NET 함수에만 이 단가를 적용한다(공식 가격 표). worked example: 1024MB(=1 GB) Python 함수가 하루 1만 호출 중 1%(=100건)이 콜드라면 SnapStart Restore 비용은 100건 × 1 GB × $0.0001397998 ≈ $0.014/일 수준이다. PC=10을 24시간 데우는 일일 비용($3.6)과 비교하면 두 자릿수 더 적다.

내가 마음속에 두고 있는 둘의 분기는 트래픽 곡선이다. baseline이 안정적이고 매 호출이 사용자 응답에 노출되면 PC. baseline이 0에 가깝고 가끔 spike가 들어오는 함수면 SnapStart. PC=0을 default로 두고 SnapStart만 켜 두면 cold start latency는 1~2초로 줄고 데워 두는 청구서는 안 든다. 이쪽을 처음 운영에 적용했을 때 청구서가 별로 안 흔들리는데 p99 latency가 절반으로 줄어서 한 번 놀란 적이 있다.

다만 SnapStart에는 한 가지 함정이 있다. INIT 시점에 만들어진 모든 메모리 상태가 스냅샷에 들어간다는 것은, 그때 잡힌 DB connection·임의의 random seed·캐시된 자격증명 만료 시간을 모든 cold start 환경이 동일하게 갖고 출발한다는 뜻이다. AWS는 이걸 위해 runtime hooks(beforeCheckpoint / afterRestore)를 열어 두었다. 스냅샷 직전에 connection을 닫고, 복구 직후에 새로 여는 패턴이다. 이걸 잊으면 1만 개 함수 인스턴스가 같은 random seed에서 출발해 ID 충돌이 일어나는 사고가 발생한다. 글로벌 정적 상태가 INIT 안에 있으면 위험하다는 한 줄로 요약한다.

셋 중 어느 길을 고르나: 트래픽 곡선에 따른 1인칭 판단

지금까지 세 갈래를 정리했다. 짧게 다시 적으면, 언어 선택(가벼운 런타임으로 INIT 자체를 줄임), Provisioned Concurrency(N개 환경을 미리 데워 둠), SnapStart(INIT 한 번, Restore 반복) 셋이다.

Cold start 손잡이를 트래픽 곡선에 따라 분기하는 결정 트리 도식. 첫 노드는 콜드스타트가 사용자 응답에 보이는지 묻고, 그 답에 따라 Provisioned Concurrency, SnapStart, 또는 mitigation 없이 표준 INIT으로 가는 세 갈래로 이어진다.

내가 운영하면서 마음속에 두고 있는 default 결정 트리는 이렇게 생겼다.

  1. baseline이 안정적이고 매 호출이 사용자에게 닿는다면, Provisioned Concurrency를 baseline 트래픽 동시성에 맞춰 잡는다. 그 위로 솟는 spike 부분은 SnapStart 또는 표준 cold start가 보조로 동작한다.
  2. baseline이 0에 가깝고 spike가 산발적이라면, SnapStart만 켠다(언어가 Java/Python/.NET이면). PC를 안 걸고도 cold start latency가 1초 안쪽으로 들어간다.
  3. 호출 방식이 비동기·polling이라 cold start가 사용자에게 닿지 않는다면, 둘 다 안 건다. 표준 cold start로 두고 청구서를 minimum으로 유지한다.

Lambda란 무엇인가: 서버리스의 경계에서 적은 관리형이 감추는 비용 네 갈래에서 둘째였던 cold start는, 이 글에서 다룬 세 손잡이 위에서 완전히 사라지는 비용은 아니지만 의도적으로 좁혀 가는 비용으로 모습을 바꾼다. AWS가 1% 미만이라 적어 둔 평균 빈도는 트래픽이 충분히 평탄한 함수의 평균이다. 트래픽 0에서 스파이크로 가는 함수에서는 첫 N개 호출이 모두 cold다. 그곳에서 판단해야 하는 한 가지는 콜드스타트 latency가 사용자에게 닿는가다. 닿으면 손잡이를 건다. 안 닿으면 청구서로만 들어오는 cold start를 받아들이고 둔다.

이어지는 글은 동시성과 Reserved Concurrency: 폭주를 막는 법으로 옮겨 간다. cold start 손잡이가 latency 쪽 손잡이라면, Reserved는 반대 방향, 즉 함수가 너무 잘 돌아 다른 함수를 굶기는 폭주를 막는 안전판이다. 같은 동시성이라는 한 단어가 두 곳에서 다른 일을 한다.

참고 자료

YouTube 영상

채널 보기
AI는 왜 수백 차원의 벡터를 사용할까? 고차원 공간과 행렬 | 선형대수학
AI는 데이터를 어떻게 분류할까? 벡터의 거리와 KNN 알고리즘 | 선형대수학
내적의 기하학적 의미와 코사인 유사도 원리 | 선형대수학
마지막편, 트라이 노드를 50% 이상 줄이는 방법? 압축 트라이 성능 분석 | Trie 자료구조 이야기
Trie 자료구조 파이썬 구현: Search와 Starts With 연산 | Trie 자료구조 이야기
트라이(Trie)를 이용한 자동 완성 알고리즘 | Trie 자료구조 이야기
스칼라 곱셈과 내적의 기하학적 의미 | 선형대수학
숫자 하나가 AI 모델의 운명을 바꾼다? | 선형대수학