번들러는 무슨 일을 하는 걸까?

Develop
2024-06-22

번들링이란?

여러 자원을 하나의 파일로 결합하는 것

우리는 수많은 모듈을 개발하고, 각 모듈이 서로를 의존하는 형태로 프로그램을 만들고 있습니다.
이 많은 모듈을 모두 개별적으로 로드하는 것은 꽤나 비효율적입니다.
번들링은 이러한 비효율을 해결하기 위해 여러 모듈을 하나의 파일로 결합하려는 것입니다.

조금 더 자세히 살펴볼까요?


번들링을 하는 이유

모듈을 개별적으로 로드하는 것은 하나의 파일을 로드하는 것보다 비효율적이기 때문이다.

애초에 모듈을 개별적으로 로드하는 것은 왜 비효율적일까요?

모듈을 개별적으로 로드하게 되면, 각 모듈마다 HTTP 요청을 보내야 합니다.
HTTP 요청을 보낼때는 도메인을 IP 주소로 변환하는 DNS 조회, TCP 연결(three-way handshake), HTTP 요청, 응답 등의 과정을 거치게 됩니다.
아무래도 이런 과정에는 조금이나마 시간이 걸리지 않을까요?

또한 각 요청과 응답에는 헤더 정보가 포함되어 있어야 합니다.
이로 인해 네트워크 비용이 증가하게 됩니다.

예를 들어 볼까요?
1kb 크기의 파일을 100개 로드하는 경우를 생각해 봅시다.

  • HTTP 헤더: 각 요청과 응답마다 한 번 포함됩니다. 약 200바이트 정도로 가정합시다.
  • TCP/IP 헤더: 각 패킷마다 포함됩니다. 각 패킷에 대해 40바이트(20바이트 IP 헤더 + 20바이트 TCP 헤더)의 오버헤드가 추가됩니다.

따라서, 각 파일에 대해 HTTP 요청과 응답이 발생하며, 이는 약 400바이트(200바이트 요청 + 200바이트 응답)의 HTTP 헤더 오버헤드를 발생시킵니다. 100개의 파일을 로드할 경우, 40kb의 HTTP 헤더 오버헤드가 발생합니다.

또한, 각 파일에 대해 TCP/IP 헤더 오버헤드도 발생합니다. 1kb의 파일은 일반적으로 하나의 패킷으로 전송될 수 있으므로, 각 파일마다 40바이트의 TCP/IP 헤더가 발생합니다. 100개의 파일을 로드할 경우, 4kb의 TCP/IP 헤더 오버헤드가 발생합니다.

결론적으로, 1kb 크기의 파일을 100개 로드할 경우 총 44kb(40kb의 HTTP 헤더 + 4kb의 TCP/IP 헤더)의 헤더 오버헤드가 발생합니다.

또한 위에서 언급한 DNS 조회, TCP 연결, HTTP 요청, 응답 등의 과정을 고려하여 각 요청마다 50ms의 네트워크 지연이 발생한다고 가정해 봅시다.
100개의 요청을 보내면 총 5초의 네트워크 지연이 발생하게 됩니다.

계산해보면, 100개의 1kb 파일을 로드하는데 총 데이터 전송량은 120kb, 5초의 네트워크 지연이 발생하게 됩니다.

이제 100kb 크기의 파일을 1개 로드하는 경우를 생각해 봅시다.

마찬가지로 하나의 모듈을 로드할 때 대략 400바이트의 HTTP 헤더 오버헤드가 발생합니다.
또한, 100kb의 파일은 TCP 계층에서 여러 패킷으로 나뉘어 전송될 수 있습니다.
패킷의 크기는 1500바이트지만, TCP/IP 헤더가 들어갈 자리를 뺀다면 1460바이트 단위로 나누어 전송될 것입니다.
따라서 100kb의 파일은 대충 70개의 패킷으로 전송되는데, 각 패킷마다 40바이트의 TCP/IP 헤더가 발생합니다.
70개의 패킷을 로드할 경우, TCP/IP 헤더 오버헤드가 한 2.8kb.. 대충 3kb로 하겠습니다😉

