어제까지 잘 돌던 코드가 왜 갑자기 깨졌을까
import/require를 혼용하다가, 혹은 package.json에 "type": "module" 한 줄을 추가한 직후 프로젝트 전체가 빨간 스택트레이스로 뒤덮인 경험은 실무에서 가장 흔하게 보고되는 상황입니다. 특히 chalk 5, node-fetch 3, execa, nanoid 같은 인기 패키지들이 ESM-only로 전환되면서, 기존 CommonJS 코드에서 그냥 require만 했을 뿐인데 ERR_REQUIRE_ESM이 터지는 사례가 급증했습니다.
이 글은 개념 강의가 아닙니다. ESM이 무엇인지, CommonJS가 무엇인지 설명하지 않습니다. 오직 다음 세 형제 에러를 만났을 때 원인을 30초 안에 특정하고, 복붙 코드로 즉시 복구하는 것만 다룹니다.
Cannot use import statement outside a moduleERR_REQUIRE_ESM(또는Error [ERR_REQUIRE_ESM]: require() of ES Module ...)require is not defined in ES module scope, you can use import instead
적용 범위는 Node.js 18/20/22, TypeScript 5.x, ts-node 10+, Jest 29+, Vite 5 / Webpack 5 입니다. 아래 진단표부터 보고 자기 상황에 해당하는 섹션으로 바로 점프하세요.
30초 진단표 — 에러 원문만 보고 원인 특정
먼저 자기가 만난 에러 원문 한 줄을 표에서 찾으세요. 원인과 이동할 섹션이 바로 매핑됩니다.
| 에러 원문 | 가장 흔한 원인 | 해결 섹션 |
|---|---|---|
Cannot use import statement outside a module | import를 쓰는데 파일이 CommonJS로 해석됨. package.json에 type 미설정이거나, .ts가 CJS로 컴파일되거나, 확장자가 .js인데 type이 없음 | ①·② / TS는 도구 섹션 |
ERR_REQUIRE_ESM / require() of ES Module ... | CJS 코드가 ESM-only 패키지를 require 함 (chalk 5, node-fetch 3 등) | ④ |
require is not defined in ES module scope | "type": "module" 파일에서 require/module.exports/__dirname 사용 | ③ |
Unknown file extension ".ts" (ts-node) | ts-node가 ESM 모드로 .ts를 로드하는데 로더 설정 누락 | 도구 섹션(ts-node) |
SyntaxError: Cannot use import statement outside a module (Jest) | Jest가 ESM/TS를 변환하지 못함 | 도구 섹션(Jest) |
한 문장 요약: "어느 쪽이 import를 쓰는데 상대가 CJS냐, 아니면 어느 쪽이 require를 쓰는데 상대가 ESM이냐" 이 두 축만 구분하면 끝입니다.
원인별 복붙 해결 런북 (Node 순수 실행)
① type 설정 정리 — 프로젝트 전체 모드 결정
가장 먼저 확인할 것은 package.json의 type 필드입니다. 이 한 줄이 .js 파일을 ESM으로 볼지 CJS로 볼지 결정합니다.
// package.json — 프로젝트를 ESM으로 통일 (import/export 사용)
{
"name": "my-app",
"type": "module"
}// package.json — 프로젝트를 CommonJS로 고정 (require/module.exports 사용)
{
"name": "my-app",
"type": "commonjs" // 또는 type 필드 자체를 생략
}동작 규칙 요약:
type 값 | .js 해석 | .mjs | .cjs |
|---|---|---|---|
"module" | ESM | ESM | CommonJS |
"commonjs" 또는 생략 | CommonJS | ESM | CommonJS |
Cannot use import statement outside a module가 났다면 → import를 쓰는 .js 파일인데 type이 없거나 commonjs입니다. 프로젝트를 ESM으로 갈 거면 "type": "module"을 넣으세요.
② .mjs / .cjs로 파일 단위 격리
프로젝트 전체를 건드리기 싫다면, 확장자로 파일 하나만 강제할 수 있습니다.
// script.mjs — type과 무관하게 항상 ESM
import fs from 'node:fs';
export const hello = () => 'esm';// legacy.cjs — type과 무관하게 항상 CommonJS
const fs = require('node:fs');
module.exports = { hello: () => 'cjs' };레거시 프로젝트에 ESM 스크립트 하나만 추가하고 싶을 때 .mjs가 가장 안전합니다. 반대로 "type": "module" 프로젝트에서 옛날 CJS 설정 파일만 남겨야 할 때 .cjs를 씁니다.
③ ESM에서 require / __dirname 대체
require is not defined in ES module scope는 ESM 파일 안에서 CJS 전용 문법을 쓴 것입니다. ESM에는 require, __dirname, __filename이 기본 제공되지 않습니다. 아래 스니펫을 그대로 넣으세요.
// ESM에서 require가 꼭 필요할 때 (CJS 패키지 로드 등)
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const someCjsModule = require('some-legacy-cjs-pkg');// ESM에서 __dirname / __filename 복원
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);require('./data.json') 처럼 JSON을 불러오던 코드는 ESM에서 import attributes로 바꿀 수 있습니다.
// Node 20.10+ / 22: JSON import (import attributes)
import data from './data.json' with { type: 'json' };④ CommonJS에서 ESM-only 패키지 로드 — 동적 import()
ERR_REQUIRE_ESM의 전형적 원인은 CJS 코드에서 require('chalk')처럼 ESM-only 패키지를 불러온 것입니다. require를 동적 import()로 바꾸면 됩니다.
// ❌ CommonJS에서 ESM-only 패키지 require → ERR_REQUIRE_ESM
const chalk = require('chalk'); // chalk 5는 ESM-only
// ✅ 동적 import()로 우회 (CJS 파일에서도 동작)
async function main() {
const { default: chalk } = await import('chalk');
console.log(chalk.green('OK'));
}
main();await를 최상위에서 쓸 수 없는 CJS라면 위처럼 async 함수로 감싸면 됩니다. 프로젝트를 통째로 ESM으로 옮기기 부담스러울 때 가장 현실적인 방법입니다. 근본 해결을 원하면 해당 패키지의 마지막 CJS 버전(예: chalk@4, node-fetch@2)으로 다운그레이드하는 것도 자주 쓰이는 우회입니다.
도구별 함정 해결 (ts-node · Jest · 번들러)
TypeScript / ts-node
TypeScript 5.x에서 Node 실행 대상이라면 NodeNext 조합이 표준 권장입니다. 이 설정은 package.json의 type과 파일 확장자를 그대로 존중합니다.
// tsconfig.json — 최신 Node ESM 대상 (권장)
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2022",
"esModuleInterop": true,
"outDir": "dist"
}
}// tsconfig.json — 레거시 CommonJS 고정
{
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "Node10", // 또는 "Node"
"target": "ES2020",
"esModuleInterop": true
}
}module 설정별 요약:
| 설정 | 출력 형태 | 언제 |
|---|---|---|
NodeNext | type에 따라 ESM/CJS 자동 | 신규 Node 프로젝트 |
CommonJS | 항상 require로 변환 | 레거시 유지, Jest CJS |
ESNext + Bundler moduleResolution | 번들러가 처리 | Vite/Webpack 앱 |
ts-node로 .ts를 직접 실행하다 Cannot use import statement outside a module나 Unknown file extension ".ts"가 나면 ESM 로더를 켜야 합니다.
// tsconfig.json 에 ts-node 블록 추가
{
"compilerOptions": { "module": "NodeNext", "moduleResolution": "NodeNext" },
"ts-node": { "esm": true }
}# 실행 (Node 20+). 정상 출력이면 스크립트 결과가 그대로 찍힘
node --loader ts-node/esm ./src/index.ts
# 또는
npx ts-node --esm ./src/index.ts예상 정상 결과는 에러 없이 스크립트가 실행되는 것입니다. 여전히 Unknown file extension이 뜨면 → package.json에 "type": "module"이 있는지, .ts가 아닌 .cts/.mts를 섞어 쓰지 않았는지 확인하세요.
Jest
Jest의 ESM 지원은 여전히 실험적입니다. 두 갈래 중 하나를 고르세요.
갈래 A — 그냥 CommonJS로 되돌리기 (가장 안정적). ESM이 꼭 필요하지 않다면 ts-jest를 CJS로 두는 것이 트러블이 가장 적습니다.
// jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
// tsconfig의 module을 CommonJS로 컴파일하도록 두면 대부분 해결
};갈래 B — ESM으로 실행. ESM-only 의존성을 반드시 그대로 써야 할 때입니다.
// package.json — Jest ESM 실행 스크립트
{
"scripts": {
"test": "node --experimental-vm-modules node_modules/.bin/jest"
}
}// jest.config.js — ESM + ts-jest useESM
export default {
preset: 'ts-jest/presets/default-esm',
testEnvironment: 'node',
extensionsToTreatAsEsm: ['.ts'],
transform: {
'^.+\\.tsx?$': ['ts-jest', { useESM: true }],
},
};Cannot use import statement outside a module가 Jest 실행 중에만 난다면 대개 변환 설정(transform) 누락입니다. 실무에서는 갈래 A(CJS 회귀)로 빠르게 복구한 뒤, 여유가 있을 때 B로 마이그레이션하는 순서가 안전합니다.
Vite / Webpack 설정 파일 이슈
"type": "module" 프로젝트에서 postcss.config.js, .eslintrc.js 같은 설정 파일이 CJS 문법(module.exports)을 쓰면 깨집니다. 해당 설정 파일만 .cjs로 바꾸면 즉시 해결됩니다.
# ESM 프로젝트에서 CJS 문법 설정 파일만 격리
mv postcss.config.js postcss.config.cjs
mv .eslintrc.js .eslintrc.cjs # 또는 flat config(eslint.config.js) ESM으로 이관Vite 앱 코드 자체는 tsconfig에서 "moduleResolution": "Bundler", "module": "ESNext"를 쓰는 것이 TypeScript 5.x 권장 방향입니다.
되돌리기(rollback) & 결정 트리
type: module 추가로 깨졌을 때 최소 되돌리기
방금 "type": "module"을 넣고 프로젝트가 무너졌다면, 가장 빠른 복구는 그 한 줄을 지우는 것입니다.
// package.json — 원상복구
{
"name": "my-app"
// "type": "module" ← 이 줄 삭제 (또는 "commonjs")
}그럼에도 ESM을 유지해야 한다면(ESM-only 의존성 때문에) 다음 순서로 마이그레이션하세요.
require→import,module.exports→export로 전면 교체__dirname/__filename→ 섹션 ③의fileURLToPath스니펫으로 대체- 로컬 상대경로 import에 확장자 명시:
import x from './util.js'(ESM은 확장자 생략 불가) - 설정 파일(
*.config.js)은.cjs로 격리 - tsconfig
module/moduleResolution을NodeNext로 통일
상황별 가장 안전한 선택
레거시 코드베이스 유지가 목표?
├─ 예 → CommonJS 고정 (type 생략/commonjs) + ESM-only 패키지는 동적 import()
│ + tsconfig module: CommonJS
└─ 아니오(신규/모던) → ESM 통일 (type: module)
+ tsconfig module/moduleResolution: NodeNext
+ Vite/Webpack 앱이면 moduleResolution: Bundler재발 방지 체크리스트 5줄
- 새 패키지 설치 전
README에서 ESM-only 여부 확인 (chalk 5+, node-fetch 3+ 등) package.json의type과 tsconfigmodule을 한 방향으로 통일- CJS↔ESM 경계는 동적
import()로만 넘나들기 - ESM에서는
require/__dirname대신createRequire/import.meta.url사용 - 설정 파일은 필요 시
.cjs로 격리해 앱 코드와 분리
자주 묻는 질문 (FAQ)
Q. chalk를 require하면 왜 ERR_REQUIRE_ESM이 나나요?
A. chalk 5부터 ESM-only로 전환되어 CommonJS require로는 로드할 수 없습니다. 동적 const { default: chalk } = await import('chalk')로 우회하거나, CJS 프로젝트라면 chalk@4로 다운그레이드하는 방법이 자주 쓰입니다.
Q. tsconfig에서 NodeNext와 CommonJS 중 뭘 골라야 하나요?
A. 신규 Node 프로젝트라면 NodeNext(module·moduleResolution 모두)가 표준입니다. 기존 CJS 코드와 Jest CommonJS 환경을 유지해야 하면 CommonJS + Node10 조합이 트러블이 적습니다. 번들러(Vite/Webpack) 앱은 moduleResolution: "Bundler"가 권장됩니다.
Q. ESM으로 바꿨더니 상대경로 import가 안 됩니다.
A. ESM은 확장자 생략을 허용하지 않습니다. import x from './util'을 import x from './util.js'처럼 확장자를 명시해야 합니다(TS 소스라도 컴파일 출력 기준 .js). moduleResolution: "NodeNext"가 이 규칙을 강제합니다.
이 글은 AI 에이전트가 자료 조사와 1차 초안 작성을 담당하고, 사람 편집자가 사실관계·출처·톤과 맥락을 검토한 뒤 발행했습니다. 환경(OS·버전)에 따라 결과가 다를 수 있으니 적용 전 공식 문서를 함께 확인하세요. 오류를 발견하시면 이메일로 제보해 주세요 — 확인 후 신속히 정정합니다.
댓글
첫 번째 댓글을 남겨보세요.