June.

11월 안에는 꼭...

Portfolio

About

간단한 유틸 함수 NPM 라이브러리 배포해보기 (feat. TypeScript 지원, ESM 지원)

2023/03/14 updated
개발
npm

16 min read

간단한 유틸 함수 NPM 라이브러리 배포해보기 (feat. TypeScript 지원, ESM 지원)

개요

약 1년 전에 테오의 스프린트 4기에 참여해서 NPM 배포하고 후기를 작성한 적이 있었는데 1년이 지난 지금 조금 더 발전된 모습으로 NPM 배포에 대해서 가이드를 작성해보려고 한다.

주제는 간단한 유틸 함수를 NPM 라이브러리로 배포해 보는 것이고, 하나하나 직접 코드를 입력해 보며 내가 작성하는 코드가 어디에서 어떻게 사용되는지를 알아보는 것이 목표이다. 요즘엔 당연하게 생각되는 TypeScript(이하 ts)와 CommonJS(이하 cjs), ECMAScript Module(이하 esm) 지원을 포함해서 배포해 보자.

정말 NPM 배포를 처음 해보는 사람들을 위해서 차근차근 가이드를 작성했으니 따라 해보며 배포해보면 어느 과정으로 배포가 되는지 알 수 있고 전반적인 느낌을 잡을 수 있을 것이다.

미리보기

개발 환경

  • Node.js v18.12.1
  • npm v8.19.2
  • 패키지 매니저는 yarn을 사용한다.
  • 리액트를 꼭 알고 있진 않아도 되지만, 알고 있다면 더 좋다.

사전 준비

NPM 계정 생성

npm 계정이 없다면 npmjs.com에서 계정을 생성한다.

NPM 로컬 로그인

1$ npm login 2$ npm whoami

첫 배포

폴더 생성

자신의 이름으로 된 폴더를 생성한다. 나는 junghyeonsu-utils라는 폴더를 생성했다.

1$ mkdir junghyeonsu-utils 2$ cd junghyeonsu-utils

package.json 생성

1$ yarn init -y
1{ 2 "name": "junghyeonsu-utils", 3 "version": "0.0.1", 4 "main": "index.js", 5 "license": "MIT" 6}

중요한 것만 설명하자면 name은 NPM에 배포할 때 사용되는 이름이다. npm install junghyeonsu-utils 이렇게 입력해서 설치를 할 수 있다.

version은 배포할 때 사용되는 버전이다. Semantic Versioning 체계를 사용한다. 각 자릿수는 순서대로 major, minor, patch 버전을 의미한다. major가 0이면 초기 개발 단계이고, 1 이상이면 안정화된 버전으로 통상 사용된다. 자신이 라이브러리 개발자라면 major 버전을 0으로 우선 유지하고, 완전히 안정화되면 1로 올리는 것이 좋다.

main은 라이브러리를 사용할 때 기본 진입점이다. index.js 파일을 기본 진입점으로 사용하겠다는 의미이다.

license는 라이브러리의 라이센스를 의미한다. MIT 라이센스를 사용하겠다는 의미인데, 자신이 사용하고 싶은 라이센스를 선택하면 된다. 라이센스에 대한 내용은 너무 많아서 여기서는 다루지 않는다.

module 작성

우선 cjs 형태로 모듈을 작성해 보자. index.js 파일을 생성하고 아래와 같이 작성한다. 기본적인 사칙연산을 하는 모듈이다.

1function add(a, b) { 2 return a + b; 3} 4 5function subtract(a, b) { 6 return a - b; 7} 8 9function multiply(a, b) { 10 return a * b; 11} 12 13function divide(a, b) { 14 return a / b; 15} 16 17module.exports = { 18 add, 19 subtract, 20 multiply, 21 divide, 22};

사실 이렇게 하면 끝이다. 이제 npm publish 명령어를 입력하면 NPM에 배포가 된다.

1$ npm notice 2$ npm notice 📦 [email protected] 3$ npm notice === Tarball Contents === 4$ npm notice 236B index.js 5$ npm notice 98B package.json 6$ npm notice === Tarball Details === 7$ npm notice name: junghyeonsu-utils 8$ npm notice version: 0.0.1 9$ npm notice filename: junghyeonsu-utils-0.0.1.tgz 10$ npm notice package size: 300 B 11$ npm notice unpacked size: 334 B 12$ npm notice shasum: 2f01de64d2c4ba507cc8c6a3c94941b5ffb4345b 13$ npm notice integrity: sha512-mmYrdon7QOWPl[...]Wyb6BcQzQ7tkw== 14$ npm notice total files: 2 15$ npm notice 16$ npm notice Publishing to https://registry.npmjs.org/ 17+ [email protected]

만약 이렇게 떴다면 배포가 성공한 것이다. 한 번 확인해 보자. 새로운 test라는 폴더를 만들고 거기서 yarn init -y로 프로젝트를 세팅하고, yarn add junghyeonsu-utils로 라이브러리를 설치해 보자.