따라서 100kb 파일 1개를 로드하는 데 총 데이터 전송량은 103.4kb가 됩니다. 네트워크 지연은 50ms에 불과합니다.

헤더 오버헤드와 네트워크 지연을 고려하면, 100개의 1kb 파일을 로드하는 것보다 100kb 파일을 하나 로드하는 것이 훨씬 효율적입니다.

그리고 사실 100개의 요청은 한 번에 보낼 수 없습니다.

HTTP/1.1 표준(RFC 2616)에서는 클라이언트가 서버에 과부하를 주지 않도록 동시 연결 수를 제한할 것을 권장합니다.
주요 브라우저 개발사들이 테스트한 결과, 동시 연결 수를 6개로 제한하는 것이 가장 효율적이라고 결론 내렸다고 합니다.
더 많은 연결을 허용하면 네트워크 혼잡과 서버 부하가 증가하고, 너무 적은 연결은 페이지 로딩 속도를 저하시킬 수 있기 때문입니다.
따라서 100개의 요청을 보내는 경우, (브라우저마다 조금씩 다르지만) 6개씩 나눠서 보내야 합니다.
100개의 요청을 동시에 병렬로 보낼 수 없으니 시간은 더욱 길어지겠죠?

HTTP/2는 이러한 문제를 해결하기 위해 하나의 TCP 연결에서 다중 요청(Multiplexing)을 지원합니다.
HTTP/2는 데이터를 작은 프레임으로 나누어 전송합니다. 각 프레임은 스트림(stream)이라는 논리적 채널에 속하며, 하나의 TCP 연결을 통해 여러 스트림이 병렬로 전송될 수 있습니다.

HTTP/2에서 다중 요청이 등장했음에도, 여러 요청을 보내는 것보다 하나의 요청을 보내는 것이 효율적이라는 사실은 변함이 없습니다.
서버와 네트워크 리소스는 여전히 제한적입니다. 너무 많은 스트림이 동시에 열리면 서버의 메모리 및 CPU 사용량이 급격히 증가하여 성능 저하가 발생할 수 있습니다.
네트워크 혼잡을 피하고 안정적인 성능을 유지하기 위해 스트림 수를 제한하는 것이 필요합니다.
일반적으로 서버는 초기 설정으로 100개의 동시 스트림을 허용하지만, 서버의 성능 및 네트워크 환경에 따라 이 값을 조정할 수 있습니다.

만약 현재 웹사이트가 HTTP/2 프로토콜을 사용하고 있는지 확인하고 싶다면, 브라우저 개발자 도구의 Network 탭에서 Protocol을 확인하면 됩니다.
h2라고 적혀 있다면 HTTP/2 프로토콜을 사용하고 있다는 뜻입니다.


번들러의 역할

여러 모듈을 하나의 파일로 결합하고, 그 과정에서 압축 및 최적화 등의 작업을 수행한다.

번들러는 여러 모듈을 하나의 파일로 결합하는 번들링을 수행하는 도구입니다.

의존성 관리

상호 의존적인 모듈들을 올바른 순서로 결합하기 위해 모듈 간의 의존성 그래프를 생성하고, 이를 기반으로 번들링을 수행합니다.
의존성 그래프를 만드는 과정을 한 번 볼까요?

// index.js
import { foo } from './foo.js';
foo();
 
// foo.js
import { bar } from './bar.js';
export const foo = () => {
  bar();
};
 
// bar.js
export const bar = () => {
  console.log('bar');
};

위와 같은 3개의 모듈이 있다고 가정해 봅시다.
번들러는 각 파일의 소스 코드를 파싱하여 추상 구문 트리(AST)를 생성합니다.
먼저 index.js를 파싱하면 다음과 같은 AST를 생성할 수 있습니다.

Program
├── ImportDeclaration (import { foo } from './foo.js')
│   ├── ImportSpecifier (foo)
│   └── StringLiteral ('./foo.js')
└── ExpressionStatement (foo())
    └── CallExpression
        └── Identifier (foo)

이제 생성된 AST를 통해 각 파일의 의존성을 추적합니다.
import 또는 require 구문을 찾으면 어떤 파일을 의존하는지 알 수 있겠죠? 이 경우, foo.js를 의존하고 있음을 알 수 있습니다. 이제 foo.js의 AST를 생성하면 다음과 같습니다.

