grep

Engineering

CommonJS와 ESM에 모두 대응하는 라이브러리 개발하기: exports field

장호승토스

2022년 10월 4일

원문에서 보기 ↗

토스 프론트엔드 챕터에서는 개발 생산성을 극대화하기 위해 코드를 지속적으로 라이브러리로 만들고 있습니다. 그 결과 지금은 100개가 넘는 라이브러리를 운영하고 있습니다.

Node.js 12부터 ECMAScript Modules라는 새로운 Module System이 추가되면서, 기존의 CommonJS라는 Module System까지, 라이브러리는 두 가지 Module System을 지원해야 하게 되었습니다.

토스팀에서는 그것을 package.json의 exports field를 통해 지원하고 있습니다. 각각의 모듈 시스템과 exports field에 대해 자세히 알아봅시다.

Node.js에는 CommonJS, ECMAScript Modules(이하 CJS, ESM)라는 두 가지 모듈 시스템이 존재합니다.

CommonJS (CJS)

module.exports.add = (x, y) => x + y;

const { add } = require('./add');

add(1, 2);

ECMAScript Modules (ESM)

export function add(x, y) {
  return x + y
}

import { add } from './add.js';

add(1, 2);

왜 두 Module System을 지원해야해요?

서로 호환되기 어려운 두 Module System을 지원해야하는 이유는 뭘까요? 그냥 하나로 통일하면 안될까요? 토스팀에서는 왜 그것을 중요하게 생각할까요?

토스팀에서는 Server-side Rendering(이하 SSR)을 적극적으로 사용하고 있기 때문에, Node.js의 CJS를 지원하는 것이 중요했습니다.

그리고 Module System의 지원은 브라우저 환경에서의 퍼포먼스와도 관련이 있습니다. 브라우저 환경에서는 페이지 렌더링을 빠르게 하는 것이 중요한데, 이 때 JavaScript는 로딩되어 실행되는 동안 페이지 렌더링을 중단시키는 리소스들 중 하나 입니다.

따라서 JavaScript 번들의 사이즈를 줄여서 렌더링이 중단되는 시간을 최소화 하는 것이 중요합니다. 이를 위해 필요한 것이 바로 Tree-shaking입니다. Tree-shaking이란 필요하지 않은 코드와 사용되지 않는 코드를 삭제하여 JavaScript 번들의 크기를 가볍게 만드는 것을 말합니다.

이 때, CJS는 Tree-shaking이 어렵고, ESM은 쉽게 가능합니다.

왜냐하면 CJS는 기본적으로 require / module.exports 를 동적으로 하는 것에 아무런 제약이 없습니다.

const utilName =
const util = require(`./utils/${utilName}`);

function foo() {
  if () {
    module.exports = ;
  }
}
foo();

따라서 CJS는 빌드 타임에 정적 분석을 적용하기가 어렵고, 런타임에서만 모듈 관계를 파악할 수 있습니다.

하지만 ESM은 정적인 구조로 모듈끼리 의존하도록 강제합니다. import path에 동적인 값을 사용할 수 없고, export는 항상 최상위 스코프에서만 사용할 수 있습니다.

import util from `./utils/${utilName}.js`;

import { add } from "./utils/math.js";

function foo() {
  export const value = "foo";
}

export const value = "foo";

따라서 ESM은 빌드 단계에서 정적 분석을 통해 모듈 간의 의존 관계를 파악할 수 있고, Tree-shaking을 쉽게 할 수 있습니다.

위와 같은 배경으로 토스팀에서는 CJS/ESM 모두 지원하는 라이브러리를 운영하게 되었습니다.

파일이 CJS인지 ESM인지 어떻게 알아요?

Module System이 두 개가 존재하며 둘 다 지원해야할 필요성은 알겠는데, .js 파일이 CJS인지 ESM인지 어떻게 알 수 있을까요? package.json의 type field 또는 확장자를 보고 알 수 있습니다.

TypeScript도 4.7부터 tsconfig.json 의 moduleResolution 이 nodenext 또는 node16 으로 설정된 경우, 위 규칙이 똑같이 적용됩니다.


CJS와 ESM의 차이, 패키지의 기본 Module System을 설정하는 방법과 확장자 모두 알아봤는데, 그래서 어떻게 하면 하나의 패키지가 CJS/ESM을 동시에 매끄럽게 제공할 수 있을까요?

정답은 exports field입니다. exports field는 무슨 문제를 해결해줄까요? 어떤 역할을 할까요?

패키지 entry point 지정

기본적으로는 package.json의 main field와 같은 역할을 합니다. 패키지의 entry point를 지정할 수 있습니다.

subpath exports 지원

기존에는 filesystem 기반으로 동작했기 때문에, 패키지 내부의 임의의 JS 파일에 접근할 수 있었고, 또한 실제 filesystem 상의 위치와 import path를 다르게 둘 수 없었습니다.

/modules
  a.js
  b.js
  c.js
index.js

require("package/a");
require("package/modules/a");

