모듈 시스템과 번들러의 모든 것

이종경·2026년 2월 6일

Vite Deep Dive

목록 보기
1/3
post-thumbnail

초기 웹에서 자바스크립트는 UI 토글, 간단한 애니메이션 등 작은 수준의 동적 처리를 담당하는 보조적인 언어였습니다 그래서 모듈 시스템을 별도로 도입할 필요가 크지 않았고, 필요한 스크립트를 HTML에 <script> 태그로 순서대로 추가하는 방식이 일반적이었습니다. 하지만 브라우저의 성능이 좋아지고 프론트엔드가 복잡한 기능을 맡기 시작하면서 코드량이 급격히 늘었고, 파일 간 의존성과 네임스페이스 충돌, 재사용성 문제 때문에 기존 방식만으로는 유지보수가 어려워졌습니다.

전역 스코프 문제

초기 브라우저 환경에서 자바스크립트 파일을 여러 개로 나누더라도, <script>로 로드된 코드는 한 페이지의 전역 컨텍스트 위에서 순서대로 실행됩니다. 그 결과 파일이 분리되어 있어도 전역 변수와 함수는 서로 공유되며, 쉽게 접근 및 수정될 수 있습니다.

// A.js
var name = 'foo';

// B.js
function sayHello() {
  alert('Hello ' + name); // 'Hello foo'
}
<!-- index.html -->
<html>
  <script src="/src/A.js" />
  <script src="/src/B.js" />
</html>

위 예시에서 name은 전역에 선언되므로 모든 스크립트가 같은 값을 공유합니다. 이는 편리해 보이지만, 다른 파일이 같은 이름을 사용하거나 값을 변경하면 의도치 않은 사이드 이펙트가 생길 수 있고, 의존성이 명시되지 않는다는 것입니다.

당시의 임시 해결책

개발자들은 전역 오염을 줄이기 위해 여러 패턴을 사용했습니다. 대표적으로는 코드 영역을 함수로 감싸 전역 노출을 최소화하는 IIFE(즉시 실행 함수), 그리고 전역 객체 하나에 기능을 모아 담는 네임스페이스 패턴이 있습니다.

// 1. IIFE (즉시 실행 함수) 패턴: 함수 스코프로 변수를 보호
(function() {
  var privateVar = 'secret'; // 외부에서 접근 불가
  window.myModule = {
    reveal: function() { console.log(privateVar); }
  };
})();

// 2. 네임스페이스 패턴: 객체 하나에 모든 기능을 담음
var MyApp = {};
MyApp.Math = {
  add: function(a, b) { return a + b; }
};

이러한 패턴은 전역 오염을 어느 정도 완화했지만, 모듈 간 의존성을 명시적으로 선언하거나 재사용 단위를 표준화하는 근본적인 해결책은 아니었습니다. 결과적으로 의존성 관리와 로드 순서 제어는 여전히 개발자가 수동으로 관리해야 했고, 규모가 커질수록 유지보수 비용이 증가했습니다.

모듈 시스템

CJS (CommonJS)

CJS

2009년 Kevin Dangoor 등을 중심으로 서버 사이드 JavaScript에 적합한 모듈 표준을 만들기 위한 논의가 시작되었고, 그 결과 CommonJS가 등장했습니다. 초기에는 ServerJS라는 이름으로 불렸지만, 서버 환경에만 국한되지 않고 범용적인 모듈 표준을 지향하면서 CommonJS로 개명되었습니다. 이후 CommonJS 모듈 사양은 Node.js에 채택되어 Node.js의 기본 모듈 시스템으로 자리 잡게 됩니다.

CommonJS에서는 한 파일이 하나의 모듈이며, module.exports 또는 exports를 통해 내보낼 값을 정의하고, 다른 모듈에서는 require() 함수로 이를 불러옵니다.

// CommonJS 모듈 정의
module.exports = foo;

// CommonJS 모듈 사용
const foo = require('./foo');

