Devin.KR
로그인

자바스크립트 빌드 도구 - 번들러와 트랜스파일러가 하는 일, 소스맵의 원리

개발자 조회 1

이 단원에서 배우는 것

21단원에서 "당장 필요 없는 코드는 나중에 불러온다"는 원칙을 말했지만, 실제로 나누는 방법은 미뤘다. 그 도구가 빌드 파이프라인이다. 지금까지 스터디 관리 대시보드를 <script type="module">으로 브라우저에 직접 물려 왔다. 파일이 여섯 개일 때는 문제가 없었다. 라이브러리를 두어 개 붙이고 파일이 200개가 되면 이 방식은 무너진다. 이번 단원은 그 지점에서 무슨 일이 벌어지고 번들러와 트랜스파일러가 각각 무엇을 해결하는지 본다.

  • 번들링, 트랜스파일, 미니파이, 트리 셰이킹, 코드 스플리팅이 각각 어떤 문제를 푸는지 구분한다.
  • 소스맵이 압축된 코드의 스택 트레이스를 원본 위치로 되돌리는 방식을 이해하고 운영 환경에 안전하게 배포한다.
  • 빌드 대상(target)과 환경변수 설정이 번들 크기와 보안에 어떤 영향을 주는지 판단한다.

기준은 Vite 5/6, esbuild, Babel 7이다. 도구는 몇 년마다 바뀌지만 여기서 다루는 개념은 그대로 남는다. 도구 이름을 외우는 대신 어떤 문제를 푸는 단계인지를 기억하는 것이 목표다.

왜 필요한가

대시보드를 모듈로 잘게 나누면 코드는 깔끔해진다. 그런데 배포하면 네트워크 탭이 이렇게 된다.

GET /js/main.js          2 KB    12 ms
GET /js/roster.js        3 KB    11 ms
GET /js/api.js           2 KB    12 ms
... (198개 더)

파일 하나하나는 작지만 요청마다 왕복 지연이 붙는다. 게다가 모듈 그래프는 계단식이다. main.js를 받아 파싱해야 roster.js가 필요하다는 것을 알고, 그것을 받아야 다음 단계를 안다. HTTP/2로 요청 비용이 줄어도 이 의존성 폭포는 남는다. 모바일 회선에서 첫 화면까지 몇 초가 그냥 사라진다.

두 번째 문제는 코드 자체다.

const rate = members.at(-1)?.sessions ?? 0;
const grouped = Object.groupBy(members, (m) => m.paid ? "paid" : "unpaid");

이 코드는 최신 브라우저에서 잘 돈다. 사내 규정상 지원해야 하는 구형 브라우저에서는 문법 오류로 페이지 전체가 죽는다. 어느 문법이 어느 브라우저부터 되는지 매번 확인하며 코딩할 수는 없다.

세 번째는 node_modules다. 날짜 라이브러리에서 함수 하나만 가져다 써도 브라우저는 그 패키지 전체 파일을 받는다. 이 세 문제를 각각 번들링, 트랜스파일, 트리 셰이킹이 담당한다.

문법과 예제

단계별로 무엇을 하는가

단계하는 일대표 도구
트랜스파일최신 문법을 옛 문법으로 바꿔 쓴다Babel, SWC, esbuild, tsc
폴리필없는 함수를 구현해 채워 넣는다core-js
번들링모듈 그래프를 따라가 파일 몇 개로 합친다Rollup, esbuild, webpack
트리 셰이킹쓰이지 않는 export 를 제거한다Rollup, esbuild
미니파이공백·변수명·죽은 코드를 줄인다esbuild, Terser
코드 스플리팅번들을 여러 조각으로 나눠 필요할 때 받는다번들러 공통

트랜스파일과 폴리필의 구분이 자주 흐려진다. 문법은 트랜스파일, 내장 함수는 폴리필이다. 화살표 함수나 옵셔널 체이닝은 코드를 다시 써서 해결되지만, Object.groupByArray.prototype.at은 없는 함수를 런타임에 만들어 넣어야 한다. 트랜스파일러만 돌리고 폴리필을 빼면 문법 오류는 사라지고 TypeError: ... is not a function이 남는다.

번들링의 결과 보기

Vite 프로젝트 기준으로 최소 설정은 이 정도다.

// vite.config.js
import { defineConfig } from "vite";

export default defineConfig({
  build: {
    target: "es2022",          // 이 문법 수준까지는 그대로 둔다
    sourcemap: "hidden",       // 파일은 만들되 참조 주석은 넣지 않는다
    rollupOptions: {
      output: {
        manualChunks: {
          vendor: ["chart.js"],   // 자주 안 바뀌는 것은 따로 묶어 캐시를 살린다
        },
      },
    },
  },
});

