Javascript

TypeScript로 구현하는 최신 ESM 기반 npm 패키지 퍼블리싱 가이드

드리프트2 2025. 3. 3. 16:53

TypeScript로 구현하는 최신 ESM 기반 npm 패키지 퍼블리싱 가이드

 

안녕하십니까?

오늘은 TypeScript와 함께 ESM 기반의 npm 패키지를 퍼블리싱하는 최신 방법에 대해 알아볼까 합니다.

지난 2년 동안 TypeScript, Node.js, 그리고 브라우저에서 ESM 지원이 크게 발전하였는데, 과거에 우리가 사용하던 번거로운 설정과 비교하면 상당히 간단한 구성이 가능해졌습니다.

이제부터 그 설정 방법을 차근차근 소개하겠습니다.


개요

이 튜토리얼은 하위 버전과의 호환성을 무시할 수 있는 패키지를 대상으로 합니다.

이 설정은 TypeScript 4.7(2022-05-24) 이후로 저에게 꾸준히 잘 작동하고 있습니다.

Node.js가 이제 CommonJS 모듈에서 ESM 라이브러리를 require(esm) 방식으로 불러올 수 있도록 지원하는 점도 큰 도움이 된다고 할 수 있습니다.

저는 단지 tsc만 사용하고 있지만, 나중에 “Compiling TypeScript with tools other than tsc” 섹션에서 다른 도구들을 지원하는 방법도 설명해 두었으니 참고할 수 있습니다.

피드백은 언제나 환영합니다.

여러분은 다른 방식으로 진행하고 있는지, 혹은 개선할 점은 무엇이 있는지 말씀해 주시기 바랍니다.

예제 패키지로는 @rauschma/helpers가 있으며, 이 블로그 포스트에서 설명한 설정을 그대로 사용하고 있습니다.


파일 시스템 레이아웃

우리의 npm 패키지는 다음과 같은 파일 시스템 레이아웃을 갖게 됩니다:


my-package/
  README.md
  LICENSE
  package.json
  tsconfig.json
  docs/
    api/
  src/
  test/
  dist/
    src/
    test/

 

설명:

  • README.md, LICENSE: 보통 패키지엔 README와 LICENSE 파일을 포함시키는 것이 좋습니다.
  • package.json: 패키지를 설명하는 파일로, 후반에 자세히 다루게 됩니다.
  • tsconfig.json: TypeScript 설정을 담당하는 파일로, 이 역시 나중에 설명합니다.
  • docs/api/: TypeDoc을 통해 생성된 API 문서를 위한 디렉터리입니다. 생성 방법은 후에 설명합니다.
  • src/: TypeScript 소스 코드를 위한 폴더입니다.
  • test/: 여러 모듈을 아우르는 통합 테스트를 위한 폴더입니다. 유닛 테스트에 관한 내용은 곧 다루겠습니다.
  • dist/: TypeScript가 출력하는 결과물이 저장되는 폴더입니다.

.gitignore 설정

저는 버전 관리를 위해 Git을 사용하고 있는데요, 아래는 my-package/ 내에 위치한 .gitignore 파일 내용입니다:

node_modules
dist
.DS_Store

 

왜 이 항목들을 추가하는지 알려드리면:

  • node_modules: 현재 가장 일반적인 방식은 node_modules 디렉터리를 버전 관리에 포함시키지 않는 것입니다.
  • dist: TypeScript의 컴파일 결과물인 이 디렉터리는 Git에 체크인하지 않으며, 대신 npm 레지스트리에 업로드되게 됩니다.
  • .DS_Store: macOS 사용자의 불필요한 파일을 무시하는 항목입니다. 이러한 파일은 운영체제 전용이기 때문에, Mac 유저들은 글로벌 설정으로 추가할 수도 있겠지만 여기서는 프로젝트 별로 추가해 주었습니다.

유닛 테스트

각 모듈에 대한 유닛 테스트는 해당 모듈 옆에 배치하는 것을 추천합니다.

예를 들어:

src/
  util.ts
  util_test.ts

 

유닛 테스트는 모듈의 동작 방식을 이해하는 데 큰 도움이 되므로, 관련 테스트를 찾기 쉽도록 하는 것이 좋습니다.

테스트 관련 팁: 패키지 자체 참조

