본문으로 건너뛰기

pnpm 모노레포 공통 의존성 관리 — catalog와 up -r 비교

·27 min read

같은 CLI를 세 앱이 각자 선언하고 있었다

앱 3개가 있는 pnpm workspace다. Turborepo로 태스크를 묶어 돌린다.

daisy-monorepo/
├─ apps/
│  ├─ platform/   # :3000
│  ├─ admin/      # :3001
│  └─ lineup/     # :3002
└─ packages/
   ├─ ui/  utils/  tokens/  configs/ ...

세 앱 모두 dev 스크립트를 같은 CLI로 감싼다. pubuilder는 dev 서버 앞단에 붙는 사내 개발 도구다.

// apps/admin/package.json
{
  "scripts": {
    "dev": "pubuilder dev -- next dev --turbopack -p 3001"
  },
  "devDependencies": {
    "pubuilder": "^0.9.4"
  }
}

apps/platform, apps/lineup 도 포트만 다르고 동일하다. "pubuilder": "^0.9.4" 선언이 세 곳에 중복되어 있었다.

0.9.5 가 나오면 package.json 3개를 각각 고쳐야 한다. 하나 빼먹으면 앱마다 다른 버전의 dev 툴이 돌고, 그 차이는 "내 로컬에서만 이상함"으로 나타난다. 공통으로 한 번만 설치하면 안 되나 싶었다.

"공통"은 보통 두 가지 중 하나를 의미한다. root package.json에 설치하거나, 공용 워크스페이스 패키지에 설치하는 것. 둘 다 해봤다.

root package.json에 올려봤더니 "일단은" 동작했다

// package.json (workspace root)
{
  "devDependencies": {
    "pubuilder": "^0.9.4",
    "turbo": "^2.3.3",
    "prettier": "^3.3.2"
  }
}

turbo, prettier 처럼 root에 두는 게 관례인 도구도 있으니 자연스러운 발상이다. 그리고 실제로 일단은 동작한다. 그래서 위험하다.

bin PATH는 워크스페이스 루트까지 올라간다

pnpm이 스크립트를 실행할 때 PATH 앞에 붙이는 건 해당 패키지의 node_modules/.bin 하나가 아니다. 워크스페이스에서는 워크스페이스 루트의 node_modules/.bin 도 함께 들어간다.

apps/admin 에서 pnpm dev 를 돌리면 PATH는 대략 이렇게 된다.

apps/admin/node_modules/.bin : <root>/node_modules/.bin : ...

그래서 root에만 설치해도 pubuilder 바이너리는 찾아진다. 여기까지 확인하고 "된다"고 결론 내리면 함정에 빠진다.

root에는 앱이 쓰는 react가 없다

pubuilder 의 실제 매니페스트를 열어봤다.

{
  "name": "pubuilder",
  "version": "0.9.4",
  "bin": { "pubuilder": "dist/cli.js" },
  "peerDependencies": {
    "react": ">=18",
    "react-dom": ">=18"
  },
  "dependencies": ["@xyflow/react", "jiti", "zustand"]
}

CLI인데 react / react-dom 을 peer로 요구한다. peerDependencies는 "내가 직접 설치하지는 않지만, 나를 쓰는 쪽이 이미 갖고 있어야 하는 패키지"를 선언하는 필드다. @xyflow/react, zustand 를 쓰는 companion UI를 띄우기 때문이다. 이 도구는 "빌드 스크립트"가 아니라 "React 앱에 붙는 개발 도구"다.

그런데 root package.json에는 react가 없다. pnpm은 격리된 node_modules 레이아웃을 쓰기 때문에, 그 패키지에 실제로 선언된 의존성만 그 위치에서 접근 가능하다.

$ ls -d node_modules/react node_modules/react-dom
ls: node_modules/react: No such file or directory
ls: node_modules/react-dom: No such file or directory

react 는 각 앱의 node_modules 에만 링크되어 있다. root에는 없다. 이게 pnpm이 npm/yarn의 flat hoisting(모든 의존성을 최상위 node_modules 한 곳에 펼쳐놓는 방식)과 다른 지점이고, 여기서는 그 엄격함이 그대로 문제로 돌아온다.