Program
├── ImportDeclaration (import { bar } from './bar.js')
│   ├── ImportSpecifier (bar)
│   └── StringLiteral ('./bar.js')
├── ExportNamedDeclaration (export const foo = ...)
│   └── VariableDeclaration (const foo = ...)
│       └── VariableDeclarator
│           ├── Identifier (foo)
│           └── ArrowFunctionExpression (foo = () => {...})
│               └── BlockStatement
│                   └── ExpressionStatement (bar())
│                       └── CallExpression
│                           └── Identifier (bar)

(bar.js는 생략합니다.)

이처럼 재귀적으로 의존 관계를 분석하고 AST를 생성하여 의존성 그래프를 그리면 이렇게 그려집니다.

index.js -> foo.js -> bar.js

위 그래프를 기반으로 번들링을 수행하면, bar.js -> foo.js -> index.js 순서로 모듈을 결합할 수 있습니다.

// 번들링 결과
const bar = () => {
  console.log('bar');
};
 
const foo = () => {
  bar();
};
 
foo();

이 과정에서 번들러는 동일한 모듈이 여러 번 포함되는 것을 방지하여 번들 파일의 크기를 줄입니다.

참고로, 생성된 AST를 어떻게 저렇게 정확히 알았냐면...
astexplorer에 접속 후 코드를 붙여넣으면 AST를 확인할 수 있습니다.(저는 acorn 파서를 사용했습니다.)
eslint 플러그인을 만들때 사용했던 적이 있어서 이번에도 슬쩍 활용해 보았습니다.

압축 및 최적화

번들링 과정에서 코드를 압축하고, 트리 쉐이킹을 통해 사용되지 않는 코드를 제거하는 등의 최적화 작업을 수행합니다.

Minify & Uglify (압축)

압축이란 코드의 크기를 줄이는 것을 의미합니다.
주석, 공백, 줄바꿈 등을 제거하고(Minify), 변수명을 짧게 바꾸는(Uglify) 등의 작업을 수행합니다.

// 압축 전
const somethingVeryLongFunction = () => {
  // hi를 출력하는 함수 :)
  console.log('hi');
};
 
// 압축 후
const a=()=>{console.log('hi')};

Tree-shaking(트리 쉐이킹)

Tree-shaking은 사용되지 않는 코드를 제거하여 번들 파일의 크기를 줄이는 작업입니다.

// utils.js
export function usedFunction() {
  console.log('This function is used');
}
 
export function unusedFunction() {
  console.log('This function is not used');
}
 
// main.js
import { usedFunction } from './utils.js';
usedFunction();

위 코드에서 unusedFunction은 사용되지 않는 코드이므로, 트리 쉐이킹 후 번들 파일에 포함되지 않습니다. 아주 편리하죠?😊

그러나 트리 쉐이킹은 ES6 모듈 시스템(이하 ESM)에서만 가능합니다.
CommonJS 모듈 시스템(이하 CJS)에서는 트리 쉐이킹이 불가능합니다.

모듈 시스템에 관한 자세한 이야기는 다른 글에서 다루도록 하겠습니다 🤗

그 이유는 무엇일까요?

트리 쉐이킹은 정적 분석(Static Analysis)을 통해 사용되지 않는 코드를 식별하고 제거합니다.
이를 위해서는 빌드 타임에 코드의 의존성 그래프를 명확하게 분석할 수 있어야 합니다.

하지만 CJS는 동적 로딩을 지원하기 때문에 실제로 어떤 함수가 사용되는지는 런타임에만 알 수 있습니다.
즉, '모듈의 의존 관계가 런타임에 결정된다'라고 표현할 수 있겠습니다.

// utils.js
exports.add = (a, b) => a + b;
exports.subtract = (a, b) => a - b;
 
// main.js
const utils = require('./utils.js');
console.log(utils.add(1, 2));

여기서 require는 런타임에 평가되므로, 번들러가 정적으로 subtract 함수가 사용되지 않음을 파악하기 어렵습니다.

그리고 동적 require 호출을 통해 어떤 모듈을 로드할지가 런타임에 결정되는 경우가 있습니다.

