웹 프론트엔드 빌드 도구는 왜 나뉘었을까

작성일:2026.09.18|수정일:2026.09.18|조회수:31

웹 프론트엔드 빌드 도구는 왜 나뉘었을까

Webpack, Babel, TypeScript를 한 프로젝트에서 함께 쓰다 보면 역할이 겹쳐 보인다. Webpack도 코드를 변환하고, Babel도 자바스크립트를 출력하며, tsc 역시 타입스크립트를 자바스크립트로 바꾼다. 설정 파일만 보면 같은 일을 세 번 하는 것 같고, 그래서 하나쯤 빼도 되지 않나 하는 생각이 든다.

이 문제를 도구 이름으로만 외우면 자꾸 헷갈린다. Webpack은 번들러, Babel은 트랜스파일러, TypeScript는 타입 검사기라고 나누면 일단 설명은 되지만, 실제 설정에는 babel-loader가 있고 tsc에는 target이 있으며 Vite는 타입스크립트 파일까지 곧바로 처리한다. 분류표만으로는 이 겹침을 설명하기 어렵다.

조금 길게 역사를 따라가 보니 질문을 바꿔야 했다. 어느 도구가 무엇을 “할 수 있는가”보다, 빌드 과정의 어떤 실패를 누구의 책임으로 둘 것인가가 더 중요했다. 모듈과 에셋의 관계를 계산하는 일, 소스 문법을 실행 환경에 맞추는 일, 프로그램 전체의 타입 관계를 검증하는 일은 서로 다른 문제이다. 한 프로그램이 둘 이상을 처리할 수는 있어도 문제의 경계까지 사라지는 것은 아니다.

이 구분은 Webpack과 Babel을 더 빠른 도구로 바꾼 뒤에도 남는다. 구현은 교체되지만 책임은 남는다. 최근의 빌드 도구를 이해할 때도 결국 돌아오게 되는 기준이다.

브라우저는 파일 사이의 관계를 몰랐다

초기의 브라우저 자바스크립트에는 언어 차원의 모듈 시스템이 없었다. HTML이 여러 <script>를 위에서 아래로 불러오면 각 파일의 최상위 선언은 같은 전역 공간을 공유했다. 파일을 나눈 것은 개발자의 디렉터리 구조일 뿐, 브라우저가 이해하는 격리 경계는 아니었다.

다음처럼 스크립트를 나열하면 main.js는 앞선 파일들이 어떤 이름을 전역에 남겼다고 가정하고 실행한다.

HTML
<script src="auth.js"></script><script src="user.js"></script><script src="payment.js"></script><script src="main.js"></script>

이 구조에서 파일 순서는 사실상 숨은 의존성 선언이다. payment.jsauth.js의 함수를 호출한다면 반드시 아래에 있어야 하고, 다른 파일이 같은 이름의 var를 선언하면 먼저 저장된 값을 덮어쓸 수 있다. 의존 관계를 HTML의 줄 순서와 개발자의 기억이 관리하는 셈이다.

문제는 코드 양이 늘었을 때 드러났다. 서로 다른 파일에서 같은 전역 이름을 쓰는 일도 생겼고, 파일 하나를 추가할 때마다 어느 <script> 앞뒤에 놓아야 하는지 다시 계산해야 했다. 페이지 이동 때마다 실행 상태를 버리던 작은 웹 문서에서는 버틸 만했지만, Ajax와 단일 페이지 애플리케이션이 보편화되면서 브라우저에 오래 머무는 코드와 상태가 늘었다. 파일 분리는 더 이상 정리 습관만으로 해결할 수 있는 문제가 아니었다.

언어가 모듈을 제공하지 않던 시기에는 함수 스코프를 이용한 IIFE(즉시 실행 함수 표현식)가 임시 경계가 되었다. 내부 상태는 함수 안에 가두고, 밖에서 써야 하는 기능만 객체로 반환하는 방식이다.

JS
var ShoppingCart = (function () {  var items = [];  function calculateTotal() {    return items.reduce(function (sum, item) {      return sum + item.price;    }, 0);  }  return {    add: function (item) {      items.push(item);    },    total: calculateTotal  };})();

items는 함수 바깥에서 직접 접근할 수 없고 공개된 메서드만 남는다. 스코프 오염은 상당히 줄어든다. 다만 다른 파일이 ShoppingCart를 쓰려면 이 이름 자체는 전역에 있어야 하고, 해당 파일보다 먼저 실행되어야 한다. IIFE가 해결한 것은 캡슐화이지 파일 탐색과 로딩 순서가 아니다.

