Azure Functions의 콜드 스타트를 다룬 글은 많은데, 대부분 “Premium 플랜을 쓰세요”로 끝납니다. 맞는 말이지만 Premium 플랜은 트래픽이 없어도 월 고정 비용이 나갑니다. 소비 플랜의 매력이 “안 쓰면 0원”인데, 그걸 포기하는 게 항상 옳은 선택은 아닙니다.
이 글은 플랜을 바꾸기 전에 코드와 설정으로 줄일 수 있는 부분을 먼저 정리하고, 그래도 안 될 때 어떤 플랜으로 갈지를 비용과 함께 비교합니다. 순서는 측정 → 원인 분해 → 개선 → 플랜 결정입니다.
1. 콜드 스타트가 실제로 얼마나 걸리는지 재기
체감으로 판단하면 안 됩니다. Application Insights에 이미 데이터가 있습니다. 콜드 스타트는 FunctionExecutionTime이 아니라 요청 전체 시간에서 드러납니다.
// 소비 플랜의 콜드 스타트 분포 확인
requests
| where timestamp > ago(7d)
| where cloud_RoleName == "func-api-prod"
| summarize
p50 = percentile(duration, 50),
p95 = percentile(duration, 95),
p99 = percentile(duration, 99),
max = max(duration),
cnt = count()
by bin(timestamp, 1h)
| sort by timestamp desc
여기서 p50과 p99의 격차가 콜드 스타트의 크기입니다. p50이 80ms인데 p99가 4,000ms라면, 대부분의 요청은 빠르고 일부가 콜드 스타트를 맞고 있다는 뜻입니다.
더 직접적으로 보려면 호스트 시작 이벤트를 셉니다. 이 숫자가 곧 콜드 스타트 발생 횟수입니다.
traces
| where timestamp > ago(24h)
| where message has "Host started" or message has "Job host started"
| summarize ColdStarts = count() by bin(timestamp, 1h)
| render timechart
소비 플랜은 약 20분간 호출이 없으면 인스턴스를 회수합니다. 위 차트에서 새벽 시간대에 콜드 스타트가 몰린다면 정상 동작입니다. 문제는 업무 시간에도 자주 뜨는 경우인데, 이건 트래픽이 산발적이거나 스케일 아웃이 반복되고 있다는 신호입니다.
2. 콜드 스타트 시간을 4구간으로 분해하기
“콜드 스타트 3초”는 하나의 덩어리가 아닙니다. 네 구간으로 나뉘고, 각 구간마다 줄이는 방법이 다릅니다. 어디가 큰지 모르면 엉뚱한 곳을 최적화하게 됩니다.
| 구간 | 내용 | 줄이는 방법 | 내가 통제 가능한가 |
|---|---|---|---|
| ① 인프라 할당 | 워커 확보, 컨테이너 준비 | 거의 불가 | ✗ (플랜 변경만) |
| ② 앱 파일 로드 | 스토리지에서 코드 내려받아 마운트 | 패키지 크기 축소, Run From Package | ✓ |
| ③ 런타임 부팅 | .NET/Node/Python 런타임 시작 | 런타임 선택, 버전 최신화 | ✓ |
| ④ 앱 초기화 | DI 구성, 커넥션 생성, 설정 로드 | 코드 개선 | ✓✓ |
대부분의 경우 ④가 가장 크고, 동시에 가장 쉽게 줄일 수 있는 구간입니다. 그런데 사람들은 ①을 해결하려고 플랜부터 바꿉니다. 순서가 반대입니다.
3. 코드로 줄이기 — 가장 효과 큰 4가지
① 클라이언트를 static으로 재사용
가장 흔하고 가장 비싼 실수입니다. 함수 안에서 HttpClient나 CosmosClient를 매번 new 하면, 콜드 스타트뿐 아니라 웜 상태 호출까지 느려지고 소켓 고갈까지 일으킵니다.
// 나쁜 예 - 호출마다 새로 생성
public static class BadFunction
{
[FunctionName("Bad")]
public static async Task<IActionResult> Run(HttpRequest req)
{
var client = new HttpClient(); // 매번 생성
var cosmos = new CosmosClient(connString); // 매번 연결
...
}
}
// 좋은 예 - 인스턴스 수명 동안 재사용
public static class GoodFunction
{
private static readonly HttpClient _http = new HttpClient();
private static readonly CosmosClient _cosmos =
new CosmosClient(connString, new CosmosClientOptions
{
ConnectionMode = ConnectionMode.Direct
});
[FunctionName("Good")]
public static async Task<IActionResult> Run(HttpRequest req)
{
... // 이미 준비된 클라이언트 사용
}
}
② 초기화를 지연(Lazy)시키기
static 필드로 올렸더라도, 모든 함수가 모든 클라이언트를 쓰는 건 아닙니다. 시작 시점에 전부 초기화하면 안 쓰는 것까지 기다리게 됩니다. Lazy<T>로 감싸면 실제로 필요할 때만 만듭니다.
private static readonly Lazy<CosmosClient> _cosmos =
new Lazy<CosmosClient>(() =>
new CosmosClient(Environment.GetEnvironmentVariable("COSMOS_CONN")));
// 사용 시점에 최초 1회만 생성
var container = _cosmos.Value.GetContainer("db", "items");
③ Key Vault 참조를 앱 설정으로 옮기기
코드 안에서 Key Vault SDK로 시크릿을 읽으면, 콜드 스타트마다 토큰 획득 + Key Vault 왕복이 발생합니다. 시크릿이 여러 개면 그만큼 곱해집니다.
대신 앱 설정에 Key Vault 참조 구문을 넣으면 플랫폼이 대신 해결해서 환경변수로 주입합니다. 코드는 그냥 환경변수를 읽으면 됩니다.
# 앱 설정 값에 참조 구문을 넣는다
az functionapp config appsettings set \
--name func-api-prod -g rg-api-prod \
--settings "DB_PASSWORD=@Microsoft.KeyVault(SecretUri=https://kv-api-prod.vault.azure.net/secrets/db-password/)"
# 관리 ID에 Key Vault 읽기 권한 부여 (선행 필요)
az role assignment create \
--assignee <FUNCTION_MI_OBJECT_ID> \
--role "Key Vault Secrets User" \
--scope /subscriptions/<SUB>/resourceGroups/rg-api-prod/providers/Microsoft.KeyVault/vaults/kv-api-prod
Key Vault 연동 전반은 Key Vault로 시크릿 안전하게 관리하기에 정리해두었습니다.
④ 배포 패키지 줄이고 Run From Package 켜기
②구간(앱 파일 로드)에 직접 영향을 줍니다. 패키지가 크면 스토리지에서 내려받는 시간이 그대로 콜드 스타트에 더해집니다.
# 압축 패키지를 마운트해서 실행 (개별 파일 복사보다 빠름)
az functionapp config appsettings set \
--name func-api-prod -g rg-api-prod \
--settings WEBSITE_RUN_FROM_PACKAGE=1
# 현재 패키지 크기 확인
az functionapp deployment source config-zip \
--name func-api-prod -g rg-api-prod --src ./app.zip --debug 2>&1 | grep -i size
Node.js 함수라면 node_modules가 주범입니다. devDependencies를 빼고 번들링하면 패키지가 크게 줄어듭니다. Python은 무거운 라이브러리(pandas, numpy 등)가 콜드 스타트를 크게 늘리므로, 정말 필요한지 재검토할 가치가 있습니다.
4. 런타임 선택도 변수다
③구간(런타임 부팅)은 언어에 따라 차이가 있습니다. 새로 만드는 함수라면 고려할 만하고, 이미 운영 중이라면 참고 정보입니다.
- .NET 격리 워커 — 별도 프로세스를 하나 더 띄우므로 인프로세스보다 시작이 느립니다. 다만 최신 버전에서 많이 개선됐고, 앞으로의 표준이라 신규는 이쪽이 맞습니다.
- Node.js — 런타임 자체는 가볍지만
node_modules크기에 좌우됩니다. 번들링 여부가 큰 변수입니다. - Python — 인터프리터 시작과 패키지 import가 겹쳐 상대적으로 불리합니다. 무거운 과학 계산 라이브러리를 쓰면 특히 그렇습니다.
- Java — JVM 부팅이 있어 콜드 스타트가 가장 큰 편입니다. 소비 플랜과 상성이 나쁩니다.
공통으로, 런타임 버전을 최신으로 유지하는 것만으로도 개선 효과가 있습니다. 오래된 버전에 고정해두고 콜드 스타트를 탓하는 경우가 있으니 현재 설정을 한 번 확인해보시길 권합니다.
az functionapp config show \
--name func-api-prod -g rg-api-prod \
--query "{stack:linuxFxVersion, netVersion:netFrameworkVersion, alwaysOn:alwaysOn}" \
-o table
5. 그래도 안 되면 — 플랜 비교
코드를 다 손봤는데도 요구 수준을 못 맞춘다면 플랜을 바꿔야 합니다. 선택지는 셋입니다.
| 소비(Consumption) | Flex Consumption | Premium (EP) | |
|---|---|---|---|
| 콜드 스타트 | 있음 | 있음 (완화 가능) | 없음 (웜 인스턴스 유지) |
| 유휴 시 비용 | 0원 | 0원 (준비 인스턴스 미사용 시) | 인스턴스 수만큼 고정 발생 |
| 과금 기준 | 실행 횟수 + 실행 시간 | 실행 시간 + 준비 인스턴스 | vCPU·메모리 시간 |
| VNet 통합 | 불가 | 가능 | 가능 |
| 최대 실행 시간 | 10분 | 더 길게 설정 가능 | 무제한 설정 가능 |
| 적합한 경우 | 산발적·비동기 처리 | 절충안이 필요할 때 | 지연에 민감한 동기 API |
판단 기준을 단순하게 정리하면 이렇습니다.
- 사용자가 응답을 기다리는 동기 API이고 p99가 요구치를 넘는다 → Premium 또는 Flex의 준비 인스턴스
- 큐·타이머·이벤트 기반 비동기 처리다 → 소비 플랜 유지. 콜드 스타트가 사용자에게 보이지 않음
- VNet 안의 리소스에 접근해야 한다 → 소비 플랜은 애초에 불가. Flex 또는 Premium
- 10분 넘게 도는 작업이 있다 → Premium, 또는 Durable Functions로 쪼개기
Flex Consumption의 준비 인스턴스(Always Ready)는 절충안으로 쓸 만합니다. 최소 인스턴스 수를 지정해 그만큼은 항상 웜으로 두고, 초과 트래픽은 기존처럼 탄력적으로 확장합니다. Premium 전체 비용을 내지 않고 콜드 스타트만 없앨 수 있습니다.
권장하지 않는 방법: 타이머로 깨우기
검색하면 자주 나오는 방법이 5분마다 자기 자신을 호출하는 타이머 함수를 두는 것입니다. 동작은 합니다. 하지만 권하지 않습니다.
- 스케일 아웃으로 새 인스턴스가 뜨면 그건 여전히 콜드 상태입니다. 한 인스턴스만 웜으로 유지될 뿐입니다
- 실행 횟수가 늘어 소비 플랜 요금이 증가합니다. “안 쓰면 0원”이 깨집니다
- 로그와 모니터링 지표가 가짜 호출로 오염됩니다
임시방편으로는 쓸 수 있지만, 이 방법이 필요한 상황이라면 이미 플랜을 바꿀 때가 된 것입니다.
정리
- Application Insights에서 p50과 p99 격차로 실제 크기를 잰다
- 클라이언트를 static + Lazy로 재사용한다 (가장 효과 큼)
- Key Vault는 SDK 호출 대신 앱 설정 참조로 옮긴다
- 패키지를 줄이고
WEBSITE_RUN_FROM_PACKAGE=1을 켠다 - 그래도 안 되면 비동기인지 동기인지로 플랜을 결정한다
2~4번은 비용이 들지 않습니다. 돈을 쓰기 전에 할 수 있는 것부터 하는 게 순서이고, 실제로 이 단계에서 해결되는 경우가 적지 않습니다. 서버리스 비용 설계를 이어서 보실 분들께는 Reserved·Savings Plan·Spot 선택 기준을 권합니다.