npm 패키지에 "exports"가 설정되어 있다면, 패키지 이름으로 자체 참조를 할 수 있습니다.

예를 들어:

// util_test.js
import { helperFunc } from 'my-package/util.js';

 

Node.js 문서에서는 "exports"가 패키지 내에 존재할 경우에만 자체 참조가 가능하며, 패키지에서 허용하는 항목만 가져올 수 있다고 안내하고 있습니다.

자체 참조의 장점은 다음과 같습니다:

  • 테스트에서 실제로 패키지를 임포트하는 방식을 보여줄 수 있습니다.
  • 패키지의 exports 설정이 제대로 되어 있는지 확인할 수 있습니다.

tsconfig.json

이번 섹션에서는 tsconfig.json의 주요 사항들에 대해 살펴보겠습니다.

관련 자료로는 블로그 포스트 “A checklist for your tsconfig.json”이 있으며, 마지막에는 여러 사용 사례에 대한 추천 tsconfig.json 설정이 요약되어 있습니다.

또한 @rauschma/helpers의 tsconfig.json 파일도 참고할 수 있습니다.

출력 결과 설정

아래 예제는 TypeScript 설정 파일에서 출력 결과를 지정하는 방법입니다:

{
  "include": ["src/**/*", "test/**/*"],
  "compilerOptions": {
    // 파일 경로를 통해 유추하지 않고 명시적으로 지정합니다.
    "rootDir": ".",
    "outDir": "dist",
    // ···
  }
}

 

이 설정의 결과는 다음과 같습니다:

  • 입력: src/util.ts출력: dist/src/util.js
  • 입력: test/my-test_test.ts출력: dist/test/my-test_test.js

출력 결과

TypeScript 파일 util.ts에 대해 tsc가 dist/src/에 생성하는 출력 파일은 다음과 같습니다:

src/
  util.ts
dist/src/
  util.js
  util.js.map
  util.d.ts
  util.d.ts.map

 

각 파일의 목적은 다음과 같습니다:

  • util.js: util.ts에 담긴 JavaScript 코드입니다.
  • util.js.map: JavaScript 코드의 소스 맵으로, 디버깅 시 TypeScript 코드를 볼 수 있게 해줍니다. 스택 트레이스에는 TypeScript 소스 코드 위치가 표시됩니다.
  • util.d.ts: util.ts에서 정의된 타입 정보입니다.
  • util.d.ts.map: 타입 정의 파일의 소스 맵으로, TypeScript 편집기가 이를 지원하면 타입 정의의 원본 TypeScript 파일로 바로 이동할 수 있게 해줍니다. 라이브러리 이용 시 유용한 기능입니다.

파일별 tsconfig.json 설정 예시는 다음과 같습니다:

File            tsconfig.json
*.js.map            "sourceMap": true
*.d.ts          "declaration": true
*.d.ts.map          "declarationMap": true

tsc 이외의 도구로 TypeScript 컴파일하기

TypeScript 컴파일러는 세 가지 작업을 수행합니다:

  1. 타입 검사
  2. JavaScript 파일 생성
  3. 타입 선언 파일 생성

현재 tsc보다 훨씬 빠르게 2번과 3번 작업을 수행할 수 있는 도구들이 많이 등장하였는데요, 다음 설정 항목들은 이러한 도구들이 더 쉽게 컴파일할 수 있도록 TypeScript의 하위 집합을 강제하는 데 도움을 줍니다.

"compilerOptions": {
  //----- .js 파일 생성에 도움을 줌 -----
  // 타입 임포트 등에 대해 반드시 `type` 키워드를 사용하도록 강제합니다.
  "verbatimModuleSyntax": true, // "isolatedModules"를 내포합니다.
  // JSX, enum, constructor 매개변수 프로퍼티,(namespace) 등 JavaScript 언어 외의 구문을 금지하여 타입 제거를 용이하게 합니다.
  "erasableSyntaxOnly": true, // TS 5.8 이상 필요

  //----- .d.ts 파일 생성에 도움을 줌 -----
  // 내보낸 함수 등의 암시적 반환 타입을 금지합니다.
  // 이는 반드시 `declaration` 또는 `composite` 옵션이 true일 때만 사용 가능합니다.
  "isolatedDeclarations": true,

  //----- tsc가 파일을 출력하지 않고 타입 검사만 하도록 함 -----
  "noEmit": true
}

 