여기서 첫 번째 책임 경계가 보인다. 소스 파일을 나누는 일과, 나뉜 파일의 의존 관계를 찾아 올바른 순서와 형태로 실행하는 일은 다르다. 후자를 자동화하려면 브라우저가 파일 목록을 받는 수준을 넘어 프로그램의 모듈 구조를 이해해야 했다.

모듈 형식은 실행 환경을 따라 갈라졌다

2009년 Node.js가 등장하면서 자바스크립트는 브라우저 밖에서도 실행되기 시작했다. 서버에서는 모듈 파일이 로컬 디스크에 있으므로 동기적으로 읽어도 브라우저의 네트워크 요청처럼 화면을 오래 멈추게 하지 않는다. CommonJS의 requiremodule.exports는 이 환경에 잘 맞았다.

CommonJS 모듈은 필요한 파일을 호출 지점에서 읽고, 공개할 값만 명시적으로 내보낸다.

JS
// math.jsfunction add(a, b) {  return a + b;}module.exports = { add: add };// app.jsvar math = require("./math");console.log(math.add(10, 20));

Node.js는 각 파일을 함수 래퍼 안에서 실행해 파일별 스코프를 만들고, 한 번 평가한 모듈은 캐시한다. 전역 변수와 HTML 순서에 기대던 코드보다 관계가 분명해졌다. 하지만 브라우저가 이 코드를 그대로 실행할 수는 없었다. 브라우저에는 requiremodule도 없고, require("./math")를 만날 때마다 원격 서버에서 파일을 동기적으로 가져오는 방식은 사용자 인터페이스를 멈추게 한다.

브라우저 쪽에서는 AMD와 RequireJS가 비동기 네트워크를 전제로 다른 답을 내놓았다. 필요한 모듈 목록을 먼저 선언하고 다운로드가 끝난 뒤 팩토리 함수를 실행했다.

JS
// app.jsrequire(["jquery", "./math"], function ($, math) {  var sum = math.add(5, 10);  $("#result").text("계산 결과: " + sum);});

이 방식은 <script> 순서를 사람이 맞추는 부담을 줄였다. 반면 의존성 배열과 콜백 인자의 위치를 맞춰야 했고, Node.js의 CommonJS 패키지를 브라우저에서 곧바로 쓰기도 어려웠다. UMD는 실행 환경을 검사해 CommonJS, AMD, 전역 변수 중 하나를 선택하는 래퍼를 제공했지만, 라이브러리 본문보다 배포용 포장이 더 복잡해지는 경우도 있었다.

이 시기에 Grunt와 Gulp 같은 작업 실행기도 널리 쓰였다. Sass를 CSS로 바꾸고, 파일을 이어 붙이고, 결과를 압축하는 반복 작업을 자동화하기에는 좋았다. 그러나 다음 설정은 파일 내부의 의존 관계를 계산하지 않는다.

JS
var gulp = require("gulp");var concat = require("gulp-concat");var uglify = require("gulp-uglify");gulp.task("scripts", function () {  return gulp.src([    "src/vendor/jquery.js",    "src/utils/*.js",    "src/components/*.js",    "src/main.js"  ])    .pipe(concat("bundle.min.js"))    .pipe(uglify())    .pipe(gulp.dest("dist/js"));});

Gulp가 아는 것은 입력 파일 패턴과 나열 순서이다. main.js가 실제로 무엇을 가져오는지, 어떤 파일이 더는 쓰이지 않는지, 특정 화면에서만 필요한 코드가 무엇인지는 알지 못한다. 파일을 합치는 자동화와 모듈 그래프를 계산하는 번들링은 비슷해 보이지만 책임이 다르다.

Webpack은 파일 목록 대신 의존성 그래프를 본다

Webpack의 출발점은 엔트리 파일이다. 엔트리를 파싱해 importrequire를 찾고, 그 대상 파일을 다시 파싱하며 재귀적으로 의존성 그래프를 만든다. 결과물도 반드시 하나일 필요가 없다. 같은 그래프에서 초기 실행에 필요한 청크와 나중에 불러올 청크를 나눌 수 있다.

이를 아주 거칠게 줄이면 다음 흐름에 가깝다.

TXT
엔트리 모듈  → import/require 분석  → 의존 모듈 탐색과 변환  → 의존성 그래프 구성  → 청크 분할과 최적화  → 브라우저에 배포할 에셋 생성