이 때, exports field를 사용해 subpath exports를 사용하면, 명시된 subpath 외에는 사용할 수 없고, filesystem 상의 위치와 import path를 다르게 지정할 수 있습니다.

{
  "name": "cjs-package",
  "exports": {
    ".": "./index.js",
    "./a": "./modules/a.js",
  },
}

require("cjs-package/a");

require("cjs-package/b");

conditional exports 지원

기존에는 filesystem 기반으로 동작했기 때문에, Dual CJS/ESM 패키지를 자연스럽게 운영하기가 어려웠습니다.

exports field를 사용하면, 똑같은 import path에 대해 특정 조건에 따라 다른 모듈을 제공할 수 있습니다.

{
  "name": "cjs-package",
  "exports": {
    ".": {
      "require": "./dist/index.cjs",
      "import": "./esm/index.mjs"
    }
  }
}

const pkg = require("cjs-package");

import pkg from "cjs-package";

올바른 exports field

Dual CJS/ESM 패키지의 exports field를 올바르게 작성하기 위해 주의해야할 점을 알아봅시다.

상대 경로로 표시하기

exports field는 모두 . 으로 시작하는 상대 경로로 작성되어야 합니다.

{
  "exports": {
    "sub-module": "dist/modules/sub-module.js"
  }
}

{
  "exports": {
    ".": "./dist/index.js",
    "./sub-module": "./dist/modules/sub-module.js"
  }
}

Module System에 따라 올바른 확장자 사용하기

conditional exports를 사용할 때, 패키지가 따르는 Module System에 따라, 즉 package.json의 type field에 따라 올바른 JS 확장자를 사용해야 합니다.

이 규칙을 지키지 않고 전부 .js 확장자를 사용했을 때는 어떤 일이 발생할까요? 아래와 같이 상황을 가정하겠습니다.

CJS 환경에서 cjs-package 를 require 했을 땐 잘 동작합니다. ./dist/index.js 는 CJS 모듈이고, 확장자가 .js 이므로, 가장 가까운 package.json의 type field를 따라 CJS Module Loader가 사용될 것이기 때문입니다.

const pkg = require("cjs-package");

하지만 ESM 환경에서 cjs-package 를 import 했을 땐 에러가 발생합니다. ./esm/index.js 는 ESM 모듈이지만, 확장자가 .js 이므로 가장 가까운 package.json의 type field를 따라 CJS Module Loader가 사용됩니다.

ESM 문법으로 작성된 JavaScript를 CJS Module Loader로 읽기 때문에 당연히 에러가 발생합니다.

(예시: import 문은 ESM에서만 사용 가능하다는 에러가 발생)

import * as pkg from "cjs-package";

TypeScript 지원하기

TypeScript에서 module import시, 항상 Type Definition을 찾게 되는데요. 기존에는 filesystem 기반으로 Type Definition을 탐색했습니다.

import subModule from "package/sub-module";

하지만 TypeScript 4.7부터 moduleResolution 옵션에 node16 과 nodenext 가 정식으로 추가되었고, node16 과 nodenext 는 filesystem 기반이 아닌 exports field로부터 Type Definition을 탐색합니다. 또한, CJS TypeScript( .cts )와 ESM TypeScript( .mts )를 구분합니다.

TypeScript는 conditional import의 조건 중 types 를 참조하며, 이 때 JavaScript와 마찬가지로 package.json의 type field에 따라 알맞은 확장자 ( .cts / .mts )를 사용해야 합니다.

그럼 TypeScript의 경우에는 위 규칙을 지키지 않으면 어떻게 될까요? 아래와 같이 상황을 가정하겠습니다.

이 때 .cts (CJS TypeScript)에서 esm-package 를 require하면 타입 에러가 발생합니다.

esm-package 는 Type Definition을 ./index.d.ts 만 지원합니다. 즉, ESM/CJS TypeScript 모두 ./index.d.ts 를 바라보게 됩니다.

이 때, esm-package 는 ESM 패키지이기 때문에 index.d.ts 는 ESM TypeScript로써 해석됩니다.

따라서 esm-package 는 CJS TypeScript 입장에서 Pure ESM Module이고, CJS는 ESM을 불러올 수 없기 때문에 esm-package 가 순수 ESM으로만 확인된다는 타입 에러가 발생합니다.

import * as esmPkg from "esm-package";

최근 토스팀 내부 라이브러리들은 위처럼 올바르게 exports field를 작성하여 배포되고 있습니다. CJS/ESM JavaScript는 물론 TypeScript 지원까지 잘 되있습니다.

JavaScript/TypeScript 생태계는 계속해서 발전하고 있지만, TypeScript까지 잘 지원하는 라이브러리는 정말 유명한 라이브러리들 중에서도 찾아보기가 많이 힘듭니다.

그렇다면 우리가 그 시작점이 되면 어떨까요? 토스팀에서는 이런 기술적인 문제를 함께 풀어가고 싶으신 분들을 언제나 환영합니다. 함께 좋은 생태계를 만들어 나가고 싶어요.