블로그 목록

ESM vs CommonJS — 트리 셰이킹과 비동기 로딩이 ESM에서만 되는 이유

2026-06-248 min read
JavaScriptESMCommonJS트리 셰이킹모듈 시스템

"트리 셰이킹과 비동기 로딩은 ESM에서만 된다"는 말은 자주 듣지만, 왜 그런지는 잘 안 다룬다. 답은 하나로 귀결된다 — ESM은 정적, CommonJS는 동적이다.

1. 두 모듈 시스템

CommonJS (CJS) — Node의 전통 방식

const add = (a, b) => a + b;
module.exports = { add };

const { add } = require("./math");

ESM (ES Modules) — 표준 방식

export const add = (a, b) => a + b;

import { add } from "./math.js";

2. 모든 차이의 뿌리 — 정적 vs 동적

CommonJSESM
import/export의 정체함수 호출 · 객체 대입문법(키워드)
무엇을 주고받는지 아는 시점실행해 봐야 안다 (동적)파싱만 해도 안다 (정적)
import 위치어디서나 (조건문 안에도)최상단 고정
경로변수로 동적 가능문자열 리터럴 고정
// CommonJS — 런타임에 결정 (동적)
if (조건) {
  const mod = require(`./${name}`); // 경로·시점 모두 실행해야 앎
}

// ESM — 실행 전 분석 가능 (정적)
import { add } from "./math.js"; // 최상단 고정, 경로 리터럴

이 "정적이냐 동적이냐" 하나가 트리 셰이킹과 비동기 로딩의 가능 여부를 모두 가른다.

3. 트리 셰이킹 — 왜 ESM에서만 안전한가

트리 셰이킹(tree shaking) 은 번들 빌드 시 사용되지 않는 코드(dead code)를 제거하는 최적화다. 안 쓰는 export를 떨궈 번들 크기를 줄인다.

핵심 전제는 하나다 — 번들러가 코드를 실행하지 않고도 "이건 안 쓰니 지워도 안전하다"를 확신할 수 있어야 한다. 그 확신은 정적 분석에서만 나온다.

ESM은 확신할 수 있다

// math.js
export const add = (a, b) => a + b;
export const sub = (a, b) => a - b; // 아무도 import 안 함

// main.js
import { add } from "./math.js"; // 'add'만 쓴다고 문법에 박혀 있음
  • import 이름이 고정된 식별자
  • import는 항상 최상단 (조건부 불가)
  • 경로가 문자열 리터럴

→ 번들러가 AST만 분석해 "sub는 어디서도 import되지 않음 → 안전하게 제거"를 확정할 수 있다.

CommonJS는 확신할 수 없다

module.exports.add = () => {};
module.exports[someKey] = () => {};      // 키가 런타임에 결정
const m = require(cond ? "./a" : "./b"); // 경로가 런타임에 결정
const fn = m[userInput];                  // 뭘 쓸지 실행해야 앎

module.exports는 평범한 객체라 런타임에 자유롭게 바뀌고, require는 조건부·동적 경로가 가능하다. 번들러는 "이걸 지워도 되는가"를 실행 없이 확신할 수 없으므로 안전하게 전부 포함시킨다. 그래서 트리 셰이킹이 안 된다.

트리 셰이킹은 "실행 없이 확신" 이 전제이고, 그 확신은 정적 분석에서만 나온다.

주의 — ESM이어도 트리 셰이킹이 깨지는 경우

  • 번들러가 부수효과(side effect)가 있다고 판단하면 제거를 보류한다. package.json의 "sideEffects": false로 "이 패키지는 import만으로 부수효과 없음"을 알려야 안전하게 떨군다.
  • CommonJS로 트랜스파일되면 정적 이점이 사라진다 (그래서 라이브러리는 ESM 빌드를 함께 제공한다).

4. 비동기 로딩 — 왜 ESM에서만 가능한가

ESM (정적) — 파싱으로 그래프를 먼저 파악 → 병렬·비동기 로드① 파싱import 전부 발견(실행하지 않음)② 병렬·비동기 다운로드③ 평가(실행)준비 완료 후 실행CommonJS (동적) — 실행 중 require를 만날 때마다 멈추고 동기 로드실행require(a)동기 로드 · 멈춤실행require(b)동기 로드 · 멈춤실행← 실행 타임라인 (멈춤 구간만큼 블로킹) →정적 import → 의존성을 미리 알 수 있음 → 병렬·비동기 가능동적 require → 실행 중 발견 → 그 자리서 동기 로드(블로킹)

ESM은 3단계로 로드한다

  1. 파싱/구성 — 코드를 실행하지 않고 파싱만 해서 import를 전부 발견, 의존성 그래프를 그린다
  2. 다운로드/인스턴스화 — 발견한 의존성들을 미리, 병렬로, 비동기로 가져온다
  3. 평가(실행) — 다 준비된 뒤 실제 코드를 실행한다

핵심은 1단계다. 실행하기 전에 모든 의존성을 알 수 있기 때문에 미리 병렬로 비동기 다운로드할 수 있다. import가 정적이라 가능한 일이다.

CommonJS는 구조상 동기일 수밖에 없다

const a = require("./a"); // 이 줄을 '실행하는 순간' 처음으로 ./a가 필요함을 앎
console.log(a.value);     // 바로 다음 줄에서 결과를 써야 함
  • require는 그 줄이 실행될 때 비로소 의존성을 발견한다 (어디든, 조건부로 있을 수 있으니 미리 알 수 없다).
  • 그런데 반환값을 그 자리에서 즉시 써야 하므로, 멈춰서 그 자리에서 동기로 로드할 수밖에 없다.

브라우저에서 이렇게 하면 require마다 네트워크를 동기로 기다리며 메인 스레드가 멈춘다. 그래서 CommonJS는 브라우저에서 쓰려면 번들러로 미리 한 파일로 합쳐야 한다.

비동기 로딩은 "의존성을 실행 전에 미리 안다" 가 전제이고, 그건 정적인 ESM만 가능하다.

5. 그 밖의 차이 (간단히)

  • 바인딩: CJS는 export한 값을 복사, ESM은 살아있는 참조(live binding) — 원본 변화가 반영된다.
  • this/__dirname: CJS 최상단 this는 module.exports, __dirname 사용 가능. ESM은 this가 undefined, import.meta.url로 대체.
  • 상호운용: ESM→CJS는 import로 대체로 가능, CJS→ESM은 require 불가라 await import() 필요 (ERR_REQUIRE_ESM의 원인).

6. 정리

항목CommonJSESM
분석 시점런타임(동적)빌드 타임(정적)
로딩동기(blocking)비동기·병렬
트리 셰이킹❌✅
바인딩값 복사live 참조
환경전통적 Node표준 (브라우저·최신 Node·번들러)

한 줄 정리 — ESM의 import/export는 실행 전에 분석 가능한 정적 선언이라, ① 무엇이 안 쓰이는지 확신해 제거할 수 있고(트리 셰이킹), ② 모든 의존성을 미리 알아 병렬로 당겨올 수 있다(비동기 로딩). CommonJS는 require가 실행 중 결정되는 동적 호출이라 둘 다 불가능하다.


참고로 이벤트 루프 글에서 다룬 Vite의 빠른 개발 서버도, 브라우저 네이티브 ESM의 이 정적·비동기 성질을 그대로 활용해 번들 없이 모듈을 서빙하는 구조다.