TypeScript 소스를 그대로 배포하는 레지스트리 — jsr.io가 npm과 다른 진짜 이유
npm에 패키지를 배포해본 적 있다면 그 과정이 얼마나 번거로운지 알 것입니다. tsconfig.json 손질하고, tsc 돌려서 dist/ 생성하고, .d.ts 파일이 제대로 따라왔는지 확인하고, package.json의 exports 필드를 맞추다 보면 정작 라이브러리 코드보다 배포 설정에 더 많은 시간을 쓰게 됩니다. 저도 처음 유틸리티 라이브러리를 오픈소스로 올릴 때 빌드 설정 때문에 반나절을 날린 기억이 있습니다.
JSR(JavaScript Registry)은 이 문제를 근본부터 다르게 접근합니다. TypeScript 소스 파일을 그대로 퍼블리시하면, 레지스트리 내부에서 런타임별 트랜스파일과 타입 정의 생성을 퍼블리시 시점에 자동으로 처리해줍니다. 빌드 파이프라인 없이도 Deno, Node.js, Bun 세 런타임에서 동작하는 패키지를 단일 소스로 배포할 수 있다는 뜻입니다.
2024년 3월 Deno 팀이 론칭한 JSR은 npm과 공존하도록 설계된 레지스트리입니다. npm을 대체하는 것이 목표가 아니라, npm이 하지 못하는 것—TypeScript 소스 직접 배포, 크로스 런타임 호환성 보장—을 레지스트리 수준에서 해결하는 방향으로 설계됐습니다. 2025년 2월에는 Evan You(Vue·Vite), Isaac Schlueter(npm 창시자), Ryan Dahl(Node.js·Deno 창시자)이 이사회에 합류하면서 특정 회사 프로젝트에서 커뮤니티 인프라로 방향을 명확히 했습니다. 경쟁 생태계 핵심 인물들이 한 이사회에 모인 것 자체가 꽤 이례적인 신호입니다.
핵심 개념
JSR이 npm과 근본적으로 다른 것들
TypeScript 소스 직접 배포가 가장 큰 차이점입니다. npm에 올리려면 반드시 JS로 컴파일된 결과물이 필요하지만, JSR은 .ts 파일 원본을 받아서 퍼블리시 시점에 내부적으로 처리합니다. 덕분에 소스와 배포 타입 간의 불일치 문제—빌드 설정을 잘못 건드려서 .d.ts가 엉뚱하게 나오는 그 상황—가 구조적으로 줄어듭니다.
버전 불변성(Immutable versions) 도 중요합니다. 퍼블리시된 버전은 삭제할 수 없습니다. npm의 unpublish 기능이 만든 left-pad 사태 같은 공급망 붕괴를 막기 위한 설계입니다. 프로덕션에서 쓰이는 패키지라면 이게 얼마나 중요한지 한 번쯤은 실감해봤을 겁니다.
ESM 전용이라는 점은 제약이기도 하고 명확한 방향성이기도 합니다. CommonJS require()는 지원하지 않습니다. 다만 Node.js 22.12.0(LTS, 2024년 10월)에서 require(esm) 동기 지원이 플래그 없이 안정화되면서 이 제약의 무게가 많이 줄었습니다. 22.0~22.11.x 사용자라면 아직 플래그가 필요하니 주의가 필요합니다.
npm.jsr.io 프록시가 하는 일
Node.js와 Bun에서 JSR 패키지를 npm install처럼 쓸 수 있는 이유는 npm.jsr.io 프록시 덕분입니다. 이 프록시는 단순 리다이렉트가 아닙니다. JSR이 퍼블리시 시점에 TypeScript 소스를 트랜스파일하고 .d.ts 파일을 생성해두면, 프록시가 이 결과물을 npm 호환 형태로 패키징해서 제공합니다. 즉, JSR이 빌드를 대신해주는 것입니다—소스를 올리면 레지스트리 인프라가 JS 변환과 타입 선언을 만들어두고, Node.js 사용자는 그걸 npm 패키지처럼 받아갑니다.
버전 불변성과 결합하면 또 하나의 이점이 생깁니다. 이미 처리된 결과물을 공격적으로 캐싱할 수 있어서 npm.jsr.io를 통한 설치 속도가 빠릅니다. 배포자 입장의 불변성 제약이 사용자 입장의 성능 이점으로 전환되는 구조입니다.
jsr.json — 최소한의 설정
package.json에 비하면 설정이 간결합니다. 핵심은 name(스코프 필수), version, exports입니다.
{
"name": "@myorg/utils",
"version": "1.0.0",
"exports": {
".": "./src/mod.ts",
"./http": "./src/http.ts",
"./validation": "./src/validation.ts"
}
}exports가 .ts 소스 파일을 직접 가리켜야 한다는 점을 기억하세요. ./dist/mod.js 같은 빌드 결과물을 가리키면 퍼블리시는 되지만 JSR의 자동 타입 생성과 문서화가 제대로 동작하지 않습니다.
스코프(@myorg 부분)는 GitHub 계정이나 조직명으로 만들 수 있습니다. JSR은 @scope/name 형태를 강제하므로 my-utils 같은 플랫 이름은 등록 자체가 안 됩니다.
런타임별 임포트 방식
// Deno — jsr: 스킴으로 직접 임포트
import { formatDate } from "jsr:@myorg/utils";
// Node.js / Bun — 먼저 설치
// pnpm add jsr:@myorg/utils
// bun add jsr:@myorg/utils
import { formatDate } from "@myorg/utils";Deno는 jsr: import를 네이티브로 지원하고, Node.js와 Bun은 npm.jsr.io 프록시를 통해 일반 npm 패키지처럼 사용합니다. 패키지 사용자 입장에서 런타임별 분기가 거의 없습니다.
JSR Score — 패키지 품질 지표
JSR은 퍼블리시된 패키지를 자동으로 점수화합니다. 먼저 용어를 짚고 가면, slow types란 타입 추론이 지나치게 복잡해 빌드 속도를 떨어뜨리는 패턴을 가리킵니다—반환 타입을 명시하지 않은 export 함수가 대표적입니다. Score 시스템이 이런 패턴을 퍼블리시 시 자동으로 검사합니다.
| 카테고리 | 영향도 | 주요 항목 |
|---|---|---|
| 런타임 호환성 | 높음 | Deno, Node, Bun, 브라우저 호환 여부 |
| 문서화 | 높음 | JSDoc 작성률, 예시 코드 |
| 베스트 프랙티스 | 높음 | slow types 없음, 테스트 존재 |
| 검색 가능성 | 보통 | 설명, 키워드, 리드미 |
| 출처 증명 | 보통 | GitHub Actions OIDC provenance |
(영향도는 공식 Scoring 문서 기준 대략적 비중입니다)
처음엔 "또 이런 게임화..." 싶었는데, 막상 보면 패키지를 처음 만들 때 놓치기 쉬운 체크리스트 역할을 꽤 잘 합니다.
// slow type — 반환 타입 추론에 의존
export function parseUrl(input: string) {
return new URL(input); // JSR이 경고를 냅니다
}
// 권장 — 명시적 반환 타입
export function parseUrl(input: string): URL {
return new URL(input);
}어떤 상황에서 JSR을 선택할까
구현 방법에 앞서, JSR이 실제로 내 상황에 맞는지 판단할 수 있어야 합니다.
장점 — JSR이 빛나는 경우
| 항목 | 실무적 의미 |
|---|---|
| TypeScript 소스 배포 | 빌드 설정 오류로 타입이 깨지는 문제 구조적 방지 |
| 자동 문서화 | JSDoc만 잘 써도 패키지 페이지가 완성 |
| JSR Score | PR 리뷰처럼 패키지 품질을 지표로 확인 |
| 버전 불변성 | 프로덕션 의존성 안정성 보장 |
| GitHub Actions OIDC | 시크릿 관리 없이 provenance 서명 |
| 크로스 런타임 단일 소스 | Deno·Node·Bun 각각 빌드 설정 없이 커버 |
단점 — 솔직하게 보는 현실적 제약
| 항목 | 실무적 영향 |
|---|---|
| ESM 전용 | CommonJS 레거시 프로젝트에서 마이그레이션 비용 발생 |
| 패키지 수 격차 (40K vs 320만) | 원하는 라이브러리가 JSR에 없을 가능성 높음 |
| 툴링 미성숙 | Dependabot, Renovate, npm audit이 JSR를 완전히 지원하지 않음 |
| 기업 채택 느림 | Artifactory 등 사내 레지스트리 연동 추가 설정 필요 |
| Node 전용 API 사용 불가 | process, __dirname, fs → Deno/Bun에서 동작 안 함 |
실전 적용
배포 방식 선택
크로스 런타임 패키지 설계 원칙
JSR에 올리는 패키지가 Deno, Node, Bun 모두에서 돌아가려면 런타임 전용 API를 피해야 합니다. 우선순위 기준으로 정리하면 이렇습니다.
- Web API(WinterCG 표준)로 가능한가? →
fetch,URL,crypto.subtle,Intl등은 세 런타임 모두에서 동작합니다. 가능하면 이걸 먼저 씁니다. - 어떻게 해도 런타임별 분기가 필요한가? →
typeof Deno !== "undefined"방식으로 분기하되, 번들러 호환성을 고려한다면 conditional exports가 더 나은 선택입니다(아래 참조).
실제로 Neon Serverless Postgres Driver(npm 패키지입니다—JSR에 별도 배포된 건 아닙니다)가 이 원칙을 잘 보여줍니다. WebSocket과 Web Fetch API 기반으로 구현해서 Cloudflare Workers, Deno Deploy 같은 엣지 환경에서도 동작합니다.
런타임별 분기가 필요한 경우
Deno.env와 process.env처럼 어떻게 해도 런타임별 동작 차이가 필요할 때는 전역 객체 존재 여부로 분기할 수 있습니다.
// src/env.ts — 크로스 런타임 환경변수 읽기
export function getEnv(key: string): string | undefined {
if (typeof Deno !== "undefined") {
return Deno.env.get(key);
}
if (typeof process !== "undefined" && process.env) {
return process.env[key];
}
// 브라우저 / 엣지 (환경변수 없음)
return undefined;
}단, typeof Deno !== "undefined" 방식은 esbuild, Vite 같은 번들러가 dead code elimination을 적용하지 못합니다. 소비자가 번들러로 패키지를 처리하면 Deno 분기 코드가 번들에 그대로 포함됩니다. 번들러 친화적인 패키지가 목표라면 런타임별 진입점을 conditional exports로 분리하는 방식이 더 낫습니다.
분기 코드가 많아질수록 테스트 부담도 커집니다. 가능하면 Web API 표준으로 먼저 해결하고, 분기는 최후 수단으로 두는 편이 낫습니다.
jsr.json과 package.json 공존하기
듀얼 퍼블리싱 구조에서는 두 파일이 나란히 존재합니다.
my-date-utils/
├── jsr.json # JSR 배포용
├── package.json # npm 배포용
├── src/
│ ├── mod.ts
│ └── ...
└── tests/
└── ...name과 version은 두 파일이 각자 가지고 있으며, 버전 릴리스 시 동기화해야 합니다. 충돌은 없습니다—pnpm dlx jsr publish는 jsr.json을 읽고, pnpm publish는 package.json을 읽기 때문에 각 명령어가 자기 파일만 씁니다.
package.json에는 빌드 결과물(dist/)을 기준으로 exports를 설정하고, jsr.json에는 .ts 소스를 직접 가리키도록 설정합니다. 두 파일이 서로 다른 exports 경로를 가지는 게 정상입니다.
// package.json — 빌드 결과물 기준
{
"name": "@myorg/date-utils",
"version": "1.0.0",
"exports": {
".": {
"import": "./dist/mod.js",
"types": "./dist/mod.d.ts"
}
}
}// jsr.json — TypeScript 소스 기준
{
"name": "@myorg/date-utils",
"version": "1.0.0",
"exports": {
".": "./src/mod.ts"
}
}듀얼 퍼블리싱 — npm과 JSR 동시 배포
Hono, Supabase 같은 주요 라이브러리가 채택한 방식입니다. 아래 CI 워크플로에서 pnpm build는 npm 배포용 JS 결과물을 만드는 단계임을 먼저 짚어둡니다. JSR 퍼블리시(pnpm dlx jsr publish)는 빌드 결과물이 아닌 소스를 직접 올리므로 build 스텝이 필요하지 않습니다—JSR이 퍼블리시 시점에 트랜스파일을 대신합니다.
# .github/workflows/publish.yml
name: Publish
on:
push:
tags:
- "v*"
permissions:
contents: read
id-token: write # JSR provenance를 위해 필요
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
registry-url: "https://registry.npmjs.org"
- run: pnpm install
- name: Build (npm 배포용 JS 결과물 생성)
run: pnpm build
- name: Publish to npm
run: pnpm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish to JSR
run: pnpm dlx jsr publish
# OIDC 토큰으로 자동 인증 — 별도 시크릿 불필요id-token: write 권한을 주면 GitHub Actions OIDC로 JSR이 자동 인증합니다. 별도 API 토큰을 관리할 필요가 없다는 게 생각보다 편합니다. 설정만 해두면 이후 릴리스는 태그 하나로 두 레지스트리에 동시 배포됩니다.
실제 패키지 구조 예시
날짜 유틸리티 패키지를 만든다고 가정해봅니다.
// src/mod.ts — JSDoc으로 자동 문서화
/**
* ISO 8601 날짜 문자열을 지정한 형식으로 변환합니다.
*
* @example
* ```ts
* import { formatDate } from "@myorg/date-utils";
* formatDate("2026-07-12", "YYYY년 MM월 DD일"); // "2026년 07월 12일"
* ```
*/
export function formatDate(iso: string, pattern: string): string {
const date = new Date(iso);
// ISO 문자열은 UTC 자정으로 파싱되므로 UTC 메서드를 써야 타임존 오프셋으로 날짜가 밀리지 않음
return pattern
.replace("YYYY", date.getUTCFullYear().toString())
.replace("MM", String(date.getUTCMonth() + 1).padStart(2, "0"))
.replace("DD", String(date.getUTCDate()).padStart(2, "0"));
}
export type { FormatPattern } from "./format.ts";JSDoc의 @example 블록은 JSR 패키지 페이지에 바로 렌더링됩니다. 별도 문서 사이트 없이도 패키지 품질이 눈에 보이는 형태로 전달됩니다.
파일 시스템 경로가 필요한 경우 import.meta.url을 활용할 수 있지만, 크로스 런타임(Deno/Node/Bun)과 크로스 플랫폼(macOS/Windows)은 다른 문제입니다.
// Windows Node.js에서 pathname은 /C:/Users/... 형태를 반환해 fs API와 직접 연결하면 오류
// fileURLToPath로 변환해야 합니다
import { fileURLToPath } from "node:url";
const root = fileURLToPath(new URL("./assets", import.meta.url));실무에서 자주 보이는 실수들
Node 전용 전역 변수를 패키지에 쓰는 경우가 가장 흔합니다. __dirname이나 process.cwd()를 라이브러리 내부에서 쓰면 Deno에서 즉시 런타임 에러가 납니다.
// 잘못된 예 — Node 전용
import path from "node:path";
const root = path.join(__dirname, "assets");
// 올바른 예 — ESM 표준 (fs API와 함께 쓰려면 fileURLToPath 추가)
import { fileURLToPath } from "node:url";
const root = fileURLToPath(new URL("./assets", import.meta.url));jsr.json의 exports 경로가 빌드 결과물을 가리키는 경우도 자주 봅니다. JSR은 소스 .ts 파일을 직접 가리켜야 합니다. ./dist/mod.js 같은 경로를 쓰면 퍼블리시는 되지만 타입 자동 생성이 제대로 동작하지 않습니다.
스코프 없는 패키지명을 시도하는 경우 — JSR은 @scope/name 형태를 강제합니다. my-utils 같은 플랫 이름은 등록 자체가 안 됩니다.
마치며
JSR을 한 줄로 정리하면 이렇습니다. TypeScript를 TypeScript답게 배포하기 위한 레지스트리입니다. npm이 JS 생태계의 공용 창고라면, JSR은 타입 안전성과 크로스 런타임 호환성을 레지스트리 수준에서 보장하려는 시도입니다.
새 유틸리티 라이브러리를 만든다면 JSR 우선 배포를 고려해볼 가치가 있습니다. 기존 npm 패키지가 있다면 듀얼 퍼블리싱이 현실적인 선택입니다. Hono와 Supabase가 이미 그 경로를 검증해뒀고, 설정만 해두면 이후 릴리스는 태그 하나로 두 레지스트리에 동시 배포됩니다.
2025년 2월 오픈 거버넌스 전환과 OpenJS Foundation 편입 목표를 보면, JSR은 Deno 팀의 프로젝트에서 JS 생태계 공용 인프라로 무게 중심을 옮기고 있습니다. 지금 당장 npm을 대체하진 않지만, 새로운 크로스 런타임 라이브러리의 기본 배포처로는 충분히 성숙한 선택지가 됐습니다.
지금 시작하는 3단계:
jsr.io에서 스코프 생성 — GitHub 계정으로 로그인 후@내계정명스코프 등록- 기존 라이브러리에
jsr.json추가 —exports가.ts소스를 가리키도록 설정하고pnpm dlx jsr publish로 첫 퍼블리시 - JSR Score 확인 후 개선 — slow types 경고 해결, JSDoc
@example추가로 점수 올리기
참고 자료
- JSR 공식 사이트
- Why JSR? — 공식 문서
- Publishing Packages — 공식 문서
- jsr.json 설정 레퍼런스
- JSR Scoring — 공식 문서
- JSR Governance 개요
- Introducing the JSR Open Governance Board — Deno 공식 블로그
- Introducing JSR (Open Beta) — Deno 공식 블로그
- How We Built JSR — Deno 공식 블로그
- JSR Is Not Another Package Manager — Deno 블로그
- Announcing Hono on JSR — Deno 블로그
- Exploring JSR for JavaScript module management — LogRocket
- JS Runtimes Have Forked in 2025: Cross-Runtime Libraries — Debugg.ai
- JSR GitHub 오픈소스 레포지토리
- FOSDEM 2025 — JSR: from private ownership to open governance