const modules = someCondition ? './utils.js' : './anotherUtils.js';
const module = require(modules);

위 예시의 modules는 런타임에 결정되므로, 번들러는 어떤 모듈이 로드되는지 빌드 타임에 알 수 없습니다.

또한 CJS는 로드된 이후에도 exports 객체를 동적으로 수정할 수 있습니다.
이는 번들러가 모듈의 구조를 신뢰할 수 없게 만듭니다.

// utils.js
exports.add = (a, b) => a + b;
exports.subtract = (a, b) => a - b;
 
// main.js
const utils = require('./utils.js');
if (addNewFunction) {
  utils.multiply = (a, b) => a * b;
}

위 예시처럼 utils 객체는 런타임에 동적으로 수정될 수 있으므로, 번들러는 어떤 코드가 실제로 사용되는지 파악하기 어렵습니다.
이처럼 런타임에만 알 수 있는 정보는 번들링 과정에서 사용할 수 없기 때문에 CJS에서는 트리 쉐이킹을 수행할 수 없습니다.

반면에 ESM에서 트리 쉐이킹이 가능한 이유는 무엇일까요?
아무래도 CJS와 반대라고 생각하면 되겠죠?

ESM의 import 구문은 정적으로 평가되기 때문에, 번들러가 빌드 타임에 코드의 의존성 그래프를 명확하게 파악할 수 있습니다.

// utils.js
export function add(a, b) {
  return a + b;
}
 
export function subtract(a, b) {
  return a - b;
}
 
// main.js
import { add } from './utils.js';
console.log(add(1, 2));

번들러는 subtract 함수가 사용되지 않는다는 것을 빌드 타임에 명확하게 파악할 수 있습니다.

또한, ESM에서는 import된 모듈을 동적으로 수정할 수 없습니다.

// utils.js
export function add(a, b) {
  return a + b;
}
 
export function subtract(a, b) {
  return a - b;
}
 
// main.js
import { add } from './utils.js';
add = (a, b) => a * b; // ❌ 불가능!

이러한 불변성 덕분에 번들러는 모듈의 구조를 신뢰할 수 있습니다.

코드 스플리팅

코드 스플리팅은 번들 파일을 여러 청크로 분할하는 작업을 의미합니다. 애써 하나로 합쳤는데 왜 분할하는 걸까요..?

번들 파일이 너무 커지면, 초기 로딩 시간이 길어져 UX를 좋지 않게 만들 수 있습니다. 따라서 초기 번들에 포함될 필요가 없다고 판단되는 코드를 필요할 때 불러오도록 하거나, 초기 번들에 필요한 코드라도 몇 개의 청크로 분할하여 병렬로 로드할 수 있습니다.

React로 개발하고 있다면, React.lazy를 사용하여 코드 스플리팅을 쉽게 구현할 수 있습니다.

const Component = React.lazy(() => import('./Component'));

React.lazy는 함수를 인자로 받아 해당 함수가 반환하는 Promiseresolve되면 컴포넌트를 렌더링합니다.
여기서 사용된 import는 번들러에서 제공하는 동적 import 구문으로, Promise를 반환합니다.
동적 import 구문을 만나면 번들러는 해당 모듈을 별도의 청크로 분리하고, 런타임이 되면 React.lazy는 해당 청크를 로드합니다.

동적 import 구문에 대해 조금 더 알아볼까요?

const module = './module.js';
 
import(module)
  .then(module => {
    // module.js를 로드한 후 실행할 코드
  });

이렇게 기본적인 예시 말고, 조금 더 고민해볼만한 예시를 하나 들어보자면...

const modules = someCondition ? './utils.js' : './anotherUtils.js';
 
import(modules)
  .then(module => {
    // modules를 로드한 후 실행할 코드
  });

위 예시에서 modules는 런타임에 결정되는 값입니다.
아까 위의 트리 쉐이킹에서 CJS가 트리 쉐이킹을 할 수 없는 이유로 들었던 예시인데요, 이는 ESM의 동적 import 구문에서도 마찬가지입니다.
ESM을 사용하면 번들러가 빌드 타임에 의존성 관계를 파악할 수 있다고 설명했지만, 어디에나 예외는 있습니다 😉
동적 import 구문을 사용하면 번들러는 빌드 타임에 의존성 관계를 파악할 수 없게 됩니다.

