작년 말, 우리 팀 — 정확히는 나 포함 세 명 — 이 API 레이턴시 문제를 본격적으로 파고들기 시작했다. 당시 주요 엔드포인트의 평균 응답 시간이 380ms 정도였는데, 서울 리전 Lambda 함수에서 나오는 숫자였다. 클라이언트는 미국, 유럽, 동남아시아 전역에 퍼져 있었고, 서울에서 잘 나오는 숫자가 해외에서는 600~900ms까지 튀었다.
그래서 Cloudflare Workers를 진지하게 고민하기 시작했다. 주변에서 “Workers 쓰면 진짜 빠르다”는 말을 많이 들었는데, 직접 써보기 전까지는 반쯤 마케팅 문구라고 생각했다. 솔직히 말하면, 처음엔 Workers가 Lambda를 완전히 대체할 거라고 기대했다. 2주간 실제 프로덕션 트래픽 일부를 Workers로 라우팅해서 테스트했고, 결론은 — 생각보다 훨씬 복잡했다.
콜드 스타트: 직접 측정해보니 생각보다 차이가 컸다
Lambda의 콜드 스타트 문제는 이미 유명하다. 그런데 얼마나 자주 발생하고 얼마나 느린지는 직접 측정해봐야 실감이 온다.
내 환경(Node.js 20.x, 256MB, ap-northeast-2)에서는 콜드 스타트가 대략 180~450ms 범위였다. VPC를 붙이면 여기에 100~200ms가 추가된다. Provisioned Concurrency를 쓰면 해결되긴 하는데, 그게 공짜가 아니라는 게 문제다 — 비용 얘기는 나중에 따로 다룰 것이다.
Workers는 달랐다. V8 isolate 기반이라 콜드 스타트가 사실상 없다고 봐도 된다. 공식 문서에서는 “5ms 이하”라고 하는데, 내가 측정한 P99는 2~4ms 수준이었다. 처음 이 숫자를 보고 뭔가 잘못 측정한 줄 알았다. 정말이다. 몇 번을 다시 돌려봐도 결과가 같았다.
트래픽이 일정하지 않은 서비스 — 새벽엔 거의 없다가 오전에 몰리는 패턴 — 라면 Lambda 콜드 스타트가 진짜로 체감된다. 우리 서비스가 딱 그 패턴이었고, 오전 9시대 첫 요청들이 유독 느렸던 이유가 여기 있었다.
V8 Isolate의 실체 — Workers가 빠른 진짜 이유, 그리고 그 대가
Workers가 빠른 이유는 단순히 인프라가 좋아서가 아니라 아키텍처 자체가 다르기 때문이다. Lambda는 요청마다 (또는 재사용되는 컨테이너 환경에서) Node.js 프로세스를 실행한다. Workers는 같은 V8 엔진 인스턴스 안에서 여러 테넌트의 코드를 isolate로 격리해서 실행한다.
메모리 공유가 없고 OS 프로세스 오버헤드가 없으니 빠른 것이다. 하지만 이 구조 때문에 Workers에서 못 하는 것들이 생긴다.
// Cloudflare Workers — 기본 요청 처리 패턴
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
// KV에서 캐시 확인 — 레이턴시가 실제로 낮음
const cached = await env.MY_KV.get(url.pathname);
if (cached) {
return new Response(cached, {
headers: { 'Content-Type': 'application/json' }
});
}
// 주의: subrequest는 free tier 기준 요청당 50개 제한
// 복잡한 팬아웃 패턴이라면 Paid 플랜 필수
const response = await fetch('https://api.internal.example.com' + url.pathname);
const data = await response.text();
// waitUntil: 응답 반환 후 비동기로 실행 (KV 캐싱)
// 이게 없으면 응답 전에 KV 쓰기를 기다려야 함
ctx.waitUntil(env.MY_KV.put(url.pathname, data, { expirationTtl: 300 }));
return new Response(data, {
headers: { 'Content-Type': 'application/json' }
});
}
};
Workers의 제약이 생각보다 많다. CPU 시간은 free tier 기준 요청당 10ms, Workers Paid는 30초다. 메모리 상한은 128MB — Lambda의 최대 10GB와 비교하면 한참 작다. 파일 시스템 접근은 없다. process.env도 없다(Wrangler의 env 객체로 따로 접근한다). npm 패키지 중 Node.js 내장 모듈에 의존하는 건 그냥 안 돌아간다.
내가 Workers로 옮기려다 막힌 것도 바로 이 패키지 호환성 문제였다. 우리가 쓰던 라이브러리 하나가 Node.js crypto 모듈을 직접 import하고 있었는데, Workers에서 에러가 터졌다. 지금은 nodejs_compat 호환성 플래그를 지원해서 많이 나아졌지만 — 솔직히 이 플래그가 정식 지원이 아닌 플래그 형태라는 점은 프로덕션에서 아직도 신경 쓰인다. “플래그를 달고 배포한다”는 게 마음 편한 상황은 아니니까.
비용 계산을 직접 해보니 예상과 달랐다
“Workers가 더 싸다”는 말을 여러 번 들었다. 틀린 말은 아닌데, 상황에 따라 차이가 크다.
Lambda(ap-northeast-2 기준):
– 요청당 $0.0000002 (월 100만 건 무료)
– 실행 시간 1ms당 $0.0000000167 (128MB 기준)
Workers:
– 무료 티어: 일 10만 요청
– Workers Paid: 월 $5 고정 + 초과분 100만 요청당 $0.30
단순 비교로는 Workers가 비슷하거나 약간 저렴하다. 내가 놓쳤던 건 Lambda의 Provisioned Concurrency 비용이었다.
콜드 스타트를 없애려면 Provisioned Concurrency를 켜야 하는데, 이게 활성화된 동안은 요청이 없어도 비용이 발생한다. ap-northeast-2 기준 동시 인스턴스 1개당 시간당 약 $0.015 수준이다. 10개를 24시간 유지하면 월 $108이 그냥 나간다. 트래픽 패턴에 따라 이 숫자가 꽤 올라갈 수 있다.
Workers는 이런 예열 비용이 없다. 트래픽이 불규칙한 서비스에서 Workers가 비용 측면에서도 유리한 이유가 여기 있다.
한 가지 주의할 점 — Workers의 R2, KV, Durable Objects 같은 바인딩 서비스들은 별도 과금이다. 순수 compute 비용만 보면 Workers가 유리하지만, 전체 스택을 Cloudflare로 옮기는 건 다른 계산이 필요하다. 이 부분은 실제 사용 패턴에 따라 결과가 많이 달라지기 때문에, 내 숫자를 그대로 믿기보단 직접 계산기 돌려보는 걸 추천한다.
Lambda가 Workers보다 확실히 나은 상황들
Workers를 써보면서 “역시 Lambda가 맞다”고 느낀 순간들이 있었다.
실행 시간이 긴 작업이 첫 번째다. Workers의 CPU 시간 제한은 30초(Paid)이고 이건 CPU 시간이지 벽시계 시간이 아니다. 이미지 처리나 외부 API를 여러 번 연쇄 호출하는 복잡한 흐름에서 Workers의 제약이 체감됐다. Lambda는 최대 15분이다.
// AWS Lambda — Node.js 20.x, DynamoDB 연동 예시
import { DynamoDBClient, GetItemCommand } from '@aws-sdk/client-dynamodb';
// Lambda 컨테이너 재사용 시 이 클라이언트도 재사용됨
// Workers의 globalThis와 다른 라이프사이클이라는 점에 주의
const client = new DynamoDBClient({ region: 'ap-northeast-2' });
export const handler = async (event) => {
const path = event.rawPath;
// 핵심 차이점: Lambda는 VPC 내부 리소스에 직접 접근 가능
// RDS, ElastiCache, 내부 서비스 등 — Workers에서 이걸 쓰려면
// 외부 API 호출로 우회해야 해서 레이턴시가 붙고 설정도 복잡해짐
try {
const result = await client.send(new GetItemCommand({
TableName: process.env.TABLE_NAME,
Key: { path: { S: path } }
}));
return {
statusCode: 200,
body: JSON.stringify(result.Item),
headers: { 'Content-Type': 'application/json' }
};
} catch (err) {
console.error('DynamoDB error:', err);
return {
statusCode: 500,
body: JSON.stringify({ error: 'Internal error' })
};
}
};
두 번째는 AWS 생태계와의 통합이다. 우리 팀은 이미 Aurora Serverless v2, SQS, S3, Secrets Manager를 쓰고 있었다. Lambda는 이 모든 것과 네이티브로 연결된다. Workers에서 AWS 서비스를 쓰려면 외부 API 호출로 우회해야 하는데, 레이턴시도 붙고 설정도 복잡해진다.
세 번째는 관찰 가능성이다. 금요일 오후에 Workers 배포 후 에러율이 0.3%에서 1.2%로 올라가는 걸 발견했을 때 — Workers 로그를 추적하는 게 Lambda CloudWatch보다 확실히 불편했다. wrangler tail로 실시간 로그를 보긴 했지만, 소급해서 특정 시간대 로그를 뒤지는 건 번거로웠다. CloudWatch, X-Ray, ADOT 조합에 익숙해진 팀이라면 이 차이가 생각보다 크게 느껴진다. 나는 그날 한 시간을 이 로그 추적에 썼다.
결국 나는 어떤 선택을 했나
2주 테스트 결과, 하이브리드 구조로 갔다.
엣지 캐싱, A/B 테스팅, 지역 기반 라우팅 → Workers. 이런 건 정말 Workers가 맞다. 콜드 스타트가 없고, 전 세계 엣지 노드에서 실행되니 레이턴시가 눈에 띄게 줄었다. 우리 글로벌 P95가 380ms에서 120ms로 떨어진 건 이 레이어 덕분이다.
실제 비즈니스 로직, DB 접근, 긴 작업 → Lambda. DB 연결, 복잡한 트랜잭션, 외부 서비스 통합은 Lambda에서 처리한다. 이미 AWS 스택에 익숙한 팀이고, 여기서 굳이 Workers로 옮길 이유가 없었다.
Workers를 “Lambda 대체”로 보는 건 잘못된 프레임이다. Workers는 Lambda가 하는 걸 다 하려고 만든 게 아니라, 네트워크 엣지에서 빠르고 가볍게 처리해야 하는 것들을 위한 도구다. 이걸 헷갈리면 Workers로 대형 이주 작업을 시작했다가 패키지 호환성 벽에 막혀 중간에 멈추는 상황이 생긴다 — 주변에서 실제로 봤다.
순수하게 하나만 골라야 한다면? 팀이 AWS 생태계에 깊이 박혀 있고, 서비스 로직이 복잡하고, 실행 시간이 가변적이라면 Lambda다. 반대로 글로벌 레이턴시가 핵심이고, 비즈니스 로직이 비교적 단순하고, AWS 의존성이 별로 없다면 Workers 단독으로 가는 것도 충분히 말이 된다. 어떤 걸 써야 하냐보다 내 서비스의 병목이 어디냐를 먼저 파악하는 게 더 중요하다.