개요

Node.js에서 CommonJS(CJS)와 ESM(ES Modules)은 공존 상태이며, 신규 프로젝트는 ESM으로 전환되는 추세임. 두 시스템의 차이를 이해하면 번들 크기, 로딩 성능, 정적 분석, 생태계 호환에서 불필요한 시행착오 감소 가능

배경

  • 2009년 Node.js는 표준 모듈 시스템이 없던 시기라 CommonJS 채택
  • 2015년 ES6에서 ESM이 언어 차원의 공식 표준으로 확정
  • 2020년대 들어 Node.js가 ESM을 정식 지원, 브라우저와 규약 수렴 진행

CommonJS 요약

// export
module.exports = { foo: 1 }
exports.bar = 2

// import
const { foo, bar } = require('./utils')

특성

  • require와 module.exports 사용
  • 동기 로딩, 호출 시점에 블로킹 발생
  • 런타임 해석 기반, 동적 require 가능
  • 상대 경로에서 확장자 생략 가능

ESM 요약

// export
export const foo = 1
export default function main() {}

// import
import { foo } from './utils.js'
import lodash from 'lodash'

특성

  • import와 export 사용
  • 비동기 로딩, 병렬 가능
  • 정적 분석 가능, tree-shaking 전제 충족
  • Node.js에서 상대 경로 사용 시 확장자 명시 필요

Tree-shaking 핵심 요지

  • 사용하지 않는 export를 번들 단계에서 제거하는 최적화 기법
  • ESM의 정적 import 그래프가 전제 조건
  • CommonJS는 require가 동적이어서 안전한 제거가 어려움

간단 예시

// utils.js
export function add(a, b) { return a + b }
export function sub(a, b) { return a - b }

// main.js
import { add } from './utils.js'
console.log(add(1, 2))

번들러는 add만 포함 가능. sub는 제거 대상

잘 동작하려면

  • 패키지와 앱 코드 ESM 사용 권장
  • 사이드 이펙트 없는 모듈 구성
  • 라이브러리는 package.json에 sideEffects: false 명시 또는 예외 파일만 whitelisting

lodash 사례 요지

  • import _ from ’lodash’는 전체 포함 위험
  • import { get } from ’lodash-es’는 필요한 함수만 포함 유리

핵심 차이점 정리

문법

  • CJS: module.exports, require
  • ESM: export, export default, import

로딩 방식

  • CJS: 동기 로딩, require 호출 지점 블로킹
  • ESM: 비동기 로딩, 브라우저 네이티브에서 병렬 요청 정합

정적 vs 동적 분석

  • CJS: 런타임 결정, require(변수) 등 동적 패턴 허용, 사전 분석 어려움
  • ESM: 컴파일 타임 확정, import는 정적 문자열과 파일 상단 배치 요구, 미사용 코드 제거 가능

this 바인딩

  • CJS: 최상위 this는 module.exports와 동일
  • ESM: 최상위 this는 undefined

__dirname, __filename

  • CJS: 기본 제공
  • ESM: 직접 생성 필요
import { fileURLToPath } from 'url'
import { dirname } from 'path'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)

JSON import

  • CJS: require(’./config.json’) 가능
  • ESM: Node 18+에서 import assertions 사용 권장
import config from './config.json' assert { type: 'json' }

또는 fs로 읽어 파싱

브라우저 호환성

  • CJS: 번들러 필요
  • ESM: 네이티브 지원, 동일 문법 사용

Top-level await

  • CJS: 미지원
  • ESM: 지원

Node.js에서 ESM 사용하기

방법 1, package.json에 type 지정

{
  "type": "module"
}

이 경우 .js는 ESM으로 해석, .cjs는 CJS로 강제 해석

방법 2, 확장자로 구분

  • .mjs는 ESM
  • .cjs는 CJS
  • .js는 package.json의 type 규칙을 따름

상대 경로 import 시 확장자 또는 exports 매핑 필요. 브라우저와 규칙을 맞추려면 확장자 명시 권장

상호 운용성

ESM에서 CJS 가져오기

import lodash from 'lodash'

일반적으로 기본 내보내기 형태로 동작. 일부 라이브러리는 네임드 내보내기 호환성 제한 존재

CJS에서 ESM 가져오기

// require로 직접 로딩 불가
// 필요 시 동적 import 사용
const esm = await import('./esm-module.mjs')

트레이드오프

  • CJS 프로젝트에서 ESM 모듈을 섞을 때 비동기 경계 발생
  • 초기화 순서와 로딩 타이밍에 주의 필요

번들링과 성능 관점

  • ESM은 정적 분석 기반으로 dead code 제거, 코드 스플리팅 최적화에 유리
  • CJS 패키지를 번들링할 때는 번들러가 안전을 위해 전체 포함하는 경우 빈번
  • 라이브러리 선택 시 ESM 제공 여부와 sideEffects 메타데이터 확인 권장

현재 생태계 상황

  • 신규 프로젝트는 ESM 권장 흐름
  • 기존 라이브러리 상당수가 여전히 CJS를 배포하나, 점진적으로 dual package 또는 ESM 우선 제공으로 이동 중
  • Node.js는 CJS와 ESM 모두 지원. 브라우저와 번들러는 ESM 우선 설계가 보편

실무 선택 가이드

  • 새 프로젝트는 ESM 우선. 브라우저와 서버 모두에서 일관된 모듈 해석 확보
  • 기존 CJS 코드베이스는 점진 전환 전략 적용. 외부 의존성 호환성, 빌드 파이프라인 영향, 배포 타깃 고려
  • 번들러 사용 환경에서는 ESM 의존성 채택이 번들 크기와 트리쉐이킹 품질에 유리
  • 상대 경로 import에는 확장자 명시. 경로 해석 차이로 인한 런타임 오류 방지
  • JSON, __dirname 등 CJS 편의 기능은 ESM 전환 시 대체 패턴을 사전에 확보

TypeScript에서 nodenext의 의미

tsconfig 예시

{
  "module": "nodenext",
  "moduleResolution": "nodenext"
}

의미

  • Node.js의 최신 ESM 해석 규칙을 따르는 모듈 시스템 적용
  • import, export 문법과 package.json의 exports, type에 맞춘 해석 수행
  • 상대 경로에는 확장자 요구. CJS와 ESM의 혼합 구성에 대한 Node 규칙을 TS 레벨에서 일치시킴

한 줄 요약

  • nodenext는 타입스크립트의 모듈 시스템과 해석을 Node ESM 규칙에 정합시키는 설정

마무리

요약

  • CommonJS는 역사적 배경과 생태계 호환에서 여전히 유효하나, 동기 로딩과 동적 해석으로 인해 정적 최적화에 불리
  • ESM은 언어 표준, 정적 분석, 브라우저 네이티브, top‑level await 등 현대적 요구에 부합
  • 신규 개발은 ESM을 기본값으로 두고, CJS 상호 운용은 경계 최소화와 비동기 초기화 관리에 집중 권장
  • 번들 크기와 로딩 성능이 중요하면 ESM과 sideEffects 메타데이터를 우선 고려

참고자료