CommonJS의 중요한 특징은 모듈 로딩이 동기적이라는 점입니다. 즉, require()가 호출되면 해당 모듈을 로드하고 평가한 뒤 결과를 반환합니다. 이 설계는 파일 시스템 접근이 자연스러운 서버 환경에서는 유효했지만, 네트워크 지연이 발생하는 브라우저 환경에서는 그대로 적용하기 어려웠습니다.

이를 보완하기 위해 브라우저에서 CommonJS 스타일의 모듈을 사용할 수 있게 해주는 빌드 도구가 등장했고, 그 대표적인 예가 Browserify입니다.

AMD (Asyncronous Module Definition)

AMD

AMD는 브라우저 환경을 우선적으로 고려해 설계된 JavaScript 모듈 시스템입니다. 당시 CJS 커뮤니티에서도 브라우저에 적합한 모듈 형식을 논의했지만, 요구사항과 접근 방식의 차이로 합의에 이르지 못했습니다. 이후 브라우저 중심의 해결책을 제시하려는 그룹이 분리되어 AMD를 제안했고, 비동기 로딩을 핵심 목표로 삼았습니다.

브라우저에서 모듈을 동기적으로 가져오면 네트워크 요청이 완료될 때까지 메인 스레드가 막혀 페이지가 멈춘 것처럼 보일 수 있습니다. 따라서 AMD는 모듈을 비동기적으로 다운로드하고, 모든 의존성이 준비된 시점에 모듈 코드를 실행하는 방식을 채택했습니다.

AMD 모듈은 define() 함수로 정의합니다. define은 의존 모듈 배열과 팩토리 함수를 인자로 받고, 의존 모듈을 비동기적으로 로드한 뒤 준비가 끝나면 팩토리 함수를 실행합니다.

// define 모듈 정의 (AMD)
define(['./util', 'jquery'], function(util, $) {
  // 의존 모듈 util.js와 jQuery를 모두 불러온 뒤 이 함수 실행
  function showValue(x) {
    $('#result').text(util.compute(x));
  }
  return { showValue };
});

// require로 모듈 사용 (AMD)
require(['./myModule'], function(myModule) {
  myModule.showValue(42);
});

AMD는 비동기 로딩을 통해 페이지 로딩을 블로킹하지 않는다는 장점이 있으며, 모듈 단위의 스코프를 제공해 전역 네임스페이스 오염을 줄일 수 있습니다. 다만 AMD는 사양에 해당하므로 실제 프로젝트에서는 RequireJS 같은 구현체를 사용해야 합니다.

UMD (Universal Module Definition)

자바스크립트 생태계가 발전하면서 하나의 라이브러리를 서버(Node)와 브라우저 모두에서 사용하려는 수요가 늘었습니다. 그러나 2010년 전후로 많은 라이브러리는 브라우저용(AMD) 빌드와 Node용(CJS) 빌드를 별도로 제공하거나, 둘 중 하나만 지원하는 경우가 많았습니다. 이로 인해 배포 형태가 늘어나고 호환성 문제가 발생하자, 이를 완화하기 위한 패턴으로 UMD가 등장했습니다.

UMD에서는 CJS와 AMD 방식을 모두 호환할 수 있도록 조건문으로 분기하고, 동일한 팩토리 함수에서 모듈을 생성하는 방식으로 구현됩니다.

(function (root, factory) {
  if (typeof define === 'function' && define.amd) {
    // AMD 환경: define을 사용하여 모듈 정의
    define(['lodash'], factory);
  } else if (typeof exports === 'object' && typeof module !== 'undefined') {
    // CommonJS 환경: module.exports 사용
    module.exports = factory(require('lodash'));
  } else {
    // 브라우저 전역 환경: window에 붙임
    root.MyLibrary = factory(root._);
  }
}(typeof globalThis !== 'undefined' ? globalThis : this, function (_) {
  // 모듈 본체 구현부
  function doSomething() { /* ... */ }

  return { doSomething };   // AMD나 CJS에서는 반환값이 exports
}));

ESM (ES6 Module)

ES6

