junyeokk
Blog
Serverless·2025. 11. 27

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의 패키징 단계에 개입해서 동작한다. 전체 흐름은 이렇다.

  1. 엔트리포인트 수집: serverless.yml에 정의된 각 함수의 handler 경로를 파싱해서 빌드할 파일 목록을 만든다.
  2. esbuild 실행: 각 엔트리포인트를 esbuild로 번들링한다. 의존성을 트리쉐이킹하면서 하나(또는 소수)의 파일로 합친다.
  3. 출력물 수집: 번들링된 파일을 .esbuild/.build 디렉토리에 저장한다.
  4. 패키징: 번들링 결과물을 zip으로 압축해서 Serverless Framework의 배포 파이프라인에 전달한다.

핵심은 함수별로 독립적인 번들을 생성한다는 점이다. package.individually: true와 함께 사용하면 각 함수가 실제로 사용하는 코드만 포함된 경량 패키지가 만들어진다.

text
# 번들링 전 (node_modules 포함)
my-function.zip: 45MB

# serverless-esbuild 적용 후
my-function.zip: 1.2MB

이 차이는 트리쉐이킹 때문이다. esbuild는 각 함수의 엔트리포인트에서 시작해서 실제로 import되는 코드만 추적(tree-shake)한다. 사용하지 않는 모듈, 함수, 심지어 사용하지 않는 export까지 제거된다.


기본 설정

설정은 serverless.ymlcustom.esbuild 섹션에 작성한다.

yaml
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를 삭제한다. 프로덕션에서는 켜는 게 좋지만, 디버깅이 어려워지기 때문에 개발 환경에서는 보통 끈다.

yaml
# 프로덕션에서만 minify
minify: ${self:provider.stage, 'dev'} == 'prod'

sourcemap

소스맵을 생성한다. minify나 번들링 후에도 에러 스택 트레이스에서 원본 소스의 라인 번호를 확인할 수 있게 해준다. Lambda 환경에서 소스맵을 사용하려면 환경 변수도 같이 설정해야 한다.

yaml
custom:
  esbuild:
    sourcemap: true

provider:
  environment:
    NODE_OPTIONS: '--enable-source-maps'

주의할 점이 있다. 소스맵 파일은 번들 크기를 상당히 늘린다. 그리고 Node.js에서 소스맵 처리 자체가 에러 발생 시 수 초의 지연을 유발할 수 있다. 이 오버헤드가 문제가 된다면 sourcemap: linked로 설정해서 소스맵을 별도 파일로 분리하거나, 프로덕션에서는 아예 끄는 것도 방법이다.

target

esbuild가 출력할 JavaScript 문법 수준을 결정한다. Lambda 런타임 버전에 맞춰야 한다.

yaml
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 전역 변수를 올바르게 처리하고, fspath 같은 내장 모듈을 번들에 포함시키지 않는다.


external 설정

번들에 포함시키지 않을 모듈을 지정한다. 이 설정이 필요한 상황은 크게 두 가지다.

네이티브 모듈

sharp, bcrypt 같은 네이티브 바이너리를 포함하는 모듈은 esbuild로 번들링할 수 없다. 이런 모듈은 external로 빼고, Lambda Layer나 Docker 이미지를 통해 별도로 제공해야 한다.

yaml
custom:
  esbuild:
    external:
      - sharp
      - '@nestjs/microservices'
      - '@nestjs/websockets'

AWS SDK

Lambda 런타임에는 AWS SDK가 이미 내장되어 있다. Node.js 18 이상에서는 AWS SDK v3가 포함되어 있으므로, 이걸 번들에 다시 포함시킬 이유가 없다.

yaml
custom:
  esbuild:
    external:
      - '@aws-sdk/*'

external로 지정된 모듈은 번들링 결과물에서 require() 호출로 남는다. Lambda 런타임에서 이 모듈을 찾을 수 있어야 하므로, Layer에 포함시키거나 런타임에 내장된 모듈이어야 한다.


package.individually

serverless-esbuild의 진가는 package.individually: true와 함께 드러난다.

yaml
package:
  individually: true

plugins:
  - serverless-esbuild

이 설정이 없으면 모든 함수가 하나의 큰 zip으로 패키징된다. 함수 A가 sharp를 쓰고 함수 B가 puppeteer를 쓰면, 두 함수 모두 sharp + puppeteer를 포함한 거대한 패키지를 갖게 된다.

