[JS] CommonJS와 ESM을 모두 지원하는 라이브러리 만들기 (Feat. Vite)

모듈 시스템의 역사

JavaScript 모듈 시스템은 크게 CommonJS와 ES Module(ESM)로 나뉩니다. 이 중 먼저 등장한 것은 CommonJS입니다.

 

CommonJS

원래 JavaScript는 모듈 시스템을 지원하지 않았습니다. 여러개의 script 태그를 이용하여 라이브러리들을 가져온다고 하더라도 모두 하나의 전역에서 객체를 정의하는 방식이었습니다. 이 방식은 당연히 변수명이 겹치는 등 많은 문제점이 있었습니다. 

 

그래서 등장한 것이 CommonJS 모듈 시스템입니다. CommonJS는 다음과 같은 방식으로 모듈을 불러오고 내보냅니다.

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

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

 

CommonJS의 등장으로 각 파일을 독립적인 모듈로 취급하여 개발할 수 있게 되었고, 한번 작성한 모듈을 require를 통해 여러 곳에서 쉽게 재사용할 수 있게 되었습니다. 

 

하지만 CommonJS도 여러가지 문제점을 가지고 있습니다.

1. CommonJS는 언어 표준이 아닙니다.

따라서 브라우저같이 Node.js가 아닌 환경에서는 사용할 수 없습니다. 

2. CommonJS는 정적 분석, Tree-shaking이 어렵습니다. 

Tree-shaking이란 필요하지 않은 코드와 사용되지 않는 코드를 삭제하여 JavaScript 번들의 크기를 가볍게 만드는 것을 말합니다. 하지만 CommonJS에서는 Tree-shaking이 어렵습니다. 왜냐하면 CommonJS는 모듈을 동적으로 불러오는 것에 아무런 제약이 없습니다.

// 동적인 값 할당 가능
const utilName = /* 동적인 값 */
const util = require(`./utils/${utilName}`);

// 조건부로 require / export 가능 
if (/* 동적인 조건 */) {
  module.exports = /* ... */;
}

if (/* 동적인 조건 */) {
  React = require('react');
}

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

3. require 함수를 마음대로 재정의할 수 있습니다.

global.require = function() {
  /* 재정의된 내용 */
};

따라서 이렇게 재정의를 하게 되면 예상치 못한 결과를 초래할 수 있습니다.

4. 비동기 모듈을 정의하기 어렵습니다.

CommonJS는 모듈을 동기적으로 불러옵니다. 따라서 모듈 내에 비동기 함수가 있을 경우 비동기 함수 실행이 완료되었는지 보장하지 못합니다. 예를 들어 DB를 읽고 쓰는 모듈을 만든다고 할 때, 읽고 쓰기 전에 DB에 연결하는 과정이 필요한데, DB 연결은 비동기적으로 동작합니다. 따라서, CommonJS에서는 모듈 내에서 비동기 작업이 완료된 후에 안전하게 접근할 수 있도록 별도의 초기화 로직을 관리해야 합니다.

// dbModule.js
let dbConnection;
let initialized = false; // 초기화 됐는지 여부

function connectToDatabase() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      dbConnection = 'DB Connection Established';
      initialized = true;
      resolve(dbConnection);
    }, 1000);
  });
}

function getData() {
  if (!initialized) { // 초기화 됐는지 확인
    throw new Error('DB is not initialized');
  }
  return 'Some data from DB';
}

module.exports = { connectToDatabase, getData };

// main.js
const { connectToDatabase, getData } = require('./dbModule');

connectToDatabase().then(() => {
  console.log(getData()); // 'Some data from DB'가 출력됨
}).catch(err => {
  console.error('Failed to connect to DB', err);
});

이렇게 읽고 쓰는 함수를 정의할 때 매번 초기화됐는지 검사한 후 실행하는 방식으로 구현해야합니다.

 

ECMAScript Modules (ESM)

