Pure ESM 으로 전환된 npm 모듈(`chalk` 5.0 등)을 CommonJS 기반 TypeScript 프로젝트에서 쓰려다 막힌 과정을 끝까지 기록한 글이다. `.mjs`, `"type": "module"`, tsconfig 의 `module` 설정, 확장자 명시까지 하나씩 뚫었지만 결국 애플리케이션 전체의 ESM 전환은 포기하고 `tsimportlib` 로 dynamic import 를 감추는 우회를 택했다.
핵심 포인트- `chalk` 5.0 이 Pure ESM 이 되면서 `require()` 하면 `ERR_REQUIRE_ESM` 이 난다. CommonJS 프로젝트에서는 ESM 모듈을 정적으로 못 불러온다.
- JavaScript 에서 Pure ESM 을 읽는 길은 `.mjs` 확장자, 프로젝트 전체 ESM 전환(`"type": "module"`), dynamic import 세 가지다.
- TypeScript 는 import 를 require 로 바꾸거나 그대로 두거나 둘뿐이라, `"module": "es2020"` 유지 + `"type": "module"` + ts-node `--esm` 을 함께 맞춰야 한다.
- ESM 전환은 import 구문만 바꾸는 게 아니라 **모든 import 경로에 확장자 명시**(TypeScript 인데 `.js`), 디렉터리 import 제거, `__dirname` 대체까지 따라온다.
- 결정타는 ESM 스코프에서 `require` 자체가 금지된다는 점이었다. config 로더와 REPL 이 require 에 묶여 있어 전환을 포기했다.
상세 정리- 배경: Node.js 를 10년 쓰며 CoffeeScript→TypeScript, 콜백→Async.js→Promise 전환을 겪었지만 모듈 시스템은 유지돼 왔는데, ESM 은 기존 것과 양립이 잘 안 돼 모듈 업데이트 자체가 막혔다.
- 순환 참조 예시: `a.ts` 와 `b.ts` 가 서로 import 하면 CommonJS 에서는 실행 순서상 한쪽 값이 `undefined` 로 찍힌다 — 이런 문제들이 ESM 이 나온 배경일 것이라고 본다.
- top-level await: CommonJS 에서는 `await Promise.resolve(1)` 이 `SyntaxError` 라 IIFE 를 써야 했지만 ESM 에서는 가능하다. 이 차이 때문에 CommonJS 가 ESM 을 require 할 수 없다.
- 생태계 압력: Sindre Sorhus 가 ESM 전환을 선언하며 관여 중인 1,000개 넘는 모듈이 Pure ESM 으로 넘어갔다. Pure ESM 은 양쪽 지원이 가능한데도 굳이 ESM 만 제공하겠다는 선택이다.
- TypeScript 에서 원인 확인: 컴파일 결과가 `var chalk_1 = require("chalk")` 로 나오는 걸 보고, 문법이 아니라 출력 모듈 시스템이 문제임을 확인한다.
- JavaScript 실험: `import` 를 쓰면 `Cannot use import statement outside a module`, 파일명을 `.mjs` 로 바꾸면 동작, `package.json` 에 `"type": "module"` 을 넣어도 동작, `await import('chalk')` 후 `chalk.default` 로 접근해도 동작한다.
- TypeScript 실험: `.mts` 는 인식되지만 변환이 적절치 않고, dynamic import 를 써도 컴파일 결과가 require 로 바뀐다. `{"target":"es2017","module":"es2020","moduleResolution":"node"}` 로 고쳐야 통과한다.
- 두 층의 구분: import 를 require 로 바꾸지 않는 것은 tsconfig 의 `module` 설정이고, Node.js 가 import 구문을 이해하는 것은 `package.json` 의 `type` 설정이라 둘 다 필요하다.
- 실전에서 터진 것들: 상대 경로에 확장자를 전부 붙여야 하고(`from './services'` → `from './services/index.js'`, 도구 `fix-esm-import-path` 활용), `__dirname` 을 못 써서 `loadSchemaSync(join(__dirname, './index.graphql'))` 같은 코드를 고쳐야 했다.
- 결정적 벽: ESM 스코프에서 `require is not defined` 가 뜬다. `tools/server.js` 의 `require('ts-node/register/transpile-only')` 와 `loadConfig` 안의 동적 `require(`${dir}/default`)` 가 당장 고치기 어려웠고, 자주 쓰는 REPL 도 제대로 안 됐다.
- 최종 우회: TypeScript 가 변환하지 못하도록 `new Function('specifier', 'return import(specifier)')` 로 import 를 감추는 방법이 있고, 더 직관적인 `tsimportlib` 라이브러리를 채택했다. dynamic import 는 서버에만 필요했고 프론트엔드는 Next.js 덕에 static import 로 동작했다.
- 남은 부채: 나중에 프로젝트가 ESM 으로 전환되면 dynamic import 를 static 으로 되돌려야 하므로 꼭 필요한 곳에만 쓰기로 했다. 저자는 이 상황을 Python 2→3 전환기의 혼란에 비유한다.
왜 읽나CommonJS TypeScript 서버에서 Pure ESM 의존성을 만나 막힌 Node.js 개발자에게 어디까지 뚫리고 어디서 포기해야 하는지 실제 경계를 보여준다.