본문 바로가기
C.W.K.
Stream
Lesson 05 of 05 · published

Bundler와 build — esbuild / TypeScript / dist/ pipeline

~13 min · bundler, esbuild, typescript, build

Level 0Extension 입덕
0 XP0/56 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"파일 하나짜리면 순수 JS 로도 충분해. npm 패키지랑 TypeScript를 쓰면서 파일이 여러 개가 되는 순간부터 bundler가 필요해지고. 이 lesson은 src/를 1 초 만에 dist/로 바꿔 주는 최소한의 esbuild 설정이야. sourcemap, 저장하면 다시 빌드, Web Store에 그대로 올릴 수 있는 결과물까지."

왜 bundler

파일 하나가 200 줄쯤까지라면 content.js / background.js / popup.js / panel.js를 그냥 흩어 놓는 순수 JS 로도 충분해. bundler가 밥값을 하기 시작하는 건 이럴 때야:

  • 타입을 쓰려고 TypeScript로 가고 싶을 때.
  • 내부에서 ES module을 import 하는 npm 패키지를 갖다 쓸 때 (파일 한 장으로 끝나지 않는 npm 패키지는 거의 다 여기 해당돼).
  • 여러 진입점이 같이 쓰는 helper가 생겼을 때 (popup도 panel도 content도 같은 clip 한 줄 렌더러가 필요하다든지).
  • source는 src/에, 배포본은 dist/에 두고, 빌드하면서 manifest 랑 HTML을 옮기거나 손보고 싶을 때.

Default 로서 esbuild

esbuild는 JS 든 TS 든 밀리초 단위로 컴파일해. 진입점 몇 개짜리 TypeScript extension 이라면 빌드 전체가 이 한 덩어리로 끝나:

esbuild \
  src/background.ts \
  src/content.ts \
  src/popup.ts \
  src/panel.ts \
  --bundle \
  --outdir=dist \
  --target=chrome120 \
  --sourcemap=inline \
  --format=iife

진입점 넷, 명령 하나, 그리고 다 묶인 결과 파일 넷 (background.js, content.js, popup.js, panel.js). 결과물은 IIFE 형태로 나오는데, 이게 Chrome이 content script와 예전 방식 background 페이지에서 원하는 모양이야. --target=chrome120은 어떤 JS 기능까지는 굳이 옛날 문법으로 낮추지 말라고 esbuild 한테 알려 주는 거고.

Layout

clipdeck/
  src/
    background.ts
    content.ts
    popup.ts
    panel.ts
    shared/
      clip-row.ts
      escape-html.ts
      types.ts
  static/
    manifest.json
    popup.html
    panel.html
    icons/
      16.png 32.png 48.png 128.png
    vendor/
      Readability.js
  scripts/
    build.mjs
  dist/         # gitignore, 생성됨
  package.json
  tsconfig.json

빌드 script는 static/을 dist/로 복사하고, src/를 묶어서 dist/에 떨궈. npm run build 한 번이면 Chrome이 바로 올릴 수 있는 dist/가 생기는 거지.

빌드 script

esbuild의 Node API를 쓰면 node scripts/build.mjs로 돌릴 파일 한 장이면 돼:

import { build } from 'esbuild';
import { copyFile, mkdir, cp } from 'fs/promises';

await mkdir('dist', { recursive: true });
await cp('static', 'dist', { recursive: true });
await build({
  entryPoints: ['src/background.ts', 'src/content.ts', 'src/popup.ts', 'src/panel.ts'],
  bundle: true,
  outdir: 'dist',
  target: 'chrome120',
  format: 'iife',
  sourcemap: 'inline',
  logLevel: 'info',
});

개발 중에 저장할 때마다 다시 빌드하고 싶으면 --watch와 esbuild의 context API를 붙여. 여기에 extension을 다시 올려 주는 도구 (extension-reloader 나, 개발용 SW에서 chrome.runtime.reload를 부르는 작은 코드)를 짝지으면 파일 저장이 곧 Chrome의 reload가 돼.

TypeScript 설정

extension에 쓸 tsconfig.json은 이래:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "types": ["chrome"],
    "noEmit": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

chrome.* API 타입을 받으려면 @types/chrome을 깔아. noEmit: true를 준 이유는 실제 컴파일은 esbuild가 하고 tsc는 타입만 봐 주면 되기 때문이야. tsc --noEmit을 돌리는 npm run typecheck script를 따로 두고 별도로 확인해.

package.json script

{
  "scripts": {
    "build": "node scripts/build.mjs",
    "watch": "node scripts/build.mjs --watch",
    "typecheck": "tsc --noEmit",
    "package": "npm run typecheck && npm run build && cd dist && zip -r ../clipdeck.zip ."
  },
  "devDependencies": {
    "esbuild": "^0.25.0",
    "typescript": "^5.5.0",
    "@types/chrome": "^0.0.300"
  }
}

npm run package 하나면 타입 검사, 빌드, zip이 한 번에 끝나. 그 zip이 Web Store에 올리는 물건이고. npm run watch는 개발할 때 도는 고리야. chrome://extensions를 자동으로 다시 올려 주는 것 (전용 extension 이든, chrome.runtime.reload를 듣는 작은 SW listener 든)과 짝지어 쓰면 돼.

넣어 둔 파일에서 패키지로 옮기기