이런 불편함을 해결하기 위해 표준 모듈 시스템인 ECMAScript Modules가 등장했습니다. 

ESM은 다음과 같은 방식으로 모듈을 불러오고 내보냅니다.

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

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

 

ESM은 CommonJS에서 발생한 다양한 문제들을 해결합니다.

1. ESM은 언어표준입니다.

따라서 Node.js가 아닌 환경, 즉 브라우저에서도 사용 가능합니다.

2. 정적 분석, Tree-shaking이 가능해집니다.

ESM은 모듈을 동적으로 불러오고 내보내는 것이 불가능합니다.

// 불가능
if (SOME_CONDITION) {
  import React from 'react'; 
}

// 불가능
const utilName = /* 동적인 값 */
import util from `./utils/${utilName}`;

따라서 파일이 어떤 파일을 참조하고 있는지 파악하기 쉽습니다. 

3. 비동기 모듈을 정의하기 쉬워집니다.

ESM은 Top-level Await을 지원하기 때문에 비동기적으로 동작합니다. 따라서 모듈의 최상단에서 async 없이 await을 쓸 수 있고, 앞서 예시로 설명드린 DB를 읽고 쓰는 모듈을 만든다고 했을 때, DB 연결을 보장한 상태에서 읽고 쓰는 함수를 내보낼 수 있습니다.

const db = await connectToDB();

export async function readFromDB() {
  await db.read();
}

export async function writeToDB() {
  await db.write();
}

 

CommonJS와 ESM을 모두 지원해야 하는 이유

두 모듈 시스템의 충돌

이런 ESM의 장점으로 인해 ESM 패키지들이 많아지고 있습니다. 하지만 기존 CJS로 작성된 패키지들이 많이 남아있기 때문에 두 모듈 시스템은 충돌하게 됩니다. 왜냐하면 ESM은 비동기적으로 작동하고, CJS는 동기적으로 작동하기 때문입니다.

 

ESM 프로젝트에서 CJS 패키지를 import 하는 것은 쉽습니다. 비동기에서 동기 함수를 가져오는 것은 그냥 동기적으로 가져오면 되기 때문에 쉽습니다.

 

하지만 CJS 프로젝트에서 ESM 패키지를 가져오는 것은 어렵습니다.

동기 함수에서 비동기를 가져오려면 동기 함수가 비동기 함수가 되어야하기 때문입니다. 

 

따라서 호환성 문제를 해결하려면 프로젝트를 ESM으로 바꾸면 됩니다. 하지만 많은 기존 프로젝트와 패키지가 여전히 CJS를 기반으로 작성되어 있고, 이 패키지들을 ESM으로 바꾸는 데에는 많은 시간이 걸리기 때문에 여전히 CJS를 지원하는 것이 필요합니다. 

 

CJS와 ESM 모두 지원하는 라이브러리 만들기 (Feat. Vite)

따라서 저는 두 모듈 시스템을 모두 지원하는 라이브러리를 만들어보기로 했습니다. 기존에 했던 프로젝트에 무한 캐러셀 컴포넌트들이 많은데, 기존에는 각 컴포넌트 모두 따로 로직을 만들어서 구현했습니다. 마침 공통 컴포넌트를 만들어서 재사용하는 방식으로 리팩토링이 필요하던 참에, 이 컴포넌트를 라이브러리로 만들어 재사용해보기로 합니다.

Vite 라이브러리 모드

Vite 라이브러리 모드는 라이브러리를 빌드할 수 있는 옵션을 제공합니다. build.lib 설정 옵션을 통해 라이브러리 빌드를 설정해봅시다.

 

우선 제 프로젝트의 구조는 다음과 같습니다. 

