pnpm 모노레포를 Nub으로 옮기며 겪은 삽질과 해결책
27 de julio de 2026
0
Computing/SoftwareComments (0)
Log in to leave a comment
No posts yet
Log in to leave a comment
No posts yet
Node.js 생태계는 지쳐있습니다. tsx, dotenv-cli, nvm, pnpm을 겹겹이 쌓아 올린 빌드 환경은 무겁고, 뭐 하나 업데이트할 때마다 스크립트가 꼬입니다. Rust로 만든 통합 툴킷 Nub이 이 패키지 난장판을 단일 바이너리로 묶어주겠다며 등장했을 때, 솔직히 반가우면서도 미심쩍었습니다.
막상 프로덕션 pnpm-workspace 모노레포를 Nub으로 전환해보니 꽤 매력적이긴 했습니다. 하지만 그냥 스무스하게 넘어가는 법은 없더군요. 의존성 충돌, 24시간 보안 잠금 정책, CI 캐시 분리 문제까지 실무에서 바로 부딪히는 걸림돌들이 튀어나왔습니다.
Nub의 패키지 엔진 aube는 기존 pnpm-workspace.yaml과 package.json의 workspaces를 곧바로 인식합니다. pnpm-lock.yaml v9 스키마와 바이트 단위로 호환된다는 설명답게, 기존 가상 Store 구조(node_modules/.store/)를 기본 활용합니다.
문제는 평탄화(hoisted)된 node_modules 구조를 암묵적으로 전제하던 구형 도구들입니다. 마이그레이션 직후 실행하면 MODULE_NOT_FOUND 에러를 뱉으며 터집니다. 당장 서비스가 돌아가게 만들려면 --node-linker hoisted 옵션으로 패키지 트리를 펼쳐두고 시작해야 합니다.
캐시가 적용된 Warm 설치 기준으로 기본 GVS 모드는 346 ms가 걸립니다. Hoisted 모드는 1461 ms로 4배 이상 느려지지만, pnpm v10+의 3453 ms보다는 여전히 2배 이상 빠릅니다. 처음에는 Hoisted 모드로 호환성을 확보한 뒤, 구형 도구 의존성을 제거하면서 기본 GVS 모드로 건너가는 편이 속 편합니다.
`bash
#!/usr/bin/env bash
set -euo pipefail
echo "==> [1/4] Nub 바이너리 확인"
if ! command -v nub &> /dev/null; then
echo "Error: Nub이 없습니다. 'npm i -g @nubjs/nub'부터 실행하세요."
exit 1
fi
echo "==> [2/4] pnpm-lock.yaml 스키마 체크"
if [ -f "pnpm-lock.yaml" ]; then
nub pm use nub
fi
echo "==> [3/4] package.json 내 실행 엔진 명시"
node -e '
const fs = require("fs");
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"));
pkg.devEngines = pkg.devEngines || {};
pkg.devEngines.packageManager = {
name: "nub",
version: "^0.4.0",
onFail: "warn"
};
fs.writeFileSync("package.json", JSON.stringify(pkg, null, 2) + "\n");
'
echo "==> [4/4] Lockfile 생성"
nub install --frozen-lockfile=false
`
이 스크립트로 pnpm-lock.yaml을 nub.lock으로 변환하면, 매번 스크립트 수정하느라 깨지던 시간을 주당 2시간 이상 아낄 수 있습니다.
Nub은 보안 정책이 꽤 깐깐합니다. 라이프사이클 스크립트를 기본 차단하고, npm Provenance 서명이 없으면 ERR_NUB_TRUST_DOWNGRADE를 던집니다. 제일 당황스러운 건 minimumReleaseAge 옵션입니다. 배포된 지 24시간이 지나지 않은 패키지 버전은 보안 검증 대기 상태로 간주해서 설치를 막아버립니다.
평소에는 훌륭한 방어막이지만, 제로데이 취약점이 터져서 1시간 전에 나온 패치 버전을 즉시 배포해야 할 때는 통곡의 벽이 됩니다. 이때는 루트 package.json에 빌드 스크립트 허용 패키지를 명시하고 명령어로 뚫어줘야 합니다.
`json
{
"name": "@org/monorepo-root",
"private": true,
"allowBuilds": {
"esbuild": true,
"sharp": true,
"@fast-cve/patch-pkg": true
}
}
`
차단된 핫픽스 패키지를 우회해서 반영하는 흐름은 이렇습니다.
`bash
nub add --allow-build=@fast-cve/patch-pkg @fast-cve/patch-pkg@1.0.1-hotfix
nub approve-builds
`
nub approve-builds를 실행하면 차단 목록을 풀고 빌드를 즉시 이어갑니다. OSV 데이터베이스에 악성 코드(MAL-*)로 등록된 패키지는 이 명령어로도 안 뚫리니, 그땐 상위 대체 버전을 찾아야 합니다.
CI/CD 속도를 끌어올리려면 Nub의 캐시 경로를 알아야 합니다. Node 바이너리는 ~/.cache/nub/node/<version>/에 들어가고, 패키지 CAS(Content-Addressable Store)는 ~/.cache/nub/ 및 node_modules/.store/에 나뉩니다.
Docker 멀티 스태이지 빌드에서는 BuildKit의 캐시 마운트를 써서 설치 레이어를 완전히 격리해야 캐시 효과를 봅니다.
`dockerfile
FROM ghcr.io/nubjs/nub:latest AS base
WORKDIR /app
FROM base AS dependencies
COPY package.json nub.lock pnpm-workspace.yaml ./
COPY packages/core/package.json ./packages/core/
COPY packages/api/package.json ./packages/api/
RUN --mount=type=cache,target=/root/.cache/nub
nub ci --prefer-offline
FROM dependencies AS builder
COPY . .
RUN nub run build --filter=@org/api
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/packages/api/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
EXPOSE 3000
CMD ["node", "dist/index.js"]
`
GitHub Actions에서는 actions/setup-node 대신 nubjs/setup-nub@v0을 올려줍니다.
`yaml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: nubjs/setup-nub@v0
with:
cache: true
- run: nub ci
- run: nub -r run build
- run: nub -r run test
`
setup-nub과 캐시를 조합한 nub ci 속도는 346 ms 수준입니다. 기존 pnpm(3453 ms) 대비 파이프라인 패키지 설치 시간이 대폭 줄어들면서 전체 CI 빌드 시간이 반토막 납니다. 월말 클라우드 인프라 청구서를 보면 확실히 기분이 좋아집니다.
Nub은 oxc 기반 메모리 트랜스파일러를 내장하고 있어서 tsx나 ts-node 없이 TypeScript 코드를 바로 실행합니다. .env 파일도 알아서 읽어옵니다. pnpm run(442.7 ms)을 쓸 때 매번 튀어나오던 Node.js 프로세스 부트스트랩 오버헤드가 사라지면서, nub run의 스크립트 실행 시간은 14.7 ms까지 떨어집니다. 체감이 제법 큽니다.
팀원 간 Node.js 버전 파편화를 막으려면 루트에 .node-version을 꽂아두면 됩니다.
`bash
echo "22.15.0" > .node-version
nub src/index.ts
`
Node 22.15.0 이상에서는 동기식 module.registerHooks() 덕분에 콜드 스타트 지연이 완전히 지워집니다. 신규 팀원이 왔을 때 복잡한 설명 없이 한 번에 환경을 맞추려면 자동화 스크립트 하나 세팅해두는 게 제일 깔끔합니다.
`bash
#!/usr/bin/env bash
set -euo pipefail
echo "==> 개발 환경 세팅 시작"
if ! command -v nub &> /dev/null; then
if command -v brew &> /dev/null; then
brew install nubjs/tap/nub
else
npm install -g --ignore-scripts=false @nubjs/nub
fi
fi
nub pm shim
nub node install
nub install
if [ ! -f ".env.local" ] && [ -f ".env.example" ]; then
cp .env.example .env.local
fi
echo "==> 세팅 완료. 'nub run dev'로 실행하세요."
`
nub pm shim을 쳐두면 습관적으로 pnpm install이나 npm run을 입력해도 Nub 실행기로 가로채서 처리합니다. CLI 실행 오버헤드가 pnpm exec(191 ms)에서 nubx(11 ms)로 줄어드는 데다, "제 컴퓨터에선 안 되는데요" 같은 소리를 들으며 디버깅하는 시간을 주당 2시간은 덜어낼 수 있습니다.