중요한 변화는 “어떤 파일을 이어 붙일까”가 “이 프로그램을 실행하려면 어떤 모듈과 에셋이 필요한가”로 바뀐 데 있다. 전자는 파일 집합을 다루고, 후자는 관계를 다룬다. 코드 스플리팅과 트리 셰이킹도 이 관계를 알아야 가능하다.

자바스크립트 밖의 파일도 그래프에 넣는다

웹 애플리케이션은 자바스크립트만으로 구성되지 않는다. 컴포넌트가 CSS, 이미지, 폰트에 의존한다면 그 파일들도 배포 관계의 일부이다. Webpack 로더는 자바스크립트가 아닌 입력을 Webpack이 다룰 수 있는 모듈로 변환한다.

예를 들어 컴포넌트가 스타일과 이미지를 직접 가져오도록 작성할 수 있다.

JSX
import React from "react";import "./PaymentButton.css";import checkIcon from "./check.png";export function PaymentButton({ onClick }) {  return (    <button className="payment-button" onClick={onClick}>      <img src={checkIcon} alt="" />      결제하기    </button>  );}

브라우저의 자바스크립트 엔진은 CSS나 PNG를 모듈로 평가하지 못한다. 이 구문은 Webpack 빌드 안에서 의미를 가진다. CSS 로더는 @importurl()을 찾아 그래프에 연결하고, 이미지 처리는 파일을 출력한 뒤 코드에는 배포 URL을 남긴다. Webpack 5에서는 이미지와 폰트 같은 일반적인 경우를 Asset Modules로 처리할 수 있어 과거의 file-loaderurl-loader를 따로 설치할 필요도 줄었다.

SCSS를 처리하는 설정을 보면 로더가 맡는 책임이 더 분명하다. 입력 한 개를 다음 단계가 이해할 형식으로 바꾸는 변환 체인이다.

JS
module.exports = {  module: {    rules: [      {        test: /\.scss$/,        use: [          "style-loader",          "css-loader",          "sass-loader"        ]      }    ]  }};

로더는 배열의 오른쪽부터 실행된다. sass-loader가 SCSS를 CSS로 바꾸고, css-loader가 CSS 내부의 의존성을 해석해 모듈로 만들며, style-loader가 브라우저에서 스타일을 주입할 런타임 코드를 붙인다. 각 단계는 변환에 집중하고, Webpack은 변환 결과를 그래프와 출력 에셋에 연결한다.

로더와 플러그인도 자주 섞여 불린다. 로더가 개별 모듈의 입력을 바꾸는 장치라면, 플러그인은 컴파일 전체 수명주기에 개입한다. HTML에 생성된 청크 이름을 넣거나, 환경 상수를 치환하거나, 결과 에셋을 분석하는 작업은 파일 하나의 변환으로 끝나지 않으므로 플러그인 쪽 책임이다.

번들에는 작은 모듈 런타임도 들어간다

CommonJS 코드를 묶는 것만으로 브라우저에 require가 생기지는 않는다. 전통적인 Webpack 번들은 모듈 함수를 모아 두고, 모듈 ID를 받아 실행 결과를 캐시하는 자체 런타임을 포함했다. 실제 출력은 버전과 설정마다 다르지만 원리는 다음처럼 줄일 수 있다.

JS
(function (modules) {  var cache = {};  function __webpack_require__(id) {    if (cache[id]) {      return cache[id].exports;    }    var module = cache[id] = { exports: {} };    modules[id](module, module.exports, __webpack_require__);    return module.exports;  }  return __webpack_require__(0);})([  function (module, exports, __webpack_require__) {    var math = __webpack_require__(1);    console.log(math.add(5, 7));  },  function (module) {    module.exports = {      add: function (a, b) {        return a + b;      }    };  }]);

여기서는 모든 모듈 코드가 이미 번들 안에 있으므로 __webpack_require__(1)이 네트워크를 기다리지 않는다. 모듈마다 함수 스코프가 생기고, 캐시는 같은 모듈을 반복 실행하지 않게 한다. 코드 스플리팅을 사용하면 별도 청크를 내려받은 뒤 이 런타임에 등록하는 과정이 더해진다.

Webpack의 책임을 “파일을 하나로 합친다”로만 설명하면 이런 부분이 빠진다. 더 정확히는 모듈과 에셋의 의존 관계를 구성하고, 대상 런타임에서 그 관계를 재현할 배포 단위를 만드는 도구이다. 이 과정에서 다음과 같은 최적화가 가능해진다.