react-infinite-carousel-component/
│
├── src/                       # 소스 코드 폴더
│   ├── App.tsx                # 컴포넌트 테스트 및 개발용 임시 애플리케이션
│   └── lib/                   # 라이브러리 소스 코드
│       ├── InfiniteSlide/     # 캐러셀 컴포넌트 폴더
│       │   ├── InfiniteSlide.tsx         # 메인 컴포넌트
│       │   └── InfiniteSlide.types.ts    # 타입 정의
│       ├── InnerInfiniteSlide/  # 내부 슬라이드 컴포넌트 폴더
│       │   ├── InnerInfiniteSlide.tsx         # 내부 슬라이드 메인 컴포넌트
│       │   └── InnerInfiniteSlide.css.ts      # 내부 슬라이드 스타일시트
│       └── index.ts           # 라이브러리 진입점
├── vite.config.ts             # Vite 설정 파일
└── package.json               # 프로젝트 의존성 및 스크립트

 

src 폴더 내에 lib 폴더를 만들고 여기에서 라이브러리 개발을 진행합니다. 

App.tsx에서는 개발중인 컴포넌트를 import 해와서 제가 만든 컴포넌트가 잘 작동하는지 실시간으로 테스트합니다. 

 

Vite 개발 서버는 index.html을 진입점으로 두지만, 라이브러리에서는 index.html을 진입점으로 둘 수 없으므로 라이브러리 빌드시 진입점을 따로 두어야 합니다. lib 폴더 내에 index.ts 파일을 만들어 진입점으로 설정합니다.

// src/lib/index.ts
import InfiniteSlide from './InfiniteSlide/InfiniteSlide';

export default InfiniteSlide;

이제 이 라이브러리는 InfiniteSlide를 default로 내보내게 됩니다. 따라서 라이브러리 사용시에 다음과 같이 불러올 수 있습니다. 

import InfiniteSlide from 'react-infinite-carousel-component';

 

그리고 vite.config.ts에서 build.lib를 다음과 같이 설정합니다.

import { resolve } from 'path';
import { defineConfig } from 'vite';

// https://vite.dev/config/
export default defineConfig({
  ...,
  build: {
    lib: {
      entry: resolve(__dirname, 'src/lib/index.ts'),
      formats: ['es', 'cjs'],
      fileName: (format, entryName) =>
        entryName === 'style' ? 'index.css' : `index.${format == 'es' ? 'mjs' : 'cjs'}`,
    },
    rollupOptions: {
      external: ['react', 'react-dom', /^react($|\/)/, /^react-dom($|\/)/],
      output: {
        globals: {
          react: 'React',
          'react-dom': 'ReactDOM',
        },
        assetFileNames: 'index.[ext]',
      },
    },
    sourcemap: true,
  },
});

entry

entry: resolve(__dirname, 'src/lib/index.ts'),

entry는 진입점을 설정하는 부분입니다. 아까 만든 진입점 파일(src/lib/index.ts)을 입력해줍니다. 

  • resolve: Node.js의 path 모듈의 함수로, 주어진 경로를 절대 경로로 변환합니다.
  • __dirname: 현재 파일이 위치한 디렉토리의 절대 경로를 나타내는 Node.js의 전역 변수입니다.

이 파일에서부터 라이브러리의 모든 종속성과 기능을 모아 패키지를 생성하는 역할을 합니다. Vite는 이 파일을 기준으로 종속성 트리를 분석하고, 최종 번들을 생성합니다.

 

formats

formats: ['es', 'cjs'],

formats를 통해 모듈 포맷을 설정할 수 있습니다. ESModule과 CommonJS 방식 두개를 만들어 줍니다. 

 

fileName

fileName: (format) => `index.${format == 'es' ? 'mjs' : 'cjs'}`,

번들링 시 생성할 출력 파일의 이름을 정하는 함수입니다. 

  • format: 현재 번들링하는 파일의 모듈 형식(예: 'es' 또는 'cjs')

