pnpm + Turborepo + Next.js: 프론트엔드 모노레포 시작하기

Frontend

이 글은 Turborepo 시리즈 2편입니다.

앞 글에서 Turborepo가 왜 필요한지, 프론트엔드 모노레포에서 어떤 문제를 줄여주는지 정리했다면, 이번에는 한 단계 더 나아가서 실제로 어떻게 시작하면 되는지를 보겠습니다.

이번 글은 아래 조합을 기준으로 합니다.

  • package manager: pnpm
  • monorepo task runner: Turborepo
  • app framework: Next.js

이 조합이 많이 쓰이는 이유는 비교적 단순합니다.

  • pnpm workspace가 의존성 연결과 설치 효율을 담당하고
  • Turborepo가 task 실행과 캐시를 담당하며
  • Next.js 앱과 shared package를 한 레포에서 관리하기 좋기 때문입니다

즉, 이 글의 핵심은 "모노레포를 어떻게 멋지게 만들까"가 아니라, 실제로 굴러가는 프론트엔드 모노레포를 어떻게 시작할까입니다.

먼저 한눈에 보면

이번 글에서 만들고 싶은 최종 모습은 아래 구조입니다.

repo/
  apps/
    web/                 # 사용자 서비스
    admin/               # 운영자 서비스
  packages/
    ui/                  # 공통 UI 컴포넌트
    config-eslint/       # 공통 ESLint 설정
    config-typescript/   # 공통 TypeScript 설정
  package.json
  pnpm-workspace.yaml
  turbo.json

이 구조에서 기대하는 것은 보통 아래입니다.

  • apps/webapps/admin이 공통 UI를 함께 사용하고
  • 루트에서 build, lint, dev를 한 번에 실행할 수 있으며
  • shared package 변경 시 필요한 app task만 다시 실행되고
  • CI에서 같은 작업은 캐시로 줄일 수 있는 상태

1. 왜 pnpm + Turborepo + Next.js 조합이 자주 나오나?

각 도구가 맡는 역할이 비교적 분명하기 때문입니다.

pnpm

  • workspace 연결
  • 빠른 설치
  • 디스크 효율
  • shared dependency 관리

Turborepo

  • build/lint/test/dev 실행 orchestration
  • task graph
  • 로컬 캐시와 remote cache
  • filter 기반 부분 실행

Next.js

  • 앱 레벨의 SSR/App Router 구조
  • 실제 사용자 서비스 앱

즉, 역할을 나누면 아래처럼 볼 수 있습니다.

pnpm         -> 패키지 설치와 workspace 연결
Turborepo    -> 태스크 실행 순서와 캐시
Next.js      -> 실제 앱 구현

이렇게 역할이 분리되어 있으면 운영할 때도 문제를 나눠서 보기 쉽습니다.

  • 설치가 느리면 pnpm 쪽 문제를 보고
  • build가 반복되면 turbo.json을 보고
  • SSR 동작은 Next.js 앱 설정을 보면 됩니다

2. 디렉토리 구조는 어떻게 시작하면 좋을까?

초기에는 너무 많은 패키지를 쪼개기보다, 앱과 공통 패키지 몇 개만 두고 시작하는 편이 좋습니다.

repo/
  apps/
    web/
    admin/
  packages/
    ui/
    config-eslint/
    config-typescript/

이 구조를 기준으로 생각하면:

  • apps/web
    • 사용자 서비스
  • apps/admin
    • 운영자 서비스
  • packages/ui
    • 버튼, 입력창, 레이아웃 같은 공통 UI
  • packages/config-eslint
    • 공통 ESLint 설정
  • packages/config-typescript
    • 공통 tsconfig preset

처음부터 utils, auth, api, hooks, shared 같은 패키지를 너무 많이 만들면 오히려 경계가 흐려질 수 있습니다. 초기에는 공통성이 명확한 것만 package로 빼는 편이 운영이 편합니다.

3. pnpm-workspace.yaml은 어떻게 두면 될까?

가장 먼저 workspace 범위를 정해줘야 합니다.