manualChunks로 라이브러리를 분리하는 이유는 캐시다. 앱 코드는 매주 바뀌지만 차트 라이브러리는 몇 달에 한 번 바뀐다. 한 파일에 합쳐 두면 우리 코드 한 줄을 고칠 때마다 사용자가 라이브러리까지 다시 받는다. 파일명에 내용 해시가 붙으므로, 분리해 두면 바뀐 조각만 새로 받는다.

트리 셰이킹이 작동하는 조건

// utils.js
export function attendanceRate(m, total) { return Math.round(m.sessions / total * 100); }
export function exportCsv(members) { /* 200줄 */ }

// main.js
import { attendanceRate } from "./utils.js";   // exportCsv 는 번들에서 빠진다

이것이 가능한 이유는 ES 모듈의 import/export정적이기 때문이다. 코드를 실행하지 않아도 무엇을 가져다 쓰는지 알 수 있다. 반면 CommonJS는 그렇지 않다.

// 실행해 봐야 알 수 있다 → 통째로 포함된다
const utils = require("./utils.js");
const fn = utils[process.env.MODE === "dev" ? "exportCsv" : "attendanceRate"];

그래서 라이브러리를 고를 때 package.json"type": "module"이나 exports 필드가 있는지, ESM 빌드를 제공하는지를 본다. 또 하나 흔한 함정은 부수 효과다.

// theme.js — import 만 해도 실행되는 코드가 있다
document.documentElement.dataset.theme = "dark";
export const colors = { primary: "#3b82f6" };

번들러는 colors를 안 쓰더라도 이 파일을 지울 수 없다. 지우면 테마 설정이 사라지기 때문이다. 패키지 저자가 package.json"sideEffects": false를 선언하면 번들러가 "이 패키지의 모듈은 지워도 안전하다"고 판단한다. 라이브러리를 만들 때는 이 필드를 챙기고, CSS 파일처럼 실제로 부수 효과가 있는 파일은 "sideEffects": ["*.css"]처럼 예외를 지정한다.

코드 스플리팅

대시보드에서 CSV 내보내기 기능은 전체 사용자의 2%만 쓴다. 그런데 라이브러리가 120KB다.

// 정적 import — 첫 화면 번들에 120KB 가 포함된다
import { exportCsv } from "./export-csv.js";

exportButton.addEventListener("click", () => exportCsv(members));
// 동적 import — 버튼을 눌렀을 때 비로소 내려받는다
exportButton.addEventListener("click", async () => {
  exportButton.disabled = true;
  try {
    const { exportCsv } = await import("./export-csv.js");
    exportCsv(members);
  } finally {
    exportButton.disabled = false;
  }
});

import()는 프로미스를 반환하는 함수 형태의 문법이고, 번들러는 이 지점을 보고 별도 파일(청크)로 잘라 낸다. 주의할 점은 지연 로딩에도 비용이 있다는 것이다. 버튼을 누른 뒤 다운로드가 시작되므로 첫 클릭이 느리다. 이 지연이 신경 쓰이면 마우스를 올렸을 때 미리 시작하는 방법이 있다.

exportButton.addEventListener("mouseenter", () => {
  import("./export-csv.js");     // 미리 받아 둔다. 결과는 캐시된다
}, { once: true });

같은 모듈에 대한 import()는 두 번째부터 이미 평가된 모듈을 돌려주므로 중복 다운로드는 없다. 다만 스플리팅을 너무 잘게 하면 요청 수가 늘어 앞서 본 폭포 문제가 되돌아온다. 기준은 라우트 단위와 무거운 선택적 기능 정도다.

소스맵

운영 환경 에러 로그가 이렇게 찍히면 손을 쓸 수 없다.

TypeError: Cannot read properties of undefined (reading 'sessions')
    at o (/assets/index-4f2a91c8.js:1:28734)

소스맵은 "빌드 결과물의 1행 28734열은 원본 src/roster.js의 42행 17열"이라는 대응표다. 빌드 결과물 끝의 주석이 그 파일을 가리킨다.

//# sourceMappingURL=index-4f2a91c8.js.map

맵 파일 안은 JSON이다. 핵심은 mappings 필드로, 위치 정보를 Base64 VLQ로 압축해 담는다. 사람이 읽을 필요는 없지만 구조는 알아 두면 좋다.

{
  "version": 3,
  "file": "index-4f2a91c8.js",
  "sources": ["../src/roster.js", "../src/api.js"],
  "sourcesContent": ["export function renderRoster(...) {...", "..."],
  "names": ["renderRoster", "members"],
  "mappings": "AAAA,SAASA,EAAaC,GAAU..."
}

