0-2. 프론트엔드 툴체인의 지형
한 줄 요약: 트랜스파일·폴리필·번들이 각각 어떤 문제를 푸는지 구분하고, DevTools를 계측 도구로 삼아 빌드 산출물이 브라우저에서 어떻게 동작하는지 확인할 수 있다.
학습 목표
- 프론트엔드에 빌드 단계가 존재하는 근본 이유를 배포 환경의 특성으로 설명할 수 있다.
- 트랜스파일, 폴리필, 번들링이 각각 다른 축의 문제를 푼다는 것을 구분하고, 주어진 호환성 문제가 어느 축인지 판정할 수 있다.
- Node.js가 브라우저 밖에서 JavaScript를 실행하는 구조와, 툴체인이 네이티브 언어로 이동한 배경을 설명할 수 있다.
- 소스맵이 변환 전후 코드를 잇는 방식을 이해하고 디버깅에 활용할 수 있다.
- DevTools의 각 패널을 "확인 도구"가 아니라 "계측 도구"로 쓸 수 있다.
배경: 왜 이것이 존재하는가
백엔드에서는 실행 환경을 개발자가 고른다. JVM 17로 배포하기로 했으면 JVM 17의 기능을 그냥 쓰면 된다. 프론트엔드는 이 전제가 성립하지 않는다. 실행 환경(브라우저)을 고르는 것은 사용자이고, 그 분포는 최신 Chrome부터 몇 년 묵은 조직 관리 브라우저까지 퍼져 있으며, 개발자는 어느 것도 강제할 수 없다.
여기에 배포 형태의 특수성이 겹친다. 서버 코드는 컴파일해서 바이너리 하나로 말아 올리면 되지만, 프론트엔드 코드는 소스 형태로, 네트워크를 통해, 요청 시점에 전달된다. 즉 코드 크기가 곧 로딩 시간이고, 파일 개수가 곧 요청 횟수다.
프론트엔드 툴체인 전체는 이 두 제약 — "실행 환경을 통제할 수 없다"와 "코드가 네트워크로 배달된다" — 에서 연역된다. 최신 문법으로 쓰되 오래된 브라우저에서 돌게 만드는 것(트랜스파일·폴리필), 수백 개 모듈을 효율적인 전송 단위로 묶는 것(번들링), 이 변환들을 개발 중에는 빠르게 돌리는 것(개발 서버)이 전부 그 파생이다.
이 문서는 각 도구의 사용법이 아니라 지형도를 그린다. 번들러 내부는 Phase 6-2, 패키지 매니저는 Phase 6-1, tsc는 Phase 4-5에서 깊게 다룬다.
핵심 개념
브라우저 밖의 JavaScript: 도구가 JS로 작성된 이유와 그 한계
빌드 도구가 돌아가려면 브라우저 밖에서 JavaScript를 실행할 런타임이 필요하다. Node.js는 Chrome의 JS 엔진인 V8을 브라우저에서 떼어내고, 브라우저가 제공하던 것들(DOM, fetch 등) 대신 파일 시스템·네트워크·프로세스 API를 붙인 것이다. 엔진(언어 실행)과 호스트 환경(API 제공)이 분리 가능한 계층이라는 사실이 여기서 드러난다 — 같은 V8 위에 브라우저를 얹으면 Chrome이고, libuv 기반 I/O를 얹으면 Node.js다.
툴체인이 JavaScript로 작성된 것은 성능 때문이 아니라 생태계 때문이다. 웹 개발자가 자기 언어로 도구를 만들고 플러그인을 쓸 수 있다는 접근성이 npm 생태계를 폭발시켰다. 그 대가가 속도였고, 코드베이스가 커지면서 청구서가 도착했다. 2020년 전후로 핵심 변환기가 네이티브 언어로 이동한 이유다 — esbuild(Go), SWC(Rust), 그리고 이들을 내장한 Vite·Next.js. JS 도구 대비 수십 배의 차이는 병렬화(JS 도구는 사실상 단일 스레드)와 JIT 워밍업 부재에서 온다.
트레이드오프도 명확하다. 네이티브 도구는 플러그인을 JS로 쓰는 순간 속도 이점이 잠식되고(경계 넘는 직렬화 비용), Babel 생태계가 축적한 세밀한 변환 플러그인 층을 포기했다. 또 esbuild와 SWC는 TypeScript의 타입 검사를 하지 않는다 — 타입을 그냥 벗겨내기만 한다. 왜 검사와 변환이 분리 가능한지는 Phase 4-5에서 다룬다.
세 가지 변환: 문법, API, 전송 단위
"구형 브라우저에서 안 돌아요"라는 하나의 증상 뒤에는 서로 다른 세 종류의 문제가 있고, 해법도 각각 다르다. 이 구분이 이 문서에서 가장 중요한 모델이다.
| 트랜스파일(transpile) | 폴리필(polyfill) | 번들(bundle) | |
|---|---|---|---|
| 푸는 문제 | 문법을 엔진이 파싱 못 함 | 런타임 API가 없음 | 모듈이 많아 전송이 비효율 |
| 예 | ?., async/await, JSX | Promise, Array.prototype.at, fetch | import 그래프 수백 개 |
| 방법 | 구문 트리를 옛 문법으로 재작성 | 없는 API를 JS로 구현해 주입 | 모듈 그래프를 파일 몇 개로 병합 |
| 실패 양상 | SyntaxError (파싱 자체가 실패) | TypeError: x is not a function (실행 중 실패) | 동작하지만 느림 |
| 도구 | Babel, SWC, esbuild, tsc | core-js | Rollup, esbuild, webpack |
구분이 중요한 이유는 문법 문제는 폴리필로 못 풀고, API 문제는 트랜스파일로 못 풀기 때문이다. ??= 문법은 아무리 많은 폴리필을 넣어도 구형 엔진에서 SyntaxError다 — 파서가 토큰을 이해하지 못하는 문제라서 코드를 고쳐 쓰는(트랜스파일) 수밖에 없다. 반대로 Array.prototype.at이 없는 것은 문법이 아니라 객체에 메서드가 없는 것이므로, 트랜스파일러는 손대지 않고 폴리필이 채운다.
에러 메시지로 어느 축인지 판정할 수 있다: SyntaxError면 트랜스파일 대상 설정을, ... is not a function / ... is not defined면 폴리필 설정을 본다.
두 축의 기준점이 되는 것이 Browserslist다. "지원 대상은 이 브라우저들"이라는 선언(.browserslistrc` 등)을 트랜스파일러와 폴리필 주입기가 공유해서, 그 대상에 필요한 만큼만 변환한다:
# .browserslistrc — 이 선언 하나를 Babel, PostCSS 등이 공유한다
defaults and fully supports es6-module번들링: 네트워크 전송 단위의 재설계
세 번째 축인 번들은 호환성이 아니라 전달 효율 문제다. 모듈 시스템으로 쪼갠 수백 개 파일을 그대로 서빙하면 요청 폭포(모듈이 import를 선언하고 → 받아서 파싱해야 다음 import를 알고 → 또 요청)가 생긴다. HTTP/2로 요청 비용이 줄었어도 이 발견 지연(waterfall) 은 남는다. 번들러는 빌드 시점에 import 그래프 전체를 미리 걸어 두고, 적절한 개수의 청크로 병합해 이 문제를 없앤다.
부가 효과가 본질만큼 커졌다: 그래프를 전부 알고 있으므로 안 쓰는 export를 제거(트리 셰이킹)하고, 라우트 단위로 청크를 쪼개고(코드 스플리팅), 파일 내용 해시를 파일명에 박아 캐시를 영구화할 수 있다. 성립 조건과 내부는 Phase 6-2에서 다룬다.
개발 중에는 요구가 반대다 — 병합은 느리고, 한 줄 고칠 때마다 전체 재번들은 낭비다. 그래서 Vite류 개발 서버는 개발 중에는 번들하지 않고 브라우저의 네이티브 ESM으로 모듈을 개별 서빙하며, 바뀐 모듈만 교체한다(HMR). 개발과 프로덕션의 산출물이 구조적으로 다르다는 뜻이고, "개발에서는 됐는데 빌드하면 깨진다"는 문제의 씨앗이기도 하다.
소스맵: 변환을 가로지르는 디버깅
변환이 몇 겹이든, 브라우저가 실행하는 것은 최종 산출물이다. 에러 스택도 산출물의 좌표(chunk-a3f9.js:1:48213)로 찍힌다. 소스맵(source map)은 산출물 좌표 → 원본 소스 좌표의 매핑 테이블로, 산출물 끝의 주석(//# sourceMappingURL=...)으로 연결된다. DevTools는 이것을 읽어 원본 TypeScript/JSX에 중단점을 걸게 해 준다.
실무 판단 지점은 프로덕션에서 소스맵을 어떻게 하느냐다. 공개하면 디버깅과 에러 추적이 쉬워지지만 소스가 사실상 공개된다. 흔한 절충은 소스맵을 생성하되 서버에 올리지 않고 에러 트래킹 서비스(Sentry 등)에만 업로드하는 것이다 — 스택 복원은 되고 소스 노출은 없다.
DevTools: 확인 도구가 아니라 계측 도구
이 커리큘럼 전체에서 DevTools는 "동작하나 보는" 도구가 아니라 주장을 검증하는 계측 장비로 쓴다. 패널마다 관할이 다르다.
| 패널 | 보여주는 것 | 이 커리큘럼에서의 용도 |
|---|---|---|
| Elements | 파싱 결과인 현재 DOM (소스 코드가 아님) | 파서 오류 복구 확인(1-1), 접근성 트리(1-7) |
| Console | 로그, 라이브 표현식 평가 | 실행 순서 관찰(3-5) |
| Sources | 소스맵 복원된 원본, 중단점 | 클로저·스코프 관찰(3-2), 변환 결과 대조 |
| Network | 요청 폭포, 타이밍 분해, 우선순위 | 로딩 병목 진단(0-1, 7-3) |
| Performance | 메인 스레드 타임라인 (파싱·스타일·레이아웃·JS) | 렌더 파이프라인 계측(7-1) |
| Memory | 힙 스냅숏, 할당 추적 | 누수 진단(3-10) |
하나만 지금 습관으로 만들자: 이 문서의 주장인 "Vite는 개발 중 번들하지 않는다"를 직접 확인하는 절차다.
npm create vite@latest probe -- --template vanilla
cd probe && npm install && npm run dev브라우저에서 열고 Network 패널을 보면 모듈이 파일 단위로 개별 요청되는 것이 보인다(main.js, 의존성은 사전 번들된 /node_modules/.vite/...). 이어서 npm run build && npm run preview로 열면 해시 붙은 청크 몇 개로 병합된 것이 보인다. 문서의 주장을 계측으로 확인하는 이 절차가 이후 모든 Phase의 기본 자세다.
실무 관점
"빌드 없는 프론트엔드"는 언제 성립하는가
ES 모듈, ?., async/await 등 한때 트랜스파일 대상이던 문법 대부분이 이제 Baseline Widely available이다(판단 기준은 0-3). 따라서 호환성 때문에 빌드가 필수인 시대는 끝났다. 사내 도구나 소규모 페이지라면 빌드 없이 네이티브 ESM으로 배포하는 선택이 실제로 성립한다.
그럼에도 번들러가 남는 이유는 호환성이 아닌 쪽에 있다: JSX·TypeScript처럼 표준이 아닌 문법을 쓰는 순간 변환이 필수가 되고, 모듈 수가 수십을 넘으면 요청 폭포가 체감되며, 트리 셰이킹·코드 스플리팅·해시 캐싱은 번들 없이는 얻을 수 없다. 즉 현재의 빌드 단계는 "돌아가게 만드는" 단계에서 "전달을 최적화하는" 단계로 성격이 바뀌었다. 이 인식이 서 있어야 도구 문제를 만났을 때 "이 변환이 정말 필요한가"부터 물을 수 있다.
과잉 변환의 비용은 측정 가능하다
Browserslist를 관성적으로 넓게 잡으면(예: 점유율 무관하게 ES5까지) 비용이 두 곳에서 발생한다. async/await가 제너레이터 기반 런타임 코드로 풀리며 번들이 커지고, 엔진이 최적화하기 좋은 네이티브 문법 대신 우회 코드를 실행하므로 느려진다. 확인 방법: 번들 분석기(rollup-plugin-visualizer, source-map-explorer)로 target을 바꿔 가며 산출물 크기를 비교하고, 산출물에서 regeneratorRuntime·core-js 청크가 차지하는 비중을 본다. 지원할 근거(실사용 통계)가 없는 브라우저를 위해 모든 사용자가 비용을 내는 구조인지 정기적으로 재검토한다.
문제가 생겼을 때 어느 계층을 의심하는가
증상에서 계층으로 내려가는 순서를 갖고 있으면 디버깅이 절반은 끝난다.
- 프로덕션에서만
SyntaxError→ 트랜스파일 target과 실제 사용자 브라우저의 불일치. - 특정 브라우저에서만
x is not a function→ 폴리필 누락. 해당 API를 MDN BCD에서 확인. - 개발은 되는데 빌드만 깨짐 → 개발(무번들 ESM)과 프로덕션(번들)의 구조 차이. 사이드 이펙트 순서, 환경 변수 치환, CJS/ESM 혼용을 의심(Phase 6-2).
- 에러 스택이 의미 불명의 좌표 → 소스맵 미생성/미업로드.
더 깊이
트랜스파일이 넘을 수 없는 선: 문법과 의미론
트랜스파일은 "새 문법을 옛 문법으로 다시 쓰는" 것이므로, 옛 문법으로 같은 의미를 표현할 수 없는 기능은 변환이 불가능하거나 비용이 폭발한다. 대표적으로 Proxy는 문법이 아니라 API지만 폴리필도 불가능하다 — 존재하지 않는 프로퍼티 접근을 가로채는 동작은 엔진 수준의 지원 없이는 JS로 재현할 수 없다. WeakRef·FinalizationRegistry도 GC와의 결합이라 재현 불가다. 정규식의 lookbehind처럼 문법 안의 문법도 트랜스파일러가 손대지 못하는 영역이다.
즉 호환성 도구의 능력 범위는 세 겹이다: 트랜스파일 가능(대부분의 문법) ⊃ 폴리필 가능(대부분의 API) ⊃ 불가능(엔진 결합 기능). 마지막 겹에 속하는 기능을 쓴다는 것은 지원 브라우저의 하한을 그 기능이 결정한다는 뜻이다.
폴리필의 전역 오염 트레이드오프
core-js류 폴리필은 Array.prototype 같은 전역 내장 객체를 직접 수정한다. 애플리케이션이라면 실용적인 선택이지만, 라이브러리가 이렇게 하면 호스트 애플리케이션의 전역을 오염시킨다 — 스펙과 미묘하게 다른 폴리필 구현이 다른 코드의 동작을 바꿀 수 있다(과거 MooTools의 Array.prototype 확장이 표준 flatten 명명을 바꾸게 만든 SmooshGate가 전형적 사례다). 그래서 라이브러리용으로는 전역을 건드리지 않고 헬퍼를 import하는 ponyfill 방식이 권장된다. "누가 폴리필을 넣을 책임을 지는가(앱이냐 라이브러리냐)"는 생태계가 앱 쪽으로 정리한 합의다.
개발 서버의 두 번째 절충: 사전 번들링
"Vite는 개발 중 번들하지 않는다"에는 각주가 있다. node_modules의 의존성만은 esbuild로 사전 번들한다(/node_modules/.vite/). 이유는 두 가지다 — CJS로만 배포된 패키지를 ESM으로 바꿔야 브라우저가 import할 수 있고, lodash-es처럼 내부 모듈이 수백 개인 패키지를 그대로 서빙하면 개발 중에도 요청 폭포가 생기기 때문이다. 즉 "무번들 개발"의 정확한 형태는 "자주 바뀌는 소스는 무번들, 안 바뀌는 의존성은 사전 번들"이라는 절충이다. 소스와 의존성의 변경 빈도 차이를 캐시 전략으로 번역한 설계로, Phase 6-2에서 HMR과 함께 다시 본다.
정리
- 프론트엔드 툴체인은 두 제약에서 연역된다: 실행 환경(브라우저)을 통제할 수 없고, 코드가 네트워크로 배달된다.
- 트랜스파일(문법)·폴리필(API)·번들(전송 단위)은 서로 다른 축의 문제다.
SyntaxError는 트랜스파일,is not a function은 폴리필, 느린 로딩은 번들의 관할이다. - 문법 대부분이 Baseline에 든 지금, 빌드의 성격은 "돌아가게 만들기"에서 "전달 최적화"로 바뀌었다. 비표준 문법(JSX/TS)과 트리 셰이킹·코드 스플리팅이 번들러가 남는 이유다.
- 호환성 도구에도 한계가 있다: Proxy·WeakRef처럼 엔진과 결합된 기능은 트랜스파일도 폴리필도 불가능하며, 지원 하한을 결정한다.
- DevTools는 확인 도구가 아니라 계측 도구다. 문서의 주장(예: Vite의 무번들 개발)을 Network/Performance 패널로 직접 검증하는 습관이 이 커리큘럼의 기본 자세다.
확인 문제
- 배포 후 구형 브라우저 사용자에게서 두 종류의 제보가 왔다. A: 페이지가 아예 하얗고 콘솔에
SyntaxError: Unexpected token '?'. B: 페이지는 뜨는데 특정 버튼을 누르면TypeError: arr.at is not a function. 각각 툴체인의 어느 계층 문제이고, 왜 증상이 다른가?
정답과 해설
A는 트랜스파일 문제다. ?. 문법을 엔진 파서가 이해하지 못해 파싱 단계에서 실패했고, 해당 스크립트 전체가 한 줄도 실행되지 못해 화면이 비었다. Browserslist/target 설정이 실제 사용자 분포보다 좁은 것이다. B는 폴리필 문제다. 문법은 전부 유효해서 파싱·실행이 진행되다가, 해당 API를 호출하는 순간에만 실패한다. 그래서 페이지는 뜨고 특정 동작만 죽는다. 파싱 실패는 전체 실패, API 부재는 지점 실패라는 증상 차이가 두 계층을 가르는 지문이다.
- 팀이 "우리 서비스는 최신 브라우저만 지원하니 빌드 단계를 없애고 네이티브 ESM으로 배포하자"고 제안했다. 이 제안이 성립하는 조건과, 그럼에도 잃는 것을 설명하라.
정답과 해설
성립 조건: 사용하는 문법·API가 전부 지원 브라우저 범위 안에 있고(JSX·TypeScript 등 비표준 문법을 안 쓰거나 별도 변환), 모듈 수가 적어 요청 폭포가 체감되지 않아야 한다. 잃는 것: 트리 셰이킹(안 쓰는 코드가 그대로 배달됨), 코드 스플리팅과 해시 기반 영구 캐싱, import 그래프의 발견 지연 제거. 즉 "돌아가는가"는 통과해도 "전달이 최적인가"에서 비용을 낸다. 모듈 수십 개 이하의 소규모 페이지라면 합리적이고, 규모가 커질수록 번들의 전달 최적화 가치가 커진다.
- 어떤 라이브러리가 구형 브라우저 지원을 위해 자기 코드 안에서 core-js로
Array.prototype에 폴리필을 주입하고 있다. 이 설계의 문제와 대안은?
정답과 해설
라이브러리가 전역 내장 객체를 수정하면 그것을 import한 모든 애플리케이션의 전역이 오염된다. 폴리필 구현이 스펙과 미묘하게 다르거나 미래 표준과 충돌하면(SmooshGate 사례) 라이브러리와 무관한 코드가 깨진다. 또 앱이 이미 같은 폴리필을 넣었다면 중복 배달이다. 대안은 전역을 건드리지 않는 ponyfill(헬퍼 함수 import) 방식으로 작성하고, 전역 폴리필 주입 여부는 애플리케이션의 결정으로 남기는 것이다. 폴리필의 책임은 최종 배포 단위(앱)에 있다는 것이 생태계의 합의다.
참고 자료
- Vite — Why Vite — 무번들 개발 서버와 사전 번들링 절충의 공식 설명.
- esbuild — FAQ: Why is esbuild fast? — JS 도구 대비 네이티브 도구의 성능 차이가 어디서 오는지 제작자의 분석.
- Browserslist — 지원 대상 선언이 어떤 브라우저 집합으로 해석되는지 쿼리로 확인할 수 있다.
- TC39 proposal-array-flatten의 SmooshGate 논의 — 전역 프로토타입 오염이 표준 제정을 실제로 바꾼 사례.
- Source Map 스펙 (TC39) — 소스맵 매핑 포맷의 표준 문서.