package.json 설정

package.json의 몇 가지 설정들은 TypeScript에도 영향을 미칩니다.

관련 자료로는 “Packages: JavaScript’s units for software distribution”이 있으니, 이를 참고하면 npm 패키지 전반에 대해 자세히 이해할 수 있습니다.

또한, @rauschma/helpers의 package.json도 좋은 참고 자료입니다.


ESM 모듈로 .js 파일 사용하기

기본적으로 .js 파일은 CommonJS 모듈로 해석됩니다.

하지만 아래의 설정을 추가하면, ESM 모듈로 .js 파일을 사용할 수 있습니다:

"type": "module"

npm 레지스트리에 업로드할 파일 지정하기

npm 레지스트리에 업로드할 파일들을 명시할 필요가 있습니다.

.npmignore 파일도 존재하지만, package.json의 "files" 속성에 명시하는 것이 더 안전한 방법입니다:

"files": [
  "package.json",
  "README.md",
  "LICENSE",

  "src/**/*.ts",
  "dist/**/*.js",
  "dist/**/*.js.map",
  "dist/**/*.d.ts",
  "dist/**/*.d.ts.map",

  "!src/**/*_test.ts",
  "!dist/**/*_test.js",
  "!dist/**/*_test.js.map",
  "!dist/**/*_test.d.ts",
  "!dist/**/*_test.d.ts.map"
]

 

.gitignore에서 dist/ 디렉터리를 무시하지만, 실제 npm 패키지에 포함되어야 할 파일들이 대부분 있으므로 명시적으로 포함시킵니다.

느낌표(!)로 시작하는 패턴들은 제외할 파일들을 정의하는 것으로, 여기서는 테스트 파일들을 제외합니다.


패키지 exports 설정

만약 구식 코드를 지원할 필요가 있다면, package.json에 아래 항목들을 고려할 필요가 있습니다:

  • "main": 예전 Node.js에서 사용되던 설정
  • "module": 번들러에서 사용되던 설정
  • "types": TypeScript에서 사용되던 설정
  • "typesVersions": TypeScript에서 사용되던 설정

하지만, 최신 코드의 경우 아래와 같이 "exports"만 사용하면 충분합니다:

"exports": {
  // 패키지 exports는 여기서 정의합니다.
}

 

세부 사항에 들어가기 전에 두 가지 질문을 먼저 고려해야 합니다:

  1. 우리 패키지가 오직 bare import만 지원할 것인지, 혹은 서브패스 임포트도 지원할 것인가?
    • import { someFunc } from 'my-package'; // bare import
    • import { someFunc } from 'my-package/sub/path'; // 서브패스 임포트
  2. 서브패스를 임포트할 경우, 파일 확장자까지 포함할 것인가?

이 질문에 대한 팁은 다음과 같습니다:

  • 확장자 없이 사용하는 방식은 오랫동안 전통적으로 사용되어 왔습니다. ESM에서도 많은 변화가 없지만, 로컬 임포트 시에는 파일 확장자를 반드시 지정해야 하는 규칙이 있음을 기억해야 합니다.
  • 확장자 없는 스타일의 단점(노드 문서 인용): “Import maps를 사용하여 브라우저나 기타 JavaScript 런타임에서 패키지 해석을 표준화 하더라도, 확장자 없는 스타일은 불필요하게 커진 import map 정의를 초래할 수 있습니다. 명시적인 파일 확장자는 import map에서 여러 서브패스를 하나의 매핑으로 묶을 수 있게 하여, 각각 별도의 매핑 항목을 작성하는 문제를 피할 수 있습니다. 이는 상대 및 절대 임포트에서 전체 명세 경로를 사용하는 요구 사항과도 일치합니다.”

현재 제가 결정하는 방식은 다음과 같습니다:

  • 대부분의 패키지는 서브패스가 없습니다.
  • 패키지가 모듈들의 집합이라면 확장자를 포함하여 임포트합니다.
  • 모듈들이 패키지의 다양한 버전(예를 들어 동기/비동기)이면 확장자 없이 임포트합니다.

단, 개인적인 선호도는 미래에 변경될 수 있습니다.

