TypeScript ESM CommonJS 오류 해결 — require/import 충돌 정리

목차

TypeScript ESM CommonJS 오류 해결에서 실제로 만지는 건 단 두 줄이다. package.json"type" 한 줄, tsconfig.jsonmodule 한 줄. 이 두 줄로 만들 수 있는 조합은 16가지쯤 되는데, 그중 정상 작동하는 건 4가지 정도다. 나머지 12가지는 require is not defined 아니면 Cannot use import statement outside a module을 뱉는다.

새 백엔드 프로젝트 세팅 도중에 이 두 에러를 짝꿍처럼 만났다. 다음에 또 헤매지 않으려고 메모를 남긴다.

오늘 본 4가지 에러 — 같은 코드, 다른 결과

같은 import express from 'express' 한 줄에서 설정만 바꿨을 뿐인데 4가지 다른 에러가 떨어졌다.

1. Cannot use import statement outside a module

SyntaxError: Cannot use import statement outside a module

그래서, Node가 ESM이 아닌 환경에서 import 구문을 만났을 때 떨어지는 에러다. 원인은 두 가지 중 하나다. 첫째, package.json"type": "module"이 없는데 .js 파일에 ESM 구문이 들어 있을 때. 둘째, tsc가 ESM 구문을 그대로 출력했는데 Node가 CJS로 해석할 때.

또한, 해결은 둘 중 하나만 고르면 된다. ESM으로 가려면 package.json"type": "module"을 박고, CJS로 가려면 tsc가 ESM을 CJS로 변환하도록 module: "commonjs"로 둔다.

2. ReferenceError: require is not defined in ES module scope

ReferenceError: require is not defined in ES module scope

따라서, 이건 반대 상황이다. package.json"type": "module"이 박혀 있어서 Node는 ESM 모드인데, tsc는 코드를 CJS(require)로 변환했을 때 발생한다.

반면, (아무도 안 알려주는데) 이 에러가 헷갈리는 이유는 메시지만 보고 "원본 코드에 require가 있나 봐"라고 오해하기 쉽다는 점이다. 원본은 import인데 컴파일러가 require로 바꾼 결과가 ESM 환경에서 돌아가니까 충돌하는 거다. 원본만 보면 어디에도 require가 없다.

3. ERR_REQUIRE_ESM

Error [ERR_REQUIRE_ESM]: require() of ES Module ... from ... not supported.

CJS 환경에서 ESM-only 패키지를 require하려고 할 때 발생한다. 대표적인 게 chalk 5.x, node-fetch 3.x, nanoid 5.x다. 이 패키지들은 ESM 빌드만 제공하기 때문에 CJS에서 import하려면 동적 import(await import(...))를 쓰거나, 한 단계 낮은 버전(chalk 4.x 같은)을 써야 한다.

4. 동적 import의 함정

const config = await import('./config.js');

tsconfig.jsonmodule: "commonjs"로 설정돼 있으면 위 코드는 await require('./config.js')로 변환된다. require는 동기 함수라 await가 무의미한데, 반환값이 Promise가 아니라 모듈 객체 그대로다. 타입 시그니처가 깨지면서 런타임 에러로 이어진다.

게다가, TypeScript 4.7부터는 module: "node16" 또는 "nodenext"를 쓰면 동적 import가 진짜 import로 유지된다. 의도가 동적 로딩이라면 module 옵션을 ESM 계열로 바꾸는 게 맞다.

새로 알게 된 것 — module과 moduleResolution이 다른 이유

특히, 여기서 설정 두 개를 같은 거라고 착각하기 쉽다. modulemoduleResolution은 역할이 완전히 다르다.

module은 "출력 형식"

module 옵션은 컴파일러가 import/export 구문을 어떤 형식으로 출력할지를 결정한다. commonjsrequire/exports로 변환되고, esnextnode16이면 import/export 그대로 둔다. preserve는 TypeScript 5.4부터 추가된 옵션으로, 입력 형태를 그대로 보존한다.

node16nodenext는 비슷한데 차이가 있다. node16은 Node.js 16 동작에 고정돼 있어 안정적이다. nodenext는 "최신 Node 버전을 따라간다"는 의미라 TypeScript 버전이 올라가면 동작이 바뀔 수 있다. 안정성이 우선이면 node16이 낫다.

