pnpm Workspaces를 프로덕션에서: 실제로 중요한 것
Source: Dev.to
네 개의 패키지, 모두 TypeScript, 총 약 8 k 라인, npm에 배포됨.
Node 22, pnpm 9, tsc 외에 빌드 도구 없음.
나는 수십 개의 “모노레포 설정” 글을 읽었다 – 대부분은 Turborepo, Nx, Lerna를 비교하는 데 2 000단어를 썼고, 실제로 무작위 화요일 오후에 깨지는 문제에 대해서는 몇 문단만 다루었다.
아래가 화요일‑오후에 발생하는 문제이다.
Source:
두 줄의 설정
워크스페이스 설정 (pnpm-workspace.yaml)
# pnpm-workspace.yaml
packages:
- "packages/*"그게 전부입니다 – packages/ 아래의 모든 디렉터리가 워크스페이스 패키지가 됩니다.
루트 package.json (private, 오케스트레이션 전용)
{
"private": true,
"scripts": {
"build": "pnpm -r build",
"dev": "pnpm -r --parallel dev",
"test": "pnpm -r test",
"clean": "pnpm -r --parallel exec rm -rf dist"
},
"engines": {
"node": ">=22",
"pnpm": ">=9"
}
}-r= 모든 패키지에서 스크립트를 실행합니다.dev와clean에--parallel을 사용하는 이유는 서로 의존성이 없기 때문입니다.build는 순차적으로 실행됩니다 – 한 패키지가 다른 패키지를 임포트하므로 먼저 컴파일되어야 합니다. pnpm은 올바른 의존성 순서를 자동으로 판단하므로, 종속 패키지는 항상 의존 패키지 이후에 빌드됩니다.
Turborepo와 Nx 중 하나를 선택하는 데 시간을 전혀 쓰지 않았습니다. pnpm -r이 오케스트레이션을 담당하고, tsc가 컴파일을 담당합니다. 그게 전부입니다.
Shared tsconfig (the part that actually saves time)
모든 모노레포 글에서는 공유 베이스 tsconfig를 만들라고 합니다. 맞는 말이지만, 베이스에 무엇을 두고 패키지별로 무엇을 두어야 하는지 설명하는 경우는 거의 없습니다.
Base (tsconfig.base.json)
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": false,
"sourceMap": false
}
}Per‑package (packages/*/tsconfig.json)
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*"]
}rootDir와outDir은 상대 경로이기 때문에 패키지별 설정입니다.- 그 외 모든 설정은 공유됩니다.
이전에는 약간씩 다른 tsconfig 네 개를 가지고 있었고, 어느 것이 strictNullChecks를 포함하고 있는지 기억하지 못했습니다. 이제 설정을 한 번만 바꾸면 모든 곳에 적용됩니다.
TypeScript 프로젝트 레퍼런스는 어떨까요?
저는 사용하지 않습니다. composite: true는 빌드 시점에 패키지 간 타입 검사를 제공하지만, 모든 tsconfig에 references 배열을 유지하고, 의존성 그래프와 동기화하며, 오래되어 가짜 오류를 발생시키는 tsBuildInfo 파일을 처리해야 합니다.
패키지 네 개, 내부 의존성 하나 – 저는 순서대로 빌드합니다. 만약 패키지가 열 개가 되거나 빌드 시간이 초가 아니라 분이 걸리기 시작한다면, 아마 다시 생각해볼 것입니다.
workspace:* vs. workspace:^
패키지 하나가 모노레포 내 다른 패키지를 의존할 때:
{
"dependencies": {
"my-daemon": "workspace:*"
}
}개발 중에는 심볼릭 링크가 생성됩니다 – 의존성의 최신 버전을 실시간으로 가리키는 링크이며, 재빌드가 필요하지 않습니다.
배포 시 pnpm은 workspace:*를 실제 버전 번호로 교체합니다. 예를 들어 배포된 package.json에서는 "my-daemon": "0.2.7"와 같이 표시됩니다.
함정
처음에 workspace:^(캐럿 포함)를 사용했습니다. 배포될 때 의존성이 "^0.2.7"으로 변환되었고, 이 경우 사용자는 마이너 버전이 일치하지 않을 수 있는 패키지를 설치하게 됩니다. 내부에서 긴밀히 연결된 의존성의 경우 정확한 버전 고정을 위해 workspace:*를 사용하세요.
팬텀 의존성이 당신을 찾아옵니다
이 문제를 해결하는 데 오후 내내 걸렸습니다. pnpm은 기본적으로 엄격한 node_modules 구조를 사용합니다: 패키지는 명시적으로 선언한 의존성만 접근할 수 있습니다. 멋지죠—하지만 여러분의 코드 절반이 알지 못했던 팬텀 의존성에 의존하고 있었다는 걸 깨달을 때까지는요.
예시:
패키지 A는 fastify에 의존합니다. 패키지 B는 이를 선언하지 않았지만, npm/yarn에서는 hoist 되기 때문에 B가 fastify를 import 할 수 있습니다. pnpm에서는 B가 import 할 수 없게 되어 선언이 누락된 것을 드러냅니다.
저는 어떤 패키지가 @types/ws에서 타입을 import 했지만 devDependency로 선언하지 않은 버그를 겪었습니다. 다른 패키지에 해당 타입이 있었기 때문에 로컬에서는 동작했고, VS Code도 워크스페이스를 통해 해결해 주었습니다. 하지만 배포 후 사용자들은 다음과 같은 오류를 보게 되었습니다:
error TS2307: Cannot find module 'ws' or its corresponding type declarations.해결 방법
각 워크스페이스에서 pnpm why를 실행하고 모든 import에 대응되는 선언이 있는지 확인하세요:
$ cd packages/client && pnpm why @types/ws
# 아무 것도 나오나요? 바로 당신의 버그입니다
$ pnpm add -D @types/ws지루한 작업이지만, 이 덕분에 당혹스러운 npm 배포 실수를 피할 수 있었을 겁니다.
Source:
Vitest + 워크스페이스
Vitest에는 워크스페이스 기능이 있습니다. 루트 설정에서는 패키지만 나열합니다:
// vitest.workspace.ts
import { defineWorkspace } from "vitest/config";
export default defineWorkspace([
"packages/server",
"packages/client",
"packages/cli",
"packages/core",
]);패키지별 설정
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
testTimeout: 10_000,
restoreMocks: true,
},
});루트에서 pnpm test를 실행하면 모든 스위트가 실행됩니다.pnpm --filter server test를 실행하면 서버 테스트만 실행됩니다.
주의: 테스트에서 형제 패키지를 import 하는 경우, 해당 의존성을 먼저 빌드했는지 확인하세요.
Vitest가 빌드를 트리거하지 않음
pretest 스크립트가 테스트 스위트 전에 pnpm -r build를 실행하도록 만들었습니다.
이것은 낭비입니다—아무것도 변경되지 않았어도 모든 것을 다시 빌드합니다.
대안은 빌드를 잊어버리고 타입이 맞지 않는 이유를 디버깅하는 데 20 분을 소비하는 것입니다.
Source: …
npm에 게시하기
Changesets도, Lerna도 없습니다. 버전을 수동으로 올리고 각 패키지 디렉터리에서 pnpm publish를 실행합니다.
prepublishOnly 훅
{
"scripts": {
"prepublishOnly": "pnpm build"
}
}이 훅이 없으면 결국 오래된 dist/ 파일이 게시되거나 dist/ 자체가 전혀 없게 됩니다. 두 번째 게시에서 npm info를 확인했을 때 dist/ 폴더가 세 커밋 전의 것이었습니다. 결국 게시하기 전에 빌드하는 것을 깜빡한 것이었습니다.
명시적인 files 필드
{
"files": ["dist", "README.md"],
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
}
}한 번은 src/ 디렉터리와 테스트 픽스처, 그리고 4 MB 크기의 디버그 로그가 실수로 포함된 패키지를 게시한 적이 있습니다. files 필드는 허용 목록이며, 나열한 것만 게시됩니다.
npm pack --dry-run은 여러분의 친구입니다—모든 게시 전에 실행하고 출력 결과를 실제로 확인하세요.
Stuff I Tried That Wasn’t Worth It
- Turborepo – 전체 빌드가 30 초 미만이에요. 원격 캐시와 스마트 작업 스케줄링은 제가 겪고 있지 않은 문제를 해결합니다.
pnpm -r build가 전체 빌드 시스템입니다. - Separate ESLint‑config package – 네 개의 패키지에 비하면 너무 많은 절차가 필요합니다. 저는 설정을 루트에 두고 상대 경로로 참조합니다.
- “Shared utils” package – 함수가 세 개뿐이었습니다. 패키지가 아니라 파일이죠. 저는 이를 삭제하고 실제로 공유되는 두 함수를 복제했습니다. 추상화가 줄어들고 심볼릭 링크 문제도 감소했습니다.
- Synchronized version numbers – 클라이언트 라이브러리와 서버는 서로 다른 주기로 배포됩니다. 두 곳에
v0.3.1을 강제로 맞추면 번호를 맞추기 위해 의미 없는 릴리스를 해야 합니다.
Keep It Boring
모든 것을 다 해본 뒤에 제가 계속 강조하는 점은 단조롭게 유지하라는 것입니다.
pnpm-workspace.yaml은 두 줄이면 충분합니다.- 루트
package.json에는 대략 다섯 개 정도의 스크립트만 두세요. - 모노레포 설정에 어떻게 동작하는지 설명하는 README 가 필요하다면, 이미 너무 멀리 온 겁니다.
무언가가 깨졌을 때, .npmrc 에서 shamefully-hoist=true 를 바로 쓰지 마세요. 왜 깨졌는지 파악하세요. 열 번 중 아홉 번은 의존성 선언이 빠졌기 때문이며, 사용자는 패키지를 설치할 때 같은 문제에 직면하게 됩니다.
모든 배포 가능한 패키지에 prepublishOnly 훅을 넣으세요. 이 점을 충분히 강조하고 싶습니다—미래의 당신이 배포 전에 빌드를 잊어버릴 수 있습니다. 이것은 가능성의 문제가 아니라 반드시 해야 할 일입니다.
네 개의 패키지, 순수 pnpm, Turborepo도, Nx도 없습니다. 빌드 시간이 문제가 된다면 무언가를 추가하겠지만, 아직은 그렇지 않습니다.