다만 트리 셰이킹은 import를 썼다는 이유만으로 저절로 끝나는 기능이 아니다. CommonJS로 미리 변환된 코드나 실행 시 부수 효과가 있는 모듈은 정적으로 제거하기 어렵다. package.jsonsideEffects 정보와 압축기의 죽은 코드 제거도 함께 작동한다. 그래프를 안다고 해서 모든 동적 동작을 안전하게 지울 수 있는 것은 아니다.

Babel은 실행 환경과 소스 문법 사이를 맡았다

Webpack이 모듈 관계를 다루는 동안 다른 문제가 커졌다. ECMAScript 표준에는 새 문법이 추가되지만 모든 사용자의 브라우저가 같은 시점에 이를 구현하지는 않는다. ES2015의 let, const, 화살표 함수, 클래스와 이후의 옵셔널 체이닝을 개발자가 쓰고 싶어도 지원 대상 브라우저의 파서가 읽지 못하면 애플리케이션은 시작조차 하지 못한다.

Babel의 전신인 6to5는 새 자바스크립트 문법을 이전 세대 문법으로 바꾸는 데서 출발했다. Babel은 소스 문자열을 단순 치환하지 않는다. 코드를 파싱해 추상 구문 트리(AST)를 만들고, 플러그인이 트리를 변환한 뒤 다시 코드를 생성한다.

그 파이프라인은 다음 세 단계로 볼 수 있다.

TXT
소스 코드  → 파싱  → AST 변환  → 코드 생성  → 대상 환경이 읽을 수 있는 자바스크립트

예를 들어 const add = (a, b) => a + b를 파싱하면 변수 선언과 화살표 함수, 이항 연산 노드가 만들어진다. 화살표 함수 변환 플러그인은 해당 노드를 일반 함수 표현식으로 바꾸고, 생성기는 변환된 트리를 자바스크립트 문자열로 출력한다.

입력과 출력만 보면 다음처럼 보인다.

JS
// inputconst add = (a, b) => a + b;// output for an older targetvar add = function (a, b) {  return a + b;};

여기서 Babel이 맡은 것은 문법의 표현 방식이다. 모듈을 몇 개의 청크로 나눌지, CSS 파일을 어디에 출력할지, 프로젝트 전체의 타입이 맞는지는 이 변환만으로 알 수 없다. Babel 플러그인이 모듈 구문을 바꿀 수는 있지만 그것이 애플리케이션의 전체 번들 그래프를 만드는 일과 같지는 않다.

문법 변환과 런타임 API 보강은 다르다

Babel을 이해할 때 가장 중요한 경계는 문법과 런타임 API 사이에 있다. 화살표 함수나 옵셔널 체이닝은 파서가 이해해야 하는 문법이다. 반면 Promise, Map, Object.assign, Array.prototype.includes는 실행 환경에 존재해야 하는 객체와 메서드이다.

다음 코드에는 두 종류의 요구가 함께 들어 있다.

JS
const includesTwo = [1, 2, 3].includes(2);const result = Promise.resolve(includesTwo);

오래된 환경을 대상으로 constvar로 바꾸더라도 includesPromise는 그대로 남는다. 일반적인 함수 호출 구문 자체는 오래된 파서도 읽을 수 있기 때문이다. 문제는 실행 시점에 그 메서드와 전역 객체가 없다는 데 있다.

문법 변환만 거친 결과는 다음과 같다.

JS
var includesTwo = [1, 2, 3].includes(2);var result = Promise.resolve(includesTwo);

이 코드는 파싱에는 성공해도 Array.prototype.includesPromise가 없는 브라우저에서는 실행 중 실패한다. 이 빈자리를 채우는 코드가 폴리필이다. 폴리필은 구형 런타임에 없는 API를 구현하므로, 소스 문법을 다시 쓰는 트랜스파일과는 배포 책임도 다르다.

오랫동안 Babel 7의 @babel/preset-env는 Browserslist 대상과 core-js 정보를 바탕으로 필요한 문법 변환과 폴리필 로딩을 조정했다. useBuiltIns: "usage"를 사용하던 설정은 코드에서 쓰인 기능에 맞춰 core-js 모듈을 추가하는 형태였다.

JSON
{  "presets": [    [      "@babel/preset-env",      {        "useBuiltIns": "usage",        "corejs": "3.33"      }    ]  ]}