그럼 utils.jsanotherUtils.js는 번들 파일에 포함될까요? 아니면 별도의 청크로 분리될까요?
정답은... 둘 다 별도의 청크로 분리됩니다!

그럼 트리 쉐이킹은 어떻게 이루어질까요?
청크로 나누어진 상태여도 트리 쉐이킹의 대상입니다. 번들러는 각 청크를 분석하여 사용되지 않는 코드를 제거합니다.

이야기가 조금 엇나갔네요.
코드 스플리팅은 번들 파일을 여러 청크로 분할하여 초기 로딩 시간을 줄이는 작업입니다.
다만, 사용하기에 따라 오히려 초기 로딩 시간이 늘어날 수도 있으니 주의해야 합니다.

모듈 포맷 변환

위에서 CJS와 ESM에 대해 대략적으로 설명했는데, 번들러는 이러한 모듈 시스템을 변환하는 작업도 수행합니다. 어떤 라이브러리를 만드는데, ESM과 CJS를 모두 지원해야 한다면 번들러를 통해 변환하는 작업이 꼭 필요합니다.

간단하게는 위에서 언급한 AST를 통해 import/export 구문을 require/module.exports로 변환하는 작업이라고 생각하면 될 것 같습니다.
ImportDeclaration 노드를 찾아서 VariableDeclarationCallExpression으로 변환하고, ExportNamedDeclaration 노드를 exports 객체로 변환하는 식으로요.
하지만 실제로는 동적 import 구문, default export, named export, Top-Level Await 등을 고려하여 복잡한 변환 작업이 필요합니다.

간단하게만 예시를 들어보자면 다음과 같습니다.

ESM에서 동적 import 구문을 사용한 경우에는 CJS에서 Promise를 사용하여 동일한 동작을 구현해야 합니다.

// ESM
const module = await import('./module.js');
 
// CJS
const modulePromise = new Promise((resolve, reject) => {
  try {
    const module = require('./module.js');
    resolve(module);
  } catch (error) {
    reject(error);
  }
});
modulePromise.then(module => {
  // module.js를 로드한 후 실행할 코드
});

이는 require가 동기적으로 동작하는 반면, import는 비동기적으로 동작하기 때문입니다.
CJS는 파일 시스템 접근을 기반으로 설계되었기 때문에, 로컬 파일 시스템에서 모듈을 동기적으로 로드하는 것이 일반적입니다.
반면 ESM은 브라우저 환경에서 네트워크를 통해 모듈을 로드하는 경우를 염두에 두었기 때문에 비동기적으로 동작합니다.
여기서 비동기적 이라는 표현은 모듈이 로드되는 동안 다른 작업을 수행할 수 있다는 의미입니다.

ESM에서 default export는 단일 값을 내보내는 용도로 사용됩니다.
CJS에서는 module.exports를 사용하여 기본 내보내기를 구현합니다.

// ESM
export default function myFunction() {
  console.log('default export');
}
 
// CJS
function myFunction() {
    console.log('default export');
}
module.exports = myFunction;

ESM에서 named export는 여러 값을 내보내는 용도로 사용됩니다. CJS에서는 exports 객체를 사용하여 명명된 내보내기를 구현합니다.

// ESM
export function myFunction() {
  console.log('named export');
}
 
// CJS
function myFunction() {
    console.log('named export');
}
exports.myFunction = myFunction;

이처럼 번들러는 ESM과 CJS를 상호 변환하는 작업도 수행합니다.

에셋 관리

번들러는 자바스크립트 파일 뿐만 아니라, CSS, 이미지, 폰트 등의 에셋도 관리합니다.
주로 loader, plugin과 같은 서드파티 도구를 사용해서 에셋을 최적화하는데,
각 번들러마다 유명한 것들이 있으니 눈치껏... 취향껏... 잘 사용하시면 될 것 같습니다.
(file-loader, css-loader, style-loader 등이 대표적인 예시입니다.)
뭐 압축도 해주고 이것저것 필요한 기능들이 대부분 개발되어 있을 겁니다.

