Node.js 교재 · 4편 · 모듈 시스템

모듈 시스템 — CommonJS vs ESM 완전 정리

require 와 import 가 무엇이 다른가. Node 진영 30년 묵은 문제의 결말.

CommonJS 와 ESM 두 모듈 시스템이 나란히 연결된 컨셉 아이소메트릭 일러스트

Node 책 어디든 5장 안에 등장하는 헷갈리는 주제 — 모듈 시스템. 어떤 글은 const fs = require('fs') 라 쓰고, 어떤 글은 import fs from 'fs' 라 쓴다. 둘 다 동작하지만 같은 게 아니다. 한 프로젝트 안에서 섞으면 에러가 폭발한다.

이번 편에서 두 모듈 시스템의 정체를 정리한다. 결론부터 — 신규 프로젝트는 ESM, 레거시는 CommonJS. 이유와 실전 적용 방법까지 단단히 굳히고 가자.

1. 두 모듈 시스템의 정체

같은 자바스크립트인데 모듈 문법이 2가지인 이유는 역사 때문이다.

CommonJS (CJS) — Node 의 원조

2009 년 Node 가 처음 만들어졌을 때 자바스크립트엔 공식 모듈 시스템이 없었다. Node 가 자기 식으로 만든 게 CommonJS. require() 함수로 가져오고 module.exports 로 내보낸다.

// math.js (CJS) function add(a, b) { return a + b; } module.exports = { add }; // app.js (CJS) const { add } = require('./math'); console.log(add(1, 2));

ESM (ECMAScript Modules) — 공식 표준

2015 년 ES6 에서 자바스크립트가 공식 모듈 문법을 도입했다. import·export. 브라우저와 Node 모두 같은 문법을 쓸 수 있게 된 결정적 사건.

// math.mjs (ESM) export function add(a, b) { return a + b; } // app.mjs (ESM) import { add } from './math.mjs'; console.log(add(1, 2));

문법 자체는 거의 같아 보이는데, 로딩 방식이 다르다. CJS 는 동기 로딩 (require 호출하는 시점에 즉시 파일 읽음), ESM 은 비동기 로딩 (파일 그래프를 먼저 분석한 뒤 실행). 이 차이가 호환성 문제의 근원.

2. 핵심 차이 한눈에

항목CommonJSESM
가져오기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 기준 결론.

신규 프로젝트 → 무조건 ESM.
레거시 유지보수 → 기존 CJS 그대로. 굳이 ESM 으로 마이그레이션하지 말 것 (호환성 폭발).
라이브러리 배포 → ESM 메인 + CJS 폴백 (tsup·tshy 같은 도구가 자동).

왜 ESM 인가:

  • 모든 신규 패키지가 ESM-only (예: node-fetch v3+, chalk v5+).
  • top-level await 가 사실상 필수가 됨 (서버 시작 전 DB 연결 등).
  • TypeScript·Vite·Next.js 모두 ESM 가정.
  • 브라우저 코드와 같은 문법 — 풀스택 일관성.

4. ESM 모드로 전환하는 3가지 방법

Node 는 파일 단위로 모드를 결정한다. 결정 방법 3가지.

① package.json 에 "type": "module"

가장 일반적. 프로젝트 루트의 package.json 에 한 줄.

{ "name": "my-app", "type": "module" }

이 줄이 있으면 그 프로젝트의 모든 .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(...) 사용.
Node 22+ 의 호의 — Node 22 부터 CJS 가 ESM 패키지를 제한적으로 require 할 수 있다 (--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편.

© 2026 주나이테크(주) @JUNAITECH