이 설정은 Babel 7 계열을 설명할 때 유효하지만, 현재 설정에 그대로 복사할 내용은 아니다. Babel 8에서는 preset-envuseBuiltInscorejs 옵션이 제거되었고 폴리필 주입은 babel-plugin-polyfill-corejs3 같은 전용 플러그인으로 분리되었다. 여기서도 같은 방향이 보인다. 대상 문법을 바꾸는 책임과 런타임 기능을 보강하는 책임은 관련되어 있지만 동일하지 않다.

애플리케이션과 라이브러리의 폴리필 전략도 같지 않다. 애플리케이션은 자신이 소유한 페이지의 전역 런타임을 보강할 수 있지만, 배포한 라이브러리가 소비자 환경의 전역 프로토타입을 임의로 수정하면 충돌이 생길 수 있다. 라이브러리에서는 런타임 헬퍼나 순수한 방식의 폴리필을 쓰는 이유이다. “Babel이 알아서 호환성을 만든다”는 문장 하나로 덮기에는 결정할 경계가 많다.

AST 변환 플랫폼이라는 두 번째 역할

Babel이 오래 쓰인 이유를 구형 브라우저 지원만으로 설명하기도 어렵다. JSX는 ECMAScript 표준 문법이 아니며, React의 JSX 변환은 빌드 단계가 처리한다. styled-components의 표시 이름 보강이나 React Compiler의 자동 메모이제이션처럼 프레임워크가 소스 구조를 분석해 코드를 바꾸는 작업도 AST 변환 파이프라인을 사용한다.

Babel은 자바스크립트로 플러그인을 작성할 수 있어 이런 실험을 흡수하기 좋았다. 반대로 말하면 프로젝트가 사용하는 특수 변환이 무엇인지 알아야 Babel을 다른 컴파일러로 교체할 수 있다. 표준 문법과 JSX만 변환한다면 SWC나 esbuild, Oxc가 대신할 수 있는 경우가 많지만, 오래된 사내 플러그인이나 특정 Babel 플러그인에 기대고 있다면 교체 비용이 남는다.

TypeScript는 프로그램 전체의 관계를 검사한다

Babel이 문법과 실행 환경의 간격을 줄여도 자바스크립트의 동적 타입에서 생기는 문제는 남는다. 함수가 기대한 객체 대신 숫자를 받거나, 리팩터링 중 바뀐 프로퍼티를 한 군데 놓쳐도 자바스크립트 파서는 이를 오류로 보지 않는다. 해당 경로가 실행될 때야 드러난다.

TypeScript는 자바스크립트에 정적 타입 층을 더한다. 중요한 부분은 타입 표기를 지우는 데 있지 않다. 파일과 선언을 연결하고 표현식의 타입을 추론하며, 서로 떨어진 호출부와 구현부가 호환되는지 검사하는 데 있다.

tsc의 내부를 단순화하면 다음 순서로 이해할 수 있다.

TXT
소스 텍스트  → 스캐너: 토큰 생성  → 파서: AST 생성  → 바인더: 선언과 심볼 연결  → 체커: 타입 계산과 호환성 검사  → 이미터: JavaScript, .d.ts, 소스맵 출력

Babel과 마찬가지로 tsc도 파싱하고 자바스크립트를 출력할 수 있다. 하지만 TypeScript의 고유한 비용과 가치는 체커에 있다. User가 어느 파일에서 선언되었는지, 제네릭 제약을 만족하는지, 함수 반환형이 호출자가 기대한 형태와 맞는지 알려면 현재 파일의 문법만 보고 끝낼 수 없다.

targetlib은 런타임 계약의 서로 다른 면이다

tsconfig.jsontargetlib은 이름만 보면 비슷해 보이지만 서로 다른 단계에 영향을 준다. target은 이미터가 어느 수준의 자바스크립트를 출력할지 결정한다. lib은 타입 체커가 실행 환경에 어떤 표준 API가 존재한다고 가정할지 결정한다.

구형 문법을 대상으로 하면서도 최신 API 타입을 허용하는 설정은 다음처럼 쓸 수 있다.

JSON
{  "compilerOptions": {    "target": "ES5",    "lib": ["DOM", "ES2016"]  }}

이 설정에서 화살표 함수는 ES5에 맞게 다운레벨될 수 있다. 그러나 libES2016을 넣는다고 Array.prototype.includes 구현이 출력 파일에 추가되지는 않는다. 체커가 해당 메서드의 타입 선언을 읽고 사용을 허용할 뿐이다. 실제 브라우저에 메서드가 없다면 별도의 폴리필이 필요하다.

