serverless-esbuild
Lambda 함수를 배포할 때 TypeScript 소스를 그대로 올릴 수는 없다. Lambda 런타임은 JavaScript만 실행하기 때문에 반드시 트랜스파일이 필요하고, node_modules를 통째로 패키징하면 배포 패키지가 수십~수백 MB까지 불어난다. Lambda에는 배포 패키지 크기 제한(zip 50MB, 압축 해제 250MB)이 있고, 패키지가 클수록 cold start 시간도 길어진다.
전통적인 방식은 webpack이나 Rollup으로 번들링하는 것이었다. 하지만 이 도구들은 설정이 복잡하고, 특히 NestJS 같은 데코레이터 기반 프레임워크에서는 플러그인 설정에 상당한 시간을 쏟아야 했다. 그리고 무엇보다 느렸다. 함수가 많아질수록 빌드 시간이 분 단위로 늘어났다.
esbuild는 Go로 작성된 번들러로, webpack 대비 10~100배 빠른 빌드 속도를 자랑한다. serverless-esbuild는 이 esbuild를 Serverless Framework의 빌드 파이프라인에 자연스럽게 통합하는 플러그인이다.
동작 원리
serverless-esbuild는 Serverless Framework의 패키징 단계에 개입해서 동작한다. 전체 흐름은 이렇다.
- 엔트리포인트 수집:
serverless.yml에 정의된 각 함수의handler경로를 파싱해서 빌드할 파일 목록을 만든다. - esbuild 실행: 각 엔트리포인트를 esbuild로 번들링한다. 의존성을 트리쉐이킹하면서 하나(또는 소수)의 파일로 합친다.
- 출력물 수집: 번들링된 파일을
.esbuild/.build디렉토리에 저장한다. - 패키징: 번들링 결과물을 zip으로 압축해서 Serverless Framework의 배포 파이프라인에 전달한다.
핵심은 함수별로 독립적인 번들을 생성한다는 점이다. package.individually: true와 함께 사용하면 각 함수가 실제로 사용하는 코드만 포함된 경량 패키지가 만들어진다.
# 번들링 전 (node_modules 포함)
my-function.zip: 45MB
# serverless-esbuild 적용 후
my-function.zip: 1.2MB
이 차이는 트리쉐이킹 때문이다. esbuild는 각 함수의 엔트리포인트에서 시작해서 실제로 import되는 코드만 추적(tree-shake)한다. 사용하지 않는 모듈, 함수, 심지어 사용하지 않는 export까지 제거된다.
기본 설정
설정은 serverless.yml의 custom.esbuild 섹션에 작성한다.
plugins:
- serverless-esbuild
custom:
esbuild:
bundle: true
minify: false
sourcemap: true
target: node18
platform: node
이것만으로도 대부분의 프로젝트에서 잘 동작한다. 각 옵션을 살펴보자.
bundle
true로 설정하면 import/require를 따라가면서 모든 의존성을 하나의 파일로 합친다. 이 옵션이 false면 트랜스파일만 하고 번들링은 하지 않는다. Lambda 배포에서는 거의 항상 true로 사용한다.
minify
코드를 압축해서 파일 크기를 줄인다. 변수명을 짧게 바꾸고, 공백을 제거하고, dead code를 삭제한다. 프로덕션에서는 켜는 게 좋지만, 디버깅이 어려워지기 때문에 개발 환경에서는 보통 끈다.
# 프로덕션에서만 minify
minify: ${self:provider.stage, 'dev'} == 'prod'
sourcemap
소스맵을 생성한다. minify나 번들링 후에도 에러 스택 트레이스에서 원본 소스의 라인 번호를 확인할 수 있게 해준다. Lambda 환경에서 소스맵을 사용하려면 환경 변수도 같이 설정해야 한다.
custom:
esbuild:
sourcemap: true
provider:
environment:
NODE_OPTIONS: '--enable-source-maps'
주의할 점이 있다. 소스맵 파일은 번들 크기를 상당히 늘린다. 그리고 Node.js에서 소스맵 처리 자체가 에러 발생 시 수 초의 지연을 유발할 수 있다. 이 오버헤드가 문제가 된다면 sourcemap: linked로 설정해서 소스맵을 별도 파일로 분리하거나, 프로덕션에서는 아예 끄는 것도 방법이다.
target
esbuild가 출력할 JavaScript 문법 수준을 결정한다. Lambda 런타임 버전에 맞춰야 한다.
target: node18 # Lambda Node.js 18.x 런타임
target: node20 # Lambda Node.js 20.x 런타임
이 설정이 중요한 이유는 esbuild가 target보다 높은 문법(예: top-level await)을 자동으로 하위 호환 코드로 변환하기 때문이다. target을 잘못 설정하면 불필요한 변환이 추가되거나, 반대로 런타임에서 지원하지 않는 문법이 그대로 출력될 수 있다.
platform
node로 설정하면 esbuild가 Node.js 환경에 맞게 번들링한다. 구체적으로는 __dirname, __filename 같은 Node.js 전역 변수를 올바르게 처리하고, fs나 path 같은 내장 모듈을 번들에 포함시키지 않는다.
external 설정
번들에 포함시키지 않을 모듈을 지정한다. 이 설정이 필요한 상황은 크게 두 가지다.
네이티브 모듈
sharp, bcrypt 같은 네이티브 바이너리를 포함하는 모듈은 esbuild로 번들링할 수 없다. 이런 모듈은 external로 빼고, Lambda Layer나 Docker 이미지를 통해 별도로 제공해야 한다.
custom:
esbuild:
external:
- sharp
- '@nestjs/microservices'
- '@nestjs/websockets'
AWS SDK
Lambda 런타임에는 AWS SDK가 이미 내장되어 있다. Node.js 18 이상에서는 AWS SDK v3가 포함되어 있으므로, 이걸 번들에 다시 포함시킬 이유가 없다.
custom:
esbuild:
external:
- '@aws-sdk/*'
external로 지정된 모듈은 번들링 결과물에서 require() 호출로 남는다. Lambda 런타임에서 이 모듈을 찾을 수 있어야 하므로, Layer에 포함시키거나 런타임에 내장된 모듈이어야 한다.
package.individually
serverless-esbuild의 진가는 package.individually: true와 함께 드러난다.
package:
individually: true
plugins:
- serverless-esbuild
이 설정이 없으면 모든 함수가 하나의 큰 zip으로 패키징된다. 함수 A가 sharp를 쓰고 함수 B가 puppeteer를 쓰면, 두 함수 모두 sharp + puppeteer를 포함한 거대한 패키지를 갖게 된다.
individually: true를 켜면 각 함수가 자기 의존성만 포함한 독립 패키지를 갖는다.
# individually: false
service.zip: 85MB (모든 함수 공유)
# individually: true
function-a.zip: 3.2MB (sharp만 포함)
function-b.zip: 12MB (puppeteer만 포함)
function-c.zip: 0.8MB (순수 JS만)
cold start 시간은 패키지 크기에 비례하므로, 이 차이는 실제 응답 속도에 직접적인 영향을 준다.
고급 설정
define
컴파일 타임에 상수를 주입한다. 환경 변수를 하드코딩하거나 조건부 코드를 제거하는 데 사용한다.
custom:
esbuild:
define:
'process.env.IS_LAMBDA': '"true"'
esbuild는 빌드 시점에 process.env.IS_LAMBDA를 문자열 "true"로 치환한다. 이렇게 하면 번들링 단계에서 dead code가 확정되어 트리쉐이킹이 더 효과적으로 동작한다.
keepNames
minify와 함께 사용할 때 중요한 옵션이다. minify는 함수명과 클래스명을 짧게 바꾸는데, NestJS처럼 클래스 이름에 의존하는 프레임워크에서는 문제가 된다.
custom:
esbuild:
minify: true
keepNames: true
keepNames: true를 설정하면 .name 속성이 원래 이름을 유지한다. NestJS의 DI 컨테이너가 클래스 이름으로 Provider를 식별하기 때문에, NestJS 프로젝트에서는 이 옵션이 필수다.
plugins 확장
esbuild의 플러그인 시스템을 사용하려면 별도의 설정 파일을 지정할 수 있다.
custom:
esbuild:
config: './esbuild.config.cjs'
// esbuild.config.cjs
const { copy } = require('esbuild-plugin-copy');
module.exports = () => ({
plugins: [
copy({
assets: [
{ from: './templates/**/*', to: './templates' }
]
})
]
});
이 방식은 HTML 템플릿, JSON 스키마 파일 등 코드가 아닌 에셋을 번들에 포함시켜야 할 때 유용하다.
exclude
serverless.yml의 package.patterns와 함께 사용해서 패키징에서 특정 파일을 제외할 수 있다.
package:
individually: true
patterns:
- '!node_modules/**'
- '!tests/**'
- '!src/**'
serverless-esbuild가 번들링한 결과물만 패키징 대상이 되므로, 원본 소스나 테스트 파일이 배포 패키지에 들어가는 것을 방지한다.
serverless-offline과 함께 사용
로컬 개발 환경에서 serverless-offline과 함께 사용하면 파일 변경 시 자동으로 다시 번들링된다.
plugins:
- serverless-esbuild
- serverless-offline
custom:
esbuild:
bundle: true
watch:
pattern: ['src/**/*.ts']
ignore: ['temp/**']
플러그인 순서가 중요하다. serverless-esbuild가 serverless-offline보다 먼저 선언되어야 한다. 그래야 offline이 실행되기 전에 esbuild가 코드를 번들링할 수 있다.
watch 설정을 통해 특정 패턴의 파일만 감시하거나, 특정 디렉토리를 감시 대상에서 제외할 수 있다. 파일이 변경되면 esbuild가 해당 함수만 다시 번들링하고, serverless-offline이 핸들러를 다시 로드한다.
webpack과의 비교
| serverless-esbuild | serverless-webpack | |
|---|---|---|
| 빌드 속도 | ~0.5초 (10개 함수) | ~15초 (10개 함수) |
| 설정 복잡도 | 최소 (yml 몇 줄) | 높음 (webpack.config.js 필요) |
| 트리쉐이킹 | 기본 지원 | 설정 필요 (mode: production) |
| 데코레이터 | emitDecoratorMetadata 미지원 | ts-loader로 지원 |
| 플러그인 생태계 | 제한적 | 풍부 |
| HMR | 미지원 | 미지원 (Lambda 환경) |
| 코드 스플리팅 | 제한적 | 완전 지원 |
속도 차이가 압도적이다. 함수 10개를 빌드할 때 webpack은 10초 이상 걸리는 반면, esbuild는 1초도 안 걸린다. 배포를 하루에 여러 번 하는 환경에서 이 차이는 개발 경험에 직접적으로 영향을 준다.
다만 주의할 점이 있다. esbuild는 TypeScript의 emitDecoratorMetadata를 지원하지 않는다. NestJS처럼 reflect-metadata에 의존하는 프레임워크에서는 이 제한이 문제가 될 수 있다. 해결 방법은 esbuild-decorators 플러그인을 사용하거나, @swc/core를 esbuild 플러그인으로 연결하는 것이다.
// esbuild.config.cjs - 데코레이터 지원
const esbuildDecorators = require('@anatine/esbuild-decorators');
module.exports = () => ({
plugins: [
esbuildDecorators({
tsconfig: './tsconfig.json'
})
]
});
트러블슈팅
"Cannot find module" 에러
번들링 결과물에서 모듈을 찾지 못하는 경우다. 대부분 동적 require 때문에 발생한다.
// esbuild가 추적할 수 없음
const module = require(`./${dynamicName}`);
esbuild는 정적 분석으로 의존성을 추적한다. 동적 경로를 사용하면 esbuild가 해당 모듈을 번들에 포함시키지 못한다. 해결 방법은 동적 import를 정적으로 바꾸거나, 해당 모듈을 external로 지정하고 Layer에 포함시키는 것이다.
번들 크기가 예상보다 큰 경우
serverless-analyze-bundle-plugin으로 번들 구성을 분석할 수 있다.
plugins:
- serverless-esbuild
- serverless-analyze-bundle-plugin
이 플러그인은 각 함수의 번들에 어떤 모듈이 얼마나 차지하는지 시각적으로 보여준다. 예상치 못한 거대한 의존성이 번들에 포함된 경우를 찾아내는 데 유용하다.
NestJS에서 Provider를 찾지 못하는 경우
minify가 클래스 이름을 변경하면서 NestJS DI가 깨지는 문제다. 앞서 설명한 keepNames: true 설정으로 해결할 수 있다. 이 문제는 빌드 시에는 에러가 없고, 런타임에서 Nest could not find XxxService 같은 에러로 나타나기 때문에 디버깅이 까다롭다.
관련 문서
- Serverless Framework - IaC 기반 서버리스 배포
- serverless-offline - 로컬 Lambda 에뮬레이션