새 패키지 매니저 vlt를 만져본 이야기

vlt(볼트라고 읽어요)라는 새 JavaScript 패키지 매니저가 나왔어요. npm을 만든 Isaac Schlueter가 옛 npm 팀 사람들과 다시 모여 세운 회사의 도구고, 지금 버전은 1.0.0-rc.32예요. 정식 1.0의 바로 앞이에요.

이 글은 그 도구를 pnpm 옆에 나란히 놓고, 모래밭에서 한나절 갖고 놀아본 기록이에요. 벤치마크는 같은 기계, 같은 회선에서 한 번씩만 돌렸으니, 엄밀한 측정이라기보다 손에 닿은 감촉의 보고로 읽어주세요.

첫 번째 놀람 — install을 해도 빌드가 돌지 않아요

빈 프로젝트에 express와 vitest를 넣어보니, 오 초쯤 지나서 이렇게 말했어요.

"message": "2 packages that will need to be built,
            run \"vlt build\" to complete the install."

esbuild처럼 네이티브 빌드가 필요한 패키지는 install 단계에서 압축만 풀리고, 코드는 한 줄도 돌지 않아요. 돌리고 싶어지면 그때 vlt build를 치는 거예요. 설치와 실행이 처음부터 서로 다른 단계로 나뉘어 있어요.

사실 pnpm도 요즘 같은 방향이에요. 스크립트를 기본으로 막아두고 pnpm approve-builds로 하나씩 허락하는 방식이죠. 공급망 공격(의존성 패키지를 통해 침입하는 공격)의 상당수가 postinstall 스크립트(설치 직후 자동으로 실행되는 스크립트)로 들어오는 시대라, "파일을 놓는 일"과 "실행을 허락하는 일"을 나누는 건 이제 새로운 발상이 아니라 공통 예절이 되어가는 것 같아요. vlt는 그걸 아예 명령어의 모양으로까지 끌어올렸고요.

node_modules의 생김새는 거의 pnpm이에요

막상 안을 들여다보니, 맨 위에는 직접 의존성만 있고 실체는 전부 심볼릭 링크 뒤에 있었어요.

node_modules/express
  -> .vlt/~npm~express@4.22.2/node_modules/express

pnpm의 .pnpm 디렉터리와 같은 설계예요. 선언하지 않은 패키지가 require로 불려오는 유령 의존성도 같은 방식으로 막히고, macOS에서는 실제 파일도 reflink(파일을 통째로 복사하지 않고 공유하는 방식)로 중앙 캐시와 나눠 쓰니 디스크 사용량도 pnpm과 같았어요. 같은 186개 패키지에 둘 다 95MB였어요.

다만 이름에 한 가지 새로운 생각이 들어 있어요. ~npm~express@4.22.2레지스트리 이름이 패키지의 주소에 처음부터 들어가 있어요. npm 말고 다른 레지스트리(JSR이나, 이 회사의 또 다른 제품인 서버리스 레지스트리 vsr)를 나란히 쓰는 미래를 디렉터리 이름 단계에서부터 미리 생각해둔 설계예요.

제일 재미있던 부분 — 의존성 그래프에 CSS로 물어봐요

vlt의 얼굴은 속도가 아니라 vlt query예요. 의존성 그래프를 CSS 선택자로 뽑아낼 수 있어요.

vlt query '#send'                          # send는 어디서 온 애일까?
vlt query ':root > :dev'                   # 직접 dev 의존성만
vlt query ':not([license=MIT]):not(:root)' # MIT가 아닌 라이선스 목록
vlt query ':outdated(major)'               # major 버전이 뒤처진 것
vlt query ':severity(high)'                # 알려진 취약점(Socket.dev 데이터)

제일 마음에 든 건 이 아이예요.

vlt query ':attr(scripts, [postinstall])'

"내 기계에서 코드를 돌리고 싶어하는 게 누구야?"가 한 줄로 나와요. 저희 모래밭에서는 vite → esbuild만 손을 들었어요.

pnpm에서는 pnpm why, pnpm audit, pnpm licenses로 흩어져 있는 질문이, 여기서는 하나의 언어예요. 게다가 같은 선택자를 스크립트 실행 대상 지정(vlt run --scope=':workspace' test — pnpm의 --filter에 해당)에도, mermaid 그림 출력(--view=mermaid)에도 그대로 쓸 수 있어요. "의존성 그래프는 질의할 수 있는 데이터여야 한다"라는 주장 하나가 도구 전체를 꿰뚫고 있어요.