배포 방식은 세 가지 중에 고른다.

설정동작쓰는 곳
sourcemap: true맵 파일 생성 + 참조 주석 삽입개발, 사내 도구
sourcemap: "hidden"맵 파일 생성, 참조 주석 없음운영(에러 추적 도구에 별도 업로드)
sourcemap: false생성 안 함추적 도구가 없고 노출이 걱정될 때

운영 환경의 표준은 "hidden"이다. 맵 파일을 만들어 Sentry 같은 에러 추적 서비스에 업로드하고, 공개 서버에는 올리지 않는다. 그러면 사용자에게 원본이 노출되지 않으면서도 대시보드에서는 원본 줄 번호가 보인다. 브라우저 개발자 도구에서도 맵 파일을 수동으로 지정해 원본을 볼 수 있다.

target 이 번들 크기를 정한다

// 원본
const paidCount = members.filter((m) => m.paid).length;
const first = members.at(0)?.name ?? "없음";
// target: "es2022" — 거의 그대로다
const t=n.filter(e=>e.paid).length,a=n.at(0)?.name??"없음";
// target: "es5" — 화살표 함수, 옵셔널 체이닝, 널 병합이 전부 풀린다
var t=n.filter(function(e){return e.paid}).length,r=n.at(0),
a=null!==(o=null==r?void 0:r.name)&&void 0!==o?o:"없음";

지원 범위를 넓히면 코드가 길어지고 폴리필까지 붙는다. 실무에서는 browserslist로 기준을 한 번 정하고 도구들이 그것을 공유하게 한다.

// package.json
{
  "browserslist": [">0.5% in KR", "not dead", "not op_mini all"]
}

여기서 판단 근거는 취향이 아니라 데이터다. 우리 서비스의 실제 접속 통계에서 구형 브라우저 비중을 확인하고, 0.1%를 위해 전체 사용자의 번들을 20% 키울 값어치가 있는지 따진다. 대개는 없다.

개발 서버는 왜 빠른가

Vite의 개발 서버는 번들링을 하지 않는다. 브라우저가 import를 만나면 그때 해당 파일 하나만 요청하고, 서버는 그 파일만 변환해 돌려준다. 파일 수가 많아도 첫 기동이 빠르고, 파일 하나를 고치면 그 모듈만 교체된다(HMR). 대신 운영 빌드는 여전히 번들링한다. 개발과 운영의 동작이 다르다는 뜻이므로, 개발에서만 되고 배포하면 깨지는 문제가 생길 수 있다. 배포 전에 vite build && vite preview로 빌드 결과를 직접 확인하는 습관이 필요하다.

실무에서 자주 틀리는 것

1. 환경변수에 비밀 값을 넣는다

// .env
VITE_API_KEY=sk_live_9f3a...        // 번들에 그대로 박힌다
const res = await fetch(url, {
  headers: { Authorization: `Bearer ${import.meta.env.VITE_API_KEY}` },
});

빌드 도구의 환경변수는 빌드 시점에 문자열로 치환된다. 결과물에 그 값이 그대로 들어가고, 누구나 /assets/index-xxxx.js를 열어 검색하면 찾는다. Vite가 VITE_ 접두사가 붙은 것만 노출하고 나머지는 무시하는 이유가 이 사고를 줄이기 위해서다. 접두사를 붙이는 순간 "공개해도 되는 값"이라고 선언한 셈이다. 비밀 키가 필요한 호출은 서버를 거쳐야 한다. 이미 노출됐다면 파일을 지우는 것으로 끝나지 않고 키를 폐기하고 재발급해야 한다.

2. 소스맵을 그대로 배포한다

sourcemap: true로 빌드해 .map 파일까지 서버에 올리면, 개발자 도구를 여는 누구나 원본 코드를 폴더 구조째로 읽는다. sourcesContent에 원본 전문이 들어 있기 때문이다. 주석만 지우고 파일은 남겨 둬도 경로가 규칙적이라 쉽게 추측된다. 반대로 sourcemap: false로 아예 끄면 운영 에러가 at o (index.js:1:28734)로만 남아 디버깅이 불가능해진다. 답은 "hidden"으로 만들고 배포 산출물에서 .map을 제외한 뒤 추적 도구에 업로드하는 것이다. 배포 스크립트에 .map 삭제 단계를 넣어 두면 실수가 줄어든다.

3. 개발 의존성과 런타임 의존성을 섞는다

npm install --save-dev vite eslint vitest      // 빌드/검사 도구
npm install chart.js                            // 실행에 필요한 코드

