Published on

Node 22에서 require() 막힘? ESM 전환 실전

Authors

서버를 Node 22로 올렸더니 평소처럼 require()를 쓰던 코드가 갑자기 터지는 경험을 하는 팀이 많습니다. 결론부터 말하면 Node 22가 전면적으로 require()를 “막아버린” 건 아닙니다. 하지만 ESM이 기본값처럼 느껴지도록 주변 생태계가 바뀌었고, 특정 조건에서는 require()가 더 이상 통하지 않는 지점이 분명히 존재합니다.

이 글에서는 실제로 자주 마주치는 에러 패턴을 기준으로, 왜 이런 일이 생기는지, 그리고 운영 환경에서 안전하게 ESM으로 전환하는 실전 절차를 정리합니다.

1) “require()가 막혔다”로 보이는 대표 증상

Node 22에서 흔히 보는 에러는 대개 아래 범주 중 하나입니다.

1-1. ESM 모듈을 require()로 불러서 실패

의존성이 ESM 전용으로 바뀌면 CommonJS에서 다음이 실패합니다.

// CommonJS 파일 (예: index.cjs 또는 package.json에 type이 없는 상태의 .js)
const chalk = require('chalk');

에러는 보통 ERR_REQUIRE_ESM 형태로 나타납니다. 즉, require() 자체가 막힌 게 아니라, “ESM 모듈은 require()로 로드할 수 없다”는 규칙에 걸린 것입니다.

1-2. type: module을 켠 뒤 CommonJS 문법을 그대로 사용

프로젝트에서 package.json"type": "module"을 추가하면 .js는 ESM으로 해석됩니다. 그 상태에서 아래 코드는 즉시 깨집니다.

// ESM으로 해석되는 .js 파일에서
const fs = require('node:fs');
module.exports = { };

ESM에서는 require, module.exports, __dirname, __filename이 기본 제공되지 않습니다.

1-3. 테스트 러너, 번들러, 런타임이 ESM을 전제로 동작

Jest, Vitest, ts-node, tsx, Babel, ESLint 설정 등 주변 도구가 ESM 쪽으로 기본값이 이동하면서, “Node 22 올렸더니” 같이 보이지만 실제로는 도구 체인에서 ESM 전제가 강해진 경우가 많습니다.

2) Node에서 ESM과 CommonJS를 가르는 핵심 규칙

전환을 제대로 하려면, Node가 파일을 어떤 모듈 시스템으로 해석하는지부터 명확히 해야 합니다.

2-1. .mjs / .cjs 확장자

  • .mjs는 무조건 ESM
  • .cjs는 무조건 CommonJS

가장 안전한 “혼합 운영” 방식입니다.

2-2. package.jsontype

  • "type": "module"이면 .js는 ESM
  • "type": "commonjs"이거나 type이 없으면 .js는 CommonJS

2-3. exports가 있는 패키지의 진입점

최근 패키지들은 package.jsonexports를 두고, ESM과 CJS 엔트리를 분기합니다. 문제는 어떤 패키지는 CJS 엔트리를 아예 제공하지 않는다는 점입니다. 이때 CJS 앱은 require()로 해당 패키지를 못 씁니다.

3) 실전 전환 전략: “한 번에 올인” 대신 단계적으로

운영 중인 서비스라면, 한 번에 "type": "module"로 올인하는 방식은 리스크가 큽니다. 아래 순서를 추천합니다.

3-1. 1단계: 앱은 CommonJS 유지, ESM 의존성만 우회 로딩

당장 전체 전환이 어렵다면, CommonJS에서 ESM 패키지를 로드하는 우회로는 import()입니다.

// index.cjs
async function main() {
  const chalk = (await import('chalk')).default;
  console.log(chalk.green('hello'));
}

main().catch(console.error);
  • import()는 CommonJS에서도 사용 가능
  • 단, 비동기이므로 초기화 흐름을 조정해야 함

이 단계는 “빌드 깨짐”을 멈추게 하는 응급처치로 좋습니다.

3-2. 2단계: 신규 코드부터 ESM으로 작성, 기존은 .cjs로 고정

프로젝트 루트에 "type": "module"을 넣고 싶다면, 기존 CommonJS 파일을 .cjs로 바꿔 안전하게 고정하세요.

