FEInterview Prep

javascript · high priority

JavaScript 모듈 — ESM vs CJS, Dynamic Import, Tree Shaking

정적 구조가 왜 중요한가 — 번들러가 보는 세계

intermediate 난이도4시간토스카카오네이버당근배민라인
시작 전
이해도
매우 낮음

학습 개요

탄생 배경

쉬운 설명

복잡한 개념을 실생활 비유로 설명합니다.

레고 설명서 vs 포장된 완성품

ESM 은 "이 블록을 저 블록에 끼운다" 라는 설명서만 읽어도 최종 형태를 예측할 수 있습니다(정적 구조). 그래서 조립자(번들러) 는 쓰지 않는 블록을 미리 빼놓을 수 있습니다. CJS 는 설명서가 없고 포장을 뜯어봐야만 무엇이 들어있는지 알 수 있어서(런타임 require), 조립자는 안전하게 "일단 전부 가져가자" 고 결정합니다.

핵심 개념

CommonJS vs ESM — 핵심 비교
관점CommonJSESM
문법require(), module.exportsimport, export
로딩 시점런타임, 동기파싱 타임, 비동기
구조동적 — require 를 조건문 안에 넣을 수 있음정적 — 최상위에서만 선언
바인딩값 복사 (한번 잡은 값)살아있는 참조 (Live Binding)
this모듈 레벨에서 module.exportsundefined
순환 참조부분적으로 깨지기 쉬움Live Binding 덕에 안전한 경우 多
브라우저 네이티브불가 (번들러 필요)가능 (<script type="module">)
top-level await불가가능 (ES2022)
tree shaking실용적으로 어려움표준화된 방법

문법 차이

CommonJS (Node 전통)
  • 파일 확장자 .cjs 또는 "type": "commonjs"
  • 실행 시점에 require 가 호출됨
  • 조건부 import 가능 (if (cond) require(...) )
  • 디폴트 export 개념 없음 — module.exports = x 로 대체
1// utils.cjs
2const log = (m) => console.log(m);
3module.exports = { log };
4
5// index.cjs
6const { log } = require('./utils.cjs');
7log('hi');
ES Modules (표준)
  • 파일 확장자 .mjs 또는 "type": "module"
  • 최상위에서만 선언 가능 — 조건부 import 는 import() 사용
  • 각 named export 는 live binding
  • strict mode 기본, this 는 undefined
1// utils.mjs
2export const log = (m) => console.log(m);
3
4// index.mjs
5import { log } from './utils.mjs';
6log('hi');
Live Binding — ESM 만의 특성javascript
1// counter.mjs
2export let count = 0;
3export function inc() { count++; }
4
5// main.mjs
6import { count, inc } from './counter.mjs';
7console.log(count); // 0
8inc();
9console.log(count); // 1 ← import 한 count 가 "살아있는 참조"
10
11// 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 의 "정적 구조" 가 주는 세 가지 이점은?

기대 키워드

tree shaking정적 분석top-level await의존 그래프Live binding

자주 하는 오해

"static 이라 유연성이 없다" 는 것을 단점으로만 보는 경우가 있지만, 정적 구조가 바로 tree shaking · 정적 타입 분석 · top-level await · live binding 을 가능하게 하는 전제라는 점을 이해하면 트레이드오프가 분명합니다.

`sideEffects: false` 필드가 번들러에 주는 약속을 한 문장으로?

기대 키워드

평가만으로부작용 없음dead code 제거polyfillCSS import

자주 하는 오해

"최적화 힌트 정도" 로 생각하지만 틀리면 실제로 기능이 사라지는 버그를 만듭니다. 이 필드는 "모듈 평가 자체가 어떤 전역 상태도 바꾸지 않는다" 는 **사실 명제** 를 번들러에 전달하는 계약입니다.

React.lazy + Suspense 가 내부적으로 뭘 하는지 dynamic import 관점에서 설명해보세요.

기대 키워드

import()Promise<Module>Suspense fallbackchunk분할

자주 하는 오해

"React 만의 기능" 으로 오해하기 쉽지만 React.lazy 는 "Promise 를 반환하는 함수" 를 받을 뿐이고, 그 Promise 를 만들어내는 본질은 표준 dynamic import + 번들러의 청크 분할입니다.

학습 자료