뒤로가기
pnpm Workspace로 모노레포 시작하기

August 15, 2023

frontendproductivity

사내 프로젝트가 3개였다. 어드민 대시보드, 유저향 웹앱, 공통 UI 라이브러리. 세 프로젝트 모두 같은 디자인 시스템을 쓰고, 같은 API 타입을 공유하고, 같은 유틸 함수를 쓰고 있었다. 문제는 이걸 세 개의 별도 레포에서 관리하고 있었다는 거다.

공통 코드를 수정하면 세 레포에 각각 가서 npm 패키지를 업데이트해야 했다. 타입 하나 고치면 3번 배포해야 했다. 어느 날 시니어가 "Button 컴포넌트 padding 수정했는데 어드민 쪽에 반영이 안 됐다"고 했을 때, 알고 보니 그쪽 레포의 패키지 버전이 2주 전 거였다.

모노레포를 도입하기로 했다. 도구는 pnpm workspace를 선택했다.

왜 pnpm인가#

Turborepo, Nx, Lerna 같은 선택지도 있었지만, 우리는 "빌드 시스템"이 아니라 "패키지 관리"가 필요했다. Turborepo의 캐싱이나 Nx의 태스크 오케스트레이션은 나중 문제였고, 당장은 여러 패키지를 하나의 레포에서 관리하면서 서로 참조할 수 있으면 됐다.

pnpm은 패키지 매니저 자체에 workspace 기능이 내장되어 있다. 별도 도구를 추가로 설치할 필요가 없다. 게다가 npm이나 yarn에 비해 확실한 이점이 있다.

bash
# node_modules 용량 비교 (같은 프로젝트)
# npm:  1.2GB
# pnpm: 380MB

pnpm은 content-addressable storage를 쓴다. 같은 패키지는 디스크에 한 번만 저장하고 심볼릭 링크로 연결한다. 모노레포에서 여러 패키지가 같은 의존성을 쓸 때 이 차이가 심하다.

기본 구조 세팅#

먼저 pnpm을 설치하고 workspace를 설정한다.

bash
npm install -g pnpm

프로젝트 루트에 pnpm-workspace.yaml을 만든다.

yaml
packages:
  - 'apps/*'
  - 'packages/*'

디렉토리 구조는 이렇게 잡았다.

text
my-monorepo/
├── apps/
│   ├── web/          # 유저향 웹앱 (Next.js)
│   ├── admin/        # 어드민 대시보드 (Next.js)
│   └── storybook/    # Storybook 전용
├── packages/
│   ├── ui/           # 공통 UI 컴포넌트
│   ├── types/        # 공유 타입 정의
│   └── utils/        # 공통 유틸 함수
├── pnpm-workspace.yaml
├── package.json
└── .npmrc

루트 package.json은 이렇게 작성한다.

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 파일도 추가한다.

ini
auto-install-peers=true
strict-peer-dependencies=false

패키지 간 참조 설정#

이게 핵심이다. packages/uipackage.json을 보자.

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에서 이 패키지를 쓰려면:

json
{
  "name": "web",
  "dependencies": {
    "@my/ui": "workspace:*",
    "@my/types": "workspace:*",
    "@my/utils": "workspace:*"
  }
}

workspace:*가 포인트다. 이렇게 하면 npm registry가 아니라 로컬 workspace에서 패키지를 가져온다. packages/ui/src/index.ts를 수정하면 apps/web에서 바로 반영된다. 별도 빌드나 배포 없이.

설치는 루트에서 한 번만 하면 된다.

bash
pnpm install

공통 UI 패키지 만들기#

packages/ui/src/index.ts에서 컴포넌트를 export한다.

tsx
// 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';
tsx
// 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한다.

tsx
// 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하는 구조로 했다.

json
// 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
  }
}
json
// packages/ui/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./dist"
  },
  "include": ["src/**/*"]
}

Next.js 앱 쪽에서는 transpilePackages 설정이 필요하다. 워크스페이스 패키지는 빌드되지 않은 TypeScript 소스를 직접 참조하기 때문이다.

js
// 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 명령어#

bash
# 특정 패키지에 의존성 추가
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 플래그가 정말 유용하다. 글로브 패턴도 된다.

bash
# apps/ 하위 패키지 전부
pnpm --filter "./apps/**" build

# 특정 패키지와  의존성까지 포함
pnpm --filter web... build

web...에서 ...은 web이 의존하는 패키지까지 포함한다는 뜻이다. web이 @my/ui를 쓰고 있으면 @my/ui도 빌드한다.

npm에서 마이그레이션할 때 겪은 문제들#

phantom dependency 에러. npm은 호이스팅 때문에 직접 설치하지 않은 패키지도 import할 수 있었다. pnpm은 이걸 허용하지 않는다. 전환하고 나니 이런 에러가 쏟아졌다.

text
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으로 배웠다.