저는 es 모듈이면 확장자를 mjs로, 아니라면(formats에서 es 또는 cjs라고 했으므로 cjs) cjs로 지정했습니다. ESModule이 등장하면서 두 시스템을 혼용해서 사용할 때 혼란을 막기위해 이 확장자들을 도입했습니다. .cjs 는 항상 CJS로 해석되고, .mjs 는 항상 ESM으로 해석됩니다.

 

rollupOptions

Vite는 기본적으로 Rollup을 번들러로 사용합니다. rollupOptions에서 빌드시 제외할 의존성과 asset 파일 이름 등을 설정할 수 있습니다. 

external

external: ['react', 'react-dom', /^react($|\/)/, /^react-dom($|\/)/],

빌드시 제외할 의존성을 설정할 수 있습니다. 제 라이브러리는 기본적으로 리액트를 사용하는 프로젝트에서 사용되므로, 리액트를 번들에 포함하면 불필요하게 크기가 늘어납니다. 따라서 리액트와 관련된 모듈들은 번들에서 제거해줍니다. 

output.assetFileNames

output: {
  assetFileNames: 'index.[ext]',
},

생성된 asset 파일의 이름을 지정합니다. [ext]는 각 자산 파일의 원래 확장자로 대체되며, 결과적으로 index.css와 같은 이름을 갖게 됩니다. 제 라이브러리는 에셋 파일이 css밖에 없으므로 생성되는 것은 index.css 뿐입니다.

sourcemap

sourcemap: true,

sourcemap은 원본 파일과 변환된 코드 사이의 매핑 관계를 선언하는 것입니다. 우리가 작성한 코드는 압축되어 빌드되는데, 압축된 코드에서 에러가 나면 원본 코드의 어느 곳에서 에러가 발생한 것인지 알기가 힘들어집니다. 따라서 원본 코드와 변환된 코드를 매핑해서 에러가 나면 원본 코드를 가르켜줍니다.

 

vite-plugin-dts

import { defineConfig } from 'vite';
import dts from 'vite-plugin-dts';

export default defineConfig({
  plugins: [
   dts({
     rollupTypes: true,
     insertTypesEntry: true,
     include: ['src/lib/**/*'],
     outDir: 'dist',
     tsconfigPath: './tsconfig.build.json',
  }),
 ], 
  ...
});

라이브러리가 타입스크립트를 지원하려면 타입 선언 파일(d.ts) 이 있어야 합니다. vite-plugin-dts는 우리의 소스파일을 분석하여 자동으로 d.ts 파일을 생성해줍니다. 

 

rollupTypes: true

TypeScript 선언 파일(.d.ts)을 번들링하여 하나로 합칩니다. 제 라이브러리는 복잡한 구조로 되어있지 않기 때문에 하나로 합쳐줍니다. 

insertTypesEntry: true,

package.jsontypes 필드가 자동으로 추가되도록 합니다. TypeScript 프로젝트에서 라이브러리를 임포트할 때 타입 정의를 자동으로 찾을 수 있게 됩니다.

include: ['src/lib/**/*'],

타입 정의 파일을 생성할 소스 파일들을 지정합니다. 라이브러리 코드는 src/lib 폴더에 있기 때문에 해당 폴더의 코드들만 타입 선언을 해줍니다.

outDir: 'dist',

생성된 타입 정의 파일(.d.ts)이 저장될 디렉토리를 지정합니다.

tsconfigPath: './tsconfig.build.json',

타입 정의 생성에 사용할 TypeScript 설정 파일의 경로를 지정합니다. 저는 기본 tsconfig.json과 분리된 라이브러리 빌드용 tsconfig.build.json을 따로 만들어서 지정해주었습니다.

 

package.json

이제 package.json도 설정해줍니다.