moduleResolution은 "찾는 방법"

moduleResolution은 TypeScript가 import { x } from 'foo'를 봤을 때 foo를 어디서 찾을지의 알고리즘이다. node10(이전 이름 node)은 레거시 Node 알고리즘으로 확장자 생략과 폴더 import를 허용한다. node16/nodenext는 Node 16+의 ESM 호환 알고리즘이고, 확장자 명시가 필수이며 package.jsonexports 필드를 존중한다. bundler는 Vite, Webpack, esbuild 같은 번들러용으로 확장자 생략이 가능하고 exports 필드도 일부 지원한다.

module: "node16"을 쓰면 moduleResolutionnode16이 강제된다. 반대로 bundlermodule: "esnext" 같은 ESM 계열과만 짝지을 수 있다.

가장 흔한 실수

module: "esnext"moduleResolution: "node" 조합. 옛날 블로그 가이드에 많이 남아 있는 형태다. TypeScript 5.x에서는 node가 deprecated로 표시되고, 동작도 직관적이지 않다. ESM을 쓸 거면 node16/nodenext로 두 옵션을 일치시키거나, 번들러를 쓰면 moduleResolution: "bundler"로 가는 게 깔끔하다.

시나리오별 작동하는 설정 조합

매번 헷갈리니까 표로 정리해뒀다. 2026년 5월 기준, TypeScript 5.4 + Node.js 22 환경에서 검증한 조합이다.

시나리오 package.json type tsconfig module moduleResolution
순수 CJS Node 백엔드 (생략) commonjs node10
순수 ESM Node 백엔드 "module" node16 node16
라이브러리 듀얼 빌드 "module" + exports node16 node16
Vite/Webpack 프론트 "module" esnext bundler
Next.js / Remix "module" esnext bundler

반면, :::tip package.json"type": "module"을 박는 순간, 프로젝트의 모든 .js 파일이 ESM으로 해석된다. CJS로 남기고 싶은 빌드 스크립트나 설정 파일(예: .eslintrc.js, webpack.config.js)은 확장자를 .cjs로 바꿔야 그대로 동작한다. :::

케이스 1: 순수 ESM 백엔드 템플릿

요즘 새 프로젝트면 이 조합이 표준이다.

// package.json
{
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js"
  },
  "devDependencies": {
    "typescript": "^5.4.0",
    "tsx": "^4.7.0"
  }
}
// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "node16",
    "moduleResolution": "node16",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "resolveJsonModule": true
  },
  "include": ["src/**/*"]
}

예를 들어, 여기서 import 할 때 확장자 .js를 붙이는 게 처음엔 어색하다. TypeScript 파일인데 왜 .js로 쓰지? 컴파일 결과 기준이라 그렇다. 원본은 .ts지만 컴파일되면 .js가 되니까 그 시점의 경로를 적는다.

// src/index.ts
import { loadConfig } from './config.js';  // .ts 아님, .js
import express from 'express';

const config = await loadConfig();  // top-level await
const app = express();

app.get('/health', (_, res) => res.json({ ok: true }));
app.listen(config.port);

__dirname이 없는 것도 ESM의 특징이다. 대신 import.meta.url을 쓴다.

import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';

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

케이스 2: CJS 유지 (레거시 호환)

특히, 기존 프로젝트가 CJS고 옮길 동기가 약할 때.

// tsconfig.json (CJS)
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "moduleResolution": "node10",
    "outDir": "dist",
    "esModuleInterop": true,
    "strict": true,
    "skipLibCheck": true
  }
}

이 설정에서 ESM-only 패키지는 동적 import로 우회한다.

// CJS 환경에서 chalk 5.x를 쓸 때
async function colorLog(msg: string) {
  const chalk = (await import('chalk')).default;
  console.log(chalk.green(msg));
}

게다가, 다만 module: "commonjs"에서는 동적 import조차 require로 변환되는 함정이 있다. 동적 import만큼은 진짜 ESM 동작으로 남기고 싶다면, module"node16"으로 올리고 package.json은 그대로 CJS로 두는 듀얼 빌드를 검토하는 게 낫다.