Track 7 에서는 Readability.js를 파일째로 넣어 뒀지. bundler가 생겼으니 npm install @mozilla/readability 하고 평범하게 import 하면 돼:

// src/content.ts
import { Readability } from '@mozilla/readability';
// ...이전과 정확히 같이 사용

묶인 content.js 안에 Readability 코드가 그대로 들어가. 더 깔끔하고, 버전이 관리되고, 가능한 데선 안 쓰는 코드도 털려 나가. 그러면 static/vendor/ 폴더는 쓸모가 없어지니까 빌드 결과에서 빼 버려.

파일 하나면 순수 JS로 가. 그보다 자라면 esbuild + TS + npm 생태계로 넘어가고. dist/ 결과물이 곧 Chrome이 올리는 것이자 Web Store에 zip 해서 보내는 것이야. manifest 랑 HTML 이랑 icon은 그대로 복사되고, src/만 묶여.
Vite 라는 선택지도 있어. Vite 에는 manifest를 손봐 주고 개발 서버에서 바로바로 갈아 끼워 주는 extension 플러그인 (vite-plugin-web-extension)이 있어. ClipDeck 정도 규모엔 esbuild로 충분하지만, 개발하면서 popup 이나 panel UI가 저장 즉시 바뀌길 원한다면 Vite가 빛나. 둘 다 멀쩡한 dist/를 만들어 주니까, 나머지 도구들이랑 잘 맞는 쪽으로 고르면 돼.

아래 build script는 static asset을 copy하기 전에 dist/를 지워서, 삭제한 옛 파일이 다음 package에 새어 들어가지 않게 해. --watch branch는 esbuild의 context().watch(), 일반 branch는 build()를 써. Script와 설명을 같은 계약으로 유지해.

Code

scripts/build.mjs — optional watch mode 가진 Node API 통한 esbuild·javascript
// scripts/build.mjs — esbuild 의 Node API 사용 single-file build script
import { build, context } from "esbuild";
import { cp, mkdir, rm } from "fs/promises";

const watch = process.argv.includes("--watch");

await rm("dist", { recursive: true, force: true });
await mkdir("dist", { recursive: true });
await cp("static", "dist", { recursive: true });

const options = {
  entryPoints: [
    "src/background.ts",
    "src/content.ts",
    "src/popup.ts",
    "src/panel.ts",
  ],
  bundle: true,
  outdir: "dist",
  target: "chrome120",
  format: "iife",
  sourcemap: "inline",
  logLevel: "info",
};

if (watch) {
  const ctx = await context(options);
  await ctx.watch();
  console.log("[ClipDeck build] watching src/ — Ctrl+C to stop");
} else {
  await build(options);
  console.log("[ClipDeck build] dist/ ready");
}
package.json — ClipDeck build 위한 script + dev dependency·json
{
  "name": "clipdeck",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "build": "node scripts/build.mjs",
    "watch": "node scripts/build.mjs --watch",
    "typecheck": "tsc --noEmit",
    "package": "npm run typecheck && npm run build && cd dist && zip -r ../clipdeck.zip ."
  },
  "devDependencies": {
    "esbuild": "^0.25.0",
    "typescript": "^5.5.0",
    "@types/chrome": "^0.0.300",
    "@mozilla/readability": "^0.6.0"
  }
}
tsconfig.json — chrome.* type 가진 strict TypeScript·json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "types": ["chrome"],
    "noEmit": true,
    "skipLibCheck": true,
    "lib": ["ES2022", "DOM", "DOM.Iterable"]
  },
  "include": ["src"]
}

External links

Exercise

ClipDeck을 순수 JS에서 묶어서 쓰는 TS 프로젝트로 옮겨 봐. .js source를 전부 .ts로 바꿔서 src/에 넣고, manifest.json 이랑 popup.html, panel.html, icons/는 static/으로 옮겨. scripts/build.mjs (첫 번째 code block), package.json (두 번째), tsconfig.json (세 번째)을 추가하고 npm install 다음 npm run build 를 돌려. dist/ 안에 manifest.json 이랑 묶인 .js 들, html, icon이 다 들어 있어야 해. 그다음 chrome://extensions의 'Load unpacked' 대상을 clipdeck/에서 clipdeck/dist/로 바꿔. Reload 하면 전부 예전이랑 똑같이 돌아야 하고. 마지막으로 TypeScript를 조금 건드려 봐 — 타입을 붙이거나, chrome.storage.local.set에 일부러 엉뚱한 타입을 넘겨 놓고 npm run typecheck 를 돌려서 tsc가 잡아내는지 확인해. 다 됐으면 npm run package 로 올릴 준비가 된 zip을 만들어.
Hint
npm run build 가 'Could not resolve @mozilla/readability' 로 터지면 npm install이 그 패키지를 안 가져온 거야. devDependencies에 들어 있는지 보고 다시 install 해. 묶인 content.js가 'Cannot find name chrome' 으로 안 올라가면 tsc가 @types/chrome을 못 집은 거고. tsconfig.json에 "types": ["chrome"] 이 있는지 확인해. 옮기고 나서 popup이 안 뜨면, 진입점 이름을 바꿨을 때 popup.html의 <script src="popup.js"> 도 같이 고쳤는지 봐. esbuild는 entryPoints 이름을 그대로 따라가서 src/popup.ts가 dist/popup.js로 나오거든.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.