packages:
  - apps/*
  - packages/*

이 설정은 단순하지만 중요합니다.

  • apps/* 아래 앱을 workspace로 인식하고
  • packages/* 아래 공유 패키지도 workspace로 연결합니다

즉, packages/uiapps/web에서 의존성으로 잡았을 때 registry 설치가 아니라 로컬 workspace 연결이 일어나게 됩니다.

4. 루트 package.json은 어떻게 시작할까?

루트에서는 보통 workspace 전체를 제어하는 스크립트만 두는 편이 깔끔합니다.

{
  "name": "my-monorepo",
  "private": true,
  "packageManager": "pnpm@10.0.0",
  "scripts": {
    "dev": "turbo run dev",
    "build": "turbo run build",
    "lint": "turbo run lint",
    "test": "turbo run test"
  },
  "devDependencies": {
    "turbo": "^2.0.0",
    "typescript": "^5.0.0"
  }
}

여기서 읽는 포인트는 이렇습니다.

  • private: true
    • 루트 패키지를 publish 대상이 아니게 둡니다.
  • packageManager
    • 팀 전체가 같은 pnpm 버전을 쓰도록 맞춥니다.
  • root scripts
    • 개발자는 루트 명령만 기억해도 됩니다.

실무에서는 루트 명령이 단순할수록 온보딩이 쉬워집니다.

5. turbo.json은 어떤 식으로 시작하면 좋을까?

처음부터 복잡하게 잡기보다 아래 정도면 충분합니다.

{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "dev": {
      "cache": false,
      "persistent": true
    },
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "dist/**"]
    },
    "lint": {},
    "test": {
      "dependsOn": ["^build"]
    }
  }
}

포인트는 이렇습니다.

dev

{
  "cache": false,
  "persistent": true
}

개발 서버는 watch 모드로 계속 살아있기 때문에 캐시하지 않는 편이 자연스럽습니다.

build

{
  "dependsOn": ["^build"],
  "outputs": [".next/**", "dist/**"]
}
  • ^build
    • 현재 패키지가 의존하는 상위 패키지의 build를 먼저 돌립니다.
  • outputs
    • Next.js 앱은 .next/**
    • 라이브러리는 dist/**

이렇게 출력물을 잡아야 캐시가 정확하게 동작합니다.

lint, test

처음에는 단순하게 두고, 필요할 때 점점 입력/출력 정의를 늘리는 편이 좋습니다.

즉, turbo.json은 처음부터 완벽하게 맞추기보다 레포가 커지면서 점진적으로 다듬는 파일에 가깝습니다.

6. 앱 package.json은 어떻게 두면 될까?

예를 들어 apps/web/package.json은 아래처럼 둘 수 있습니다.

{
  "name": "web",
  "private": true,
  "scripts": {
    "dev": "next dev --port 3000",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "@repo/ui": "workspace:*",
    "next": "^15.0.0",
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  }
}

apps/admin/package.json도 거의 비슷하게 두되 포트나 서비스별 의존성만 다르게 갈 수 있습니다.

{
  "name": "admin",
  "private": true,
  "scripts": {
    "dev": "next dev --port 3001",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "@repo/ui": "workspace:*",
    "next": "^15.0.0",
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  }
}

여기서 중요한 것은 shared package를 registry 버전이 아니라 workspace 의존성으로 분명하게 묶는 것입니다.

7. 공통 UI 패키지는 어떻게 시작할까?

예를 들어 packages/ui/package.json은 아래처럼 둘 수 있습니다.

{
  "name": "@repo/ui",
  "version": "0.0.0",
  "private": true,
  "main": "./src/index.ts",
  "types": "./src/index.ts",
  "scripts": {
    "lint": "eslint .",
    "build": "tsc -p tsconfig.json --emitDeclarationOnly false"
  },
  "peerDependencies": {
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  }
}

그리고 packages/ui/src/index.ts는 보통 entry 역할만 두는 편이 좋습니다.

export * from './button';

예를 들어 packages/ui/src/button.tsx는 아주 단순하게 시작할 수 있습니다.

import type { ButtonHTMLAttributes, PropsWithChildren } from 'react';
 
type ButtonProps = PropsWithChildren<ButtonHTMLAttributes<HTMLButtonElement>>;
 
export function Button({ children, ...props }: ButtonProps) {
  return (
    <button
      {...props}
      style={{
        padding: '12px 16px',
        borderRadius: 8,
        border: '1px solid #e5e7eb',
        background: '#111827',
        color: '#ffffff',
      }}
    >
      {children}
    </button>
  );
}

이후 apps/web/app/page.tsx에서는 아래처럼 바로 가져다 쓸 수 있습니다.

import { Button } from '@repo/ui';
 
export default function Page() {
  return (
    <main>
      <h1>Web App</h1>
      <Button>저장하기</Button>
    </main>
  );
}

이 정도만 돼도 shared UI package가 실제 앱에서 어떻게 소비되는지 감이 옵니다.

8. 공통 TypeScript 설정은 어떻게 두면 좋을까?

공통 설정 패키지를 두면 앱마다 반복을 줄이기 좋습니다.

예를 들어 packages/config-typescript/base.json:

{
  "$schema": "https://json.schemastore.org/tsconfig",
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "jsx": "preserve",
    "esModuleInterop": true,
    "skipLibCheck": true
  }
}

그리고 apps/web/tsconfig.json은:

{
  "extends": "@repo/config-typescript/base.json",
  "compilerOptions": {
    "plugins": [{ "name": "next" }]
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
  "exclude": ["node_modules"]
}

이 방식은 단순하지만, TS 설정을 바꿀 때 영향 범위가 넓어질 수 있다는 점도 같이 기억해야 합니다. 공통 설정 패키지는 편하지만, 동시에 여러 앱의 캐시 무효화 범위를 넓히는 요소이기도 합니다.

9. 개발할 때는 어떤 명령을 쓰게 될까?

보통 개발자는 아래 정도 명령을 가장 많이 보게 됩니다.

pnpm install
pnpm dev
pnpm build
pnpm lint

앱 하나만 실행하고 싶으면 filter를 씁니다.

turbo run dev --filter=web
turbo run dev --filter=admin
turbo run build --filter=@repo/ui

즉, 루트에서는 전체 흐름을 보고, 로컬 디버깅에서는 filter로 범위를 줄이는 감각이 중요합니다.

10. CI에서는 어떻게 연결할 수 있을까?

아주 단순한 시작점은 아래 정도입니다.

- uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: 'pnpm'
 
- run: corepack enable
- run: pnpm install --frozen-lockfile
- run: pnpm turbo run lint test build

여기서 한 단계 더 가면 remote cache를 붙일 수 있습니다. 다만 초반에는 CI가 정말 병목인지부터 보는 편이 좋습니다. 캐시를 붙이기 전에 task 입력/출력 정의가 부정확하면, 빠르기보다 오히려 신뢰도가 떨어질 수 있습니다.

11. 초기에 많이 하는 실수는 무엇일까?

1. 공통 패키지를 너무 많이 만든다

처음부터 shared, core, utils, common, hooks, api, lib를 다 쪼개면 오히려 경계가 흐려질 수 있습니다.

초기에는:

  • ui
  • config-eslint
  • config-typescript

정도처럼 정말 공통성이 분명한 것만 package로 빼는 편이 좋습니다.

2. turbo.json을 처음부터 복잡하게 만든다

처음부터 모든 inputs/outputs를 완벽히 정의하려고 하면 설정이 과해질 수 있습니다.

시작은 단순하게:

  • dev
  • build
  • lint
  • test

정도만 두고, 실제 병목이 보일 때 조금씩 늘리는 편이 현실적입니다.

3. 공통 설정 패키지의 영향 범위를 과소평가한다

config-eslint, config-typescript, ui 패키지는 생각보다 많은 앱에 영향을 줍니다.

즉, 공통화는 좋지만 동시에 변경 영향 반경이 넓어지는 선택이기도 합니다.

4. graph보다 사람 감을 믿는다

레포가 커질수록 "이건 web에만 영향 있을 것 같다"는 판단이 자주 틀립니다.

이럴수록:

  • 의존성 선언을 명확히 하고
  • task 관계를 명시하고
  • graph와 cache 기준으로 실행되게 하는 편

이 안전합니다.

정리하면

pnpm + Turborepo + Next.js 조합은 프론트엔드 모노레포를 시작할 때 꽤 현실적인 출발점입니다.

핵심은 이렇습니다.

  • pnpm은 workspace와 설치를 담당하고
  • Turborepo는 task 실행과 캐시를 담당하며
  • Next.js는 실제 앱 레이어를 담당합니다

실무에서는 이 조합이 특히 아래에서 도움이 됩니다.

  • 앱 여러 개를 한 레포에서 운영할 때
  • shared UI와 공통 설정을 재사용할 때
  • CI 반복 비용을 줄이고 싶을 때
  • 루트 명령 중심으로 팀 DX를 단순하게 가져가고 싶을 때

중요한 것은 도구를 많이 붙이는 것이 아니라, 레포 구조와 task 관계를 팀이 설명할 수 있는 상태로 만드는 것입니다. 그 상태가 만들어지면 pnpm + Turborepo + Next.js는 꽤 안정적인 출발점이 됩니다.

다음 글에서 이어서 보기

여기까지는 pnpm + Turborepo + Next.js 조합으로 어떻게 시작하면 좋은지 정리했습니다. 다음 글에서는 이 구성을 실제로 운영하면서 만나게 되는 remote cache, CI 최적화, shared package 전략을 더 깊게 정리합니다.

같이 보면 좋은 글