CommonJS와 AMD는 각자의 방식으로 모듈화를 가능하게 했지만, 언어 차원의 표준 모듈 시스템이 없다는 점은 오랫동안 한계로 남아 있었습니다. 결국 2015년 ECMAScript 6(ES6)에서 ECMAScript Modules(ESM)를 표준 모듈 시스템으로 채택하게 됩니다.

ESM은 import와 export 문법을 통해 모듈 경계를 파일 단위로 명확히 구분합니다. 필요한 값을 export로 내보내고, 다른 모듈의 내용을 import로 가져오는 방식입니다.

// math.mjs (ES 모듈 정의)
export function add(a, b) { return a + b; }
export function multiply(a, b) { return a * b; }
export default add;            // 기본(Default) 내보내기

// main.mjs (ES 모듈 사용)
import myAdd, { multiply as mul } from './math.mjs';
console.log(myAdd(2, 3));      
console.log(mul(2, 3));        

ESM은 import와 export 기반의 간결한 문법으로 모듈 경계를 명확히 합니다. 정적 import는 모듈 의존성을 선언적으로 표현해 로더가 미리 의존성 그래프를 구성할 수 있고, import()를 통해 필요 시점에 비동기적으로 모듈을 로드하는 방식도 지원합니다. 또한 CommonJS처럼 값을 복사해 가져오는 방식이 아니라 live binding으로 내보낸 값의 바인딩을 참조하기 때문에, 순환 참조 상황에서도 더 예측 가능한 동작을 보이며, 정적인 모듈 구조 덕분에 번들러의 정적 분석과 트리 쉐이킹 같은 최적화가 쉬워졌습니다.

번들러의 필요성

모듈 시스템이 확산되면서 코드를 모듈 단위로 분리해 개발하는 방식이 표준이 되었습니다. 하지만 브라우저 환경은 이러한 모듈 방식을 호환성 문제로 실행하기 어렵거나, 많은 모듈 파일을 그대로 내려받기에도 부담이 있었습니다. 번들러는 이러한 제약을 해결해 모듈 기반 코드를 실행 가능한 형태로 제공하기 위해 필요해졌습니다.

브라우저 호환성 및 모듈 해석의 한계

초기 브라우저는 require나 import 같은 모듈 문법을 자체적으로 해석할 수 없었습니다. 이후 ESM을 지원하는 브라우저가 늘어나면서 <script type="module"> 같은 방식으로 모듈을 실행할 수 있게 됐지만, 구형 브라우저 지원과 CJS 기반 npm 패키지 호환을 위해서는 여전히 변환과 번들링 과정이 필요했습니다.

네트워크 효율성 문제

HTTP/1.1에서는 모듈 파일을 많이 만들수록 개별 요청이 늘어나고, 그만큼 로딩 비용이 커지기 쉬웠습니다.

  • 요청마다 연결 설정 비용(DNS/TCP/TLS)이 발생해 오버헤드가 누적됩니다.
  • 브라우저의 동시 연결 제한 때문에 요청이 대기하며 워터폴 현상이 생깁니다.

Browserify의 탄생

browserify

자바스크립트 생태계에 CJS, AMD 등의 모듈 시스템이 도입됨에 따라, 코드를 모듈 단위로 분할하여 관리하고자 하는 요구가 증대되었습니다. 그러나 위에서 말했듯이 브라우저 환경은 이를 기본적으로 지원하지 않았고, 모듈 파일을 그대로 다수 로드하는 방식도 네트워크 측면에서 비효율적이었습니다.

이 문제를 해결하기 위해 Browserify가 등장했습니다. Browserify는 의존성 그래프를 따라 모듈을 묶어 브라우저에서 CommonJS 모듈을 실행 가능하게 만들고, 동시에 요청 횟수를 줄여 로딩 비용을 완화했습니다.

결과적으로 초기 번들러는 모듈 기반 코드를 브라우저에서 실행 가능하게 만들기 위해 파일 병합과 호환성 확보에 집중한 도구였습니다.

통합 빌드 시스템 Webpack

webpack

