사내 프로젝트가 3개였다. 어드민 대시보드, 유저향 웹앱, 공통 UI 라이브러리. 세 프로젝트 모두 같은 디자인 시스템을 쓰고, 같은 API 타입을 공유하고, 같은 유틸 함수를 쓰고 있었다. 문제는 이걸 세 개의 별도 레포에서 관리하고 있었다는 거다.
공통 코드를 수정하면 세 레포에 각각 가서 npm 패키지를 업데이트해야 했다. 타입 하나 고치면 3번 배포해야 했다. 어느 날 시니어가 "Button 컴포넌트 padding 수정했는데 어드민 쪽에 반영이 안 됐다"고 했을 때, 알고 보니 그쪽 레포의 패키지 버전이 2주 전 거였다.
모노레포를 도입하기로 했다. 도구는 pnpm workspace를 선택했다.
왜 pnpm인가
Turborepo, Nx, Lerna 같은 선택지도 있었지만, 우리는 "빌드 시스템"이 아니라 "패키지 관리"가 필요했다. Turborepo의 캐싱이나 Nx의 태스크 오케스트레이션은 나중 문제였고, 당장은 여러 패키지를 하나의 레포에서 관리하면서 서로 참조할 수 있으면 됐다.
pnpm은 패키지 매니저 자체에 workspace 기능이 내장되어 있다. 별도 도구를 추가로 설치할 필요가 없다. 게다가 npm이나 yarn에 비해 확실한 이점이 있다.
# node_modules 용량 비교 (같은 프로젝트)
# npm: 1.2GB
# pnpm: 380MBpnpm은 content-addressable storage를 쓴다. 같은 패키지는 디스크에 한 번만 저장하고 심볼릭 링크로 연결한다. 모노레포에서 여러 패키지가 같은 의존성을 쓸 때 이 차이가 심하다.
기본 구조 세팅
먼저 pnpm을 설치하고 workspace를 설정한다.
npm install -g pnpm프로젝트 루트에 pnpm-workspace.yaml을 만든다.
packages:
- 'apps/*'
- 'packages/*'디렉토리 구조는 이렇게 잡았다.
my-monorepo/
├── apps/
│ ├── web/ # 유저향 웹앱 (Next.js)
│ ├── admin/ # 어드민 대시보드 (Next.js)
│ └── storybook/ # Storybook 전용
├── packages/
│ ├── ui/ # 공통 UI 컴포넌트
│ ├── types/ # 공유 타입 정의
│ └── utils/ # 공통 유틸 함수
├── pnpm-workspace.yaml
├── package.json
└── .npmrc
루트 package.json은 이렇게 작성한다.
{
"name": "my-monorepo",
"private": true,
"scripts": {
"dev:web": "pnpm --filter web dev",
"dev:admin": "pnpm --filter admin dev",
"build": "pnpm -r build",
"lint": "pnpm -r lint",
"type-check": "pnpm -r type-check"
}
}.npmrc 파일도 추가한다.
auto-install-peers=true
strict-peer-dependencies=false패키지 간 참조 설정
이게 핵심이다. packages/ui의 package.json을 보자.
{
"name": "@my/ui",
"version": "0.0.0",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"type-check": "tsc --noEmit",
"lint": "eslint src/"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
}
}apps/web에서 이 패키지를 쓰려면:
{
"name": "web",
"dependencies": {
"@my/ui": "workspace:*",
"@my/types": "workspace:*",
"@my/utils": "workspace:*"
}
}workspace:*가 포인트다. 이렇게 하면 npm registry가 아니라 로컬 workspace에서 패키지를 가져온다. packages/ui/src/index.ts를 수정하면 apps/web에서 바로 반영된다. 별도 빌드나 배포 없이.
설치는 루트에서 한 번만 하면 된다.
pnpm install공통 UI 패키지 만들기
packages/ui/src/index.ts에서 컴포넌트를 export한다.
// packages/ui/src/index.ts
export { Button } from './components/Button';
export { Input } from './components/Input';
export { Modal } from './components/Modal';
// 타입도 함께
export type { ButtonProps, InputProps, ModalProps } from './types';// packages/ui/src/components/Button.tsx
import type { ButtonHTMLAttributes } from 'react';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'ghost';
size?: 'sm' | 'md' | 'lg';
isLoading?: boolean;
}
export function Button({
variant = 'primary',
size = 'md',
isLoading,
children,
disabled,
...props
}: ButtonProps) {
return (
<button
className={`btn btn-${variant} btn-${size}`}
disabled={disabled || isLoading}
{...props}
>
{isLoading ? <Spinner size={size} /> : children}
</button>
);
}앱에서는 이렇게 import한다.
// apps/web/src/components/LoginForm.tsx
import { Button, Input } from '@my/ui';
export function LoginForm() {
return (
<form>
<Input name="email" placeholder="이메일" />
<Input name="password" type="password" placeholder="비밀번호" />
<Button variant="primary">로그인</Button>
</form>
);
}경로가 깔끔하다. ../../packages/ui 같은 상대 경로 대신 @my/ui라는 패키지 이름으로 가져온다.
TypeScript 설정
모노레포에서 TypeScript 설정이 좀 까다롭다. 루트에 base config를 두고, 각 패키지에서 extends하는 구조로 했다.
// tsconfig.base.json (루트)
{
"compilerOptions": {
"target": "ES2020",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"declaration": true,
"declarationMap": true,
"sourceMap": true
}
}// packages/ui/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist"
},
"include": ["src/**/*"]
}Next.js 앱 쪽에서는 transpilePackages 설정이 필요하다. 워크스페이스 패키지는 빌드되지 않은 TypeScript 소스를 직접 참조하기 때문이다.
// apps/web/next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: ['@my/ui', '@my/utils'],
};
module.exports = nextConfig;이걸 안 하면 Next.js가 node_modules 안의 TypeScript 파일을 트랜스파일하지 못해서 빌드 에러가 난다. 처음에 이거 때문에 한 시간 날렸다.
자주 쓰는 pnpm 명령어
# 특정 패키지에 의존성 추가
pnpm --filter web add axios
# 특정 패키지의 스크립트 실행
pnpm --filter @my/ui type-check
# 모든 패키지에서 스크립트 실행
pnpm -r build
# 루트에 devDependency 추가 (워크스페이스 공통 도구)
pnpm add -Dw prettier eslint
# 어떤 패키지가 어떤 패키지를 참조하는지 확인
pnpm ls --depth 0 -r--filter 플래그가 정말 유용하다. 글로브 패턴도 된다.
# apps/ 하위 패키지 전부
pnpm --filter "./apps/**" build
# 특정 패키지와 그 의존성까지 포함
pnpm --filter web... buildweb...에서 ...은 web이 의존하는 패키지까지 포함한다는 뜻이다. web이 @my/ui를 쓰고 있으면 @my/ui도 빌드한다.
npm에서 마이그레이션할 때 겪은 문제들
phantom dependency 에러. npm은 호이스팅 때문에 직접 설치하지 않은 패키지도 import할 수 있었다. pnpm은 이걸 허용하지 않는다. 전환하고 나니 이런 에러가 쏟아졌다.
Module not found: Can't resolve 'date-fns'
분명 쓰고 있는데 package.json에는 없는 패키지들. npm에서는 다른 패키지의 의존성으로 설치된 게 호이스팅돼서 그냥 쓸 수 있었다. pnpm은 각 패키지가 자기 의존성만 볼 수 있기 때문에 이런 "유령 의존성"이 전부 에러로 드러났다. 오히려 좋은 일이었다. 숨겨진 의존성을 전부 명시적으로 선언하게 되니까.
lock 파일 충돌. package-lock.json을 삭제하고 pnpm-lock.yaml로 전환해야 한다. CI/CD 파이프라인에서 npm ci 대신 pnpm install --frozen-lockfile로 바꾸는 것도 잊으면 안 된다.
IDE 인식 문제. VS Code에서 워크스페이스 패키지의 타입을 못 찾는 경우가 있었다. TypeScript 서버를 재시작하면 대부분 해결되지만, paths 설정을 명시적으로 추가해야 하는 경우도 있었다.
3개월 후
지금은 꽤 안정적으로 돌아가고 있다. 공통 컴포넌트를 수정하면 어드민과 웹앱에 즉시 반영된다. 타입 하나 고치는 데 3번 배포하던 시절은 끝났다. 새로 들어온 동료도 pnpm install 한 번이면 전체 프로젝트가 셋업된다.
아직 개선할 여지는 있다. Turborepo를 얹어서 빌드 캐싱을 적용하는 것도 고려 중이고, 체인지셋 기반 버저닝도 나중에 도입하고 싶다. 하지만 지금 단계에서는 pnpm workspace만으로도 충분하다. 도구를 한꺼번에 다 얹으면 복잡도만 올라간다. 문제가 생겼을 때 하나씩 추가하는 게 맞다는 걸 몇 번의 over-engineering으로 배웠다.
