Thumbnail

8분

ESM + TypeScript

들어가면서

자바스크립트의 모듈 시스템은 역사적으로 복잡하고 많은 변화가 있었습니다. 여기서는 모듈 시스템에 대해서 깊게 다루진 않겠지만 다른 분이 쓴 좋은 글을 공유합니다.

ES6와 함께 도입된 ES Module 시스템은 현재 우리가 가장 많이 사용하는 import, export 구문을 사용합니다. 그에 비해 CommonJS 모듈 시스템은 require, module.exports 구문을 사용합니다.

Node.js는 CommonJS 모듈 시스템을 선택하였습니다. 개발자가 Node.js에서 import, export와 같은 ES Module 구문으로 모듈을 작성하고 실행하면 에러가 발생합니다. babel과 같은 트랜스파일러를 통해서 ES Module 구문을 CommonJS 구문으로 변환해야 합니다. TypeScript도 별도의 설정 없이는 디폴트로 CommonJS 모듈 시스템으로 컴파일합니다.

Node.js도 12버전부터 ES Module 시스템을 지원하기 시작했습니다. 현재 LTS버전에서는 CJS 모듈 시스템과 ESM 모듈 시스템이 공존하고 있습니다.

CommonJS와 ES Module 간의 호출

최근 chalk 라이브러리를 사용한 적이 있었는데 이 라이브러리는 v5 부터는 ES Module로 작성된 package만 제공합니다. 그래서 CommonJS 모듈 시스템에서 require()를 통해 이 라이브러리를 호출할 수 없습니다.

그래서 먼저 궁금했던 CJS와 ESM 사이의 호환성에 대해 알아보았습니다.

got 라이브러리 메인테이너인 sindresorhus가 작성한 ESM 가이드 파일은 ESM을 다룰 때 다양한 케이스를 설명하고 있습니다. 이 글에서도 위 gist 문서를 참고하였습니다.

CJS(CommonJS)에서 ESM(ES Module) 호출

CJS에서는 기본적으로 ESM 모듈을 호출할 수 없습니다.

Error [ERR_REQUIRE_ESM]: Must use import to load ES Module: /Users/.../index.js

우리는 이를 해결하기 위해서 어떤 방법이 있을까요?

  1. CJS를 사용하지 않고 ESM으로 사용. (권장)
    package를 import하기 위해서 require 대신에 import ESM 모듈 시스템을 사용하는 것이 가장 좋습니다. 그러기 위해서는 package.json 파일에 "type": "module"을 추가해야 합니다. 또는 .mjs 확장자를 사용하는 방법이 있지만 프로젝트에 ESM을 사용하기 위해서는 package.json"type": "module"을 추가하는 것이 좋습니다.
  2. package가 비동기로 사용된다면, CJS에서 await import(...)를 사용할 수 있습니다.
  3. 본인 프로젝트를 ESM으로 옮기기 전까지 해당 package 버전을 업데이트하지 않고 유지.
    오랫동안 사랑받는 Node.js의 여러 라이브러리는 기본적으로 CJS를 제공하였기에 기존 버전을 사용하는 것도 하나의 방법입니다. 그러나 추천하진 않습니다.

결론적으로, CJS에서 ESM으로 옮기는 것이 강력하게 추천됩니다.

ESM에서 CJS 호출

ESM에서는 CJS 모듈을 호출할 수 있습니다.

import { createRequire } from "node:module"
 
const require = createRequire(import.meta.url)
 
// sibling-module.js은 CJS 모듈입니다.
const siblingModule = require("./sibling-module")

TypeScript에서 ESM 사용하기

https://www.typescriptlang.org/docs/handbook/esm-node.html

자바스크립트와 Node.js에서 ESM을 사용하는 것은 복잡하고 헷갈리며, TypeScript와 번들러를 함께 사용하면 더욱 복잡해집니다.

그러나 Node.js가 ESM을 공식 지원하기 시작하면서 TypeScript도 이를 지원하게 되었습니다.

TypeScript 4.7 버전 이상에서는 공식 문서에서 ESM 사용에 대한 가이드를 제공합니다.

ESM 설정하기

Node.js 프로젝트에서 ESM을 사용하려면, package.json"type": "module"을 추가해야 합니다.

{
  "name": "my-project",
  "version": "0.0.1",
  "type": "module",
  ...
}

TypeScript에서는 ESM 지원을 위해 tsconfig.jsonmodulemoduleResolution의 새로운 설정인 node16nodenext를 사용합니다.

{
  "compilerOptions": {
    "module": "nodenext", // es6, esnext, node16, nodenext...
    "moduleResolution": "nodenext" // classic, node, node16, nodenext...
  }
}

주의 사항

