모듈 시스템 — CommonJS vs ESM 완전 정리
require 와 import 가 무엇이 다른가. Node 진영 30년 묵은 문제의 결말.
Node 책 어디든 5장 안에 등장하는 헷갈리는 주제 — 모듈 시스템. 어떤 글은 const fs = require('fs') 라 쓰고, 어떤 글은 import fs from 'fs' 라 쓴다. 둘 다 동작하지만 같은 게 아니다. 한 프로젝트 안에서 섞으면 에러가 폭발한다.
이번 편에서 두 모듈 시스템의 정체를 정리한다. 결론부터 — 신규 프로젝트는 ESM, 레거시는 CommonJS. 이유와 실전 적용 방법까지 단단히 굳히고 가자.
1. 두 모듈 시스템의 정체
같은 자바스크립트인데 모듈 문법이 2가지인 이유는 역사 때문이다.
CommonJS (CJS) — Node 의 원조
2009 년 Node 가 처음 만들어졌을 때 자바스크립트엔 공식 모듈 시스템이 없었다. Node 가 자기 식으로 만든 게 CommonJS. require() 함수로 가져오고 module.exports 로 내보낸다.
ESM (ECMAScript Modules) — 공식 표준
2015 년 ES6 에서 자바스크립트가 공식 모듈 문법을 도입했다. import·export. 브라우저와 Node 모두 같은 문법을 쓸 수 있게 된 결정적 사건.
문법 자체는 거의 같아 보이는데, 로딩 방식이 다르다. CJS 는 동기 로딩 (require 호출하는 시점에 즉시 파일 읽음), ESM 은 비동기 로딩 (파일 그래프를 먼저 분석한 뒤 실행). 이 차이가 호환성 문제의 근원.
2. 핵심 차이 한눈에
| 항목 | CommonJS | ESM |
|---|---|---|
| 가져오기 | const x = require('m') | import x from 'm' |
| 내보내기 | module.exports = … | export default … |
| 로딩 | 동기, 실행 중 | 비동기, 미리 분석 |
| top-level await | 불가 | 가능 |
| JSON 로딩 | require('./x.json') 바로 | with {type:'json'} 필요 |
| 파일 확장자 | 생략 가능 | 반드시 명시 (.js·.mjs) |
| __dirname | 기본 제공 | import.meta.url 로 계산 |
| npm 호환 | 옛 패키지 대부분 | 신규 패키지 대부분 |
실전에서 가장 큰 차이는 top-level await. ESM 에선 파일 최상위에서 await fetch(…) 가 그대로 동작한다. CJS 는 무조건 async 함수로 감싸야 함.
3. 어느 쪽으로 가나 — 결정 트리
Node 22 기준 결론.
레거시 유지보수 → 기존 CJS 그대로. 굳이 ESM 으로 마이그레이션하지 말 것 (호환성 폭발).
라이브러리 배포 → ESM 메인 + CJS 폴백 (
tsup·tshy 같은 도구가 자동).
왜 ESM 인가:
- 모든 신규 패키지가 ESM-only (예:
node-fetchv3+,chalkv5+). - top-level await 가 사실상 필수가 됨 (서버 시작 전 DB 연결 등).
- TypeScript·Vite·Next.js 모두 ESM 가정.
- 브라우저 코드와 같은 문법 — 풀스택 일관성.
4. ESM 모드로 전환하는 3가지 방법
Node 는 파일 단위로 모드를 결정한다. 결정 방법 3가지.
① package.json 에 "type": "module"
가장 일반적. 프로젝트 루트의 package.json 에 한 줄.
이 줄이 있으면 그 프로젝트의 모든 .js 파일이 ESM 으로 해석된다. create-next-app 같은 최신 도구가 자동으로 박아 둔다.
② 파일 확장자 .mjs
한두 파일만 ESM 으로 쓰고 싶을 때. .mjs 확장자는 항상 ESM. 반대로 .cjs 는 항상 CommonJS. 둘 다 package.json 설정을 덮어쓴다.
③ node --input-type=module
스크립트가 아닌 표준입력으로 ESM 코드 실행. CI 인라인 같은 특수 상황만.
5. 흔한 함정 5가지
전환할 때 가장 자주 부딪치는 에러.
- "Cannot use import statement outside a module" —
"type": "module"빠뜨림. package.json 추가. - "ERR_MODULE_NOT_FOUND" — ESM 에선 확장자 필수.
./math는 안 되고./math.js여야. __dirname is not defined— ESM 에 없음.import { fileURLToPath } from 'url'; const __dirname = fileURLToPath(new URL('.', import.meta.url));패턴.- JSON 로딩 실패 —
import data from './x.json' with { type: 'json' }(Node 22+). 옛 버전이면fs.readFileSync+JSON.parse. - "require() of ES Module not supported" — CJS 가 ESM 패키지를 부른 것. CJS 측을 ESM 으로 바꾸거나, 동적
await import(...)사용.
--experimental-require-module). 100% 호환은 아니지만 마이그레이션 통증을 많이 줄여준다. 23 부터는 기본 활성화.
요약 — 4편 좌표
여기까지 정리. Node 에는 두 모듈 시스템 — 옛 CommonJS(require) 와 표준 ESM(import). 신규 프로젝트는 무조건 ESM, 레거시는 그대로. ESM 모드로 들어가는 가장 일반적 방법은 package.json 의 "type": "module". 흔한 함정 5가지(확장자·__dirname·JSON·import 위치·CJS→ESM 부름)만 외워두면 전환이 매끄럽다. 다음 편에선 npm 과 package.json 의 의존성 관리 기본을 본다.
다음 편 예고 — npm과 package.json
의존성 관리의 모든 것 — 설치·버전 범위·lockfile·script. 5편.