root에 설치한 pubuilder 는 앱이 쓰는 react와 연결되지 않는다. pnpm은 autoInstallPeers 기본값이 true라 누락된 peer를 자동으로 설치하는데, 그러면 root에 앱과 별개의 react가 하나 더 붙는다. 앱이 쓰는 react와 같은 인스턴스가 아니다. "설치는 잘 됐는데 dev 붙이면 이상하다"라는, 원인 찾기 제일 귀찮은 형태의 버그로 이어진다.

pnpm은 peer 조합별로 다른 인스턴스를 만든다

pnpm은 같은 이름·같은 버전이라도 peer가 어떻게 해석됐는지에 따라 별도 인스턴스를 만든다. lockfile을 보면 그 키가 노출된다.

pubuilder@0.9.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(immer@10.1.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(use-sync-external-store@1.5.0(react@19.2.7)):

디스크상의 경로도 그 조합을 해시로 갖는다.

$ ls -l apps/admin/node_modules/pubuilder
apps/admin/node_modules/pubuilder ->
  ../../../node_modules/.pnpm/pubuilder@0.9.6_@types+react-dom@19.2.3_..._zrxi5f4uisvzq7mba4uzdtmqya/node_modules/pubuilder

root에 설치하면 그 인스턴스는 "react가 붙지 않은 pubuilder"라는 다른 인스턴스가 된다. 앱들이 쓰는 인스턴스와 같은 물건이 아니다. 앱과 root에 동시에 두면 인스턴스가 둘로 늘어난다.

"어디에 설치했는지"가 "무엇이 설치됐는지"를 바꾼다. pnpm을 쓸 때 이 감각이 중요하다.

중복 설치를 줄이는 효과는 0이었다

"3앱에 각각 있으면 3번 설치되는 거 아니냐"는 걱정이 root hoisting의 동기인 경우가 많다. 그런데 이 레포는 root에서 react 버전을 고정해두고 있다.

// package.json
"pnpm": {
  "overrides": {
    "react": "19.2.7",
    "react-dom": "19.2.7",
    "@types/react": "19.2.17",
    "@types/react-dom": "19.2.3"
  }
}

react가 하나로 고정되면 세 앱의 peer 해석 결과가 전부 같아진다. 따라서 pnpm은 인스턴스를 이미 하나로 합쳐놓는다. lockfile에서 세 앱의 pubuilder 항목을 보면 peer 해석 문자열까지 완전히 동일했다.

# apps/platform, apps/admin, apps/lineup — 세 곳 모두
pubuilder:
  specifier: ^0.9.6
  version: 0.9.6(@types/react-dom@19.2.3(...))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(...)

node_modules/.pnpm(pnpm이 virtual store라고 부르는, 모든 패키지의 실체가 한 번씩만 놓이는 디렉토리)에 실제 pubuilder는 1개다. 세 앱의 node_modules/pubuilder 는 모두 그 하나를 가리키는 심볼릭 링크(symlink, 다른 경로를 가리키는 바로가기 파일)일 뿐이다. root로 올려도 절약되는 디스크와 설치 시간은 없다.

실제로 겪고 있던 불편은 "설치 중복"이 아니라 "버전 선언 중복"이었다. 문제를 잘못 진단하면 이런 해법이 나온다.

앱이 자기 실행 조건을 다 선언하지 않게 된다

root에만 dep이 있으면 apps/admin 디렉토리는 자기 실행에 필요한 걸 다 선언하지 않은 상태가 된다. turbo prune --scope=admin 같은 앱 단위 분리, 개별 배포, 앱만 다른 레포로 떼내기 등에서 매번 "root에 뭐가 있었지"를 따져야 한다. pubuilder는 dev 전용이라 프로덕션 빌드에는 영향이 없지만, 원칙 자체는 지켜두는 편이 싸다.

공용 패키지에 넣으면 bin이 아예 전파되지 않았다

두 번째 해석. root가 아니라 packages/configs 같은 공용 패키지에 넣고 앱들이 그걸 참조하면 어떻게 되는지 봤다.

// packages/configs/package.json
{ "name": "@daisy/configs", "dependencies": { "pubuilder": "^0.9.6" } }
// apps/admin/package.json
{
  "scripts": { "dev": "pubuilder dev -- next dev -p 3001" },
  "devDependencies": { "@daisy/configs": "workspace:*" }
}

이건 root 설치보다 더 확실하게 실패한다. CLI 바이너리는 transitive dependency(내가 직접 선언하지 않았지만 의존성의 의존성으로 딸려오는 패키지)로 전파되지 않기 때문이다.

pnpm의 격리된 node_modules 레이아웃에서는 그 패키지에 실제로 선언된 의존성만 접근 가능하다. @daisy/configs 의 의존성인 pubuilder의 bin은 packages/configs/node_modules/.bin/pubuilder 에만 생기고 apps/admin/node_modules/.bin/ 에는 생기지 않았다.

앞서 본 "워크스페이스 루트의 .bin 도 PATH에 들어간다" 규칙도 도움이 안 된다. packages/configs 는 워크스페이스 루트가 아니라 apps/admin 의 형제 디렉토리다. 결과는 pubuilder: command not found.

우회하려면 configs 쪽에 "pubuilder-dev": "pubuilder dev" 같은 프록시 스크립트를 만들고 앱에서 pnpm --filter @daisy/configs ... 로 부르거나, node_modules/@daisy/configs/node_modules/.bin/pubuilder 를 하드코딩해야 한다. CLI 하나 버전 통일하려고 치를 대가는 아니다.

필요한 건 설치 위치를 통합하는 게 아니라 버전 선언을 통합하는 것이었다. 패키지는 peer 해석과 bin 링크 때문에 각 앱이 그대로 직접 의존해야 하고, 버전 문자열의 출처만 한 곳이면 됐다. 정확히 이걸 하는 기능이 pnpm에 있다.

catalog는 기술적으로 정확히 맞는 도구였다

pnpm-workspace.yamlcatalog 를 선언한다. 버전 문자열을 워크스페이스 한 곳에 모아두는 섹션이다.

# pnpm-workspace.yaml
packages:
  - 'apps/*'
  - 'packages/*'
 
catalog:
  pubuilder: ^0.9.6

각 앱은 버전 대신 catalog: 프로토콜을 쓴다. "버전은 카탈로그에서 가져와라"라는 의미의 특수 지정자다.

// apps/{platform,admin,lineup}/package.json
{
  "devDependencies": {
    "pubuilder": "catalog:"
  }
}

설치는 root에서 한 번이면 된다.

pnpm install

lockfile에 전용 섹션이 생긴다.

catalogs:
  default:
    pubuilder:
      specifier: ^0.9.6
      version: 0.9.6

앱 입장에서 의존 관계는 이전과 100% 동일하다. 바뀐 건 버전 문자열을 pnpm이 pnpm-workspace.yaml 에서 읽어온다는 것뿐이다. 그래서 peer도, bin도, 앱이 자기 의존성을 직접 선언하는 성질도 그대로 유지된다. root 설치와 공용 패키지의 문제가 전부 없다.

문제root 설치공용 패키지catalog
peer(react) 해석NG — root에 별개 react가 붙는다주의 — configs 기준으로 해석OK — 각 앱 컨텍스트에서
node_modules/.bin 링크주의 — 워크스페이스 루트 PATH 의존NG — 전파 안 됨OK — 앱 로컬에 생성
앱이 자기 의존성을 직접 선언NGNGOK — 앱이 직접 선언
버전 선언 한 곳OKOKOK

기술적으로 완벽하게 맞는 도구다. 실제로 여기까지 적용했다. 그런데 적용해놓고 나서 이런 질문을 받았다.

pnpm up pubuilder --latest -r 하면 되는 거 아냐? 그러면 catalog 필요 없는 거 아냐?

pnpm up --latest -r 을 실측하니 전제가 틀렸다

"버전업하려면 3곳을 각각 고쳐야 한다"를 한 번도 확인하지 않은 채로 catalog까지 적용해뒀다. 스크래치 워크스페이스를 만들어 확인했다. 앱 3개, 버전을 일부러 어긋나게 선언한 상태에서 시작한다.

# apps/a: ^4.5.0   apps/b: ^4.4.0   apps/c: ^4.5.0
$ pnpm up zustand --latest -r
Scope: all 4 workspace projects
Done in 272ms
매니페스트   a: ^5.0.14   b: ^5.0.14   c: ^5.0.14
실제 설치    a: 5.0.14    b: 5.0.14    c: 5.0.14

세 개의 package.json이 한 번에 재작성됐다. 드리프트(앱마다 버전 선언이 서로 어긋난 상태)였던 ^4.4.0 까지 통일됐다. 전제가 틀렸다. 버전업은 3곳 수동 편집이 아니라 명령 한 줄이었다.

catalog가 내세우던 핵심 가치("수정 지점 3 → 1")가 여기서 대부분 사라진다.

--latest 를 빼면 어긋난 버전은 그대로 남았다

같은 워크스페이스를 더 심하게 어긋난 상태로 되돌리고, 이번엔 --latest 없이 돌렸다.

# apps/a: ^4.5.0   apps/b: ^3.7.0   apps/c: ^4.5.0
$ pnpm up zustand -r
매니페스트   a: ^4.5.7   b: ^3.7.2   c: ^4.5.7      ← b는 3.x에 갇힘
실제 설치    a: 4.5.7    b: 3.7.2    c: 4.5.7
virtual store 인스턴스: zustand@3.7.2 / zustand@4.5.7   ← 둘로 갈라짐

--latest 는 "package.json의 range를 무시하고 최신으로"이고, 없으면 "각자의 range 안에서만"이다. 후자는 이미 벌어진 드리프트를 고쳐주지 않는다.

이 차이가 catalog와 up -r 의 성격 차이를 그대로 보여준다. pnpm up --latest -r 은 주기적으로 실행해서 어긋난 걸 사후 교정하는 절차(routine)고, catalog: 는 애초에 어긋날 수 없게 만드는 구조(guarantee)다.

outdated 와 up, 플래그에 따라 결과가 갈렸다

실측하면서 두 명령의 플래그를 다시 훑어봤다. 내가 그동안 대충 쓰고 있던 게 몇 개 있었다.

pnpm outdated 는 아무것도 바꾸지 않는다

조회 전용 명령이다. Current → Latest 를 보여주므로, 업데이트하려는 버전을 미리 알고 있을 필요가 없다.

$ pnpm outdated -r
66 outdated packages (of 66)
react-hook-form: 7.52.0 7.84.0
swiper: 12.1.4 14.0.7
lodash: 4.17.21 4.18.1
turbo: 2.9.12 2.10.8
@eslint/js: 9.39.1 10.0.1
...
플래그의미
-r워크스페이스 전체 (없으면 현재 디렉토리 하나만)
--filter <app>특정 앱만
<pkg> 인자패턴 지원 — pnpm outdated pubuilder, pnpm outdated @daisy/*
--compatiblerange 내에서 올릴 수 있는 것만 표시 (메이저 점프 제외)
-D / -PdevDependencies만 / dependencies만
--longrepo 링크 등 상세
--format json|list스크립트/CI용

--compatible 이 실무에서 제일 중요하다. 기본 출력의 Latest 는 range를 무시한 최신이라 @eslint/js: 9.39.110.0.1 같은 메이저 점프가 섞인다. "지금 pnpm up 만 해도 올라가는 것"을 보려면 --compatible 로 봐야 한다.

특정 버전 문자열만 알고 싶을 때는 pnpm info 를 썼다.

pnpm info pubuilder version     # 최신 하나
pnpm info pubuilder versions    # 전체 목록
pnpm info pubuilder dist-tags   # latest / next 등

pnpm up 은 인자 없이 쓰면 66개를 한꺼번에 올린다

플래그의미
-L, --latestrange 무시하고 최신으로 (메이저 포함, 매니페스트 재작성)
-r워크스페이스 전체
--filter <app>특정 앱만
-i, --interactive목록에서 골라서 업데이트
--depth <n>--recursive 와 함께 검사 깊이 지정 (pnpm -r update --depth 100)
--workspaceworkspace 내부 패키지 버전에 맞춰 링크

가장 안전한 사용법은 pnpm up -i -r --latest 다. 목록을 보고 고를 수 있다. 반대로 인자 없이 pnpm up -r --latest 는 위에서 본 66개를 한 번에 올려버린다. 절대 그렇게 시작하지 않는다.

outdated 의 권고를 그대로 따라가면 설치가 실패하는 항목이 있었다

outdated 출력과 root package.json 을 대조하니 그냥 따라가면 안 되는 항목이 두 개 나왔다.

lodash는 patch가 버전에 묶여 있다.

"pnpm": { "patchedDependencies": { "lodash@4.17.21": "patches/lodash@4.17.21.patch" } }

outdatedlodash: 4.17.214.18.1 을 권하지만, 올리면 lodash@4.17.21 키가 매칭되지 않는다. allowUnusedPatches 기본값이 false라서 적용되지 않은 patch가 있으면 설치 자체가 실패한다. 패치 재생성이 필요하다.

react 계열은 overrides 가 이긴다.

"overrides": { "react": "19.2.7", "react-dom": "19.2.7", ... }

outdatedreact-dom: 19.2.719.2.8 을 권하지만, overrides 를 같이 고치지 않으면 package.json만 바뀌고 설치 버전은 안 바뀐다. react 계열은 overrides 값을 함께 수정해야 한다.

catalog를 쓰면 pnpm up 이 조용히 아무 일도 하지 않는다

여기서 catalog를 다시 봤다. catalog를 적용한 워크스페이스에서 pnpm up 을 돌리면 어떻게 되는지 실측했다. pnpm 9.15.4 기준이다.

# pnpm-workspace.yaml:  catalog: { zustand: ^4.5.0 },  설치: 4.5.7
$ pnpm up zustand --latest -r
Scope: all 3 workspace projects
Progress: resolved 3, reused 3, downloaded 0, added 0, done
Done in 345ms                      # ← 성공처럼 보인다

yaml과 실제 설치 버전을 확인해봤다.

$ cat pnpm-workspace.yaml
  zustand: ^4.5.0                  # ← 그대로
 
$ node -e '...zustand/package.json).version'
4.5.7                              # ← 그대로

에러도, 경고도, "catalog는 지원하지 않는다"는 안내도 없다. 성공 메시지를 남기고 아무 일도 하지 않는다. 앞서 확인한 up --latest -r 의 3앱 동시 갱신이, catalog를 쓰는 순간 사라진다.

pnpm 10에서 추가된 --save-catalog 도 9.x에는 없다.

$ pnpm add zustand@latest --save-catalog
 ERROR  Unknown option: 'save-catalog'

catalog 항목을 올리는 정상 경로는 yaml 직접 수정 + install 뿐이다. 이건 확실히 동작했다.

$ sed -i '' 's/zustand: ^4.5.0/zustand: ^5.0.14/' pnpm-workspace.yaml
$ pnpm install
a: 5.0.14
b: 5.0.14      # 두 앱 모두 반영됨

pnpm outdated 는 catalog dep도 정상적으로 인식하니, "버전을 몰라서 못 올린다"는 문제는 없다.

$ pnpm outdated -r
zustand: 4.5.7 5.0.14

catalog를 쓰면 업데이트가 2단계 수동 절차가 된다. outdated 로 확인 → yaml 수정 → install.

catalog는 pnpm 9.5.0에 들어왔다

2024년 7월 릴리스다. 꽤 최근에 들어온 기능이다.

버전내용
9.5.0 (2024-07)catalog: / named catalogs: 도입
9.xlockfile에 catalog 스냅샷 기록, publish 시 실제 버전으로 치환
10.xpnpm add --save-catalog, catalog 대상 update 등 편의 개선

그 전에는 overrides 로 강제하거나 syncpack 같은 외부 도구로 여러 package.json의 버전을 맞췄다. 둘 다 "선언 자체를 공유"하는 게 아니라 "어긋난 걸 사후에 맞추는" 접근이었다.

이후 Bun도 catalog: 프로토콜을 같은 형태로 도입했다. 선언 위치가 루트 package.jsonworkspaces.catalog 라는 점만 다르다. Yarn도 4.10.0부터 catalog를 기본 지원하는데, 여기는 .yarnrc.yml 에 선언한다. Yarn의 resolutions 는 pnpm의 overrides 계열(강제 치환)이라 catalog와는 목적이 다르다.

프로젝트에 적용하려면 packageManager 필드로 버전이 9.5.0 이상인지 먼저 확인한다.

"packageManager": "pnpm@9.15.4+sha512...."

catalog를 걷어내고 alias 두 줄을 남겼다

앱별 직접 선언 + up -rcatalog
버전업명령 한 줄yaml 수정 + install
pnpm up --latest -r 자동화OKNG — silent no-op (9.x)
pnpm outdatedOKOK
한 앱만 pnpm add 해서 생긴 드리프트NG — 다음 일괄 업데이트까지 방치OK — 애초에 불가
앱 복붙으로 새 앱 만들 때 딸려오는 구버전NGOK
--latest 없이 up -r 만 습관적으로 실행NGOK

dep 1개, 앱 3개, dev 전용, 이미 세 앱 버전이 일치하는 상황이라면 왼쪽이 낫다. catalog가 막아주는 시나리오들은 "실수로 어긋났을 때"인데, 그건 up --latest -r 로 언제든 되돌릴 수 있고, 그 대가로 그 명령 자체를 잃는 건 손해다.

그래서 catalog를 걷어내고 앱별 직접 선언으로 되돌린 뒤, root package.json에 alias만 두었다.

// package.json (workspace root)
{
  "scripts": {
    "outdated:pubuilder": "pnpm outdated pubuilder -r",
    "up:pubuilder": "pnpm up pubuilder --latest -r"
  }
}
pnpm outdated:pubuilder   # 최신 확인
pnpm up:pubuilder         # 3앱 동시 갱신

alias를 root에 둔 이유는 -r 이 워크스페이스 루트 컨텍스트에서 실행돼야 전체 앱을 스코프에 넣기 때문이다. packages/configs 같은 공용 패키지에 두면 pnpm --filter @daisy/configs run ... 로 우회 호출해야 하고, -r 의 스코프가 실행 위치에 의존해 불안정해진다. bin이 형제 디렉토리로 전파되지 않던 것과 같은 결이다.

의존성의 물리적 위치는 앱에 그대로 두고, "여러 앱을 한 번에 다루는 명령"만 root로 올렸다. 공유해야 할 것은 패키지가 아니라 절차였다.

catalog를 다시 꺼낼 시점

  • 공유 dep 세트가 커질 때. next / react / typescript / tailwindcss 처럼 3앱 + 여러 패키지가 함께 쓰는 것들은 개수가 많아 up --latest -r 의 부작용 범위가 넓고, 드리프트가 실제로 생기기 쉽다.
  • 메이저 마이그레이션 중 앱마다 다른 버전을 의도적으로 고정해야 할 때 (named catalog).
  • Renovate를 쓸 때. Renovate는 pnpm catalog를 인식해 pnpm-workspace.yaml 을 직접 올려주므로 silent no-op의 약점이 줄어든다. 다만 named catalog를 쓰면 lockfile 재생성이 누락되는 사례가 보고돼 있어 완전히 상쇄된다고 보긴 어렵다.

named catalog는 이름 붙인 카탈로그를 여러 개 두고 앱마다 골라 쓰는 방식이다.

catalogs:
  react18:
    react: ^18.3.1
  react19:
    react: ^19.2.7
{ "dependencies": { "react": "catalog:react19" } }

pnpm.overrides 와의 차이

버전 통일 수단으로 overrides 를 떠올리기도 하는데 성격이 다르다.

overrides 는 강제 치환이다. 앱 package.json에 뭐라고 적혀 있든 무시하고 덮어쓴다. transitive dep까지 전부 대상이다. 선언과 실제 설치 버전이 어긋나 보이는 상태가 남는다. 보안 패치 강제, 깨진 transitive 버전 핀 고정 같은 예외 처리용이다.

catalog: 는 선언 공유다. 앱이 "카탈로그를 따른다"고 명시적으로 말한다. 읽는 사람에게 의도가 드러난다. 상시 운영용이다.

이 레포의 react 고정(overrides)은 "모든 transitive react를 하나로 강제"하는 목적이라 overrides가 맞다.

"한 곳에만 설치하자"는 직관은 설치 위치를 건드리려 하는데, 실제 통증은 버전 선언에 있었다. 이 둘을 분리해서 보게 되니 도구 선택이 쉬워졌다.

그리고 그 통증조차 재보지 않은 채로 catalog까지 적용해뒀다. 명령 한 줄이면 되는 일이었다. 도구를 도입하기 전에 도구 없이 얼마나 아픈지를 재는 게 먼저였다. 최종 해법도 의존성을 옮기는 게 아니라 npm script alias 두 줄이었다.

참고