ESM 사용 시 고려해야 할 몇 가지 사항이 있습니다:

  • import/export 구문은 top-level await를 사용할 수 있습니다
  • 상대 경로로 import할 경우에 .js 확장자를 명시해야 합니다. TypeScript 파일도 마찬가지로 .js 확장자로 명시해야 합니다. (이러한 컨벤션이 조금 더 복잡하게 만드는게 아닌가 싶기도 합니다만...)
// ./bar.ts
import { helper } from "./foo" // only works in CJS
 
// ./foo.ts
export function helper() {
  // ...
}
 
helper()
// ./bar.ts
import { helper } from "./foo.js" // works in ESM & CJS
 
helper()
  • CJS 모듈 시스템에서만 사용가능했던 require 구문은 ESM에서는 사용할 수 없습니다. 또한 __dirname__filename도 사용할 수 없습니다. 대신 node:urlnode:path를 이용해 사용할 수 있습니다.
  • node:fs와 같이 node: 프로토콜을 사용해 Node.js 모듈을 사용하는 것을 권장합니다.(이 이슈에서 node: 프로토콜에 대한 몇 가지 장점을 설명합니다.)

TypeScript 5.0과 함께 --moduleResolution 옵션에 bundler 항목이 추가되었습니다. Vite, esbuild, swc, Webpack, Parcel과 같은 최신 번들러를 사용하는 경우 이 새로운 옵션인 bundler 옵션이 적합할 것입니다. 여기서 더 자세하게 확인하세요.

Package.json

Node.js의 package.json 시스템은 복잡해 보일 수 있지만, 이 글을 통해 이해를 돕고자 합니다. 이 글에서는 Node.js 패키지의 엔트리 포인트를 지정하는 방법을 소개합니다.

해당 문서를 찾아보면 아래와 같은 내용을 볼 수 있습니다.

  • main: 모든 Node.js 버전에서 엔트리 포인트로 사용되지만, 단일 엔트리 포인트만 지정할 수 있어 제한적입니다.
  • exports: main의 한계를 극복하고 다양한 엔트리 포인트를 지정할 수 있는 현대적인 방법입니다. exports 맵을 사용해 다양한 엔트리 포인트를 설정할 수 있습니다.
{
  "name": "my-package",
  "exports": {
    ".": "./lib/index.js",
    "./lib": "./lib/index.js",
    "./lib/index": "./lib/index.js",
    "./lib/index.js": "./lib/index.js",
    "./feature": "./feature/index.js",
    "./feature/index": "./feature/index.js",
    "./feature/index.js": "./feature/index.js",
    "./package.json": "./package.json"
  }
}

또는 다음과 같은 형태로 사용할 수 있습니다.

{
  "name": "my-package",
  "exports": {
    ".": "./lib/index.js",
    "./feature/*.js": "./feature/*.js",
    "./feature/internal/*": null
  }
}

main 필드 대신 exports 필드를 사용하는 것이 권장되며, 하위 호환성을 위해 두 필드를 함께 사용하는 것도 가능합니다.

다양한 케이스가 문서에 있어서 참고하시면 도움이 될 것 같습니다.

Conditional Exports

Node.js는 exports 필드에서 조건부로 export하여 해당 프로젝트에서 모듈 시스템에 따라 import할 수 있도록 지원합니다.

// package.json
{
  "name": "my-package",
  "type": "module",
  "exports": {
    ".": {
      // TypeScript 프로젝트에서 타입을 사용할 시에 Entry-point로 지정할 타입 파일
      "types": "./types/index.d.ts",
      // ESM 프로젝트에서 import할 시에 Entry-point로 지정할 파일
      "import": "./esm/index.js",
      // CJS 프로젝트에서 require할 시에 Entry-point로 지정할 파일
      "require": "./commonjs/index.cjs"
    }
  },
  // Node.js의 오래된 버전을 사용하고 있는 CJS 프로젝트에서 fall-back으로 지정할 파일
  "main": "./commonjs/index.cjs",
  // Node.js의 공식적인 필드는 아니지만 esbuild, rollup 등 몇몇 번들러에서 ESM 지원을 위한 목적으로 사용될 수 있는 필드
  "module": "./esm/index.js",
  // TypeScript의 오래된 버전을 사용하고 있는 프로젝트에서 Fall-back으로 지정할 타입 파일
  "types": "./types/index.d.ts"
}

exports 필드를 사용하면 프로젝트에 따라 적절한 모듈 시스템을 사용할 수 있습니다. 추가로, 비슷한 방식으로 동작하는 imports 필드도 참고하시면 도움이 될 것입니다.

ESM을 위한 VSCode 설정

최신 버전 VSCode, TypeScript를 사용하고 있다면 별다른 설정없이 ESM 프로젝트에서는 .js 확장자를 붙여줍니다.

