빠른 비교
// ESM 파일에서 CJS 모듈 불러오기
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const legacy = require("./legacy.cjs");
// CJS 파일에서 ESM 모듈 동적 로드
async function loadESM() {
const { helper } = await import("./helper.mjs");
return helper();
}모듈 경계
먼저 머릿속에 넣어둘 기본형은 아래입니다.
- 패키지 기본 포맷 선언:
"type": "module" - 파일 단위 예외 분리:
.mjs,.cjs - ESM에서 CJS 읽기:
import pkg from "./lib.cjs" - ESM에서
require붙이기:createRequire(import.meta.url) - CJS에서 ESM 읽기:
await import("./lib.mjs")
프로젝트 기본 포맷을 하나 정한다
Interop 문제는 대부분 "기본 포맷이 모호한 상태"에서 커집니다. 새 프로젝트라면 보통 ESM을 기본으로 두고, 필요한 레거시 파일만 .cjs로 남기는 편이 낫습니다.
파일 확장자와 type이 해석 규칙을 결정한다
같은 코드라도 .mjs, .cjs, package.json의 "type"에 따라 Node가 전혀 다르게 읽습니다.
그래서 interop 이슈는 import 문법보다 "이 파일이 지금 무엇으로 해석되는가"부터 먼저 봐야 합니다.
CJS에서 ESM은 비동기 로드다
CJS에서는 정적 import가 아니라 await import(...)를 써야 합니다. 즉, "기존 동기 초기화 흐름 안에서 ESM을 바로 가져오고 싶다"는 요구가 나오면 구조를 다시 봐야 합니다.
ESM에서 CJS는 default import 감각으로 본다
ESM에서 CJS를 읽을 때는 module.exports 전체가 한 덩어리로 들어온다고 생각하면 덜 헷갈립니다. named import가 되는 경우가 있어도 그 동작에 기대는 건 보수적이지 않습니다.
createRequire()는 ESM에서 레거시 생태계를 붙이는 도구다
JSON, CJS 전용 라이브러리, 오래된 require 흐름을 ESM에서 잠깐 붙일 때 가장 실용적입니다.
interop는 "섞어 쓰는 기술"보다 "경계를 줄이는 설계"가 더 중요하다
한 파일 안에서 ESM과 CJS 관용구를 모두 끌어안기 시작하면 부트스트랩과 배포 경계가 빠르게 복잡해집니다. 보통은 패키지 기본 포맷 하나를 정하고, 예외 파일만 가장자리에서 interop로 처리하는 편이 낫습니다.
언제 섞이나
체크포인트
- ESM 기본 프로젝트 선언:
"type": "module" - 파일 단위 예외를 명시한다:
.mjs/.cjs - ESM에서 CJS 읽기:
import pkg from "./lib.cjs" - ESM에서
require필요:createRequire(import.meta.url) - CJS에서 ESM 읽기:
await import("./mod.mjs") - named import보다 기본 덩어리 import 감각을 우선
주의할 점
CJS에서 ESM을 읽는 순간 그 경로는 비동기가 된다. 기존 동기 부트스트랩에 그대로 끼워 넣으려 하면 설계가 꼬이기 쉽다.
참고 링크
2 sources