pnpm 프로젝트에서도 query는 돌아가요

여기서 욕심이 났어요. 지금 쓰는 pnpm 프로젝트에 query만 빌려올 수는 없을까.

돌아갔어요. vlt의 락파일이 없어도, pnpm이 만든 node_modules를 그대로 읽어서 그래프로 만들어줘요. 갈아타지 않아도 감사 도구로만 들여올 수 있어요.

한 가지 고백하자면, "읽기 전용"인 줄 알고 돌린 query가 node_modules/.vlt-lock.json이라는 캐시 파일을 말없이 써두고 있었어요. git 추적 밖이라 해는 없지만, 남의 저장소에 들어갈 때는 미리 알아두면 좋아요.

워크스페이스, 그리고 말없이 헛돈 이야기

모노레포 설정은 vlt.json에 적어요. workspace:* 프로토콜도 제대로 통해서, 상대 경로 심볼릭 링크로 이어졌어요.

다만 여기서 한 번 틀렸어요. 설정 키를 단수형 "workspace"로 적었더니, 에러도 경고도 없이 빈 의존성 그래프인 채로 install이 성공해버렸어요. 맞는 키는 복수형 "workspaces". 고치니 곧바로 전부 이어졌지만, 모르는 키를 보고도 아무 말이 없는 건 RC다운 어림이라고 생각해요. 낯선 키를 보면 알려주는 날을 기다리고 있어요.

속도 이야기 — 정직하게

React + Vite + ESLint라는 흔한 구성(186개 패키지)으로 재봤어요. cold는 캐시도 락파일도 없는 상태, warm은 node_modules만 지우고 다시 설치한 상태예요.

coldwarm
vlt3.8초1.07초
pnpm4.1초0.53초
npm8.7초0.95초

공식 주장인 "npm보다 73% 빠르다"는 cold라면 대체로 사실이었어요. 그리고 cold에서는 이미 pnpm과 호각이에요. 다만 warm — 하루에 스무 번씩 밟는 길 — 에서는 pnpm이 아직 깔끔하게 두 배 빨라요. CPU 사용을 보면 pnpm은 코어를 꽉 채워 도는데 vlt는 아직 여유가 남아 있어서, 조일 자리가 남은 느낌이었어요.

(express만 넣은 작은 구성에서는 cold도 pnpm이 이겼으니, 규모나 회선에 따라 뒤집히는 정도의 차이로 읽는 게 정직할 것 같아요.)

RC다운 거친 모서리도 적어둘게요

  • vlt -a의 출력이 JSON 이스케이프된 문자열 그대로 화면에 나와요(\n이 날것으로 보여요)
  • vlx cowsay(npx에 해당)를 파이프 너머에서 치면, 보이지 않는 확인 프롬프트를 기다리며 말없이 멈춰요(--yes를 붙이면 통해요)
  • install을 할 때마다 .gitignore.npmignore를 묻지 않고 만들어줘요(친절과 오지랖의 딱 중간이에요)

어느 것도 치명상은 아니고, 고쳐질 종류의 것들이에요. 정작 중요한 속은 단단해서, vitest도 esbuild도 심볼릭 링크 위에서 한 번에 제대로 돌았어요.

다시 앉아서, 결론

오늘 pnpm에서 갈아탈 이유는 찾지 못했어요. warm의 속도도, 도구로서 다듬어진 정도도 아직 pnpm이 위예요.

그래도 vlt는 "pnpm을 한 번 더 만든" 도구가 아닌 것 같아요. node_modules의 생김새는 거의 같고, 거기는 이미 답이 나온 자리예요. 다른 건 그 위층이에요. 의존성 그래프를 하나의 질의 언어로 들여다보게 한 것, 그리고 레지스트리를 복수형으로 생각하는 것. 갈아탈 곳이라기보다, 옆에 놓아둘 도구가 먼저 온 인상이에요.

pnpm 프로젝트는 그대로 두고, 라이선스 점검이나 "postinstall 가진 애 찾기"에 vlt query만 빌려 쓰는 것 — 그건 오늘부터도 돼요. 1.0이 나오고 warm이 pnpm을 따라잡으면, 그때 다시 앉아서 생각해볼게요.

다음 이야기에서 또 만나요.