티스토리 뷰

Node.js 생태계에서 활동하는 개발자라면 누구나 한 번쯤 ReferenceError: require is not defined 또는 ERR_UNKNOWN_FILE_EXTENSION 같은 불쾌한 에러 메시지를 마주하게 됩니다. 이는 단순히 문법의 차이를 넘어, 지난 10년간 JavaScript 생태계를 지배해온 CommonJS(CJS)와 웹 표준으로 자리 잡은 ECMAScript Modules(ESM)라는 두 거대한 설계 철학이 충돌하며 발생하는 파열음입니다.

 

2025년 현재, 신규 프로젝트의 85% 이상이 ESM을 기본값으로 채택하고 있지만, 우리가 의존하는 수많은 npm 라이브러리들은 여전히 CJS 기반으로 작성되어 있습니다. 이 두 시스템의 '불편한 동거'는 빌드 설정의 복잡도를 높이고, 런타임 성능을 저하시키는 주요 원인이 됩니다. 본 리포트에서는 수석 연구원의 시각으로 두 모듈 시스템의 기술적 간극을 해부하고, 실무에서 즉시 적용 가능한 상호 운용성(Interoperability) 가이드를 제시합니다.

 

 

1. 왜 두 시스템은 서로를 '이해'하지 못하는가?

CJS와 ESM의 충돌은 단순히 require()import로 바꾸는 수준의 문제가 아닙니다. 근본적인 차이는 모듈을 로드하는 시점과 방식에 있습니다.

 

 

동기적 로드(CJS) vs 비동기적 구조(ESM)

CommonJS는 파일 시스템에서 모듈을 읽어오는 '동기적' 방식을 취합니다. require()가 실행되는 순간 스크립트 실행이 잠시 멈추고 해당 모듈을 완전히 불러온 뒤 다음 줄로 넘어갑니다. 반면, ESM은 정적 분석을 통해 모듈 간의 의존성 그래프를 먼저 구성합니다. 이 과정에서 파일 다운로드와 파싱이 비동기적으로 이루어지며, 실제 실행 전에 모든 연결 구조가 확정됩니다.

 

 

정적 구조와 트리 쉐이킹(Tree Shaking)

CJS는 조건문 안에서 require()를 호출할 수 있을 만큼 동적입니다. 하지만 이 유연함은 빌드 도구가 어떤 코드가 사용되지 않는지 판단하는 것을 방해합니다. ESM은 반드시 파일 최상단에서만 임포트가 가능하므로, 롤업(Rollup)이나 웹팩(Webpack) 같은 도구가 불필요한 코드를 100% 제거하여 번들 크기를 평균 30~40% 줄일 수 있게 합니다.

 

 

2. 기술 사양 비교: CJS vs ESM 핵심 차이점

운영 환경을 설계할 때 반드시 고려해야 할 두 시스템의 기술적 명세입니다.

 

구분 CommonJS (CJS) ECMAScript Modules (ESM)
키워드 require, module.exports import, export
로드 방식 동기(Runtime) 비동기(Static/Parsing time)
기본 확장자 .js, .cjs .js, .mjs
특수 변수 __dirname, __filename 사용 가능 사용 불가 (import.meta.url로 대체)
Top-level Await 지원하지 않음 지원함

 

 

3. 실무 솔루션: 하이브리드 환경에서의 완벽한 호환성 확보

기존 CJS 프로젝트를 ESM으로 전환하거나, 두 시스템을 동시에 지원해야 하는 라이브러리 제작자를 위한 3단계 전략입니다.

 

  1. Dual-Package Strategy 구축: package.jsonexports 필드를 활용하여 CJS와 ESM 환경 모두에 대응하는 진입점을 제공하십시오.
    • "import": "./dist/index.mjs" - ESM 환경용
    • "require": "./dist/index.cjs" - CJS 환경용
  2. ESM에서 CJS 호출하기: ESM 파일 내에서는 require를 직접 사용할 수 없습니다. createRequire 유틸리티를 사용하여 호환 레이어를 생성하십시오.
    • import { createRequire } from 'module';
    • const require = createRequire(import.meta.url);
  3. CJS에서 ESM 호출하기: CJS 환경에서 ESM 모듈을 불러올 때는 동기적인 require가 작동하지 않습니다. 이때는 반드시 dynamic import()를 사용해야 합니다.
    • const module = await import('esm-package'); (비동기 함수 내부에서 실행)

 

4. 전문가의 제언: 2026년을 대비하는 아키텍처 가이드

단순히 에러를 해결하는 수준을 넘어, 장기적인 유지보수성을 확보하기 위해 다음의 팁을 가슴에 새기시기 바랍니다.

 

  • "Pure ESM" 패키지 주의보: node-fetchchalk 같은 유명 패키지들은 이미 ESM 전용으로 전환되었습니다. CJS 환경에서 이들을 무분별하게 업데이트하면 전체 시스템이 중단될 수 있습니다. npm outdated를 정기적으로 확인하고 마이그레이션 계획을 수립하십시오.
  • TypeScript 설정 최적화: tsconfig.json에서 module: "NodeNext" 설정을 사용하는 것이 현재 가장 안전한 선택입니다. 이는 확장자 규칙을 엄격히 적용하여 배포 후 발생할 수 있는 잠재적 로드 에러를 99% 차단합니다.
  • 성능 데이터 지표: 대규모 모듈 그래프 환경에서 ESM의 초기 파싱 속도는 CJS보다 약 15~20% 느릴 수 있으나, 실행 시점의 최적화와 트리 쉐이킹을 통한 전체 페이로드 감소로 인해 결과적인 사용자 경험(LCP)은 ESM이 훨씬 우월합니다.

 

자주 묻는 질문 (FAQ)

  • Q: 왜 ESM 파일에서는 __dirname을 쓸 수 없나요?A: ESM은 로컬 파일 시스템뿐만 아니라 URL 기반의 원격 모듈 로드도 고려하여 설계되었기 때문입니다. 대신 URL 객체와 import.meta.url을 조합하여 절대 경로를 계산해야 합니다.
  • Q: 신규 프로젝트라면 무조건 ESM을 써야 할까요?A: 네, 그렇습니다. Node.js 20+ 버전 이상을 타겟팅한다면 ESM이 표준입니다. 레거시 시스템과의 연동이 필수적인 특수 상황이 아니라면 ESM으로 시작하는 것이 기술 부채를 줄이는 유일한 길입니다.

JavaScript의 모듈 시스템 변화는 단순한 문법의 변화가 아닌, 언어의 현대화 과정입니다. 이 가이드에서 제시한 호환성 전략을 통해 귀하의 프로젝트가 더욱 견고하고 미래지향적인 아키텍처를 갖추길 바랍니다.

한 번에 이해하기 어려운 부분은 다시 천천히 훑어보시길 권하며, 끝까지 정독해주셔서 고맙습니다.