번들링, 빌드, 그리고 TypeScript 변환의 모든 것
프론트엔드 개발을 하다 보면 webpack, 번들링, 빌드, TypeScript, Babel 등의 용어를 자주 접하게 됩니다.
하지만 이들의 정확한 차이와 관계를 이해하는 것은 쉽지 않습니다.
이 글에서는 이러한 개념들을 체계적으로 정리하고, 실제로 어떻게 동작하는지 상세히 알아보겠습니다.
1. 번들링 vs 빌드: 어떤 개념이 더 클까?
빌드가 더 큰 개념입니다
빌드(Build)는 소스 코드를 프로덕션 환경에서 실행 가능한 형태로 변환하는 전체 프로세스를 의미합니다.
반면 **번들링(Bundling)은 여러 파일을 하나 또는 몇 개의 파일로 합치는 특정 작업을 가리킵니다.
번들링이란?
번들링은 여러 개의 파일을 하나로 합치는 과정입니다:
src/
├── index.js
├── utils.js
└── components/
├── Header.js
└── Footer.js
↓ 번들링
dist/
└── bundle.js (모든 파일이 합쳐짐)
빌드 프로세스에 포함되는 작업들
빌드는 훨씬 더 많은 작업을 포함합니다:
번들링 - 파일 합치기
트랜스파일링 - Babel로 ES6+ → ES5 변환
압축/최적화 - 코드 minification, uglification
코드 스플리팅 - 필요에 따라 여러 번들로 나누기
에셋 처리 - 이미지, 폰트, CSS 처리
환경 변수 주입 - production/development 설정
소스맵 생성 - 디버깅을 위한 맵 파일 생성
해싱 - 캐시 무효화를 위한 파일명 해싱
실제 webpack 설정 예시
// webpack.config.js
module.exports = {
// 빌드 프로세스 설정
mode: 'production',
// 번들링 시작점
entry: './src/index.js',
output: {
filename: '[name].[contenthash].js' // 해싱
},
module: {
rules: [
{ test: /\.js$/, use: 'babel-loader' }, // 트랜스파일링
{ test: /\.css$/, use: ['style-loader', 'css-loader'] } // CSS 처리
]
},
optimization: {
minimize: true, // 압축
splitChunks: { /* ... */ } // 코드 스플리팅
}
};
관계 정리
빌드 (Build)
├── 번들링 (Bundling) ← 핵심 작업
├── 트랜스파일링
├── 최적화
├── 압축
└── 기타 변환 작업들
2. Webpack을 왜 '번들러'라고 부를까?
Webpack은 실제로는 빌드 도구의 역할을 하는데, 왜 '번들러'라고 부를까요?
Webpack은 2012년에 등장했을 때 "모듈 번들러(Module Bundler)"로 소개되었습니다.
당시 여러 script 태그로 파일을 관리하던 문제를 해결하기 위해, **"여러 파일을 하나로 합치는 것"**이 핵심 가치였습니다.
이미 존재하던 Grunt, Gulp 같은 태스크 러너와 차별화하기 위해 "의존성 그래프 기반 모듈 번들링"이라는 독특한 정체성을 강조했고, 그래서 '번들러'라는 이름이 붙었습니다.
// Webpack의 핵심 아이디어: 모든 것을 모듈로
import Header from './Header.js'
import utils from './utils.js'
import './styles.css' // CSS도 모듈!
import logo from './logo.png' // 이미지도 모듈!
// → 의존성 그래프를 만들고 번들링
실제로는 "빌더"가 더 정확한 표현이지만, "번들링"이 가장 핵심적이고 독특한 기능이기 때문에 그 이름으로 불립니다.
3. TSX에서 JS로: 상세한 변환 과정
.tsx 파일이 .js 파일로 변환되는 과정을 단계별로 살펴보겠습니다.
ts-loader의 역할
먼저, Webpack에서 TypeScript 파일을 처리하는 ts-loader의 역할을 이해해야 합니다.
ts-loader는 내부적으로 **TypeScript 컴파일러(tsc)**를 호출합니다. 실제 변환 작업은 tsc가 수행하고, ts-loader는 Webpack과 tsc 사이의 중개자(bridge) 역할만 합니다:
// webpack.config.js
module.exports = {
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader', // TypeScript Compiler를 호출
exclude: /node_modules/
}
]
},
resolve: {
extensions: ['.tsx', '.ts', '.js']
}
};
// tsconfig.json (ts-loader가 이 설정을 따름)
{
"compilerOptions": {
"target": "ES5", // ES5 JavaScript로 변환
"module": "ESNext", // 모듈 시스템
"strict": true, // 엄격한 타입 체크
"jsx": "react", // JSX 처리 방법
"sourceMap": true // 소스맵 생성
}
}
이제 실제 변환 과정을 살펴보겠습니다.
원본 TSX 파일
// src/components/UserCard.tsx
import React, { useState } from 'react';
interface UserProps {
name: string;
age: number;
isAdmin?: boolean;
}
const UserCard: React.FC<UserProps> = ({ name, age, isAdmin = false }) => {
const [count, setCount] = useState<number>(0);
const handleClick = (): void => {
setCount(prev => prev + 1);
};
return (
<div className="user-card">
<h2>{name}</h2>
<p>Age: {age}</p>
{isAdmin && <span>Admin</span>}
<button onClick={handleClick}>
Clicked {count} times
</button>
</div>
);
};
export default UserCard;
1단계: TypeScript 컴파일러 (타입 제거)
TypeScript의 모든 타입 정보를 제거합니다:
// 타입 제거 후
import React, { useState } from 'react';
const UserCard = ({ name, age, isAdmin = false }) => {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(prev => prev + 1);
};
return (
<div className="user-card">
<h2>{name}</h2>
<p>Age: {age}</p>
{isAdmin && <span>Admin</span>}
<button onClick={handleClick}>
Clicked {count} times
</button>
</div>
);
};
export default UserCard;
제거된 것들:
interface UserProps: React.FC<UserProps>: string,: number,: boolean<number>(제네릭): void
2단계: JSX 변환
JSX 문법을 일반 JavaScript 함수 호출로 변환합니다:
// JSX → JavaScript
import React, { useState } from 'react';
const UserCard = ({ name, age, isAdmin = false }) => {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(prev => prev + 1);
};
return React.createElement(
'div',
{ className: 'user-card' },
React.createElement('h2', null, name),
React.createElement('p', null, 'Age: ', age),
isAdmin && React.createElement('span', null, 'Admin'),
React.createElement(
'button',
{ onClick: handleClick },
'Clicked ', count, ' times'
)
);
};
export default UserCard;
JSX 변환 규칙:
<div className="box">Hello</div>
↓
React.createElement('div', { className: 'box' }, 'Hello')
<Component prop={value}>Children</Component>
↓
React.createElement(Component, { prop: value }, 'Children')
3단계: ES6+ → ES5 변환 (선택적)
tsconfig.json의 target 설정에 따라 추가 변환이 이루어집니다:
// target: "ES5"인 경우
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
var react_1 = __importDefault(require("react"));
var UserCard = function (_a) {
var name = _a.name, age = _a.age, _b = _a.isAdmin, isAdmin = _b === void 0 ? false : _b;
var _c = react_1.default.useState(0),
count = _c[0],
setCount = _c[1];
var handleClick = function () {
setCount(function (prev) { return prev + 1; });
};
return react_1.default.createElement(
"div",
{ className: "user-card" },
react_1.default.createElement("h2", null, name),
react_1.default.createElement("p", null, "Age: ", age),
isAdmin && react_1.default.createElement("span", null, "Admin"),
react_1.default.createElement(
"button",
{ onClick: handleClick },
"Clicked ", count, " times"
)
);
};
exports.default = UserCard;
변환된 것들:
const→varArrow functions →
functionDestructuring → 명시적 변수 할당
import→requireexport→exports
전체 변환 흐름도
┌─────────────────────────────────┐
│ UserCard.tsx │
│ - TypeScript │
│ - JSX │
│ - ES6+ 문법 │
└────────────┬────────────────────┘
│
↓ ts-loader (TypeScript Compiler)
│
┌────────────┴────────────────────┐
│ 중간 단계 │
│ - TypeScript 타입 제거 │
│ - 여전히 JSX │
└────────────┬────────────────────┘
│
↓ JSX Transform
│
┌────────────┴────────────────────┐
│ 중간 단계 │
│ - React.createElement() │
│ - 여전히 ES6+ │
└────────────┬────────────────────┘
│
↓ Target 변환 (선택적)
│
┌────────────┴────────────────────┐
│ UserCard.js │
│ - 순수 JavaScript │
│ - 브라우저 실행 가능 │
└─────────────────────────────────┘
4. 변환 과정의 담당자들
각 변환 단계를 누가 담당하는지 명확히 정리해보겠습니다.
핵심은 Loader는 실제 변환을 하지 않고, 컴파일러/트랜스파일러를 호출만 한다는 것입니다.
TypeScript Compiler (tsc)의 역할
담당 작업:
타입 체크
타입 제거
JSX 변환
ES6+ → target 문법 변환
// 입력: .tsx
interface User { name: string }
const App: React.FC = () => <div>Hello</div>;
// 출력: .js
const App = () => React.createElement('div', null, 'Hello');
ts-loader의 실제 역할
ts-loader는 실제 변환을 하지 않고, TypeScript Compiler를 호출하는 역할만 합니다:
// ts-loader 내부 동작 (개념적)
const typescript = require('typescript');
function tsLoader(source) {
// TypeScript 컴파일러 호출
const result = typescript.transpileModule(source, {
compilerOptions: { /* tsconfig.json 설정 */ }
});
return result.outputText; // JavaScript 반환
}
변환 흐름: TypeScript만 사용
┌──────────────────────┐
│ Component.tsx │
│ - TypeScript │
│ - JSX │
│ - ES6+ │
└──────────┬───────────┘
│
│ Webpack
↓
┌──────────────┐
│ ts-loader │ (단순히 tsc 호출)
└──────┬───────┘
│
↓
┌─────────────────────────┐
│ TypeScript Compiler │
│ (tsc) │
│ │
│ 1. 타입 체크 & 제거 │
│ 2. JSX → createElement │
│ 3. ES6+ → target 문법 │
└──────┬──────────────────┘
│
↓
┌──────────────┐
│ Component.js │
│ (ES5/ES6) │
└──────────────┘
변환 흐름: TypeScript + Babel 조합
┌──────────────────────┐
│ Component.tsx │
└──────────┬───────────┘
│
│ Webpack
↓
┌──────────────┐
│ ts-loader │
└──────┬───────┘
│
↓
┌─────────────────────────┐
│ TypeScript Compiler │
│ │
│ 1. 타입 체크 & 제거 │
│ 2. JSX → createElement │
│ 3. target: ESNext 유지 │
└──────┬──────────────────┘
│
↓
┌──────────────┐
│ babel-loader │
└──────┬───────┘
│
↓
┌─────────────────────────┐
│ Babel │
│ │
│ 1. ES6+ → ES5 │
│ 2. Polyfill 추가 │
│ 3. 브라우저 호환성 처리 │
└──────┬──────────────────┘
│
↓
┌──────────────┐
│ Component.js │
│ (ES5) │
└──────────────┘
각 도구의 역할 정리
| 도구 | 역할 | 특징 |
| TypeScript Compiler (tsc) | 타입 체크 + 타입 제거 + JSX 변환 + 문법 변환 | 타입 안정성 보장, 느림 |
| ts-loader | tsc를 호출하는 Webpack 로더 | tsc의 래퍼 |
| Babel | 문법 변환 + Polyfill | 유연함, 플러그인 많음 |
| babel-loader | Babel을 호출하는 Webpack 로더 | Babel의 래퍼 |
핵심: Loader는 실제 변환을 하지 않고, 해당 컴파일러/트랜스파일러를 호출만 합니다!
5. Babel vs TypeScript Compiler: 속도만 다를까?
많은 개발자들이 Babel과 TypeScript Compiler의 차이를 단순히 "속도" 정도로만 생각합니다.
하지만 실제로는 훨씬 더 근본적인 차이가 있습니다.
타입 체크 여부 (가장 중요한 차이)
TypeScript Compiler (tsc):
interface User {
name: string;
age: number;
}
const user: User = {
name: "John",
age: "30" // ❌ 에러: string은 number에 할당 불가
};
// 컴파일 실패!
Babel (@babel/preset-typescript):
interface User {
name: string;
age: number;
}
const user: User = {
name: "John",
age: "30" // ⚠️ 에러를 발견하지 못함!
};
// 컴파일 성공! (런타임에 버그 발생 가능)
지원하는 TypeScript 기능
TypeScript Compiler:
// ✅ 모든 TypeScript 기능 지원
// const enum (인라인 최적화)
const enum Direction {
Up,
Down
}
const dir = Direction.Up; // → const dir = 0;
// namespace
namespace Utils {
export function log() {}
}
// legacy decorators
@sealed
class MyClass {}
Babel:
// ❌ 일부 TypeScript 기능 미지원
// const enum - 일반 enum처럼 처리 (최적화 안 됨)
const enum Direction {
Up,
Down
}
// namespace - 지원 안 함
namespace Utils { // ❌ 에러
export function log() {}
}
Babel이 지원하지 않는 TypeScript 기능:
const enum(최적화된 형태)namespaceLegacy decorators
export =/import =(CommonJS 스타일)
Polyfill 처리
TypeScript Compiler:
// tsconfig.json
{
"compilerOptions": {
"target": "ES5",
"lib": ["ES2015"]
}
}
const promise = new Promise();
// ❌ Promise polyfill을 수동으로 추가해야 함
Babel:
// babel.config.js
{
"presets": [
["@babel/preset-env", {
"useBuiltIns": "usage", // 자동 polyfill 추가
"corejs": 3
}]
]
}
const promise = new Promise();
// ✅ 필요한 polyfill 자동으로 추가됨!
브라우저 타겟팅
TypeScript Compiler:
{
"compilerOptions": {
"target": "ES5" // 단순히 ES 버전만 지정
}
}
Babel:
{
"presets": [
["@babel/preset-env", {
"targets": {
"chrome": "58",
"ie": "11",
"> 1%",
"not dead"
}
}]
]
}
// 특정 브라우저에 맞춰 최적화!
비교 표
| 항목 | TypeScript Compiler | Babel |
| 타입 체크 | ✅ 수행 | ❌ 수행 안 함 |
| 속도 | 느림 (타입 체크 때문) | 빠름 |
| TypeScript 기능 | 100% 지원 | ~90% 지원 |
| Polyfill | 수동 추가 필요 | 자동 추가 가능 |
| 브라우저 타겟팅 | 제한적 (ES 버전만) | 정교함 (browserslist) |
| 플러그인 | 제한적 | 매우 풍부 |
| 설정 파일 | tsconfig.json | .babelrc / babel.config.js |
| 목적 | TypeScript → JavaScript | 모던 JS → 레거시 JS |
핵심 차이점 요약
TypeScript Compiler (tsc)
✅ 타입 체크로 안전성 보장
✅ 모든 TS 기능 100% 지원
❌ 빌드 속도 느림
❌ 제한적인 변환 옵션
Babel
✅ 빠른 빌드 속도
✅ 유연한 플러그인 생태계
✅ 정교한 브라우저 타겟팅
✅ 자동 Polyfill 주입
❌ 타입 체크 불가
❌ 일부 TS 기능 미지원
6. 실무 전략: 어떤 조합을 선택할까?
전략 1: Babel만 사용
추천 상황: 빠른 빌드가 최우선인 경우
// 개발: Babel로 빠르게 변환
// 타입 체크: 별도로 tsc --noEmit 실행
// webpack.config.js
{
test: /\.tsx?$/,
use: 'babel-loader'
}
// package.json
{
"scripts": {
"dev": "webpack serve",
"type-check": "tsc --noEmit --watch"
}
}
장점
빠른 HMR (Hot Module Replacement)
유연한 변환 옵션
단점
- 타입 체크 별도 실행 필요
전략 2: tsc만 사용
추천 상황: 설정 단순화와 타입 안전성이 중요한 경우
// webpack.config.js
{
test: /\.tsx?$/,
use: 'ts-loader'
}
// tsconfig.json
{
"compilerOptions": {
"target": "ES5"
}
}
장점
설정 단순
타입 안전성 보장
단점
느린 빌드
제한적인 변환
전략 3: 하이브리드
추천 상황: 빌드 속도와 타입 안전성 모두 중요한 경우
// webpack.config.js
{
test: /\.tsx?$/,
use: [
{
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env']
}
},
{
loader: 'ts-loader',
options: {
transpileOnly: true // 타입 체크 스킵
}
}
]
}
// package.json
{
"scripts": {
"dev": "webpack serve",
"type-check": "tsc --noEmit --watch",
"build": "tsc --noEmit && webpack"
}
}
장점
빠른 빌드
Babel의 유연성 활용
단점
- 설정 복잡
전략 4: esbuild
추천 상황: 초고속 빌드가 필요하고 최신 도구를 선호하는 경우
// 둘 다 대체하는 초고속 빌드 도구
{
test: /\.tsx?$/,
use: 'esbuild-loader'
}
장점
- 매우 빠름 (Go로 작성)
단점
타입 체크 안 함
일부 기능 제한
결론
Webpack과 관련된 빌드 프로세스를 이해하는 것은 프론트엔드 개발자에게 필수적입니다.
핵심을 다시 정리하면:
빌드는 번들링을 포함하는 더 큰 개념입니다.
Webpack은 의존성 그래프 기반 모듈 번들링이라는 핵심 정체성 때문에 '번들러'라고 불립니다.
ts-loader는 TypeScript Compiler를 호출하는 중개자입니다.
TSX → JS 변환은 타입 제거, JSX 변환, 문법 변환의 3단계로 이루어집니다.
Babel과 TypeScript Compiler는 속도뿐만 아니라 기능, 목적, 철학이 다릅니다.
각 도구의 특성을 이해하고 프로젝트의 요구사항에 맞는 전략을 선택하는 것이 중요합니다.
타입 안전성이 중요하다면 TypeScript Compiler를, 빌드 속도와 유연성이 중요하다면 Babel을, 또는 두 가지를 조합하여 사용할 수 있습니다.
📝 참고사항: 이 글은 AI 어시스턴트 Claude(Anthropic)와의 대화를 바탕으로 작성되었습니다. 기술적 정확성을 위해 노력했지만, 실제 프로젝트에 적용하기 전에 공식 문서를 참고하시기 바랍니다.