인터페이스/제네릭은 모듈 시스템과 무관

타입 시스템 자체는 ESM이든 CJS든 동일하게 작동한다. 다음 코드는 두 환경 모두에서 그대로 컴파일된다.

// src/repository.ts
export interface Repository<T extends { id: string }> {
  findById(id: string): Promise<T | null>;
  save(entity: T): Promise<T>;
  list(filter?: Partial<T>): Promise<T[]>;
}

export class InMemoryRepo<T extends { id: string }> implements Repository<T> {
  private store = new Map<string, T>();

  async findById(id: string): Promise<T | null> {
    return this.store.get(id) ?? null;
  }

  async save(entity: T): Promise<T> {
    this.store.set(entity.id, entity);
    return entity;
  }

  async list(filter?: Partial<T>): Promise<T[]> {
    const items = [...this.store.values()];
    if (!filter) return items;
    return items.filter((item) =>
      Object.entries(filter).every(([k, v]) => (item as Record<string, unknown>)[k] === v)
    );
  }
}

타입은 런타임에 사라지니까 모듈 시스템과 무관하다. 헷갈리는 건 어디까지나 import/export 구문의 런타임 동작이다. 타입 에러와 모듈 에러를 분리해서 보는 습관이 필요하다. 빌드 에러가 떴을 때 tsc --noEmit으로 타입 검사만 따로 돌려보면 둘이 섞여 있는지 가려진다.

메모 — 언제 ESM 쓰고 언제 CJS 쓸지

실제로, 판단 기준은 단순하다.

ESM이 맞는 상황

  • 새 프로젝트를 시작할 때. 2026년 기준으로 ESM이 표준이고, 주요 라이브러리 대부분이 ESM-first다.
  • Top-level await가 필요할 때. CJS에선 불가능하다.
  • chalk 5+, node-fetch 3+, nanoid 5+ 같은 ESM-only 패키지를 정식으로 쓰고 싶을 때.
  • 번들러 환경(Vite, Next.js, Remix, Astro). 내부적으로 ESM 기반이라 자연스럽다.

CJS가 맞는 상황

  • 기존 CJS 프로젝트에 작은 기능을 추가할 때. 굳이 마이그레이션할 동기가 약하면 그대로 둔다.
  • __dirname, __filename, require.resolve, require.cache를 빈번하게 쓰는 코드. ESM에선 대체 코드가 두 줄씩 길어진다.
  • Jest로 모킹을 많이 하는 테스트 환경. Jest 29부터 ESM 지원이 있지만 --experimental-vm-modules 플래그가 필요하고 jest.mock 호이스팅이 한계가 있다.
  • Electron 메인 프로세스 같은 일부 런타임. 최근 ESM 지원이 추가됐지만 안정화는 더 지켜볼 만하다.

마이그레이션은 신중하게

이처럼, CJS에서 ESM으로 옮기는 비용은 생각보다 크다. 모든 상대 경로 import에 .js 확장자를 붙여야 하고, __dirname 쓰던 자리를 다 고쳐야 한다. 의존성 중에 CJS-only(예: 오래된 npm 패키지)가 있으면 우회 코드도 추가된다. 새 프로젝트라면 처음부터 ESM, 기존 프로젝트라면 굳이 안 옮기는 게 실용적이다. 한 번에 다 옮기겠다고 덤비면 다른 일이 일주일은 밀린다.

오늘 정리한 액션

  1. 새 TS 프로젝트 만들 때 package.json"type": "module" 먼저 박기. 이 한 줄로 절반은 해결된다.
  2. tsconfig.json에서 modulemoduleResolution을 같은 값(node16 또는 nodenext)으로 맞추기.
  3. 상대 경로 import에 .js 확장자 붙이는 습관 들이기. ESLint 규칙 import/extensions로 자동화하면 손이 덜 간다.

TypeScript 5.x의 모듈 옵션 동작은 공식 핸드북 – Modules Reference에 상세히 정리돼 있다. Node.js의 ESM 정책은 Node.js 공식 문서 – ECMAScript modules를 보면 된다. 두 문서를 한 번씩 읽어두면 다음 에러를 만났을 때 추적 시간이 확실히 줄어든다.

관련 글