예시 구조:

  • src/index.js는 ESM
  • src/legacy/*.cjs는 CommonJS
// src/index.js (ESM)
import http from 'node:http';
import { legacyHandler } from './legacy/handler.cjs';

const server = http.createServer((req, res) => {
  legacyHandler(req, res);
});

server.listen(3000);
// src/legacy/handler.cjs (CommonJS)
exports.legacyHandler = function legacyHandler(req, res) {
  res.end('ok');
};

이 방식은 “ESM로의 점진 전환”에서 가장 사고가 적습니다.

3-3. 3단계: 엔트리 포인트부터 ESM으로 바꾸고, 내부 의존성 정리

엔트리 포인트가 ESM이 되면, 내부도 ESM으로 정리하는 게 장기적으로 유지보수 비용이 줄어듭니다.

  • require()import로 변경
  • module.exportsexport로 변경
  • __dirname 대체

4) ESM 전환 시 가장 많이 깨지는 포인트와 해결법

4-1. __dirname, __filename 대체

ESM에는 __dirname이 없습니다. 아래 패턴을 사용합니다.

// ESM
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

const configPath = join(__dirname, 'config.json');
console.log(configPath);

4-2. JSON import

Node는 JSON import에 조건이 붙습니다. 가장 호환성 좋은 방식은 fs로 읽는 것입니다.

import { readFile } from 'node:fs/promises';

const raw = await readFile(new URL('./config.json', import.meta.url), 'utf-8');
const config = JSON.parse(raw);

4-3. require.resolve 대체

ESM에서는 createRequire를 섞어 쓰는 방식이 현실적입니다.

import { createRequire } from 'node:module';

const require = createRequire(import.meta.url);
const resolved = require.resolve('some-package');
console.log(resolved);

이 패턴은 “ESM로 전환했지만, 일부 Node 생태계 API는 여전히 require 기반”인 현실을 부드럽게 연결해줍니다.

4-4. exports와 조건부 엔트리 때문에 발생하는 런타임 불일치

패키지의 exports가 ESM만 제공하면 CJS는 원천적으로 로딩이 불가합니다. 이때 선택지는 3가지입니다.

  1. 앱을 ESM으로 전환
  2. 해당 패키지의 CJS 지원 버전으로 다운그레이드
  3. 대체 라이브러리로 교체

운영 서비스라면 2)로 급한 불을 끄고, 1)로 로드맵을 잡는 경우가 많습니다.

5) TypeScript 프로젝트라면: 컴파일 타깃과 모듈 해석부터 고정

TS에서 ESM 전환은 Node 런타임보다 tsconfig.json이 더 큰 변수입니다. 추천 조합은 아래 중 하나입니다.

5-1. NodeNext 전략 (권장)

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist",
    "esModuleInterop": true,
    "resolveJsonModule": true
  }
}
  • Node의 ESM 규칙을 TS가 그대로 따라가도록 맞추는 방식
  • .ts.cts/.mts를 섞어 점진 전환하기 좋음

5-2. 빌드 산출물 확장자 관리

빌드 후 산출물이 .js로만 나오면, type 설정에 따라 런타임 해석이 바뀌어 사고가 납니다. 가능하면 아래 중 하나로 고정하세요.

  • ESM 산출물은 .mjs
  • CJS 산출물은 .cjs

툴체인에 따라 설정법이 달라, “산출물의 모듈 시스템을 확장자로 고정”하는 게 운영 안정성에 유리합니다.

6) 패키지 배포자 관점: dual package를 제대로 만들기

라이브러리를 배포하는 입장이라면, 소비자가 ESM과 CJS를 모두 쓸 수 있게 exports를 구성하는 게 중요합니다.

{
  "name": "my-lib",
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    }
  }
}
  • ESM 소비자는 import 경로를 타고
  • CJS 소비자는 require 경로를 탐

여기서 한쪽 산출물이 누락되면, 특정 소비자군에서 “Node 22에서 갑자기 깨졌다” 같은 사건이 발생합니다.

7) 운영 전환 체크리스트: 배포 전 꼭 확인할 것

7-1. 실행 커맨드와 엔트리 파일 확장자

  • node dist/index.js가 ESM인지 CJS인지
  • package.jsontype이 무엇인지
  • 엔트리가 .mjs/.cjs로 명확히 고정되어 있는지

7-2. 로깅으로 모듈 해석을 빠르게 판별

문제가 생기면, 해당 파일이 ESM으로 해석되는지부터 확인하는 게 빠릅니다.

// ESM에서만 동작
console.log(import.meta.url);
// CJS에서만 동작
console.log(__filename);

둘 중 무엇이 찍히는지로 “현재 런타임 해석”을 즉시 판별할 수 있습니다.

7-3. CI에서 Node 버전 매트릭스

Node 20과 22를 동시에 돌려보면, ESM 관련 회귀를 조기에 잡을 수 있습니다. GitHub Actions를 쓰는 팀이라면 OIDC나 배포 파이프라인 문제도 함께 얽히는 경우가 많아, CI 안정화가 중요합니다. 관련해서는 GitHub Actions OIDC assume-role 실패 7분 해결법도 같이 참고하면 좋습니다.

8) 전환 이후에 자주 겪는 “배포는 됐는데 죽는” 문제

ESM 전환 자체는 성공했는데, 배포 환경에서만 프로세스가 재시작 루프에 빠지는 경우가 있습니다. 원인은 대개 다음입니다.

  • 엔트리 파일 경로가 잘못되어 Node가 즉시 종료
  • 런타임에서만 필요한 파일이 빌드 산출물에 누락
  • systemd나 컨테이너에서 작업 디렉터리가 달라 상대경로가 깨짐

이런 상황에서는 systemd 로그와 종료 코드를 함께 보는 게 빠릅니다. 배포가 systemd 기반이라면 systemd 서비스 무한 재시작 - Exit code 203 해결systemd 서비스가 계속 재시작될 때 원인 추적법을 함께 보면 원인 파악 시간이 크게 줄어듭니다.

9) 마이그레이션 권장 로드맵 요약

  • 당장 장애가 났다면 CommonJS에서 import()로 ESM 의존성만 우회 로딩
  • 중기적으로는 "type": "module" 도입 후 레거시는 .cjs로 격리
  • 장기적으로는 엔트리부터 내부까지 ESM 정리, __dirname/JSON/resolve 패턴 표준화
  • 라이브러리를 배포한다면 exportsimport/require 듀얼 지원

Node 22에서 require()가 “막힌 것처럼” 보이는 순간은 대개 생태계가 ESM 중심으로 재편된 결과입니다. 핵심은 감으로 고치지 말고, 현재 파일이 ESM인지 CJS인지 해석 규칙을 고정하고, 확장자와 exports를 기준으로 단계적으로 전환하는 것입니다.

필요하면 다음 단계로, 실제 프로젝트 구조를 기준으로 package.json, tsconfig.json, 엔트리 파일, 테스트 러너 설정까지 포함한 “전환 PR 체크리스트” 형태로도 정리해드릴 수 있습니다.