트랜스파일링

최신 자바스크립트 문법을 구형 브라우저에서도 동작할 수 있도록 변환하는 작업을 트랜스파일링이라고 합니다.
번들러가 한다고 표현하기는 조금 그렇고, 트랜스파일러가 해당 작업을 수행하는데 번들링 과정에서 함께 수행되는 경우가 많습니다.
대표적으로 Babel이 있는데, 공식 문서에서는 Babel을 이렇게 소개하고 있습니다.

Babel is a JavaScript compiler
Babel is a toolchain that is mainly used to convert ECMAScript 2015+ code into a backwards compatible version of JavaScript in current and older browsers or environments.

즉, Babel은 ECMAScript 2015+ 코드를 현재 및 이전 브라우저 또는 환경에서 호환되는 버전의 JavaScript로 변환하는 컴파일러입니다.
근데 왜 트랜스파일러가 아니라 컴파일러라고 소개하는 걸까요?

컴파일러는 코드를 더 낮은 수준의 코드로 변환하는 프로그램을 의미합니다. 그런데 그 중에서도 같은 수준의 언어로 컴파일하는 프로그램은 트랜스파일러라고 부릅니다.
트랜스파일러가 컴파일러의 하위 개념이라고 생각한다면 틀린 소개는 아닌 것 같네요.

이제 트랜스파일링이 왜 필요한지 알아보겠습니다.

제가 개발자로 전직하기 이전, 예전에 다니던 회사는 커머스 회사였습니다. 주 사용자층이 40~50대 이상일 정도로 높았는데, 그러다보니 갤럭시S2같은 옛날 스마트폰을 사용하는 고객도 꽤나 많았습니다.

그러던 어느 날, 갑자기 구매 페이지의 구매 버튼이 사라졌다고 합니다.
손실이 얼마나 발생했는지는 정확히 모르겠습니다만, 규모가 꽤 큰 커머스였기 때문에 손실이 아주 없지는 않았던 것 같습니다.
다행히도 크게 이슈가 되지는 않았고, 조용히 몇몇 사람만 아는 사건으로 남았습니다.

시간이 지난 후, 친한 개발자 동기가 찾아와 괴로워하며 그날의 진실을 털어놓았습니다.
제 동기는 백엔드 개발자로 입사했는데, 프론트엔드 개발도 간간히 하곤 했던 것 같습니다.
당시에는 제가 개발자가 아니었기 때문에 개발팀의 사정은 모르지만, 회사에 프론트엔드 개발자가 따로 없었을 수도 있겠네요.

어쨌든 그날도 어김없이 동기는 백엔드, 프론트엔드를 넘나드는 걸출한 개발 역량을 뽐내며 코드를 작성했습니다.
그때 자연스럽게 화살표 함수를 사용했는데, 그것이 구형 브라우저에서 동작하지 않아 버튼을 없애버린 것이었습니다.
얼마 전까지 학생으로 최신 스택만 보면서 개발 공부를 해왔던 그에게는 너무나 당연한 선택이었겠지만, 버튼을 잃어버린 갤럭시S2 사용자들에게도 당연한 선택이었을까요?

그렇다면 Babel은 그때 무슨 역할을 했을까요?
아까는 분명 구형 브라우저에서도 동작할 수 있도록 변환해주는 도구라고 했는데... 어째서 버튼이 사라진 걸까요?

이유는 바로바로~ Babel이 없었기 때문이라고 합니다.
근데 구형 브라우저를 지원해야 하는데 왜 Babel이 없었을까요?

지금은 저도, 그 동기도, 다른 동기들도 대부분이 퇴사 및 이직을 했기 때문에 앞으로도 미스테리로 남을 것 같습니다.
정말로 Babel이 없었을까요? 아니면 친구의 MSG였던 걸까요?

// ES6
const example = () => {
  console.log('example');
};
 
// ES5
var example = function example() {
  console.log('example');
};

위 코드는 화살표 함수를 일반 함수로 변환하는 예시입니다.
이외에도 let/constvar로 변환하거나, 템플릿 리터럴문자열 연결로 변환합니다.
이렇게 최신 문법을 ES5로 변환해주는 트랜스파일러가 있었다면 버튼이 사라지지 않았을지도 모르겠네요.