{
  "name": "react-infinite-carousel-component",
  "private": false,
  "version": "0.2.0",
  "type": "module",
  "files": [
    "dist"
  ],
  "main": "dist/index.cjs",
  "module": "dist/index.mjs",
  "types": "dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "require": "./dist/index.cjs",
      "import": "./dist/index.mjs"
    },
    "./style.css": "./dist/index.css"
  },
  "sideEffects": [
    "*.css"
  ],
  "license": "MIT",
  "peerDependencies": {
    "react": "^18.0.0 || ^19.0.0",
    "react-dom": "^18.0.0 || ^19.0.0"
  },
  "devDependencies": {
    ...
  }
}

 

진입점

- main: "dist/index.cjs" - CommonJS 형식 진입점 (Node.js, 기존 환경)
- module: "dist/index.mjs" - ES 모듈 형식 진입점 (최신 번들러용)
- types: "dist/index.d.ts" - TypeScript 타입 선언 파일 진입점

라이브러리를 import 할 때의 진입점을 설정합니다. CommonJS에서는 main을 기준으로, ESM 프로젝트에서는 module을 기준으로 진입점을 인식합니다. 

내보내기(export)

"exports": {
  ".": {
    "types": "./dist/index.d.ts",
    "require": "./dist/index.cjs",
    "import": "./dist/index.mjs"
  },
  "./style.css": "./dist/index.css"
}

require는 require(commonJS)로 가져올 때 내보낼 파일, import는 import(ESModule)로 가져올 때 내보낼 파일을 지정합니다. 루트 경로('react-infinite-carousel-component')로 가져오면 각각 index.cjs, index.mjs 파일을 내보냅니다. 

./style.css('react-infinite-carousel-component/style.css') 경로로 가져오면 css 파일을 내보냅니다. 따라서 사용자는 CSS 파일을 따로 가져와서 사용할 수 있습니다.

 

"sideEffects": [
  "*.css"
]

css 파일은 트리셰이킹 대상에서 제거합니다. css는 트리셰이킹 과정에서 제거되면 사이드이펙트가 발생할 수 있으므로 트리셰이킹 대상에서 제외시킵니다.

 

peerDependencies

"peerDependencies": {
  "react": "^18.0.0 || ^19.0.0",
  "react-dom": "^18.0.0 || ^19.0.0"
},

react & react-dom 18버전 이상 20버전 미만이 있어야 함을 의미합니다. 20버전은 아직 나오지 않았으므로 18버전 이상을 지원합니다. 

 

배포 구성

"private": false,
"files": ["dist"],
"license": "MIT",
"repository": {
  "type": "git",
  "url": "https://github.com/Nangniya/react-infinite-carousel-component.git"
},

- private : false : npm에 공개적으로 배포 가능함을 의미합니다.

- files : npm에 배포될 때 포함될 파일/폴더를 명시합니다.

- license: npm 레지스트리는 package.json의 license를 보고 라이센스를 인식합니다. MIT로 명시해줍니다.

- repository: 깃 레포지토리 URL을 연결해줍니다.

 

npm에 배포하기

npm에 로그인하고 publish하면 npm 레지스트리에 등록됩니다.

npm login
npm publish

 

하지만 저는 깃을 이용한 CI/CD 설정을 추가로 해주었습니다. npm에서 토큰을 발급받은 후 깃허브 secret에 NPM_TOKEN이라는 이름으로 환경변수를 넣어줍니다. 그 후 .github/workflows/npm-publish.yml 파일을 만듭니다.

name: Publish Package to npmjs
on:
  push:
    branches:
      - main

jobs:
  build-and-publish:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20.x'
          registry-url: 'https://registry.npmjs.org/'

      - name: Install dependencies
        run: npm ci

      - name: Build
        run: npm run build

      - name: Publish to npm
        run: npm publish
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

main 브랜치에 push되면 자동으로 npm 레지스트리에 반영됩니다.

 

 

npm에 잘 등록된것을 볼 수 있습니다. 

'JS' 카테고리의 다른 글

[JS] webpack, babel로 개발환경 구축하기  (2) 2024.12.04