최근 VSCode와 TypeScript버전에서는 별다른 설정을 하지 않아도 tsconfig.jsonmoduleResolution에 따라 VSCode가 자동으로 .js와 같은 확장자를 알아서 붙여줍니다.

자동으로 .js 확장자가 붙지 않는 경우

만약 자동으로 .js 확장자가 붙지 않는 경우에는, VSCode 설정을 조정해주면 됩니다. 아래와 같이 .vscode/settings.json 파일에 설정을 추가하면 확장자를 자동으로 붙여줍니다.

{
  "javascript.preferences.importModuleSpecifierEnding": "js",
  "typescript.preferences.importModuleSpecifierEnding": "js"
}

예제: 직접 라이브러리를 만들어보기

https://github.com/JHSeo-git/practice-typescript-esm-node

이 예제에서는 CJS와 ESM을 모두 제공하는 라이브러리를 생성하고, 이를 사용하는 CJS 및 ESM 프로젝트를 만들어봅니다. Rollup 번들러를 사용하며, 다음과 같은 버전의 도구들을 사용합니다.

  • Node.js: v16.17.0
  • TypeScript: v4.8.3
  • yarn: v3.2.3

1. 초기 설정

먼저 Node.js와 TypeScript 설정을 진행합니다.

yarn init -y
yarn add -D typescript
yarn tsc --init

빌드 폴더는 lib로 지정하고, 소스 코드는 src 폴더에 작성합니다.

├── lib
   ├── index.js
   ├── index.cjs
   └── ...
├── package.json
├── src
   ├── index.ts
   └── ...
└── tsconfig.json

가이드에 따라 package.jsontsconfig.json을 설정합니다.

{
  "name": "@seo-practice/practice-library",
  "version": "0.0.1",
  "type": "module",
  "exports": {
    ".": {
      "import": "./lib/index.js",
      "require": "./lib/index.cjs",
      "types": "./lib/index.d.ts"
    }
  },
  "main": "./lib/index.cjs",
  "module": "./lib/index.js",
  "source": "./src/index.ts",
  "types": "./lib/index.d.ts",
  ...
}
  • "type": "module"을 선언하여 해당 프로젝트가 ESM을 사용하도록
  • exports 필드를 사용하여 conditional export가 가능하도록
  • main 필드를 사용하여 오래된 Node 버전을 가진 프로젝트를 위한 fallback
  • TypeSciprt를 위해 exports 필드와 types 필드 적용

TypeScript 설정을 위한 tsconfig.json을 다음과 같이 작성합니다.

{
  "compilerOptions": {
    "target": "ESNext",
    "module": "NodeNext",
    "rootDir": "./src",
    "moduleResolution": "NodeNext",
    "declaration": true,
    "outDir": "./lib",
    ...
  }
}

TypeScript ESM 가이드 문서에 따라

  • module 필드와 moduleResolution 필드를 NodeNext로 설정
  • declaration을 위해 outDir 필드와 declaration 필드를 설

Rollup을 사용하여 CJS파일과 ESM파일을 생성하기 위해 빌드를 진행합니다.

// rollup.config.js
import esbuild from "rollup-plugin-esbuild"
 
const packageJson = require("./package.json")
 
const bundle = (config) => ({
  ...config,
  input: "src/index.ts",
  external: (id) => !/^[./]/.test(id),
})
 
export default [
  bundle({
    plugins: [esbuild()],
    output: [
      {
        file: packageJson.main,
        format: "cjs",
        sourcemap: true,
      },
      {
        file: packageJson.module,
        format: "es",
        sourcemap: true,
      },
    ],
  }),
]

declaration 파일은 번들러를 통하지 않고 tsc 사용하여 생성합니다.

{
  "scripts": {
    "build": "yarn build:rollup",
    "build:rollup": "yarn clear && rollup -c && tsc --emitDeclarationOnly",
    "clear": "rimraf lib"
  }
}

2. 빌드

간단한 라이브러리 코드를 작성한 뒤 빌드합니다.

// src/index.ts
export { getSlug } from "./utils.js"
 
// src/utils.ts
const slugify = (str: string) =>
  str
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/(^-|-$)+/g, "")
 
export const getSlug = (str: string) => {
  const slug = slugify(str)
  return slug === "-" ? "" : slug
}
$ yarn build

빌드 결과는 다음과 같습니다.

// lib/index.cjs
'use strict';
 
Object.defineProperty(exports, '__esModule', { value: true });
 
const getSlug = (...)
 
exports.getSlug = getSlug;
//# sourceMappingURL=index.cjs.map
// lib/index.js
const getSlug = (...)
 