이 차이는 사소한 옵션 설명이 아니라 책임 경계이다. 타입 선언은 런타임에 무엇이 있다고 믿을지를 기술한다. 폴리필은 런타임에 실제 구현을 공급한다. 선언과 구현이 어긋나면 컴파일은 통과하고 실행은 실패한다.

tsc가 자바스크립트를 출력한다고 번들러가 되는 것은 아니다

tsc는 타입을 소거하고 최신 문법을 다운레벨하며 .d.ts도 만들 수 있다. 그렇다고 웹 애플리케이션 번들러의 책임까지 맡는 것은 아니다. 엔트리에서 시작해 npm 패키지와 CSS, 이미지를 하나의 배포 그래프로 만들거나, 사용 경로에 따라 청크를 나누거나, 콘텐츠 해시를 붙이는 것이 tsc의 중심 목적은 아니다.

outFile 옵션이 파일을 합치는 경우도 있지만 지원하는 모듈 형식과 사용 목적이 제한적이다. 오늘날의 웹 앱이 요구하는 코드 스플리팅, 에셋 처리, 청크 최적화까지 대신하지 않는다. 파일을 출력할 수 있다는 사실과 배포용 모듈 그래프를 구성한다는 사실을 구분해야 한다.

반대로 TypeScript 소스를 빠르게 자바스크립트로 바꾸는 도구가 모두 타입을 검사하는 것도 아니다. 다음 코드를 Babel의 TypeScript 프리셋이나 esbuild, SWC가 타입 소거 모드로 처리하면 자바스크립트는 생성될 수 있다.

TS
const count: number = "not a number";const enabled: boolean = 123;

타입 주석을 지우는 일만 보면 결과는 유효한 자바스크립트이다. 하지만 타입 계약은 명백히 깨졌다. 단일 파일 변환기는 프로그램 전체의 타입 관계를 검사하지 않기 때문에, 빠른 변환과 타입 검사를 같은 것으로 보면 안 된다.

세 도구를 함께 쓴 이유는 중복보다 분리였다

Webpack, Babel, tsc가 함께 있던 전형적인 구성을 다시 보면 각 도구의 입력과 출력이 연결된다.

초기 TypeScript Webpack 구성에서는 ts-loader가 공식 TypeScript 컴파일러를 호출해 타입 검사와 코드 출력을 함께 처리하는 방식이 자연스러웠다. 설정도 이해하기 쉬웠다.

JS
module.exports = {  entry: "./src/index.ts",  module: {    rules: [      {        test: /\.tsx?$/,        use: "ts-loader",        exclude: /node_modules/      }    ]  }};

다만 프로젝트가 커지면 파일별 변환이 진행되는 경로에서 프로그램 전체 타입 검사가 함께 기다리는 구성이 개발 서버의 갱신을 늦출 수 있었다. 이것이 ts-loader 자체가 언제나 느리다는 뜻은 아니다. 증분 컴파일과 transpileOnly, 별도 검사 플러그인 같은 선택지가 생긴 것도 이 결합 지점을 조정하려는 시도였다.

Babel 7의 @babel/preset-typescript는 다른 선택을 가능하게 했다. Babel은 타입의 옳고 그름을 판단하지 않고 현재 파일에서 타입 문법을 지운 뒤 기존 자바스크립트 변환을 이어간다. 무거운 타입 검사는 tsc --noEmit이나 별도 프로세스로 옮길 수 있다.

CI에서는 다음처럼 타입 검사와 번들 생성을 명시적으로 직렬화할 수 있다.

JSON
{  "scripts": {    "type-check": "tsc --noEmit",    "build": "npm run type-check && webpack --mode production"  }}

첫 명령은 자바스크립트를 출력하지 않고 타입만 검사한다. 타입 검사가 실패하면 번들 빌드로 넘어가지 않는다. 로컬 개발에서는 빠른 변환기로 화면 갱신을 먼저 진행하면서 에디터의 TypeScript 언어 서비스나 백그라운드 체커가 오류를 알리도록 구성할 수 있다.

나는 이 구조를 “성능을 위해 타입 검사를 생략한다”라고 설명하는 것보다, 피드백의 종류와 시점을 나눈다고 보는 편이 낫다고 생각한다. 브라우저에서 변경 화면을 확인하는 피드백과 프로그램 전체의 타입 계약을 확인하는 피드백은 계산 범위가 다르다. 둘을 한 동기 경로에 두지 않더라도 배포 전에는 반드시 합류시킬 수 있다.