1$ mkdir test 2$ cd test 3$ yarn init -y 4$ yarn add junghyeonsu-utils
1{ 2 "name": "test", 3 "version": "1.0.0", 4 "main": "index.js", 5 "license": "MIT", 6 "dependencies": { 7 "junghyeonsu-utils": "^0.0.1" 8 } 9}

그리고 node_modules 폴더를 확인해 보자. junghyeonsu-utils 폴더가 생성되었고, index.js 파일이 있다. 이 파일이 우리가 작성한 index.js 파일이다.

우리가 작성한 index.js파일이 node_modules안에 잘 들어가 있다.
우리가 작성한 index.js파일이 node_modules안에 잘 들어가 있다.

라이브러리를 한 번 사용해 보자. test 프로젝트에서 index.js 파일을 생성하고 아래와 같이 작성한다.

1// -> junghyeonsu-utils에서 정의한 함수들을 가져온다. 2const { add, divide, multiply, subtract } = require("junghyeonsu-utils"); 3 4// -> 가져온 함수들을 사용하고 결과를 출력한다. 5console.log(add(1, 2)); 6console.log(subtract(1, 2)); 7console.log(multiply(1, 2)); 8console.log(divide(1, 2));

그리고 한 번 실행시켜 보자.

1$ node index.js
결과
결과

흐음... 잘 작동하는 것 같다! 벌써 우리는 라이브러리를 만들고 배포하고 사용하는 것까지 성공했다. 사실 이 과정만 기억하면 나머지 다른 라이브러리들을 제작하는 방법은 크게 다르지 않다. 중간마다 파일들을 변환하고, 빌드하는 과정들이 추가되는 것뿐이다.

ES Module 지원하기

지금 우리가 작성한 라이브러리는 CommonJS 형태로 작성되어 있다. 토스 테크에서 작성한 CommonJS와 ESM에 모두 대응하는 라이브러리 개발하기: exports field 포스팅에서 인용을 한다면...

  • CJS는 require / module.exports를 사용하고, ESM은 import / export 문을 사용합니다.
  • CJS module loader는 동기적으로 작동하고, ESM module loader는 비동기적으로 작동합니다.
  • ESM은 Top-level Await을 지원하기 때문에 비동기적으로 동작합니다.
  • 따라서 ESM에서 CJS를 import 할 수는 있지만, CJS에서 ESM을 require 할 수는 없습니다. 왜냐하면 CJS는 Top-level Await을 지원하지 않기 때문입니다.
  • 이 외에도 두 Module System은 기본적으로 동작이 다릅니다.
  • 따라서 두 Module System은 서로 호환되기 어렵습니다.

그리고 최신 브라우저에서는 대부분 esm을 지원하고, 대부분 코드를 작성할 때도 esm을 사용한다. (require, module.exports를 이용해서 react 코드를 작성한 적이 있는가를 생각해 보면 된다.)

우선 사용하는 측에서 esm을 작성해서 코드를 작성해 보자.

1// -> junghyeonsu-utils에서 정의한 함수들을 가져온다. 2import { add, divide, multiply, subtract } from "junghyeonsu-utils"; 3 4// -> 가져온 함수들을 사용하고 결과를 출력한다. 5console.log(add(1, 2)); 6console.log(subtract(1, 2)); 7console.log(multiply(1, 2)); 8console.log(divide(1, 2));

그리고 똑같이 실행시켜 보자. 그럼, 다음과 같은 에러가 뜰 것이다.

1$ node index.js 2 3$ (node:63686) Warning: To load an ES module, set "type": "module" in the package.json or use the .mjs extension. 4$ (Use `node --trace-warnings ...` to show where the warning was created) 5$ /Users/jeonghyeonsu/Documents/GitHub/test/index.js:2 6$ import { add, divide, multiply, subtract } from "junghyeonsu-utils"; 7$ ^^^^^^ 8$ 9$ SyntaxError: Cannot use import statement outside a module

esm을 사용하기 위해서는 package.json"type": "module"을 추가해야 한다.

1{ 2 "name": "test", 3 "version": "1.0.0", 4 "type": "module", 5 "main": "index.js", 6 "author": "junghyeonsu <[email protected]>", 7 "license": "MIT", 8 "dependencies": { 9 "junghyeonsu-utils": "0.0.1" 10 } 11}

그리고 다시 실행시켜 보자.

1$ node index.js
결과
결과

다행히 잘 되는 것 같다! 그러면 이제 우리가 만든 라이브러리도 esm을 지원하도록 만들어보자. module.exportsexport 문으로 바꿔주자.

1function add(a, b) { 2 return a + b; 3} 4 5function subtract(a, b) { 6 return a - b; 7} 8 9function multiply(a, b) { 10 return a * b; 11} 12 13function divide(a, b) { 14 return a / b; 15} 16 17export { add, subtract, multiply, divide };

그리고 package.json"type": "module"을 추가해 주자. type 필드는 commonjs, module 이렇게 두 개의 옵션이 있고, commonjsrequire를 사용하는 cjs, moduleimport를 사용하는 esm이다.

1{ 2 "name": "junghyeonsu-utils", 3 "version": "0.0.1", 4 "type": "module", 5 "main": "index.js", 6 "license": "MIT" 7}

