
트랜스파일러: Babel, TypeScript
최신 문법(ES6+)을 구형 브라우저(IE)가 알아듣게 번역해주는 통역사. 컴파일러와는 묘하게 다른 트랜스파일러의 세계.

최신 문법(ES6+)을 구형 브라우저(IE)가 알아듣게 번역해주는 통역사. 컴파일러와는 묘하게 다른 트랜스파일러의 세계.
내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.

미로를 탈출하는 두 가지 방법. 넓게 퍼져나갈 것인가(BFS), 한 우물만 팔 것인가(DFS). 최단 경로는 누가 찾을까?

프론트엔드 개발자가 알아야 할 4가지 저장소의 차이점과 보안 이슈(XSS, CSRF), 그리고 언제 무엇을 써야 하는지에 대한 명확한 기준.

이름부터 빠릅니다. 피벗(Pivot)을 기준으로 나누고 또 나누는 분할 정복 알고리즘. 왜 최악엔 느린데도 가장 많이 쓰일까요?

새벽 2시에 모니터링 알람이 울렸다. "사용자들이 로그인을 못하고 있어요!" 급하게 Sentry를 열어보니 IE11 사용자들에게서만 Promise is not defined 에러가 쏟아지고 있었다. 분명히 나는 Babel을 설정했고, ES6 코드를 작성했는데 왜 IE11에서 터질까?
답은 간단했다. Babel은 문법(syntax)은 변환해주지만 새로운 객체나 메서드(Promise, Array.includes 등)는 자동으로 추가해주지 않는다. 그게 폴리필(polyfill)의 역할이었고, 나는 core-js를 제대로 설정하지 않았던 것이다.
이 사건 이후 나는 트랜스파일러가 정확히 무엇을 하고 무엇을 하지 않는지, 컴파일러와는 어떻게 다른지, Babel의 프리셋과 플러그인 시스템이 어떻게 작동하는지 제대로 이해해야 했다. 그 과정에서 정리한 내용이 이 글이다.
처음에는 이 둘이 비슷해 보였다. 둘 다 코드를 입력받아서 다른 형태의 코드로 바꿔주니까. 하지만 프론트엔드 빌드 파이프라인을 구축하면서 결정적인 차이를 받아들였다.
컴파일러(Compiler)는 언어의 추상화 레벨을 바꾼다. C 코드를 어셈블리로, 자바를 바이트코드로 변환한다. 고수준 언어를 저수준 언어로 변환하는 것이다. 마치 소설을 영화 대본으로 바꾸는 것과 비슷하다. 매체 자체가 바뀐다.
트랜스파일러(Transpiler)는 비슷한 추상화 레벨에서 언어를 변환한다. TypeScript를 JavaScript로, ES2023 JavaScript를 ES5 JavaScript로 바꾼다. 같은 매체 안에서 방언을 바꾸는 것이다. 한국어를 북한어로, 영국 영어를 미국 영어로 번역하는 느낌이랄까.
공식 용어로는 "Source-to-Source Compiler"라고도 부른다. 결국 컴파일의 한 형태지만, 목적과 결과물의 레벨이 다르다는 걸 이해했다.
2015년, ES6(ES2015)가 나왔을 때 프론트엔드 개발자들은 환호했다. const, let, 화살표 함수, 클래스, Promise, 구조 분해 할당... 드디어 모던한 문법으로 코드를 쓸 수 있게 됐다.
하지만 현실은 달랐다. 회사 고객 중 30%가 여전히 IE11을 쓰고 있었다. IE11은 ES6를 거의 지원하지 않았다. 그렇다고 최신 문법을 포기할 순 없었다. 개발자 경험(DX)과 코드 품질을 위해서 최신 문법은 필수였다.
이 딜레마의 해법이 바로 Babel이었다. 나는 ES6+로 코드를 작성하고, Babel이 빌드 타임에 ES5로 변환해주면 된다. 개발자는 미래에서 코드를 쓰고, 사용자는 과거의 브라우저로 실행하는 시간 여행 같은 구조였다.
Babel이 정확히 어떻게 코드를 변환하는지 이해하기 위해 내부 동작을 파헤쳐봤다. 핵심은 AST(Abstract Syntax Tree) 변환이었다.
예를 들어 화살표 함수를 일반 함수로 변환하는 과정은 이렇다.
// 입력 코드
const add = (a, b) => a + b;
// 1. Parse - AST 생성
{
type: "ArrowFunctionExpression",
params: [{ name: "a" }, { name: "b" }],
body: {
type: "BinaryExpression",
operator: "+",
left: { name: "a" },
right: { name: "b" }
}
}
// 2. Transform - ArrowFunctionExpression -> FunctionExpression
{
type: "FunctionExpression",
params: [{ name: "a" }, { name: "b" }],
body: {
type: "BlockStatement",
body: [{
type: "ReturnStatement",
argument: {
type: "BinaryExpression",
operator: "+",
left: { name: "a" },
right: { name: "b" }
}
}]
}
}
// 3. Generate - 코드 생성
var add = function(a, b) {
return a + b;
};
이 AST 변환 과정을 이해하니 Babel의 플러그인 시스템이 와닿았다. 각 플러그인은 특정 AST 노드를 찾아서 변환하는 역할을 한다.
처음 Babel을 설정할 때 가장 헷갈렸던 부분이 프리셋(preset)과 플러그인(plugin)의 차이였다. 결국 이렇게 정리했다.
플러그인: 하나의 문법 변환 기능. 예를 들어 @babel/plugin-transform-arrow-functions는 화살표 함수만 변환한다.
프리셋: 여러 플러그인의 묶음. @babel/preset-env는 수십 개의 플러그인을 포함한다.
매번 필요한 플러그인을 일일이 추가하는 건 비효율적이다. 그래서 실제로는 프리셋을 사용한다.
// .babelrc
{
"presets": [
["@babel/preset-env", {
"targets": {
"browsers": ["> 0.5%", "last 2 versions", "not dead", "IE 11"]
},
"useBuiltIns": "usage",
"corejs": 3
}]
]
}
이 설정의 의미를 하나씩 뜯어보자.
targets: 어떤 브라우저를 지원할지 정의한다. 시장 점유율 0.5% 이상, 각 브라우저의 최신 2버전, 그리고 IE11까지 지원한다는 뜻이다.
useBuiltIns: "usage": 이게 폴리필의 핵심이다. 코드에서 실제로 사용된 최신 기능만 폴리필을 추가한다. "entry"로 설정하면 타겟 브라우저에 필요한 모든 폴리필을 추가하는데, 번들 크기가 커진다.
corejs: 3: 폴리필 라이브러리 버전. core-js@3를 사용하겠다는 의미다.
Babel만 브라우저 타겟을 설정하는 게 아니다. Autoprefixer(CSS 벤더 프리픽스), ESLint, Webpack 등 여러 도구가 타겟 브라우저 정보를 필요로 한다. 각 도구마다 따로 설정하면 불일치가 생긴다.
그래서 browserslist라는 통합 설정을 사용한다.
// package.json
{
"browserslist": [
"> 0.5%",
"last 2 versions",
"not dead",
"IE 11"
]
}
혹은 .browserslistrc 파일을 만들어도 된다.
# .browserslistrc
> 0.5%
last 2 versions
not dead
IE 11
이제 Babel, Autoprefixer, 모든 도구가 이 설정을 공유한다. 설정 하나로 전체 빌드 파이프라인의 타겟이 통일된다.
새벽 2시 사건으로 돌아가보자. 왜 Babel이 Promise를 변환하지 못했을까?
Babel은 문법(syntax) 변환만 한다. 화살표 함수(=>)를 일반 함수로, const를 var로 바꾼다. 하지만 새로운 객체나 메서드(Promise, Map, Set, Array.includes 등)는 변환할 수 없다. 이건 런타임에 존재해야 하는 것들이기 때문이다.
이때 필요한 게 폴리필이다. 폴리필은 구형 브라우저에 없는 기능을 JavaScript로 구현해서 추가한다.
// Promise 폴리필 예시 (core-js가 이런 식으로 추가함)
if (typeof Promise === 'undefined') {
window.Promise = function(executor) {
// Promise 구현 코드...
};
Promise.prototype.then = function(onFulfilled, onRejected) {
// then 구현...
};
// ...
}
core-js는 ES5+의 모든 폴리필을 제공하는 라이브러리다. Babel의 useBuiltIns: "usage" 설정과 함께 사용하면, 코드에서 실제로 사용한 기능만 자동으로 임포트된다.
// 내가 작성한 코드
const promise = Promise.resolve(42);
[1, 2, 3].includes(2);
// Babel + core-js가 변환한 코드
import "core-js/modules/es.promise.js";
import "core-js/modules/es.array.includes.js";
var promise = Promise.resolve(42);
[1, 2, 3].includes(2);
나는 폴리필을 추가하지 않았지만, Babel이 알아서 필요한 것만 임포트했다. 이게 useBuiltIns: "usage"의 마법이다.
TypeScript 컴파일러(tsc)도 트랜스파일러다. 하지만 Babel과는 역할이 다르다.
// 입력: TypeScript 코드 (ES2020 + 타입)
interface User {
name: string;
age: number;
}
const getUser = async (id: number): Promise<User> => {
const response = await fetch(`/api/users/${id}`);
return response.json();
};
const user: User = await getUser(1);
console.log(user?.name);
// 출력: JavaScript 코드 (ES5, tsconfig의 target에 따라)
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
// async/await 폴리필 코드...
};
var getUser = function (id) {
return __awaiter(this, void 0, void 0, function () {
var response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4, fetch("/api/users/" + id)];
case 1:
response = _a.sent();
return [2, response.json()];
}
});
});
};
var user = await getUser(1);
console.log(user === null || user === void 0 ? void 0 : user.name);
tsc가 한 일:
interface User, : number, : Promise<User> 등)async/await를 제너레이터 기반 폴리필로 변환 (downleveling)?.)을 삼항 연산자로 변환이게 Type Stripping(타입 제거)과 Downleveling(하위 레벨 변환)이다.
TypeScript 컴파일러의 동작은 tsconfig.json으로 제어한다.
{
"compilerOptions": {
"target": "ES5", // 출력 JavaScript 버전
"module": "commonjs", // 모듈 시스템 (commonjs, es6, esnext 등)
"lib": ["ES2020", "DOM"], // 사용 가능한 내장 API 타입 정의
"strict": true, // 엄격한 타입 검사
"esModuleInterop": true, // CommonJS/ES Module 호환성
"skipLibCheck": true, // node_modules 타입 검사 생략
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true, // Babel/SWC 호환성 (파일별 독립 변환)
"noEmit": true // .js 파일 생성 안 함 (타입 검사만)
}
}
target이 핵심이다. ES5로 설정하면 화살표 함수, const, 클래스 등을 ES5 문법으로 변환한다. ES2020으로 설정하면 그대로 둔다.
noEmit: true는 흥미로운 옵션이다. 요즘 프로젝트에서는 TypeScript를 타입 검사용으로만 쓰고, 실제 트랜스파일은 Babel이나 SWC에 맡긴다. 이유는 속도 때문이다.
실제로는 TypeScript 프로젝트에서도 Babel을 함께 쓰는 경우가 많다. 왜일까?
TypeScript만 사용하는 경우:tsc만 실행하면 된다@babel/preset-typescript 사용)tsc --noEmit)결국 나는 이렇게 정리했다.
작은 프로젝트: tsc만 사용
큰 프로젝트: Babel로 트랜스파일, TypeScript로 타입 검사
Babel과 tsc의 공통된 단점은 속도다. JavaScript로 만들어졌고, 큰 프로젝트에서는 느리다.
이 문제를 해결하기 위해 나온 게 SWC(Rust 기반)와 esbuild(Go 기반)다.
SWC (Speedy Web Compiler):속도 비교를 해보니 체감이 확실했다.
# 같은 프로젝트 빌드 시간
Babel: 15초
tsc: 12초
SWC: 0.8초
esbuild: 0.3초
단, SWC와 esbuild는 Babel처럼 플러그인 생태계가 풍부하지 않다. 커스텀 변환이 많이 필요하면 Babel을 써야 한다. 하지만 대부분의 일반적인 케이스에서는 SWC나 esbuild로 충분하다는 걸 깨달았다.
트랜스파일러가 JavaScript만의 영역은 아니다. CSS 세계에도 트랜스파일러가 있다.
Sass/SCSS: SCSS는 CSS의 슈퍼셋이다. 변수, 중첩, 믹스인, 함수 등을 제공한다. 브라우저는 SCSS를 이해하지 못하므로, Sass 컴파일러가 CSS로 변환한다.
// 입력: SCSS
$primary-color: #3498db;
$padding: 16px;
.button {
background-color: $primary-color;
padding: $padding;
&:hover {
background-color: darken($primary-color, 10%);
}
&--large {
padding: $padding * 2;
}
}
/* 출력: CSS */
.button {
background-color: #3498db;
padding: 16px;
}
.button:hover {
background-color: #2980b9;
}
.button--large {
padding: 32px;
}
PostCSS: PostCSS는 JavaScript 플러그인으로 CSS를 변환하는 도구다. Babel의 CSS 버전이라고 보면 된다.
가장 유명한 플러그인이 Autoprefixer다. 최신 CSS를 작성하면 자동으로 벤더 프리픽스를 추가해준다.
/* 입력 */
.container {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
/* Autoprefixer 적용 후 */
.container {
display: -ms-grid;
display: grid;
-ms-grid-columns: (1fr)[3];
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
PostCSS는 Tailwind CSS의 핵심 엔진이기도 하다. Tailwind의 유틸리티 클래스를 실제 CSS로 변환하는 게 PostCSS의 역할이다.
실제 프로젝트에서 트랜스파일러는 단독으로 쓰이지 않는다. Webpack, Vite, Rollup 같은 번들러와 통합되어 사용된다.
Webpack + Babel 예시:// webpack.config.js
module.exports = {
module: {
rules: [
{
test: /\.(js|jsx|ts|tsx)$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: [
'@babel/preset-env',
'@babel/preset-typescript',
'@babel/preset-react'
]
}
}
}
]
}
};
흐름은 이렇다:
import 구문을 따라가며 파일들을 찾음.js, .jsx, .ts, .tsx 파일을 발견하면 babel-loader에게 넘김Vite의 경우: 개발 모드에서는 esbuild로 TypeScript를 빠르게 변환하고, 프로덕션 빌드에서는 Rollup + Babel(또는 SWC)를 사용한다.
// vite.config.js
import { defineConfig } from 'vite';
export default defineConfig({
esbuild: {
target: 'es2015'
},
build: {
target: 'es2015'
}
});
새벽 2시 사건을 해결한 최종 설정을 공유한다.
// package.json
{
"browserslist": [
"> 0.5%",
"last 2 versions",
"not dead",
"IE 11"
],
"dependencies": {
"core-js": "^3.30.0"
},
"devDependencies": {
"@babel/core": "^7.21.0",
"@babel/preset-env": "^7.21.0",
"babel-loader": "^9.1.0"
}
}
// .babelrc
{
"presets": [
[
"@babel/preset-env",
{
"useBuiltIns": "usage",
"corejs": 3,
"modules": false,
"debug": true // 어떤 폴리필이 추가되는지 로그 출력
}
]
]
}
빌드를 돌려보니 콘솔에 이런 로그가 나왔다.
Using polyfills with `usage` option:
[/path/to/file.js] Added following core-js polyfills:
es.promise
es.array.includes
es.object.assign
es.string.starts-with
이제 내가 사용한 모든 최신 기능에 폴리필이 추가되는 걸 확인할 수 있었다. IE11에서 다시 테스트해보니 완벽하게 작동했다.
결국 이렇게 정리했다.
레거시 브라우저 지원 필요 → Babel + core-js.babelrc로 오버라이드 가능트랜스파일러를 이해하고 나니, 프론트엔드 개발의 본질이 보였다. 우리는 미래의 문법으로 코드를 작성하고, 트랜스파일러가 과거의 브라우저에서도 작동하게 만든다.
컴파일러와 트랜스파일러의 차이는 명확하다. 컴파일러는 언어의 레벨을 바꾸고(고수준 → 저수준), 트랜스파일러는 같은 레벨에서 방언을 바꾼다(ES2023 → ES5, TypeScript → JavaScript).
Babel의 AST 변환, 프리셋과 플러그인 시스템, 폴리필과 문법 변환의 차이, TypeScript 컴파일러의 이중 역할(타입 검사 + 트랜스파일), SWC와 esbuild의 속도 혁명, 그리고 CSS 트랜스파일러까지.
이 모든 도구들은 하나의 목표를 향한다. 개발자가 최고의 도구로 코드를 쓰고, 사용자는 어떤 환경에서든 실행할 수 있게 하는 것.
새벽 2시 사건은 고통스러웠지만, 트랜스파일러의 동작 원리를 깊이 이해하는 계기가 됐다. 이제는 폴리필 설정도, browserslist도, 빌드 파이프라인 구성도 자신 있게 할 수 있다.
트랜스파일러는 단순한 도구가 아니다. 프론트엔드 생태계의 시간 여행 기계다.