패키지 exports 예시

// Bare export
".": "./dist/src/main.js",

// 확장자를 포함한 서브패스
"./util/errors.js": "./dist/src/util/errors.js", // 단일 파일
"./util/*": "./dist/src/util/*", // 서브트리

// 확장자 없이 사용하는 서브패스
"./util/errors": "./dist/src/util/errors.js", // 단일 파일
"./util/*": "./dist/src/util/*.js", // 서브트리

 

주의 사항:

  • 모듈의 수가 많지 않다면, 여러 개의 단일 파일 엔트리가 하나의 서브트리 엔트리보다 설명에 용이합니다.
  • 기본적으로 .d.ts 파일은 .js 파일과 나란히 위치해야 합니다. 하지만 이는 types import condition을 통해 변경할 수 있습니다.
  • 자세한 내용은 “Package exports: controlling what other packages see” 섹션(“Exploring JavaScript” 참고)을 참고하면 좋습니다.

패키지 imports

Node의 패키지 imports는 TypeScript에서도 지원되어, 경로에 대한 별칭(alias)을 정의할 수 있습니다.

별칭은 패키지의 최상위부터 시작하게 되어 유용합니다.

예를 들어:

"imports": {
  "#root/*": "./*"
}

 

이 별칭은 다음과 같이 사용할 수 있습니다:

import pkg from '#root/package.json' with { type: 'json' };
console.log(pkg.version);

 

이를 위해 tsconfig.jsoncompilerOptions에서 JSON 모듈 해석을 활성화해야 합니다:

"compilerOptions": {
  "resolveJsonModule": true
}

 

패키지 imports는 TypeScript 입력 파일 보다 JavaScript 출력 파일이 더 깊은 폴더 구조에 위치할 때, 최상위 파일에 접근하기 위해 상대 경로를 사용하기 어려운 문제를 보완해 줍니다.


패키지 스크립트

패키지 스크립트는 빌드용 쉘 명령어의 별칭을 정의하여 npm run 명령어로 쉽게 실행할 수 있도록 해줍니다.

npm run (스크립트 이름 없이 실행)으로 해당 별칭 목록을 확인할 수 있습니다.

제가 라이브러리 프로젝트에서 유용하게 사용하는 몇 가지 명령어는 다음과 같습니다:

"scripts": {
  "\n========== Building ==========": "",
  "build": "npm run clean && tsc",
  "watch": "tsc --watch",
  "clean": "shx rm -rf ./dist/*",
  "\n========== Testing ==========": "",
  "test": "mocha --enable-source-maps --ui qunit",
  "testall": "mocha --enable-source-maps --ui qunit \"./dist/**/*_test.js\"",
  "\n========== Publishing ==========": "",
  "publishd": "npm publish --dry-run",
  "prepublishOnly": "npm run build"
}

 

설명:

  • build: 매 빌드 시 dist/ 디렉터리를 삭제하는데, 이는 TypeScript 파일의 이름을 변경할 때 이전의 출력 파일이 남아 있어 문제가 발생하지 않도록 하기 위함입니다.
  • test, testall: --enable-source-maps 옵션은 Node.js에서 소스 맵 지원을 활성화하여, 스택 트레이스에 정확한 줄 번호를 표시하도록 도와줍니다. Mocha는 여러 테스트 스타일을 지원하는데, 저는 --ui qunit 스타일을 선호합니다.
  • publishd: npm publish 명령의 dry-run 버전을 실행하여, 실제 변경 없이 어떤 파일들이 패키지에 포함될지를 확인합니다.
  • prepublishOnly: npm publish 전에 빌드를 실행하여, 오래된 파일이나 불필요한 파일들이 업로드되지 않도록 합니다.

이름이 붙은 구분자(separator)를 사용한 이유는, npm run의 출력 결과를 보다 보기 쉽게 하기 위함입니다.

만약 패키지에 "bin" 스크립트가 포함되어 있다면, 다음 스크립트를 빌드 후에 실행하면 유용합니다:

"chmod": "shx chmod u+x ./dist/src/markcheck.js"

문서 생성

저는 TypeDoc을 사용하여 JSDoc 주석을 API 문서로 변환합니다:

"scripts": {
  "\n========== TypeDoc ==========": "",
  "api": "shx rm -rf docs/api/ && typedoc --out docs/api/ --readme none --entryPoints src --entryPointStrategy expand --exclude '**/*_test.ts'"
}

 

보완 조치로, docs/ 폴더를 GitHub Pages로 서빙하고 있습니다.


저장소 내 파일: my-package/docs/api/index.html

@rauschma/helpers API 문서도 온라인에서 확인할 수 있습니다(아직 문서가 부족할 수 있습니다).


개발 의존성

비록 제 패키지가 일반적인 의존성이 없더라도, 보통 아래와 같은 개발 의존성이 포함됩니다:

"devDependencies": {
  "@types/mocha": "^10.0.6",
  "@types/node": "^20.12.12",
  "mocha": "^10.4.0",
  "shx": "^0.3.4",
  "typedoc": "^0.27.6"
}

 

설명:

  • @types/node: 유닛 테스트에서 node:assert같은 모듈의 타입을 제공하여, assert.deepEqual() 등의 사용 시 타입 정보를 지원합니다.
  • shx: Unix 쉘 명령어의 크로스 플랫폼 버전을 제공하며, shx rm -rfshx chmod u+x 같은 명령어를 사용할 수 있게 합니다.
  • mocha, @types/mocha: Mocha의 API와 CLI 사용 경험이 여전히 마음에 들기 때문에 사용하고 있으며, Node 내장 테스트 러너도 흥미로운 대안이 되고 있습니다.
  • typedoc: API 문서 생성을 위해 사용합니다.

npm run을 사용할 때, 로컬에 설치된 명령어가 전역처럼 동작하기 때문에 편리하게 사용할 수 있습니다.


사용 도구

npm 패키지 린팅

다양한 npm 패키지 린터들이 존재합니다:

  • publint: Vite, Webpack, Rollup, Node.js 등 다양한 환경과의 호환성을 위해 npm 패키지를 검사합니다.
  • npm-package-json-lint: package.json 파일에 대한 구성 가능한 린터입니다.
  • installed-check: package.json에 명시된 Node.js "engines" 버전 범위 등과 비교하여, 설치된 모듈이 요구사항을 충족하는지 확인합니다.
  • Knip: 사용되지 않는 파일, 의존성, exports 등을 찾아 수정합니다.

모듈 린팅

  • Madge: 모듈 간 의존성 그래프를 시각화하고, 순환 참조 등을 찾아줍니다.

TypeScript 타입 린팅

  • arethetypeswrong: npm 패키지의 TypeScript 타입에서 ESM 관련 모듈 해석 문제를 분석합니다.

CommonJS 관련 도구

ESM 방식이 보편화됨에 따라, 이제 CommonJS 관련 도구들은 점차 중요성이 낮아지고 있습니다.

다음 도구들은 Node.js에서 ESM을 CommonJS에서 불러오는 “require(esm)”이 잘 작동함에 따라 참고 수준으로 사용할 수 있습니다:

  • tshy - TypeScript HYbridizer: TypeScript를 ESM/CommonJS 하이브리드 패키지로 컴파일합니다.
  • ESM-CJS Interop Test: ESM에서 CommonJS 모듈을 임포트할 때 발생할 수 있는 문제들을 체크하는 유용한 리스트입니다.

추가 자료

더 깊이 있는 내용을 원한다면 아래 자료들을 참고해 보시기 바랍니다:

  • JavaScript 모듈(ESM): “Exploring JavaScript”의 “Modules” 챕터
  • npm 패키지: “Shell scripting with Node.js”의 “Packages: JavaScript’s units for software distribution” 챕터
  • tsconfig.json: “A checklist for your tsconfig.json” 블로그 포스트

또한, 아래 자료들도 유용합니다:

  • Node.js 문서의 “Modules: Packages” 챕터
  • TypeScript 핸드북의 “package.json "exports"” 섹션

이상으로 TypeScript와 함께 ESM 기반의 npm 패키지를 퍼블리싱하는 최신 방법에 대해 알아보았습니다.

여러분의 프로젝트에 도움이 되기를 바라며, 지속적으로 업데이트되는 정보를 통해 더욱 발전하는 개발 환경을 함께 경험해 보시길 바랍니다.

감사합니다.