individually: true를 켜면 각 함수가 자기 의존성만 포함한 독립 패키지를 갖는다.

text
# 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

컴파일 타임에 상수를 주입한다. 환경 변수를 하드코딩하거나 조건부 코드를 제거하는 데 사용한다.

yaml
custom:
  esbuild:
    define:
      'process.env.IS_LAMBDA': '"true"'

esbuild는 빌드 시점에 process.env.IS_LAMBDA를 문자열 "true"로 치환한다. 이렇게 하면 번들링 단계에서 dead code가 확정되어 트리쉐이킹이 더 효과적으로 동작한다.

keepNames

minify와 함께 사용할 때 중요한 옵션이다. minify는 함수명과 클래스명을 짧게 바꾸는데, NestJS처럼 클래스 이름에 의존하는 프레임워크에서는 문제가 된다.

yaml
custom:
  esbuild:
    minify: true
    keepNames: true

keepNames: true를 설정하면 .name 속성이 원래 이름을 유지한다. NestJS의 DI 컨테이너가 클래스 이름으로 Provider를 식별하기 때문에, NestJS 프로젝트에서는 이 옵션이 필수다.

plugins 확장

esbuild의 플러그인 시스템을 사용하려면 별도의 설정 파일을 지정할 수 있다.

yaml
custom:
  esbuild:
    config: './esbuild.config.cjs'
javascript
// esbuild.config.cjs
const { copy } = require('esbuild-plugin-copy');

module.exports = () => ({
  plugins: [
    copy({
      assets: [
        { from: './templates/**/*', to: './templates' }
      ]
    })
  ]
});

이 방식은 HTML 템플릿, JSON 스키마 파일 등 코드가 아닌 에셋을 번들에 포함시켜야 할 때 유용하다.

exclude

serverless.ymlpackage.patterns와 함께 사용해서 패키징에서 특정 파일을 제외할 수 있다.

yaml
package:
  individually: true
  patterns:
    - '!node_modules/**'
    - '!tests/**'
    - '!src/**'

serverless-esbuild가 번들링한 결과물만 패키징 대상이 되므로, 원본 소스나 테스트 파일이 배포 패키지에 들어가는 것을 방지한다.


serverless-offline과 함께 사용

로컬 개발 환경에서 serverless-offline과 함께 사용하면 파일 변경 시 자동으로 다시 번들링된다.

yaml
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-esbuildserverless-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 플러그인으로 연결하는 것이다.

javascript
// esbuild.config.cjs - 데코레이터 지원
const esbuildDecorators = require('@anatine/esbuild-decorators');

module.exports = () => ({
  plugins: [
    esbuildDecorators({
      tsconfig: './tsconfig.json'
    })
  ]
});

트러블슈팅

"Cannot find module" 에러

번들링 결과물에서 모듈을 찾지 못하는 경우다. 대부분 동적 require 때문에 발생한다.

javascript
// esbuild가 추적할 수 없음
const module = require(`./${dynamicName}`);

esbuild는 정적 분석으로 의존성을 추적한다. 동적 경로를 사용하면 esbuild가 해당 모듈을 번들에 포함시키지 못한다. 해결 방법은 동적 import를 정적으로 바꾸거나, 해당 모듈을 external로 지정하고 Layer에 포함시키는 것이다.

번들 크기가 예상보다 큰 경우

serverless-analyze-bundle-plugin으로 번들 구성을 분석할 수 있다.

yaml
plugins:
  - serverless-esbuild
  - serverless-analyze-bundle-plugin

이 플러그인은 각 함수의 번들에 어떤 모듈이 얼마나 차지하는지 시각적으로 보여준다. 예상치 못한 거대한 의존성이 번들에 포함된 경우를 찾아내는 데 유용하다.

NestJS에서 Provider를 찾지 못하는 경우

minify가 클래스 이름을 변경하면서 NestJS DI가 깨지는 문제다. 앞서 설명한 keepNames: true 설정으로 해결할 수 있다. 이 문제는 빌드 시에는 에러가 없고, 런타임에서 Nest could not find XxxService 같은 에러로 나타나기 때문에 디버깅이 까다롭다.


관련 문서