그리고 patch 버전을 올려주고 다시 배포해 보자.

1$ npm version patch 2$ npm publish
1$ npm notice 2$ npm notice 📦 [email protected] 3$ npm notice === Tarball Contents === 4$ npm notice 226B index.js 5$ npm notice 98B package.json 6$ npm notice === Tarball Details === 7$ npm notice name: junghyeonsu-utils 8$ npm notice version: 0.0.2 9$ npm notice filename: junghyeonsu-utils-0.0.2.tgz 10$ npm notice package size: 293 B 11$ npm notice unpacked size: 324 B 12$ npm notice shasum: 89aa2f7243c9603c1ed916e4e184b8164bc04277 13$ npm notice integrity: sha512-xXufGijD9quRe[...]v6AfdzfOsO3NA== 14$ npm notice total files: 2 15$ npm notice 16$ npm notice Publishing to https://registry.npmjs.org/ 17$ + [email protected]

성공적으로 배포가 됐다면 다시 test 프로젝트로 와서 라이브러리의 버전을 올리고 실행을 해 보자. 새로 배포된 버전을 설치하려면 @0.0.2를 붙여주어서 명시적으로 버전을 지정해주어야 할 수도 있고, 그냥 @latest를 붙여주어서 최신 버전을 설치할 수도 있다. 혹은 아무것도 붙이지 않아도 된다.

1$ yarn add junghyeonsu-utils 2$ npm install junghyeonsu-utils

or

1$ yarn add [email protected] 2$ npm install [email protected]
1$ node index.js

아마 잘 될 것이다.

결과
결과

근데 항상 이렇게 esm만을 사용하는 것은 아니다. cjs를 사용하는 프로젝트와 esm을 사용하는 프로젝트가 같이 존재할 수도 있다. cjs는 보통 server-side rendering을 지원할 때 사용되기도 하는데, cjs는 tree-shaking(트리 쉐이킹: Tree-shaking이란 필요하지 않은 코드와 사용되지 않는 코드를 삭제하여 JavaScript 번들의 크기를 가볍게 만드는 것) 이 어려워서, esm을 동시 지원해야 하는 경우가 다반사라고 한다.

그럼 이와 같은 상황에서 test 프로젝트가 commonjs를 사용해야 한다고 가정해 보자. 그러면 package.json"type": "module"type: "commonjs"로 바꾸고 다시 실행시켜 보자. (default가 commonjs라서 그냥 지워도 된다.)

1{ 2 "name": "test", 3 "version": "1.0.0", 4 "type": "commonjs", 5 "main": "index.js", 6 "author": "junghyeonsu <[email protected]>", 7 "license": "MIT", 8 "dependencies": { 9 "junghyeonsu-utils": "0.0.2" 10 } 11}

그러면 우리가 자주 마주친 에러를 마주하게 된다.

1$ (node:69037) Warning: To load an ES module, set "type": "module" in the package.json or use the .mjs extension. 2$ (Use `node --trace-warnings ...` to show where the warning was created) 3$ /Users/jeonghyeonsu/Documents/GitHub/test/index.js:2 4$ import { add, divide, multiply, subtract } from "junghyeonsu-utils"; 5$ ^^^^^^ 6$ 7$ SyntaxError: Cannot use import statement outside a module

위에서 설명했다시피 esm 환경에서 cjs를 사용하는 것은 괜찮다. 하지만 그 반대로 cjs 환경에서 esm을 사용하려고 하면 에러가 발생한다.

라이브러리 제공자가 일일이 cjsesm을 모두 지원해주지 않는다면, cjs를 사용하는 프로젝트에서는 esm을 사용하는 라이브러리를 사용할 수 없다. 그렇다면 cjsesm을 어떻게 하면 동시에 지원할 수 있을까?

package.json의 exports 필드 명시하기

exports 필드는 node v12.7.0 버전에 추가된 필드로, cjsesm을 동시에 지원할 수 있게 해 준다. exports 라이브러리 제공자가 입력해 주는 정보로, 라이브러리 사용자가 입력하는 정보는 아니다.

junghyeonsu-utilspackage.jsonexports 넣어보자.

1{ 2 "name": "junghyeonsu-utils", 3 "version": "0.0.2", 4 "type": "module", 5 "main": "index.js", 6 "exports": { 7 ".": { 8 "import": "./index.js", 9 "require": "./index.cjs" 10 } 11 }, 12 "license": "MIT" 13}

exports 필드는 모두 . 으로 시작하는 상대 경로로 작성되어야 한다. 해당 경로는 라이브러리의 subpath를 의미하고 그 안의 객체에는 import, require, types, default 와 같은 conditional 필드가 존재한다.

