esbuild로 빠르게 번들링하기: Build API, TypeScript·JSX, Watch·Dev Server, 플러그인
이 글의 핵심
esbuild가 Go로 작성된 파서·번들 파이프라인에서 어떤 단계를 거치는지, 타입 검사가 없다는 전제를 어떻게 보완하는지, 플러그인과 Watch가 내부적으로 무엇을 다시 계산하는지까지 실무 관점에서 정리합니다.
이 글의 핵심
esbuild로 초고속 번들링을 구현하는 글입니다. Go 기반 성능, TypeScript, JSX, Plugins, Watch Mode까지 실전 예제로 정리했습니다.
실무에서 마주치는 문제들
TypeScript 빌드가 느려요
tsc는 타입 검사와 변환을 함께 하느라 느립니다. esbuild는 변환만 하므로 수십 배 이상 빠른 경우가 흔합니다.
번들링이 느려요
Webpack은 JavaScript로 작성된 로더 체인을 거치느라 큰 프로젝트에서 느려집니다. esbuild는 공식 벤치마크 기준 10~100배 빠릅니다.
빠른 피드백이 필요해요
느린 빌드는 생산성을 떨어뜨립니다. esbuild는 즉시 피드백을 제공합니다.
esbuild가 빠른 이유
핵심 특징
esbuild는 Go로 작성된 초고속 JavaScript 번들러입니다. 주요 장점:
- 초고속: 10~100배 빠름
- TypeScript: 네이티브 지원
- JSX: 네이티브 지원
- Tree Shaking: 자동
- Source Maps: 지원
esbuild가 빠른 이유는 “Go로 작성되었다”는 한 줄보다 구체적입니다. 첫째, 네이티브 코드로 컴파일되어 JavaScript로 작성된 도구처럼 실행할 때마다 JIT 워밍업을 기다리지 않습니다. 둘째, 파싱·링킹·코드 생성을 여러 CPU 코어에서 병렬로 처리합니다. 셋째, 대부분의 번들러가 파일마다 AST를 여러 번 만들고 도구 사이에 문자열로 넘기는 것과 달리, esbuild는 전체 과정에서 AST를 몇 번만 순회하도록 설계되었습니다. 그리고 가장 큰 이유는 하지 않는 일이 많다는 것입니다. 타입 검사를 하지 않고, Babel처럼 임의의 AST 변환 플러그인을 허용하지 않으며, 지원 기능 범위를 의도적으로 좁게 유지합니다.
이 선택에는 트레이드오프가 따릅니다. Webpack 로더처럼 코드를 마음대로 바꾸는 확장이 어렵고, 오래된 브라우저를 위한 ES5 대상 변환은 일부만 지원해서, 클래스나 제너레이터 같은 문법을 ES5로 낮춰야 한다면 Babel이나 SWC를 함께 써야 합니다. 그래서 esbuild는 단독 번들러로 쓰이기도 하지만, Vite가 개발 서버의 의존성 사전 번들링과 TypeScript 변환에 쓰듯이 다른 도구 안에서 “빠른 변환 엔진”으로 들어가는 경우도 많습니다.
설치와 CLI 사용
설치
npm install -D esbuild
CLI
# 번들링
esbuild src/index.ts --bundle --outfile=dist/bundle.js
# Minify
esbuild src/index.ts --bundle --minify --outfile=dist/bundle.js
# Watch
esbuild src/index.ts --bundle --watch --outfile=dist/bundle.js
--bundle을 빼면 esbuild는 파일 하나만 변환하고 import 문은 그대로 둡니다. 처음 써 볼 때 결과 파일에 import React from "react"가 그대로 남아 브라우저에서 Failed to resolve module specifier "react"가 나는 경우가 대부분 이 옵션을 빠뜨린 것입니다. 또 기본 출력 형식은 번들 시 브라우저용 IIFE이므로, Node.js용으로 만들 때는 --platform=node를 주어야 fs 같은 내장 모듈을 번들하려다 실패하지 않습니다. --minify는 공백 제거, 식별자 줄이기, 구문 단순화를 한 번에 켜는 옵션이며, 개별적으로 --minify-whitespace 등을 켤 수도 있습니다.
build API와 package.json 스크립트
// build.js
const esbuild = require('esbuild');
esbuild.build({
entryPoints: ['src/index.ts'],
bundle: true,
minify: true,
sourcemap: true,
target: ['es2020'],
outfile: 'dist/bundle.js',
}).catch(() => process.exit(1));
build()는 Promise를 반환하고, 빌드 에러가 나면 reject됩니다. esbuild는 에러 메시지를 이미 터미널에 보기 좋게 출력해 주므로 catch에서는 따로 출력하지 않고 종료 코드만 1로 만들어 CI가 실패를 알 수 있게 합니다. 이 catch를 빼먹으면 Node.js가 처리되지 않은 reject로 종료하면서 같은 에러가 한 번 더 출력됩니다. target: ['es2020']은 출력 코드의 문법 수준을 정하는 옵션으로, 이보다 새로운 문법(예: ES2022 클래스 필드)은 가능한 범위에서 낮춰 변환하고, 낮출 수 없는 문법이 있으면 Transforming ... to the configured target environment is not supported yet 에러를 냅니다. ['chrome100', 'safari15']처럼 브라우저 버전으로 지정할 수도 있습니다.
package.json
{
"scripts": {
"build": "node build.js"
}
}
TypeScript 트랜스파일
esbuild.build({
entryPoints: ['src/index.ts'],
bundle: true,
outfile: 'dist/bundle.js',
loader: {
'.ts': 'ts',
'.tsx': 'tsx',
},
});
사실 .ts와 .tsx는 esbuild가 확장자만 보고 기본으로 올바른 로더를 고르므로 위의 loader 설정은 없어도 됩니다. loader 옵션은 .js 파일 안에 JSX를 쓰는 오래된 프로젝트('.js': 'jsx')나 .svg를 문자열로 가져오는 경우('.svg': 'text')처럼 기본값을 바꿀 때 의미가 있습니다.
TypeScript 처리에서 중요한 것은 esbuild가 파일 하나씩 독립적으로 변환한다는 점입니다. 다른 파일의 타입 정보를 보지 않으므로 export { SomeType }처럼 타입만 다시 내보내는 코드는 그것이 타입인지 값인지 알 수 없습니다. tsconfig.json에 "isolatedModules": true(TypeScript 5 이상에서는 "verbatimModuleSyntax": true)를 켜 두면 tsc가 이런 코드를 미리 에러로 알려 주므로, esbuild와 함께 쓸 때는 사실상 필수 설정입니다. const enum이 파일 경계를 넘어 인라인되지 않는 것도 같은 이유에서 생기는 차이입니다. esbuild는 tsconfig.json의 paths, jsx, experimentalDecorators 같은 일부 설정만 읽고 나머지(strict 등 타입 검사 옵션)는 무시합니다.
React/JSX 설정
esbuild.build({
entryPoints: ['src/index.tsx'],
bundle: true,
outfile: 'dist/bundle.js',
loader: {
'.tsx': 'tsx',
},
jsxFactory: 'React.createElement',
jsxFragment: 'React.Fragment',
});
jsxFactory/jsxFragment는 JSX를 React.createElement(...) 호출로 바꾸는 “classic” 방식 설정이고, 사실 esbuild의 기본값이기도 합니다. 이 방식에서는 JSX를 쓰는 모든 파일에 import React from 'react'가 있어야 하며, 빠뜨리면 빌드는 성공하지만 브라우저에서 ReferenceError: React is not defined가 납니다. React 17부터 도입된 새 JSX 변환을 쓰면 이 import가 필요 없으므로, 요즘 프로젝트라면 위 두 옵션 대신 jsx: 'automatic'을 지정하는 것이 맞습니다. 그러면 esbuild가 react/jsx-runtime에서 필요한 함수를 자동으로 import합니다. Preact나 Solid처럼 다른 JSX 런타임을 쓴다면 jsxImportSource: 'preact'로 바꿀 수 있습니다.
watch 모드
const ctx = await esbuild.context({
entryPoints: ['src/index.ts'],
bundle: true,
outfile: 'dist/bundle.js',
});
await ctx.watch();
console.log('Watching...');
esbuild 0.17부터는 build({ watch: true }) 옵션이 제거되고, context()로 빌드 컨텍스트를 만든 뒤 watch()나 serve()를 호출하는 방식으로 바뀌었습니다. 예전 블로그 글의 코드를 그대로 쓰면 Invalid option in build() call: "watch" 에러가 나는 이유입니다. 컨텍스트는 이전 빌드 결과(파싱된 파일)를 메모리에 유지하다가 바뀐 파일만 다시 파싱하는 증분 빌드를 하므로, 두 번째 빌드부터는 첫 빌드보다 훨씬 빠릅니다. 이 코드는 최상위 await를 쓰므로 ES 모듈(build.mjs 또는 "type": "module")로 실행하거나 async 함수로 감싸야 합니다. 앞의 require 예제와 같은 CommonJS 파일에 그대로 넣으면 await is only valid in async functions 에러가 납니다. 작업이 끝나면 ctx.dispose()로 파일 감시와 메모리를 정리합니다.
내장 개발 서버
const ctx = await esbuild.context({
entryPoints: ['src/index.ts'],
bundle: true,
outfile: 'public/bundle.js', // servedir 안에 있어야 /bundle.js로 서빙됨
});
await ctx.serve({
servedir: 'public',
port: 3000,
});
console.log('Server running on http://localhost:3000');
serve()는 요청이 올 때마다 필요하면 다시 빌드하고, 빌드 결과를 디스크에 쓰지 않고 메모리에서 바로 응답합니다. servedir를 지정하면 그 폴더의 정적 파일(index.html 등)도 함께 제공하는데, 이때 빌드 결과는 출력 경로가 servedir 안에 있을 때만 해당 URL로 매핑됩니다. outfile을 dist/bundle.js처럼 public 바깥에 두면 index.html의 <script src="/bundle.js">가 404를 받는데, 처음 dev server를 설정할 때 가장 흔히 겪는 문제라 위 코드는 public/bundle.js로 맞춰 두었습니다.
esbuild의 dev server는 기본적으로 자동 새로고침을 하지 않습니다. 0.17부터 제공되는 /esbuild 이벤트 스트림을 구독하는 코드를 페이지에 한 줄 넣으면 빌드가 바뀔 때 새로고침하게 할 수 있습니다. new EventSource('/esbuild').addEventListener('change', () => location.reload())이며, 이 기능이 동작하려면 serve() 전에 ctx.watch()도 함께 호출해야 합니다. React Fast Refresh 같은 상태를 유지하는 핫 리로드는 지원하지 않으므로, 그런 개발 경험이 필요하다면 Vite가 더 적합합니다.
플러그인 작성
const envPlugin = {
name: 'env',
setup(build) {
build.onResolve({ filter: /^env$/ }, (args) => ({
path: args.path,
namespace: 'env-ns',
}));
build.onLoad({ filter: /.*/, namespace: 'env-ns' }, () => ({
// 전체 process.env를 넣으면 비밀 값까지 번들에 포함되므로 공개용 접두사만 허용
contents: JSON.stringify(Object.fromEntries(
Object.entries(process.env).filter(([k]) => k.startsWith('PUBLIC_'))
)),
loader: 'json',
}));
},
};
esbuild.build({
entryPoints: ['src/index.ts'],
bundle: true,
plugins: [envPlugin],
outfile: 'dist/bundle.js',
});
esbuild 플러그인은 두 가지 훅으로 동작합니다. onResolve는 import 'env'의 경로 문자열을 어디서 찾을지 정하고, onLoad는 그 경로의 내용을 무엇으로 채울지 정합니다. filter는 성능 때문에 필수인 정규식입니다. esbuild는 Go 쪽에서 이 정규식을 먼저 검사해 해당하는 경로에 대해서만 JavaScript 플러그인 함수를 호출하므로, /.*/처럼 모든 경로에 걸리는 필터를 쓰면 파일마다 Go와 JavaScript 사이를 오가는 비용이 생겨 esbuild의 속도 이점이 크게 줄어듭니다. 이 예제처럼 namespace를 따로 두면 onLoad의 /.*/는 그 네임스페이스 안에서만 적용되어 문제가 없습니다. 참고로 Go의 정규식 엔진은 JavaScript와 달라서 lookahead((?=...)) 같은 문법을 쓸 수 없습니다.
이 플러그인의 원래 예제는 JSON.stringify(process.env)로 환경 변수 전체를 번들에 넣었는데, 이렇게 하면 빌드 서버의 AWS_SECRET_ACCESS_KEY나 DB 비밀번호까지 브라우저로 내려가는 번들에 그대로 들어갑니다. 공개 저장소의 CI에서 빌드한 결과물을 누구나 받아 볼 수 있다면 치명적인 유출입니다. Vite의 VITE_, Next.js의 NEXT_PUBLIC_처럼 접두사로 공개 변수만 허용하는 것이 일반적인 방어 방법이라 위 코드도 그렇게 고쳤습니다.
React 프로젝트 빌드 스크립트
// build.js
const esbuild = require('esbuild');
const { copy } = require('esbuild-plugin-copy');
const isProduction = process.env.NODE_ENV === 'production';
esbuild.build({
entryPoints: ['src/index.tsx'],
bundle: true,
minify: isProduction,
sourcemap: !isProduction,
target: ['es2020'],
outfile: 'dist/bundle.js',
loader: {
'.tsx': 'tsx',
'.ts': 'ts',
},
define: {
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
},
plugins: [
copy({
resolveFrom: 'cwd',
assets: {
from: ['./public/**/*'],
to: ['./dist'],
},
}),
],
}).catch(() => process.exit(1));
define은 코드 안의 process.env.NODE_ENV라는 표현식 자체를 문자열 상수로 치환합니다. 번들 결과에서 if ("production" !== "production") 같은 조건이 되면 minify 단계에서 그 블록이 통째로 제거되고, React의 개발용 경고 코드가 빠지면서 번들 크기가 크게 줄어듭니다. 값에 JSON.stringify를 씌우는 이유는 define의 값이 코드 조각으로 삽입되기 때문입니다. 'production'을 그대로 넣으면 production이라는 변수 이름이 되어 버립니다. 원래 코드처럼 NODE_ENV가 설정되지 않은 상태에서 JSON.stringify(undefined)를 넘기면 값이 undefined가 되어 esbuild가 define 값 에러를 내므로 기본값을 두었습니다. 브라우저 번들에서 define을 빠뜨리면 빌드는 성공하지만 실행 시 ReferenceError: process is not defined가 나는데, React 같은 라이브러리가 내부적으로 process.env.NODE_ENV를 참조하기 때문입니다.
esbuild-plugin-copy는 esbuild 공식 기능이 아닌 서드파티 플러그인입니다. esbuild는 번들 그래프에 속하지 않는 파일을 복사하는 기능이 없어서 public 폴더의 index.html, 파비콘 등은 이런 플러그인이나 별도 cp 명령으로 옮겨야 합니다. HTML 파일을 엔트리로 받아 스크립트 태그를 자동으로 연결해 주는 기능도 없으므로, 해시가 붙은 파일 이름을 HTML에 넣으려면 metafile: true로 출력 정보를 받아 직접 HTML을 생성해야 합니다. 이 부분이 Vite나 Parcel보다 설정할 것이 많은 지점입니다.
내부 구조와 한계: Go 병렬성·번들 그래프
esbuild는 Go로 작성되어 병렬 파싱·번들링에 강점이 있습니다. 입력 파일을 그래프로 펼친 뒤, 의존성이 없는 부분부터 동시에 처리하는 이미지로 이해하면 디버깅이 쉬워집니다. 순환 참조 자체는 ES 모듈 의미론대로 처리되므로 빌드가 막히지는 않지만, 초기화 순서 때문에 한쪽 모듈에서 아직 값이 할당되지 않은 export를 읽는 문제는 다른 번들러와 똑같이 생깁니다. 동적 import()는 경로가 문자열 리터럴이면 별도 청크로 나누고(splitting: true, format: 'esm' 필요), import(`./locales/${lang}.js`)처럼 변수가 섞이면 해석하지 않고 그대로 남겨 두므로 런타임에 파일을 찾지 못할 수 있습니다. Webpack처럼 패턴에 맞는 파일을 모두 번들에 넣어 주는 동작은 기본으로 하지 않는다는 점이 “의도적으로 단순화한 부분”입니다.
타입 검사는 하지 않습니다. TypeScript는 트랜스파일(구문 제거) 수준이므로 CI에 tsc --noEmit 또는 typescript-eslint를 별도 게이트로 두는 것이 일반적인 프로덕션 패턴입니다. TypeScript의 레거시 데코레이터는 tsconfig.json의 experimentalDecorators를 읽어 변환하고, 표준(TC39) 데코레이터는 0.21부터 지원합니다. 다만 NestJS·TypeORM이 의존하는 emitDecoratorMetadata는 타입 정보가 필요해서 esbuild가 지원하지 않으므로, 이런 프레임워크를 쓰는 프로젝트는 SWC나 tsc로 변환해야 합니다. 이처럼 esbuild가 처리하지 못하는 부분과 Babel/SWC가 맡을 부분의 역할 분담을 문서화해 두면 팀 온보딩 비용이 줄어듭니다.
프로덕션 체크리스트(번들러 관점)
define/inject:process.env치환 누락으로 런타임 undefined가 나지 않았는지 확인합니다.external: Node 번들에서pg,aws-sdk같은 네이티브/대형 모듈을 외부화했는지 확인합니다.- 소스맵: 프로덕션은 보통 끄거나 hidden-source-map에 해당하는 전략을 취하며, Sentry 등에만 업로드합니다.
모듈 해석 실패·중복 React 같은 번들 문제
| 증상 | 흔한 원인 | 대응 |
|---|---|---|
Could not resolve "x" | browser 필드·조건부 export | alias·mainFields 조정 |
| 번들에 dev 의존성 포함 | NODE_ENV 미설정 | define + --packages=external 검토 |
| 두 개의 React | 중복 해상 | alias로 한 경로에 고정 또는 패키지 구조 정리 |
| 플러그인이 무시됨 | onResolve 필터 불일치 | 디버그 로그로 경로 확인 |
“두 개의 React” 문제는 증상이 특히 헷갈립니다. 모노레포나 npm link로 로컬 패키지를 연결했을 때 앱과 라이브러리가 각자의 node_modules/react를 번들에 넣으면, 훅을 호출하는 순간 Invalid hook call. Hooks can only be called inside of the body of a function component가 납니다. 코드에 아무 문제가 없어 보여서 원인을 찾기 어려운데, metafile: true로 빌드한 결과를 esbuild의 번들 분석 페이지에 올려 보면 react가 두 번 들어 있는지 바로 확인할 수 있습니다. alias: { react: path.resolve('node_modules/react') }처럼 한 곳으로 고정하면 해결됩니다.
esbuild 사용 요약
핵심 요약
- esbuild: 초고속 번들러
- Go 기반: 10~100배 빠름
- TypeScript: 네이티브 지원
- JSX: 네이티브 지원
- Tree Shaking: 자동
- Plugins: 확장 가능
언제 esbuild를 직접 쓰는가
라이브러리 번들, Node.js 서버나 CLI 도구 번들, AWS Lambda 함수 패키징(AWS CDK의 NodejsFunction이 내부적으로 esbuild를 씁니다)처럼 HTML과 개발 서버가 필요 없는 작업에는 esbuild를 직접 쓰는 것이 가장 단순하고 빠릅니다. 반대로 HMR, CSS 모듈 전처리, HTML 템플릿 처리가 필요한 웹 애플리케이션이라면 esbuild를 내부에 쓰는 Vite 같은 상위 도구가 설정 부담을 크게 줄여 줍니다.
같이 보면 좋은 글
자주 묻는 질문 (FAQ)
Q. Webpack을 완전히 대체할 수 있나요?
A. 간단한 프로젝트는 가능하지만, 복잡한 설정이 필요하면 Webpack이 더 적합합니다.
Q. Vite와 비교하면 어떤가요?
A. Vite는 개발 서버에서 의존성 사전 번들링과 TypeScript·JSX 변환에 esbuild를 사용하고, 프로덕션 빌드는 Rollup(최신 버전에서는 Rust 기반 Rolldown으로 전환 중)으로 합니다. HMR과 HTML 처리까지 포함한 개발 경험은 Vite가 편하고, 설정 없이 가장 빠른 단일 번들이 필요하면 esbuild를 직접 쓰는 편이 낫습니다.
Q. 타입 체크를 하나요?
A. 아니요, 타입을 제거만 합니다. tsc로 별도로 타입 체크를 해야 합니다.
Q. esbuild는 아직 1.0 이전인데 버전은 어떻게 관리하나요?
A. 아직 1.0 이전 버전이라 마이너 버전에서도 호환성이 깨지는 변경이 있을 수 있으므로 package.json에서 버전을 고정해 두는 것이 좋습니다.