그런데 실제로 Babel을 설치하고 트랜스파일링을 해보면, ES5로 변환되지 않고 그대로 뱉어내는 것을 확인할 수 있습니다.
그 이유는 Babel이 어떻게 트랜스파일링 할지 알려주는 plugin들을 추가하지 않았기 때문입니다.
@babel/preset-env만 추가하면 대부분의 필요한 plugin들을 사용할 수 있습니다.

또한, Babel은 내부적으로 browserlist를 사용합니다.
브라우저별로, 버전별로 사용할 수 있는 문법이 다르기 때문에 사용할 폴리필도 달라지고, 빌드 결과도 달라집니다.

// .browserslistrc 혹은 package.json
{
  "browserslist": [
    "last 2 versions",
    "not dead"
  ]
}

last 2 versions는 이전 2개 버전의 브라우저를 지원하겠다는 뜻이고, not dead는 지원이 중단된 브라우저를 제외한다는 의미입니다.

이렇게 Babel을 열심히 알아봤는데요, 최근에는 swc(Speedy Web Compiler)라는 트랜스파일러가 주목받고 있습니다.
한국인 개발자가 만들고 Vercel에 합류했다는 이야기를 듣고 국뽕... 아니 국뽕 빼고 봐도 멀티스레드 언어인 Rust로 만들어져 17배나 속도 향상이 가능하다고 합니다.
(17배는 특정한 환경에서의 테스트 결과이고, 실제로는 그렇게 빠르지 않을 수도 있습니다. 솔직히 17배는 너무 차이가 커서 신뢰도가 약간...)

swc는 Next.js 12부터 기본으로 사용되기 때문에, Next.js로 개발하고 있다면 이미 swc를 사용하고 있다는 뜻입니다.
어떻게... 체감이 좀 되시나요?

소스맵 생성

번들러는 소스맵이라고 하는 파일을 생성하여 디버깅을 보조합니다.
소스맵은 번들 파일과 원본 파일 간의 매핑 정보를 갖고 있는 .map파일로, 디버거나 에러가 어느 파일의 어느 라인에서 발생했는지 확인할 수 있게 해줍니다.
구체적으로 소스맵이 어떻게 생긴 것이고, 어떻게 매핑하는지는 아래 발표를 참고하시면 좋을 것 같습니다.

Jonathan Kuperman - The Future of Source Maps Web Engines Hackfest 2024

발표에서는 소스맵에 추가될 기능에 대해서도 설명하고 있습니다. 그 중에서 scope information에 대한 내용이 흥미롭습니다.
기존 소스맵의 한계는 컴파일러에 의해 인라인된 함수를 추적하기 어렵다는 것이었는데요,
인라인된 함수를 다시 분리하고, 해당 스코프 내에서 접근 가능한 변수는 무엇인지, 원래 함수의 네이밍은 무엇이었는지 등을 확인할 수 있는 scope information을 추가할 예정이라고 합니다.
이를 위해서 originalScopesgeneratedRanges라는 새로운 필드를 추가할 예정으로 보이네요.


결론

이렇게 번들러가 하는 일을 알아보았습니다.
번들러는 여러 모듈을 하나의 파일로 결합하고, 그 과정에서 압축 및 최적화, 코드 스플리팅, 모듈 포맷 변환, 에셋 관리, 트랜스파일링, 소스맵 생성 등의 작업을 수행합니다.
많은 일을 해주는 도구지만, 우리는 번들러에 대해 잘 모르고 그저 습관처럼 사용하고 있는 것 같습니다.
특히 CRA, CNA등으로 프로젝트를 생성하면 번들러 설정을 손보지 않아도 어느 정도의 성능을 보장받을 수 있기 때문에 작은 프로젝트에서는 번들러를 직접 설정할 기회가 없을 때도 많습니다.
어쩌다 보니 회사 프로젝트를 CRA에서 Vite로 마이그레이션 하고있는데, 이 글을 작성하면서 공부했던 것이 큰 도움이 되었습니다.
마이그레이션 했던 경험도 추후 정리하여 공유해보겠습니다. 안녕~ 👋