SPA와 컴포넌트 기반 프레임워크의 확산으로 프론트엔드 코드는 자바스크립트만으로 구성되지 않게 되었습니다. CSS, 이미지, 폰트 같은 정적 리소스도 컴포넌트와 함께 관리 및 배포해야 했고, 번들러는 자바스크립트 병합을 넘어 빌드 전반을 다뤄야 했습니다.

이러한 요구를 반영해 Webpack은 번들러의 역할을 에셋 처리와 변환 파이프라인까지 넓혔습니다.

  • 에셋 관리의 일원화: CSS/이미지 등도 모듈처럼 import하여 처리할 수 있게 했습니다.
  • 트랜스파일링 통합: ES6+와 TypeScript 변환을 빌드 흐름에 포함했습니다.

결과적으로 번들러는 단순 병합 도구에서 통합 빌드 시스템으로 발전했습니다.

Rollup과 Parcel

rollup

번들러가 처리하는 범위가 넓어지면서 번들 크기가 커지고 초기 로딩 비용이 증가하는 문제가 나타났습니다. 이에 따라 번들러가 커진 번들을 어떻게 줄이고 빠르게 로드할 것인가가 중요해졌습니다.

Rollup

ES Modules 표준을 따르며, 불필요한 코드를 제거하여 더 작고 효율적인 결과물을 만드는 데 집중했습니다.

  • 트리 쉐이킹(Tree-shaking): 정적 분석으로 사용되지 않는 코드를 제거해 번들 크기를 줄입니다.
  • 스코프 호이스팅(Scope Hoisting): 여러 모듈을 별도의 함수로 감싸지 않고 하나의 스코프로 평탄화하여 실행 속도를 높입니다.

Parcel

Webpack의 복잡한 설정에 지친 개발자들을 위해, 별도의 설정 없이 바로 사용할 수 있는 편의성을 제공했습니다.

  • 제로 컨피그레이션(Zero Configuration): 설정 파일 없이 진입점 파일만 지정하면, 필요한 변환 도구를 자동으로 설치하고 번들링합니다.
  • 멀티 코어 캐싱: 단일 스레드로 동작하던 기존 도구와 달리, 멀티 코어 프로세싱을 적극 활용해 빌드 속도를 개선했습니다.

결과적으로 이 시점부터 번들러는 단순한 빌드 도구를 넘어, 성능 최적화와 개발 생산성을 동시에 높이는 방향으로 발전했습니다.

새로운 패러다임 Vite

vite

번들러의 기능은 향상되었지만, 프로젝트 규모가 커지고 설정이 복잡해지면서 빌드 및 리빌드 시간이 증가하는 문제가 나타났습니다. 이는 개발 과정의 피드백 루프를 느리게 만들어 개발 생산성에 직접적인 영향을 미쳤습니다.

이러한 배경에서 Vite는 DX와 속도를 최우선으로 두고, 기존 번들러 중심 개발 흐름과는 다른 접근을 제시했습니다.

  • Native ESM 활용: 변경이 발생해도 전체 번들을 다시 만들지 않고, 브라우저가 모듈을 불러오는 방식 그대로 필요한 파일만 갱신합니다.
  • esbuild 기반 사전 번들링: node_modules처럼 변경이 적은 의존성을 esbuild로 미리 처리해 초기 구동과 의존성 해석 비용을 줄입니다.

참고
https://blog.sangwook.dev/posts/no-one-asked-library-bundler-01-concept/
https://wormwlrm.github.io/2020/08/12/History-of-JavaScript-Modules-and-Bundlers.html
https://deemmun.tistory.com/86
https://dev.to/marcogrcr/nodejs-a-brief-history-of-cjs-bundlers-and-esm-2nlb
https://deemmun.tistory.com/87
https://toss.tech/article/commonjs-esm-exports-field
https://frontend-fundamentals.com/bundling/
https://developer.mozilla.org/ko/docs/Web/JavaScript/Guide/Modules
https://d2.naver.com/helloworld/12864
https://v8.dev/features/modules
https://bundlers.tooling.report/
http://codilime.com/blog/history-of-javascript-module-systems/

profile
잘 하고 싶어요

1개의 댓글

comment-user-thumbnail
2026년 2월 7일

최고의 스터디

답글 달기