여기에도 대가는 있다. Babel처럼 파일 단위로 TypeScript를 변환할 때는 다른 파일의 타입 정보가 있어야만 올바르게 방출할 수 있는 일부 문법을 피해야 한다. isolatedModules 계열 검사는 이런 단일 파일 변환 환경과 맞지 않는 코드를 일찍 찾는 장치이다. 타입 검사와 변환을 분리했다면 양쪽 설정이 같은 모듈 의미와 실행 대상을 가정하는지도 확인해야 한다.

최신 도구는 구현을 바꾸었지 경계를 없애지 않았다

2020년 무렵부터 빌드 도구의 속도 경쟁은 구현 언어와 개발 서버 구조를 바꾸었다. Go로 작성된 esbuild와 Rust로 작성된 SWC는 파싱과 코드 생성을 네이티브 코드와 병렬 처리에 맞게 다시 구현했다. Rspack은 Webpack과 비슷한 설정 및 생태계 호환성을 목표로 Rust 코어를 제공했고, Turbopack은 증분 계산을 중심에 둔 번들러로 발전해 현재 Next.js의 기본 번들러가 되었다. Bun은 런타임, 패키지 관리자, 트랜스파일러와 번들러를 한 배포물 안에 묶는다.

이름만 보면 예전의 세 도구가 하나로 합쳐진 듯하지만 내부 책임은 여전히 나뉜다. esbuild와 SWC의 TypeScript 트랜스파일, 그리고 현재 Vite가 사용하는 Oxc Transformer는 타입 문법을 빠르게 제거하지만 프로젝트 전체 타입 검사는 수행하지 않는다. Rspack이나 Turbopack이 번들과 에셋을 만들더라도 타입 계약은 별도의 검사 단계에서 확인한다. TypeScript 프로젝트에서는 tsc가 여전히 관례상 가장 권위 있는 프로젝트 검사기이지만, 중요한 것은 특정 구현을 고집하는 일이 아니라 트랜스파일 성공과 타입 검사 성공을 구분하는 일이다.

TypeScript 자체도 네이티브 툴체인 흐름 밖에 머물지 않았다. 2026년 7월 공개된 TypeScript 7은 컴파일러와 언어 서비스를 Go로 포팅했고, 공식 발표는 TypeScript 6 대비 전체 빌드에서 대체로 8~12배의 향상을 보고했다. 설치 패키지는 계속 typescript이고 명령 진입점도 tsc이다. 체커 책임은 그대로 남았지만 그 구현이 자바스크립트에서 네이티브 코드로 바뀐 셈이다. 책임의 분리와 구현 언어의 선택을 같은 문제로 보면 안 되는 사례이기도 하다.

Vite가 보여준 변화도 같은 맥락이다. 전통적인 개발 서버는 애플리케이션 그래프를 먼저 번들링한 뒤 서버를 열었다. 초기 Vite는 브라우저의 네이티브 ES Modules를 이용해 소스 파일을 요청 시점에 변환하고, 자주 바뀌지 않는 외부 의존성은 미리 묶었다.

두 접근의 차이는 다음처럼 요약할 수 있다.

TXT
전통적인 번들 기반 개발 서버  전체 그래프 탐색 → 개발 번들 생성 → 서버 준비 → 브라우저 요청Vite의 ESM 기반 개발 서버  서버 준비 → 브라우저가 모듈 요청 → 요청된 소스를 변환해 응답

개발 중에는 현재 화면이 요구한 소스만 처리하므로 시작 시간을 줄일 수 있었다. 그러나 프로덕션에서는 중첩된 모듈을 그대로 배포할 때 네트워크 요청이 늘고 최적의 청크 구성이 어려워 번들링이 여전히 필요했다. Vite는 오랫동안 개발 변환과 의존성 사전 번들링에 esbuild를, 프로덕션 번들에 Rollup을 사용했다.

2026년 3월 공개된 Vite 8은 번들러를 Rust 기반 Rolldown으로 통합했다. TypeScript 소스의 트랜스파일은 Oxc Transformer가 맡는다. 하나의 제품 안에서도 모듈 그래프를 구성하는 일과 파일 문법을 변환하는 일은 별도 단계인 셈이다. 이것이 “더는 번들러와 트랜스파일러를 구분할 필요가 없다”는 뜻은 아니다. Vite 팀이 대형 코드베이스를 위해 개발 중 전체 번들 모드도 검토하는 것처럼, 네이티브 ESM과 사전 번들링 중 어느 쪽이 나은지도 프로젝트 규모와 요청 비용에 따라 다시 조정된다.

