pile·
백엔드·카카오 스타일 (지그재그)카카오 스타일 (지그재그)·

ESM 삽질기

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 개발자에게 어디까지 뚫리고 어디서 포기해야 하는지 실제 경계를 보여준다.
카카오 스타일 (지그재그)
카카오 스타일 (지그재그) 블로그
원문은 여기서 이어서 읽을 수 있어요
원문 읽기
읽음 (0)

이 글과 비슷한

  1. 백엔드·twilio-engTwilio Engineering·

    Programmable Messaging에서 Verify API로 마이그레이션하기

    Twilio의 Programmable Messaging API로 자체 OTP 솔루션을 운영하던 서비스가 Verify API로 전환하는 방법을 코드 예시와 함께 설명한다. Verify는 OTP 전송·검증을 위한 전용 API로, 전화번호 구매, 토큰 생성, DB 저장·만료 관리를 내부에서 처리해 개발자가 직접 구현할 코드를 크게 줄인다.

    요약 이어보기
    #authentication#twilio#sms+2
  2. 백엔드·포스타입포스타입·

    포스타입이 개인화 추천을 하는 방법 2부

    포스타입 백엔드 엔지니어가 벡터 기반 개인화 추천 시스템을 실제 운영하며 맞닥뜨린 성능 장애와 용량 문제를 해결한 과정을 담은 2부다. 수백만 개의 벡터 KNN 검색이 피크 시간대에 전체 Elasticsearch 검색 성능을 흔드는 문제부터 클러스터 OOM 사태까지, 쿼리 최적화와 인프라 분리 두 가지 경로로 근본 해결에 이른다.

    요약 이어보기
    #elasticsearch#vector-search#recommendation-system+2
  3. 백엔드·포스타입포스타입·

    포스타입이 개인화 추천을 하는 방법 1부

    포스타입이 태그 기반 추천의 한계를 극복하고 벡터 임베딩 기반 개인화 추천 시스템을 구축한 과정을 담은 1부다. 유사한 콘텐츠가 다른 용어를 쓰거나 동일한 태그가 전혀 다른 톤의 콘텐츠를 가리키는 문제를 임베딩 벡터로 해결하고, OpenSearch의 HNSW ANN 검색으로 수백만 벡터를 실시간 검색하는 시스템을 구축해 구매율 15% 향상을 달성했다.

    요약 이어보기
    #opensearch#vector-search#recommendation-system+2