javascript · high priority
JavaScript 모듈 — ESM vs CJS, Dynamic Import, Tree Shaking
정적 구조가 왜 중요한가 — 번들러가 보는 세계
학습 개요
탄생 배경
쉬운 설명
복잡한 개념을 실생활 비유로 설명합니다.
“레고 설명서 vs 포장된 완성품”
ESM 은 "이 블록을 저 블록에 끼운다" 라는 설명서만 읽어도 최종 형태를 예측할 수 있습니다(정적 구조). 그래서 조립자(번들러) 는 쓰지 않는 블록을 미리 빼놓을 수 있습니다. CJS 는 설명서가 없고 포장을 뜯어봐야만 무엇이 들어있는지 알 수 있어서(런타임 require), 조립자는 안전하게 "일단 전부 가져가자" 고 결정합니다.
핵심 개념
| 관점 | CommonJS | ESM |
|---|---|---|
| 문법 | require(), module.exports | import, export |
| 로딩 시점 | 런타임, 동기 | 파싱 타임, 비동기 |
| 구조 | 동적 — require 를 조건문 안에 넣을 수 있음 | 정적 — 최상위에서만 선언 |
| 바인딩 | 값 복사 (한번 잡은 값) | 살아있는 참조 (Live Binding) |
| this | 모듈 레벨에서 module.exports | undefined |
| 순환 참조 | 부분적으로 깨지기 쉬움 | Live Binding 덕에 안전한 경우 多 |
| 브라우저 네이티브 | 불가 (번들러 필요) | 가능 (<script type="module">) |
| top-level await | 불가 | 가능 (ES2022) |
| tree shaking | 실용적으로 어려움 | 표준화된 방법 |
문법 차이
- 파일 확장자
.cjs또는"type": "commonjs" - 실행 시점에
require가 호출됨 - 조건부 import 가능 (
if (cond) require(...)) - 디폴트 export 개념 없음 —
module.exports = x로 대체
1// utils.cjs2const log = (m) => console.log(m);3module.exports = { log };45// index.cjs6const { log } = require('./utils.cjs');7log('hi');
- 파일 확장자
.mjs또는"type": "module" - 최상위에서만 선언 가능 — 조건부 import 는
import()사용 - 각 named export 는 live binding
- strict mode 기본, this 는 undefined
1// utils.mjs2export const log = (m) => console.log(m);34// index.mjs5import { log } from './utils.mjs';6log('hi');
1// counter.mjs2export let count = 0;3export function inc() { count++; }45// main.mjs6import { count, inc } from './counter.mjs';7console.log(count); // 08inc();9console.log(count); // 1 ← import 한 count 가 "살아있는 참조"1011// CJS 에서는 이렇게 동작하지 않음 — count 는 import 시점 값이 복사된 것12// module.exports = { count }; 는 값을 프로퍼티로 새로 배치함
Node.js 에서 파일 확장자 규칙
Node 는 package.json 의 "type" 필드로 해당 디렉토리의 기본 모듈 종류를 결정합니다. "type": "module" 이면 .js 가 ESM, "type": "commonjs" (또는 미지정) 이면 CJS. 다른 종류를 명시적으로 쓰려면 .mjs / .cjs 확장자를 사용합니다.
실무 적용
어떤 상황에서 사용하는가
대시보드 앱의 초기 JS 번들이 800KB 라 LCP 가 나빠, 사내 차트 라이브러리가 유입이 1% 도 안 되는데 번들의 30% 를 차지한다.
어떻게 적용하는가
(1) 차트 페이지를 React.lazy + dynamic import 로 분리해 초기 번들에서 제거. (2) 사용 중인 차트 유틸을 `lodash-es` 같은 ESM 빌드로 교체하고 `import { get } from "lodash-es"` 형태로 변경. (3) 자체 UI 라이브러리의 barrel file 을 제거하거나 `modularizeImports` 로 우회해 tree shaking 정확도를 올림. (4) 번들 분석 툴(Rollup Visualizer, @next/bundle-analyzer) 로 전후 비교.
흔한 실수와 안티패턴
- `sideEffects: false` 를 뜻 없이 true 로 선언해 polyfill/CSS import 가 제거됨 → 배열로 예외 파일을 나열
- `import _ from "lodash"` 를 남겨둔 채 "tree shaking 이 왜 안 되지" 라고 고민 → lodash 는 CJS, lodash-es 로 교체 필요
- 동적 import 를 return 문 안에서 남발 → 매 렌더마다 청크를 재평가하는 것처럼 보이지만 실제로 Promise 가 매번 새로 생성돼 캐싱 유실 가능
- Next.js 에서 next/dynamic 의 `ssr: false` 를 무분별하게 사용 → 하이드레이션 불일치 또는 SEO 손실
- Node 서버에서 ESM 라이브러리를 CJS 코드에서 require → ERR_REQUIRE_ESM. 동적 import 로 전환하거나 전체 ESM 으로 이전
흔한 오해
"tree shaking 은 번들러가 알아서 해준다."
교정ESM + side-effect 약속(sideEffects) + 올바른 import 패턴이 모두 맞아야 동작한다.
왜 중요번들러는 "이 모듈을 제거해도 관찰 가능한 동작이 같은가" 를 알아야 한다. CJS, barrel, side-effectful 모듈은 이 증명을 불가능하게 만들어 안전한 쪽으로 "포함" 을 택한다.
"dynamic import 는 무조건 성능에 좋다."
교정초기 번들은 줄지만 네트워크 왕복과 추가 HTTP 요청이 늘어나므로 작은 모듈엔 역효과.
왜 중요모듈 하나당 청크 생성 + 요청/파싱 비용이 있다. HTTP/2 라도 수백 개의 마이크로 청크는 브라우저/번들러 양쪽의 오버헤드를 만든다.
"ESM 과 CJS 는 문법만 다르고 의미는 같다."
교정로딩 타이밍(파싱 vs 런타임), 바인딩 의미(라이브 vs 스냅샷), this 값, 최상위 await 지원 등이 모두 다르다.
왜 중요이 차이들이 순환 참조 동작, 라이브러리 동작, Node 환경에서의 상호 운용에 그대로 반영된다.
면접 질문
답변 방향 힌트
번들러가 파일을 실행하지 않고도 무엇을 알 수 있느냐로 접근하세요.
반드시 언급할 키워드
- ESM 의 import/export 는 최상위 선언만 허용 → 파싱만으로 의존 그래프 확정
- CJS 의 require 는 런타임 호출이라 정적 분석이 어려움
- tree shaking 은 "쓰이지 않는 export 는 번들에 포함 안 함"
- 정적 그래프 + side-effect 선언이 있어야 dead code 제거가 안전
- 결과적으로 번들 크기 · 빌드 속도 · LCP 개선
예상 꼬리 질문
- `sideEffects: false` 를 잘못 선언했을 때 발생할 수 있는 버그 사례는?
- barrel file 이 tree shaking 을 깨는 구체적 메커니즘을 설명해주세요.
자기 점검
스크롤 올리지 말고 답해보세요. ESM 의 "정적 구조" 가 주는 세 가지 이점은?
기대 키워드
자주 하는 오해
"static 이라 유연성이 없다" 는 것을 단점으로만 보는 경우가 있지만, 정적 구조가 바로 tree shaking · 정적 타입 분석 · top-level await · live binding 을 가능하게 하는 전제라는 점을 이해하면 트레이드오프가 분명합니다.
`sideEffects: false` 필드가 번들러에 주는 약속을 한 문장으로?
기대 키워드
자주 하는 오해
"최적화 힌트 정도" 로 생각하지만 틀리면 실제로 기능이 사라지는 버그를 만듭니다. 이 필드는 "모듈 평가 자체가 어떤 전역 상태도 바꾸지 않는다" 는 **사실 명제** 를 번들러에 전달하는 계약입니다.
React.lazy + Suspense 가 내부적으로 뭘 하는지 dynamic import 관점에서 설명해보세요.
기대 키워드
자주 하는 오해
"React 만의 기능" 으로 오해하기 쉽지만 React.lazy 는 "Promise 를 반환하는 함수" 를 받을 뿐이고, 그 Promise 를 만들어내는 본질은 표준 dynamic import + 번들러의 청크 분할입니다.
학습 자료
- JavaScript modules — MDNES Modules 의 기본 문법, 브라우저 지원, import maps, dynamic import 까지 표준 수준에서 정리한 공식 문서.DocMDN Web Docs
- Modules: Packages — Node.js docs`type`, `exports`, conditional exports, dual package hazard 등 패키지 작성자가 반드시 알아야 할 내용.DocNode.js 공식 문서
- Tree shaking — webpack docstree shaking 의 전제, `sideEffects` 필드의 실제 예시, usage analysis 동작 방식.Blogwebpack 공식 문서
- Dynamic imports — V8 blog동적 import 의 의미론, 사용 사례(조건부 로딩, 모듈 레벨 초기화), 브라우저/Node 구현 차이.BlogV8 Team