dependenciesdevDependencies의 구분은 프런트엔드 앱에서 번들 크기와 직접 관련은 없지만(어차피 import한 것만 번들에 들어간다), 배포 서버에서 npm ci --omit=dev로 설치할 때 갈린다. 라이브러리를 만들 때는 훨씬 중요해진다. 잘못 넣으면 그 패키지를 쓰는 모든 프로젝트에 빌드 도구가 딸려 설치된다. 반대로 peerDependencies에 넣어야 할 프레임워크를 dependencies에 넣으면 리액트가 두 벌 설치돼 훅이 깨지는 사고가 난다.

4. 번들이 커진 원인을 추측한다

npx vite-bundle-visualizer          # 무엇이 얼마나 차지하는지 그림으로 본다

"번들이 크다"는 감상을 원인으로 바꾸려면 구성을 봐야 한다. 실제로 열어 보면 예상 밖의 것이 1등인 경우가 많다. 아이콘 세트 전체 import, 날짜 라이브러리의 모든 로케일, 개발용 목 데이터가 흔한 범인이다. 특히 import * as icons from "./icons" 같은 코드는 트리 셰이킹을 무력화한다. 측정 없이 라이브러리를 하나씩 교체하는 것은 21단원에서 본 "느리지 않은 코드 고치기"의 빌드 버전이다.

5. 캐시 무효화를 잊는다

<script type="module" src="/assets/main.js"></script>   <!-- 이름이 안 바뀐다 -->

파일명이 고정이면 브라우저와 CDN이 이전 파일을 계속 쓴다. 배포했는데 일부 사용자만 옛 화면을 보는 문제가 여기서 나온다. 번들러는 기본적으로 내용 해시를 파일명에 붙여(main-4f2a91c8.js) 이 문제를 해결한다. 이때 index.html절대 장기 캐시하면 안 된다. 해시가 붙은 자산은 1년 캐시, HTML은 캐시 금지 또는 짧은 재검증이 기본 조합이다. 반대로 HTML을 오래 캐시하면 새 자산이 있어도 아무도 받지 않는다.

모듈 해석 규칙과 exports 필드의 정확한 동작은 Node.js 패키지 문서에 정리돼 있다.

스스로 확인하기

  1. 트랜스파일러만 적용하고 폴리필을 넣지 않은 상태로 Object.groupBy를 쓰는 코드를 구형 브라우저에서 실행하면 어떤 오류가 나는가? 문법 오류와 무엇이 다른가?
  2. 운영 환경에서 원본 줄 번호가 나오는 에러 리포트를 받으면서도 사용자에게 원본 코드를 노출하지 않으려면 소스맵을 어떻게 설정하고 배포해야 하는가?
  3. CSV 내보내기 라이브러리(120KB)가 첫 화면 번들에 들어 있다. 사용률은 2%다. 어떻게 고치겠는가? 그 방법의 단점과 완화책도 함께 쓰라.

정답

  1. TypeError: Object.groupBy is not a function이 실행 중에 발생한다. 문법 오류는 파싱 단계에서 나므로 파일 전체가 실행되지 않아 페이지가 통째로 죽지만, 이 오류는 해당 코드에 도달했을 때만 발생한다. 그래서 특정 버튼을 눌렀을 때만 깨지는 식으로 나타나 발견이 늦다. 문법은 트랜스파일러가, 내장 함수는 core-js 같은 폴리필이 담당하므로 둘을 모두 설정해야 한다.
  2. 빌드 설정을 sourcemap: "hidden"으로 둔다. 맵 파일은 생성되지만 결과물에 sourceMappingURL 주석이 들어가지 않는다. 배포 파이프라인에서 맵 파일을 Sentry 같은 에러 추적 서비스에 업로드한 뒤, 공개 서버에 올릴 산출물에서는 .map 파일을 제거한다. 추적 서비스는 업로드된 맵으로 스택 트레이스를 원본 위치로 복원하고, 사용자는 맵 파일에 접근할 수 없다.
  3. 정적 import를 동적 import()로 바꿔 버튼 클릭 시점에 내려받게 한다. 번들러가 별도 청크로 잘라 내므로 첫 화면 번들에서 120KB가 빠진다. 단점은 첫 클릭 시 다운로드 시간만큼 반응이 늦고, 네트워크 실패 시 처리가 필요하다는 것이다. 완화책으로 버튼에 mouseenter나 포커스 시점에 import()를 미리 호출해 두면 클릭 시점에는 캐시된 모듈을 즉시 쓸 수 있고, 로딩 중에는 버튼을 비활성화해 중복 클릭을 막는다. 실패 시 재시도나 안내 메시지를 catch에 둔다.