1{ 2 "exports": { 3 ".": { 4 // 라이브러리의 subpath 5 "types": "./index.d.ts", // typescript를 사용하는 경우 사용될 파일을 명시한 conditional 필드 6 "import": "./index.js", // esm 환경에서 사용될 파일을 명시한 conditional 필드 7 "require": "./index.cjs", // cjs 환경에서 사용될 파일을 명시한 conditional 필드 8 "default": "./index.js" // default 환경에서 사용될 파일을 명시한 conditional 필드 9 } 10 } 11}

만약 맨 첫 번째 ../util로 바꾸면 다음과 같이 사용된다.

1import { add, divide, multiply, subtract } from "junghyeonsu-utils/util"; // esm 2const { add, divide, multiply, subtract } = require("junghyeonsu-utils/util"); // cjs

하지만 지금은 상대경로를 . 으로 작성했기 때문에 exports 필드는 junghyeonsu-utils의 루트 폴더를 바라보게 된다.

1import { add, divide, multiply, subtract } from "junghyeonsu-utils"; // esm 2const { add, divide, multiply, subtract } = require("junghyeonsu-utils"); // cjs

exports 필드에서 importesm 환경에서 사용되고, requirecjs 환경에서 사용된다. 현재 junghyeonsu-utilsesm을 지원하고 있기 때문에 그냥 import를 하게 되면 esm 파일인 index.js 파일을 사용할 것이다. 하지만 cjs 환경에서는 require를 사용하기 때문에 require를 키로 가지는 객체 안에 index.cjs 파일을 바라보게 해야 한다.

commonjs를 사용하는 index.cjs를 작성하고, 프로그램 파일이 많아지니 소스 파일을 src 폴더를 만들어 그 안에 넣어주자. 그리고 그것에 맞게 package.json의 파일도 수정해 주자.

우리 junghyeonsu-utils 폴더 구조는 이렇게 될 것이다.

1// src/index.cjs 2function add(a, b) { 3 return a + b; 4} 5 6function subtract(a, b) { 7 return a - b; 8} 9 10function multiply(a, b) { 11 return a * b; 12} 13 14function divide(a, b) { 15 return a / b; 16} 17 18module.exports = { 19 add, 20 subtract, 21 multiply, 22 divide, 23};
1{ 2 "name": "junghyeonsu-utils", 3 "version": "0.0.2", 4 "type": "module", 5 "main": "src/index.js", 6 "exports": { 7 ".": { 8 "import": "./src/index.js", 9 "require": "./src/index.cjs" 10 } 11 }, 12 "license": "MIT" 13}
1📦junghyeonsu-utils 2 ┣ 📂src 3 ┃ ┣ 📜index.cjs 4 ┃ ┗ 📜index.js 5 ┣ 📜package.json 6 ┗ 📜yarn.lock

그리고 patch를 올리고 npm에 배포해 보자. 그럼 0.0.3 버전이 배포될 것이다.

1$ npm version patch 2$ npm publish

그러면 다시 test 프로젝트에서 [email protected]을 설치하고, 다시 실행해 보자. 그럼 아까 전에는 에러가 나던 부분이 잘 실행될 것이다.

1$ yarn add [email protected] 2 3$ node index.js 4$ 3 5$ -1 6$ 2 7$ 0.5

이렇게 해서 cjsesm을 동시에 지원하는 라이브러리를 만들었다.

TypeScript 지원하기

타입스크립트는 이제 대부분의 현직 개발자가 사용하고 있다. 많은 리액트 개발자가 프로젝트 세팅할 때 자주 사용하는 vite으로 리액트 타입스크립트 템플릿을 생성할 수 있다. 한 번 생성해 보고 우리 라이브러리를 설치해 보자.

기존의 test 프로젝트 안의 파일들을 전부 지우고 vite 리액트 프로젝트로 대체해 보자.

1$ rm -rf * 2$ yarn create vite . --template react-ts
1yarn create v1.22.19 2$ [1/4] 🔍 Resolving packages... 3$ [2/4] 🚚 Fetching packages... 4$ [3/4] 🔗 Linking dependencies... 5$ [4/4] 🔨 Building fresh packages... 6$ success Installed "[email protected]" with binaries: 7$ - create-vite 8$ - cva 9$ [#############################################################################] 77/77 10$ Scaffolding project in /Users/jeonghyeonsu/Documents/GitHub/test... 11$ 12$ Done. Now run: 13$ 14$ yarn 15$ yarn dev 16$ 17$ ✨ Done in 0.83s.

정상적으로 설치가 되었다면 프로젝트의 의존성을 설치하고 우리 라이브러리도 한 번 설치해 보자.

1$ yarn 2$ yarn add junghyeonsu-utils

그리고 src/App.tsx 에서 우리가 만든 라이브러리를 사용하려고 해 보자. (지금은 단순 더하기, 빼기 등의 연산만 가능한 라이브러리지만 더 많은 기능들이 있다고 상상해 보자.) 아마 에러가 날 것이다.

1import React from 'react'; 2import logo from './logo.svg'; 3import './App.css'; 4import { add } from 'junghyeonsu-utils'; 5 6function App() { 7 return ( 8 <div className="App"> 9 <header className="App-header"> 10 <img src={logo} className="App-logo" alt="logo" /> 11 <p> 12 Edit <code>src/App.tsx</code> and save to reload. 13 </p> 14 <a 15 className="App-link" 16 href="https://reactjs.org" 17 target="_blank" 18 rel="noopener noreferrer" 19 > 20 Learn React 21 </a> 22 </header> 23 </div> 24 ); 25} 26 27export default App;
정말 많은 개발자들이 만나는 에러
정말 많은 개발자들이 만나는 에러

이 에러는 junghyeonsu-utils 라이브러리가 타입스크립트 지원을 하지 않기 때문에 발생하는 에러다. 요즘 라이브러리들은 웬만하면 타입스크립트 지원을 잘해주지만, 옛날 라이브러리 같은 경우에는 타입스크립트를 지원하지 않는 경우가 많다. 라이브러리가 @types 패키지를 지원해 준다면 해당 패키지를 설치해서 타입스크립트를 지원할 수 있고, 만약 @types 패키지도 지원하지 않는다면 사용하는 측에서 d.ts 파일을 직접 만들어서 타입을 정의해줘야 한다.

하지만 우리는 사용자를 만족시켜야 하는 라이브러리 제공자로서 타입스크립트를 지원해 보자.

타입스크립트 설정

타입스크립트를 지원하기 위해서는 라이브러리 package.jsontypes 필드에 d.ts 파일의 경로를 지정해 주면 된다. 그 전에 d.ts 파일을 뽑아내기 위해서는 라이브러리 코드 자체에서 타입스크립트 설정을 해 보자.

다시 junghyeonsu-utils 프로젝트로 돌아와서 타입스크립트를 설치해 준다. 타입스크립트 패키지 자체는 빌드 결과물에는 포함되지 않아도 되기 때문에 devDependencies에 설치해 준다. @types/nodenode의 타입을 지원해 주는 패키지로 같이 설치해 주자.

1$ yarn add typescript @types/node -D

그리고 tsconfig.json 파일을 생성해 준다.

1{ 2 "compilerOptions": { 3 "target": "es6" /* 최신 브라우저는 es6을 대부분 지원한다. */, 4 "module": "commonjs" /* 모듈 시스템을 지정한다. */, 5 "lib": [ 6 "es5", 7 "es6", 8 "dom" 9 ] /* 타입스크립트가 어떤 버전의 JS의 빌트인 api를 사용할건지에 대한 것을 명시해 준다. */, 10 "declaration": true /* 타입스크립트가 자동으로 타입정의 (d.ts) 파일을 생성해 준다. */, 11 "outDir": "dist" /* 컴파일된 결과물을 어디에 저장할지에 대한 것을 명시해 준다. */, 12 "strict": true /* 타입스크립트의 엄격한 모드를 활성화한다. */ 13 } 14}

요기서 중요한 것은 declarationoutDir이다. declaration은 타입스크립트가 자동으로 타입정의 (d.ts) 파일을 생성해 준다는 것이고, outDir은 컴파일된 결과물을 어디에 저장할지에 대한 것을 명시해 준다. 타입스크립트는 해당 라이브러리가 타입스크립트를 지원해 주는지 하지 않는지를 타입정의(d.ts) 파일을 찾아서 결정하기 때문에 declarationtrue로 설정해 준다.

기존의 src/index.js 파일의 내용을 src/index.ts 파일로 바꿔주고, 타입 에러가 발생하는 부분을 모두 수정해 주자.

1// src/index.ts 2function add(a: number, b: number) { 3 return a + b; 4} 5 6function subtract(a: number, b: number) { 7 return a - b; 8} 9 10function multiply(a: number, b: number) { 11 return a * b; 12} 13 14function divide(a: number, b: number) { 15 return a / b; 16} 17 18module.exports = { 19 add, 20 subtract, 21 multiply, 22 divide, 23};

그리고 tsconfig.jsoninclude 필드를 추가해 준다.

1{ 2 "compilerOptions": { 3 "target": "es6" /* 최신 브라우저는 es6을 대부분 지원한다. */, 4 "module": "ES6" /* 모듈 시스템을 지정한다. */, 5 "lib": ["es5", "es6", "dom"] /* 타입스크립트가 어떤 버전의 JS의 빌트인 API를 사용할 건지에 대한 것을 명시해 준다. */, 6 "declaration": true /* 타입스크립트가 자동으로 타입정의 (d.ts) 파일을 생성해 준다. */, 7 "outDir": "dist" /* 컴파일된 결과물을 어디에 저장할지에 대한 것을 명시해 준다. */, 8 "strict": true /* 타입스크립트의 엄격한 모드를 활성화한다. */ 9 }, 10 "include": ["src/index.ts"] /* 컴파일할 대상을 명시해 준다. */ 11}

그리고 터미널에서 yarn tsc로 컴파일을 해도 되지만, 우리는 package.jsonbuild script를 작성해 보자.

1{ 2 "name": "junghyeonsu-utils", 3 "version": "0.0.3", 4 "type": "module", 5 "main": "src/index.js", 6 "scripts": { 7 "build": "yarn build:tsc", 8 "build:tsc": "yarn tsc" 9 }, 10 "exports": { 11 ".": { 12 "import": "./src/index.js", 13 "require": "./src/index.cjs" 14 } 15 }, 16 "license": "MIT", 17 "devDependencies": { 18 "@types/node": "^18.15.0", 19 "typescript": "^4.9.5" 20 } 21}

그리고 yarn build를 실행해 보자. 그럼 tsconfig.json에 명시해 준 outDirdist 폴더가 생성되고, dist 폴더 안에 index.jsindex.d.ts 파일이 생성된 것을 확인할 수 있다. 생성된 파일들을 가볍게 살펴보면 index.js는 타입이 빠진 코드가 생성되었고, index.d.ts는 타입스크립트가 자동으로 생성해 준 타입정의 파일이다.

1// dist/index.js 2function add(a, b) { 3 return a + b; 4} 5function subtract(a, b) { 6 return a - b; 7} 8function multiply(a, b) { 9 return a * b; 10} 11function divide(a, b) { 12 return a / b; 13} 14export { add, subtract, multiply, divide };
1// dist/index.d.ts 2declare function add(a: number, b: number): number; 3declare function subtract(a: number, b: number): number; 4declare function multiply(a: number, b: number): number; 5declare function divide(a: number, b: number): number; 6export { add, subtract, multiply, divide };

그리고 폴더와 컴파일된 결과물들에 맞춰서 main, types 그리고 exports 필드도 그것에 맞게 변경해 주자. 기존에는 그냥 index.jsmain 필드에 넣어주었지만, 이제는 컴파일된 결과물인 dist/index.jsmain 필드에 넣어주자. 그리고 추가로 exports 필드 안에 types 필드를 추가할 수 있다. 생성된 d.ts 파일을 명시해 주자. 그리고 마지막으로 exports 필드의 import에는 컴파일 결과물인 dist/index.js을 넣어주자. (require는 우선 가만히 놔둬도 된다.)

1{ 2 "name": "junghyeonsu-utils", 3 "version": "0.0.3", 4 "type": "module", 5 "main": "dist/index.js", 6 "scripts": { 7 "build": "yarn build:tsc", 8 "build:tsc": "yarn tsc" 9 }, 10 "exports": { 11 ".": { 12 "import": "./dist/index.js", 13 "require": "./src/index.cjs", 14 "types": "./dist/index.d.ts" 15 } 16 }, 17 "license": "MIT", 18 "devDependencies": { 19 "@types/node": "^18.15.0", 20 "typescript": "^4.9.5" 21 } 22}

그럼 만반의 준비는 끝났다. 다시 배포를 해 보자! 우리는 typescript 지원이라는 새로운 기능을 추가했기 때문에 minor 버전을 올려주자.

1$ yarn version --minor 2$ npm version minor
1$ yarn version v1.22.19 2$ info Current version: 0.0.1 3$ info New version: 0.1.0 4$ ✨ Done in 0.02s.

그리고 배포하기 전에 확실히 build 명령어를 실행하고 배포하기 위해 prepack 스크립트를 package.json에 추가해 주자. prepack 스크립트는 npm publish를 실행하기 전에 실행되는 스크립트이다.

1{ 2 "name": "junghyeonsu-utils", 3 "version": "0.1.0", 4 "type": "module", 5 "main": "dist/index.js", 6 "scripts": { 7 "prepack": "yarn build", 8 "build": "yarn build:tsc", 9 "build:tsc": "yarn tsc" 10 }, 11 "exports": { 12 ".": { 13 "import": "./dist/index.js", 14 "require": "./src/index.cjs", 15 "types": "./dist/index.d.ts" 16 } 17 }, 18 "license": "MIT", 19 "devDependencies": { 20 "@types/node": "^18.15.0", 21 "typescript": "^4.9.5" 22 } 23}

그리고 npm publish를 실행해 보자.

1$ npm publish
1$ ✨ Done in 2.40s. 2$ npm notice 3$ npm notice 📦 [email protected] 4$ npm notice === Tarball Contents === 5$ npm notice 266B dist/index.d.ts 6$ npm notice 222B dist/index.js 7$ npm notice 468B package.json 8$ npm notice 236B src/index.cjs 9$ npm notice 290B src/index.ts 10$ npm notice 710B tsconfig.json 11$ npm notice === Tarball Details === 12$ npm notice name: junghyeonsu-utils 13$ npm notice version: 0.1.0 14$ npm notice filename: junghyeonsu-utils-0.1.0 15$ npm notice package size: 1.1 kB 16$ npm notice unpacked size: 2.2 kB 17$ npm notice shasum: 0afd0f07f886608b1cca34c9f4ceeec8cf1beba1 18$ npm notice integrity: sha512-00ha4+oTyEQ0z[...]1SZ7l5cPMaj0w== 19$ npm notice total files: 6 20$ npm notice 21$ npm notice Publishing to https://registry.npmjs.org/ 22$ + [email protected]

정상적으로 배포가 되었고, 한 번 테스트를 해 보자. 기존에 만들어둔 test 프로젝트로 다시 돌아가서 minor 버전이 올라간 우리의 라이브러리를 다시 설치해 보자.

1$ yarn add [email protected]

그리고 다시 test(vite 프로젝트)에서 import를 해 보자.

타입 지원이 잘된 모습
타입 지원이 잘된 모습

DX(개발자 경험) 향상시키기

이제 우리의 라이브러리는 typescript를 지원하고, ESMCommonJS를 모두 지원하고 있다. 그런데 라이브러리 코드를 수정할 때마다 esm, cjs 둘 다 고칠 수는 없다. 타입스크립트 코드 하나로 esm, cjs 둘 다 컴파일되게 하면 좋을 것 같다. 다양한 번들러들이 있지만 요기서는 esbuild를 이용해서 esm, cjs 둘 다 컴파일해 보자.

esbuild 설정

다시 junghyeonsu-utils 프로젝트로 돌아와서 esbuild를 설치해 보자.

1$ yarn add esbuild -D

esbuild는 커맨드라인에서도 사용할 수 있지만 스크립트 파일을 작성해서 사용할 수도 있다. 다양한 옵션들을 조정하기 위해서 build.js 파일을 만들어서 esbuild를 실행해 보자.

1// build.js 2import esbuild from "esbuild"; 3 4// 공통으로 사용할 옵션들 5// https://esbuild.github.io/api/#build 에서 다양한 옵션들을 확인할 수 있다. 6const baseConfig = { 7 entryPoints: ["src/index.ts"], // 컴파일할 파일 8 outdir: "dist", // 컴파일된 파일이 저장될 경로 9 bundle: true, // 번들링 여부 10 sourcemap: true, // 소스맵 생성 여부 11}; 12 13Promise.all([ 14 // 한 번은 cjs 15 esbuild.build({ 16 ...baseConfig, 17 format: "cjs", 18 outExtension: { 19 ".js": ".cjs", 20 }, 21 }), 22 23 // 한 번은 esm 24 esbuild.build({ 25 ...baseConfig, 26 format: "esm", 27 }), 28]).catch(() => { 29 console.log("Build failed"); 30 process.exit(1); 31});

위와 같이 작성했으면 package.jsonscriptsesbuild로 세울 스크립트를 추가해 주자. 그리고 추가로 세우기 전에 dist 폴더를 삭제하는 스크립트도 추가해 주자. 또한 기존 tsc 명령어가 js 파일도 생성해 주는데 esbuild로 자바스크립트 cjs, esm을 모두 생성해 주니, 더 이상 tsc로는 자바스크립트 파일을 생성할 필요가 없다.

esbuild로 타입스크립트 타입모듈(d.ts)도 생성하면 되는 것 아닌가요?

현재 esbuild는 타입스크립트 d.ts 컴파일을 지원하지 않는다. 그래서 tscesbuild를 같이 사용해야 한다.

https://esbuild.github.io/content-types/#no-type-system

1{ 2 "scripts": { 3 "prepack": "yarn build", 4 "build": "yarn clean && yarn build:tsc && yarn build:js", 5 "build:tsc": "yarn tsc --emitDeclarationOnly", 6 "build:js": "node build.js", 7 "clean": "rm -rf dist" 8 } 9}

이렇게 하고 나서 다시 yarn build 명령어로 빌드를 해 보자.

1yarn run v1.22.19 2$ yarn clean && yarn build:tsc && yarn build:js 3$ rm -rf dist 4$ yarn tsc --emitDeclarationOnly 5$ /Users/jeonghyeonsu/Documents/GitHub/junghyeonsu-utils/node_modules/.bin/tsc --emitDeclarationOnly 6$ node build.js 7✨ Done in 2.76s

dist 폴더에 cjs, esm 둘 다 생성되고, d.ts까지 생성된 것을 확인할 수 있다.

1📦dist 2 ┣ 📜index.cjs 3 ┣ 📜index.cjs.map 4 ┣ 📜index.d.ts 5 ┣ 📜index.js 6 ┗ 📜index.js.map

이렇게 build 명령어 한 번으로 모든 필요한 파일들이 생성됐으니 src/index.cjs는 필요 없으니 삭제를 해 주자. 그리고 그것에 맞게 exportsrequire 필드도 dist 폴더를 참조하도록 수정해 주자.

추가로 package.jsonfiles 필드를 추가해 주자. files 필드는 라이브러리에 포함될 파일 또는 폴더를 명시해 주는 필드다. build.js 같은 경우에는 라이브러리를 사용하는 입장에서는 필요 없는 파일이니 제외하고, 우리는 src 폴더와 dist 폴더만 라이브러리에 포함하자. (src 폴더는 가끔 라이브러리 사용자들이 라이브러리의 소스 코드를 보고 싶을 때가 있을 경우를 대비해서 포함한다.)

1{ 2 "exports": { 3 "types": "./dist/index.d.ts", 4 "import": "./dist/index.js", 5 "require": "./dist/index.cjs" 6 }, 7 "files": [ 8 "dist", 9 "src" 10 ], 11}

이렇게 하면 정말 끝났다. 마지막으로 junghyeonsu-utilsmajor 버전을 올려서 1.0.0로 배포해 보자.

1$ npm version major 2$ npm publish

그리고 test 프로젝트에서 junghyeonsu-utils를 설치해서 node_modules를 확인해 보면 dist 폴더와 src 폴더 그리고 package.json(default) 만 포함된 것을 확인할 수 있다.

junghyeonsu-utils를 설치하고 node_modules를 확인
junghyeonsu-utils를 설치하고 node_modules를 확인

마무리

이렇게 esbuildtsc를 사용해서 cjs, esm 그리고 타입스크립트까지 모두 지원하는 라이브러리를 만들었다. 사실 위의 얘기 말고도 모든 프로젝트는 배포하고 난 후가 더 중요하다.

버전 관리, 문서화, 테스트, CI/CD 등등 많은 것들이 필요하고, cli 라이브러리, 브라우저 라이브러리, node 라이브러리 등등 환경이 다양하다.

해당 글이 반응이 좋다면 더 많은 내용을 추가해 보도록 하겠다. 깃허브에도 라이브러리를 올려놨으니 참고하면 좋을 것 같다.

업데이트

types 필드는 항상 맨 위에 위치해야 한다. (2023년 3월 14일 추가)

exports 필드는 types 필드가 항상 맨 위에 위치해야 한다.

패키지가 잘 배포가 되었는지 확인해 주는 publink에서 exports 필드를 검사해 보면 types 필드가 맨 위에 위치하지 않으면 경고가 발생한다.

types 필드는 맨 위로 와야 한다..
types 필드는 맨 위로 와야 한다..

exports 필드가 순서 기반으로 동작하기 때문에 타입스크립트를 사용하는 환경에서는 d.ts 파일이 먼저 참조돼서 타입 유형을 제공해야 한다. 타입스크립트 공식 문서의 package.json Exports, Imports, and Self-Referencing 문서에서도 types 필드가 먼저 발생해야 한다고 설명되어 있다.

타입스크립트 공식 문서
타입스크립트 공식 문서

다양한 exports 필드의 conditional 필드에 대해서 (2023년 3월 14일 추가)

node 공식 홈페이지의 package.json conditional exports 문서를 보면 exports 필드의 conditional 필드로 올 수 있는 값들에 대해서 설명이 되어있다.

node-addons, node, import, require, default의 값들이 사용 가능하다.

conditional exports
conditional exports

또한 다른 플랫폼에 대해서도 exports 필드를 지정할 수 있다. node 공식 홈페이지의 Community Conditions Definitions 문서를 참고하면 types (위에서 설명한 타입스크립트를 위한 필드), deno, browser, react-native, development, production 등의 값들이 사용 가능하다.

자세한 내용들은 링크된 문서를 참고하면 좋을 것 같다.

@[email protected]가 쓴 트위터 (2023년 3월 14일 추가)

어떤 다른 분이 exports 필드가 엄청나다면서 올린 트윗을 보고 @[email protected]가 쓴 트위터 게시물에서 꽤 많은 정보를 얻을 수 있었다. 위의 추가된 두 개의 설명도 해당 트윗을 보고 추가한 내용이다.

트위터 게시물
트위터 게시물

핵심은 module과 exports 필드밖에 존재하는 types 필드는 꼭 존재하지 않아도 되고, require, import를 무조건 둘 다 지원해주어야 하는 것은 아니고 둘의 용도를 잘 파악하고 사용해야 한다는 것이다. (esm으로 나아가는 것이 올바른 길이라고는 하지만 cjs를 지원해야 하는 경우도 있기 때문에 결론은 둘 다 지원하는 게 맞다.)

그러면서 마지막에 추천해 준 types wrong이라는 사이트와 publint와 같은 사이트는 아주 유용했다. 다들 시간이 난다면 한 번씩 들어가서 확인해 보면 좋을 것 같다.

참고

관련 포스트가 4개 있어요.

테크밋업 발표에서 하지 못한 Icona의 남은 과제들에 대한 이야기에 대해서 적어보았다.

개발
icon
about-icona-remaining-tasks cover image
2024/10/26

아이콘 피그마 배포 시스템, Icona의 남은 과제들 (부제. 테크밋업 발표에서 하지 못한 얘기들)

ESM, CJS, TS를 모두 지원하는 라이브러리를 만들어보고, CLI를 간단하게 만들어보겠습니다.

개발
npm
deploy-simple-npm-library cover image
2024/03/19 (updated)

간단한 NPM 라이브러리 배포해보기 2탄 (CLI 간단한 라이브러리 만들기 & 모든 환경 지원, ESM, CJS, TS)

Figma Plugin을 이용하여 에셋을 전시하는 방법에 대해 알아봅니다.

개발
에셋타운
asset-town-figma-plugin cover image
2024/03/17

에셋 관리 시스템을 만들어보자 (에셋타운 4편 - Figma Plugin을 이용한 에셋 전시)

에셋타운에서 Cloudflare Workers와 R2를 이용하여 웹훅을 처리하고 CDN 캐싱을 제공하는 방법에 대해 설명합니다.

개발
에셋타운
asset-town-provide-cdn-using-webhook-and-cloudflare cover image
2024/03/01

에셋 관리 시스템을 만들어보자 (에셋타운 3편 - Cloudflare Workers, R2를 이용한 웹훅 처리와 CDN 캐싱)

profile

정현수.

Currently Managed

Currently not managed

© 2024. junghyeonsu all rights reserved.