Babel의 자리도 같은 방식으로 봐야 한다. 표준 자바스크립트, JSX, TypeScript 문법 제거만 필요하다면 SWC, esbuild, Oxc 같은 구현으로 옮길 수 있다. 반면 특정 Babel 플러그인에 의존하는 변환이 있다면 Babel이 계속 남을 수 있다. React Compiler도 현재 Babel 플러그인으로 통합할 수 있지만 핵심 컴파일러는 Babel과 분리된 구조이며 다른 툴체인과의 통합이 진행되고 있다. 제품 이름보다 필요한 변환 계약을 먼저 확인해야 하는 이유이다.

설정 파일에서 먼저 확인할 것

새 프로젝트의 빌드 설정을 볼 때 나는 패키지 목록보다 실패의 책임부터 나누는 편이 낫다고 생각한다. 이름이 낯선 도구라도 아래 질문에 답하면 파이프라인의 위치가 대체로 보인다.

이 질문은 Webpack을 쓰든 Vite를 쓰든 크게 달라지지 않는다. tsc --noEmit이 빌드 스크립트와 별개로 존재한다면 타입 검사 경계가 분리된 것이다. Babel 설정이 사라졌다면 필요한 변환을 SWC나 Oxc가 맡는지 확인하면 된다. Webpack 설정이 보이지 않는다고 번들링 책임 자체가 사라진 것도 아니다. 프레임워크나 상위 빌드 도구가 안으로 감췄을 가능성이 크다.

물론 모든 프로젝트에 세 종류의 독립된 프로그램이 필요한 것은 아니다. 최신 브라우저만 대상으로 하는 작은 애플리케이션이라면 폴리필이 거의 필요 없을 수 있고, Node.js용 라이브러리는 브라우저 에셋 파이프라인이 필요하지 않을 수 있다. 단순한 TypeScript 스크립트는 tsc 하나로 충분할 때도 있다. 책임을 분리한다는 말은 패키지를 무조건 늘리라는 뜻이 아니라, 생략한 책임이 정말 불필요한지 알고 결정한다는 뜻이다.

결국 남는 것은 책임의 경계이다

초기의 <script> 태그 문제에서 Webpack까지 이어진 흐름은 모듈과 에셋의 관계를 누가 관리할 것인가에 대한 답이었다. ES2015 이후 Babel이 맡은 일은 작성 가능한 문법과 실제 실행 환경 사이의 간격을 다루는 일이었다. TypeScript는 프로그램이 실행되기 전에 파일을 가로질러 타입 계약을 검사하는 층을 만들었다.

세 도구가 한동안 함께 쓰인 이유는 우연한 유행도, 기능이 부족한 도구를 마구 덧댄 결과만도 아니다. 서로 다른 범위의 계산을 한 동기 경로에 억지로 넣지 않고, 필요한 지점에서 연결한 구조에 가깝다. 물론 당시의 구체적인 설정이 오늘날에도 정답이라는 뜻은 아니다. babel-loader가 SWC로 바뀌고 Webpack이 Rspack이나 Vite로 바뀔 수 있으며, 폴리필 전략도 지원 환경에 따라 달라진다.

그래도 설계 질문은 남는다. 어떤 도구를 쓰는지가 아니라 모듈 그래프, 문법과 런타임 호환성, 타입 검증의 실패를 각각 어디에서 발견하고 누가 책임질 것인가이다. 최신 도구는 여러 책임을 한 제품 안에서 더 빠르게 처리한다. 그러나 책임을 구분하지 않은 채 제품 이름만 바꾸면, 빌드는 성공했는데 타입 오류가 남거나, 타입 검사는 통과했는데 오래된 런타임에서 실패하거나, 개발 서버에서는 보이던 에셋이 배포 청크에서 빠지는 식의 문제가 다시 나타난다.

내가 보기에는 빌드 설정이 복잡한 진짜 이유도 여기에 있다. 같은 코드를 여러 번 만지는 도구가 많아서가 아니라, 같은 소스를 서로 다른 경계에서 검사하고 변환해야 하기 때문이다. 구현은 계속 바뀔 것이다. 그때도 먼저 볼 것은 도구의 이름보다 책임이 끊기는 지점이다.

댓글

댓글을 불러오는 중...