export { getSlug };
//# sourceMappingURL=index.js.map

위 빌드 결과를 보면 CJS는 exports.getSlug, ESM은 export { getSlug }로 export되는 것을 확인할 수 있습니다.

3. 라이브러리 사용

CJS 프로젝트와 ESM 프로젝트를 각각 생성하고 라이브러리를 사용해봅니다.

CJS 프로젝트

package.jsontsconfig.json을 다음과 같이 설정합니다.

// practice-node-cjs/package.json
{
  "name": "practice-node-cjs",
  "version": "0.0.1",
  "scripts": {
    "build": "tsc",
    "start": "yarn build && node ./dist/index.js"
  },
  "dependencies": {
    "@seo-practice/practice-library": "*"
  }
  ...
}

package.json에서 type 필드를 선언하지않으면 기본적으로 CJS로 인식합니다.

// practice-node-cjs/tsconfig.json
{
  "compilerOptions": {
    "incremental": true,
    "composite": true,
    "target": "es2016",
    "module": "commonjs",
    "rootDir": "./src",
    "moduleResolution": "node",
    "outDir": "./dist",
    ...
  }
}
  • 모노레포로 구성된 내부 프로젝트를 의존하고 있는 모듈을 참조, 빌드하기 위해 composite 옵션을 사용
  • modulemoduleResolution 옵션을 node로 설정하여 CJS로 인식하고 빌드
// practice-node-cjs/src/index.ts
import { getSlug } from "@seo-practice/practice-library"
 
const title = "Hello World"
const slug = getSlug(title)
console.log(slug)

여기서는 따로 번들러를 쓰지 않고 tsc로 빌드합니다.

// practice-node-cjs/dist/index.js
"use strict"
Object.defineProperty(exports, "__esModule", { value: true })
const practice_library_1 = require("@seo-practice/practice-library")
const title = "Hello World"
const slug = (0, practice_library_1.getSlug)(title)
console.log(slug)
hello-world

require()를 이용해 import하는 CJS로 빌드된 라이브러리를 사용하는 것을 확인할 수 있습니다.

ESM 프로젝트

src/index.ts 코드는 동일하게 작성되어있습니다.

package.jsontsconfig.json을 다음과 같이 설정합니다.

// practice-node-esm/package.json
{
  "name": "practice-node-esm",
  "version": "0.0.1",
  "type": "module",
  "scripts": {
    "build": "tsc",
    "start": "yarn build && node ./dist/index.js"
  },
  "dependencies": {
    "@seo-practice/practice-library": "*"
  }
  ...
}
// practice-node-esm/tsconfig.json
{
  "compilerOptions": {
    "incremental": true,
    "composite": true,
    "target": "ESNext",
    "module": "NodeNext",
    "rootDir": "./src",
    "moduleResolution": "NodeNext",
    "outDir": "./dist",
    ...
  }
}
  • CJS 프로젝트와 마찬가지로 composite 옵션을 사용
  • modulemoduleResolution 옵션을 NodeNext로 설정하여 ESM으로 인식하고 빌드

마찬가지로 여기서도 tsc로 빌드하여 실행합니다.

// practice-node-esm/dist/index.js
import { getSlug } from "@seo-practice/practice-library"
 
const title = "Hello World"
const slug = getSlug(title)
console.log(slug)

빌드 결과물을 확인해보면 import를 이용해 import하는 ESM으로 빌드된 라이브러리를 사용하는 것을 확인할 수 있습니다.

마무리하며

이 글에서는 CommonJS 모듈 시스템과 ES Module 시스템에 대한 이해를 돕기 위해 간단한 예시를 통해 설명했습니다. ERR_REQUIRE_ESM와 같은 에러를 해결하는 데 어려움을 겪은 경험을 바탕으로, 이 문제를 근본적으로 이해하고자 노력했습니다.

이 과정에서 chalk 라이브러리를 시작점으로, ESM과 CJS 간의 차이와 각 시스템의 동작 원리를 더 깊이 있게 이해할 수 있었습니다. 최근에는 ESM만 지원하는 라이브러리가 점점 늘고 있는 추세입니다. 따라서 ESM에 대한 이해가 점점 더 중요해질 것으로 보입니다.

Node.js 커뮤니티에서도 ESM이 점차 자리잡으면서, 복잡하거나 번거로운 부분들이 개선될 것으로 기대할 수 있습니다. 이 글을 통해 ESM과 CJS의 차이점을 이해하는 데 도움이 되길 바랍니다.

Reference

마지막 업데이트

3/26/2023


Avatar

JHSeo

배우는 것을 좋아하고 관심이 많은 웹 엔지니어 입니다. 느리더라도 꾸준하게 성장하려고 노력하는 개발자입니다.