PUBLISHED

pnpm과 terborepo로 시작하는 모노레포

작성일: 2025.08.16

pnpm과 terborepo로 시작하는 모노레포

최근 개발 현장에서는 여러 프로젝트를 하나의 저장소에서 관리하는 모노레포 구조가 빠르게 확산되고 있다. 모노레포는 여러 패키지를 한 곳에서 관리하기 때문에 공통 코드 재사용이 쉽고, 여러 팀이 동시에 작업하더라도 의존성과 변경 이력을 일관되게 관리할 수 있다는 장점이 있다. 반면, 패키지 간 의존성 관리, 빌드 시간 증가, 배포 전략 설계 등 초기 설정 단계에서 고려해야 할 요소도 많다.

pnpm은 의존성을 전역 저장소에 한 번만 저장하고, 각 패키지에서는 이를 심볼릭 링크로 참조하는 패키지 매니저이다. 이 구조를 통해 중복 설치를 방지할 수 있고, 모노레포 환경에서도 디스크 사용량과 설치 시간을 안정적으로 관리할 수 있다. 특히 여러 패키지가 동일한 라이브러리를 사용하는 경우 효과가 크다.

turborepo는 모노레포 환경에서 작업 실행 흐름을 관리하는 도구이다. 각 패키지의 작업을 그래프 형태로 분석하고, 캐시와 병렬 실행을 활용해 빌드 및 테스트 시간을 단축한다. 변경된 작업만 다시 실행하도록 최적화할 수 있어 규모가 커질수록 효과가 분명해진다.

기본 환경 설정

모노레포 프로젝트를 시작하기 위해 먼저 작업 디렉터리를 생성하고 pnpm으로 초기화한다.

untitled
SH
mkdir my-monorepo
cd my-monorepo
pnpm init

모노레포 환경에서 pnpm이 여러 패키지를 인식하도록 하려면 루트에 pnpm-workspace.yaml 파일이 필요하다. 이 파일은 워크스페이스로 관리할 패키지 경로를 정의한다. 예를 들어, packages/*와 같이 설정하면 packages 폴더 아래 모든 하위 폴더를 하나의 워크스페이스로 인식해 의존성 설치와 스크립트 실행을 통합 관리할 수 있다. 따라서 pnpm-workspace.yaml은 모노레포를 효율적으로 운영하기 위한 필수 구성 요소이다.

pnpm-workspace.yaml
packages:
  - apps/*
  - packages/*

그 이후 turborepo를 개발 의존성(devDependencies)으로 설치한다. 여기서 -D 또는 -w 옵션은 각각 --save-dev와 --workspace-root를 의미한다. 즉, turborepo를 모노레포 루트(workspace root)에 개발용 도구로 추가하여 빌드, 캐시, 병렬 작업 관리 등 모노레포 전반의 작업 흐름을 최적화할 수 있도록 준비하는 단계이다.

untitled
pnpm add turbo -Dw

turbo.json은 프로젝트 내 작업 흐름과 캐시 전략을 정의하는 중요한 역할을 한다. 아래 예시에서는 dev와 build라는 두 가지 작업(task)을 설정했다. dev 작업은 캐시를 사용하지 않고(pnpm 캐시와는 별개로), 개발 서버처럼 지속해서 실행되는(persistent) 작업임을 명시한다.

반면 build 작업은 의존하는 상위 패키지의 build 작업이 먼저 실행되어야 함을 dependsOn: ["^build"]로 지정했고, 빌드 결과물이 .next/**와 dist/** 폴더에 생성된다는 것을 outputs로 알려준다. 이렇게 하면 turborepo가 효율적으로 빌드 결과를 캐싱하고, 변경된 부분만 다시 처리하도록 최적화할 수 있다.

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

모노레포 내에 여러 애플리케이션을 구성하기 위해 apps 폴더 아래에 web, admin, docs 세 개의 하위 디렉터리를 만든다. 각 디렉터리로 이동한 뒤, pnpm create next-app@latest . --ts --eslint 명령어를 사용해 타입스크립트와 ESLint 설정이 포함된 Next.js 애플리케이션을 빠르게 생성한다. 이렇게 하면 각각의 앱이 독립적인 Next.js 프로젝트로 초기화되어, 모노레포 내에서 다양한 서비스를 한꺼번에 관리할 수 있는 기반이 마련된다.

untitled
SH
mkdir -p apps/web apps/admin apps/docs
 
cd apps/web
pnpm create next-app@latest . --ts --eslint
cd ../../
 
cd apps/admin
pnpm create next-app@latest . --ts --eslint
cd ../../
 
cd apps/docs
pnpm create next-app@latest . --ts --eslint
cd ../../

마지막으로 루트 package.json 파일에 모노레포 전체를 관리하는 주요 스크립트를 추가한다. dev 스크립트는 turborepo의 turbo run dev --parallel 명령어를 통해 여러 패키지의 개발 서버를 병렬로 실행하도록 설정했다. build와 lint 스크립트도 각각 turborepo를 이용해 관련 작업을 한 번에 처리하도록 구성되어 있다. 이렇게 하면 모노레포 내 모든 패키지에 걸쳐 일관된 개발, 빌드, 린트 작업을 손쉽게 실행할 수 있다.

package.json
JSON
{
  "name": "my-monorepo",
  "private": true,
  "scripts": {
    "dev": "turbo run dev --parallel",
    "build": "turbo run build",
    "lint": "turbo run lint"
  },
  "devDependencies": {
    "turbo": "latest"
  }
}

공통 패키지 생성과 공유

모노레포의 큰 장점 중 하나는 여러 애플리케이션 간에 공통 코드를 쉽게 재사용할 수 있다는 점이다. 이를 위해 /packages 폴더를 만들고, 그 안에 공통 라이브러리나 유틸리티 패키지를 생성한다. 예를 들어, 디자인 시스템, API 클라이언트, 혹은 공통 타입 정의 등을 이곳에 모아두면 관리가 훨씬 수월해진다.

패키지를 생성할 때는 일반적인 npm 패키지 구조를 따라 package.json을 작성하고, 필요한 의존성을 설치한다. 이후 앱 프로젝트에서는 pnpm 워크스페이스 덕분에 해당 공통 패키지를 마치 외부 라이브러리처럼 dependencies에 추가하여 바로 사용할 수 있다. 이렇게 하면 중복된 코드를 줄이고, 변경사항이 즉시 모든 앱에 반영되어 유지보수 효율이 크게 올라간다.

untitled
SH
mkdir -p packages/ui
cd packages/ui
pnpm init
pnpm add react react-dom
pnpm add -D typescript @types/react @types/react-dom

모노레포 내에서 /packages에 만든 공통 패키지는 단순히 소스 코드 파일만 있는 상태로는 다른 앱에서 바로 사용할 수 없다. 대부분의 경우 TypeScript나 JSX 같은 고급 문법을 그대로 실행 환경에 적용할 수 없기 때문에, 빌드 과정을 거쳐 일반 자바스크립트 코드와 타입 선언 파일(.d.ts)로 변환해야 한다.

이 빌드 결과물이 dist 같은 별도의 폴더에 생성되며, 앱들은 이 결과물을 참조해 정상적으로 동작한다. 따라서 공통 패키지에 변경 사항이 생기면 반드시 빌드를 실행해 최신 상태로 만들어야 다른 앱에서 수정된 내용을 문제없이 사용할 수 있다. turborepo 같은 도구를 사용하면 모노레포 전체 빌드를 자동화하고 최적화할 수 있어 이 과정을 더욱 편리하게 관리할 수 있다.

untitled
JSON
{
  "name": "@my-monorepo/ui",
  "version": "0.1.0",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "scripts": {
    "build": "tsc --build"
  }
}

모노레포 환경에서 같은 워크스페이스 내의 패키지를 의존성으로 추가할 때는 pnpm add [패키지명] --workspace 명령어를 사용하는 것이 일반적이다. 이 옵션을 사용하면 pnpm이 외부 레지스트리 대신 로컬의 해당 패키지 경로를 심볼릭 링크로 연결해주어, 별도의 배포 과정 없이도 최신 코드를 바로 참조할 수 있다. 덕분에 여러 패키지를 동시에 개발하고 테스트할 때 효율적이며, 중복 설치를 방지해 디스크 공간도 절약할 수 있다.

untitled
SH
cd apps/web
pnpm add @my-monorepo/ui --workspace

개별 패키지 설치와 공유

각 앱에서 필요한 라이브러리를 따로 설치하려면, 해당 앱 디렉터리로 이동한 후 일반적인 pnpm add 명령어를 사용하면 된다. 예를 들어, apps/web에서 최신 버전의 caro-kann을 설치하려면 다음과 같이 실행한다.

untitled
SH
cd apps/web
pnpm add @ilokesto/caro-kann

이렇게 하면 해당 앱의 package.json과 node_modules에 라이브러리가 추가된다. 모노레포 루트의 pnpm-workspace.yaml 덕분에 의존성 관리가 통합되지만, 각 앱은 독립적으로 자신에게 필요한 패키지를 설치하고 사용할 수 있다. 만약 여러 앱에서 공통으로 사용하는 라이브러리가 있다면, 이를 /packages에 공통 패키지로 만들거나, 루트 레벨에 설치해 모든 앱이 공유하도록 할 수도 있다.

개별 어플리케이션 배포

모노레포 환경에서 여러 애플리케이션을 성공적으로 개발했다면, 다음으로 고려해야 할 과제는 각 애플리케이션을 어떻게 독립적으로 배포할 것인가이다. 단순히 모노레포 전체를 한 번에 배포하는 방식은 불필요하게 무겁고 비효율적이다. 실제 서비스 운영에서는 각 애플리케이션을 가볍고 최적화된 형태로 분리하여 배포하는 것이 중요하다. 이를 위해 현업에서 가장 널리 쓰이는 방식 중 하나가 Docker를 활용한 컨테이너화이다.

Next.js 애플리케이션을 컨테이너화할 때 핵심은 standalone 출력 모드를 활용하는 것이다. 이 모드를 활성화하면 빌드 시점에 애플리케이션 실행에 필요한 최소한의 파일만 .next/standalone 폴더에 모아준다. 이 폴더에는 프로덕션용 node_modules, 서버 실행 파일 등이 포함되며, 다른 워크스페이스와 완전히 분리된 배포 단위를 만들어 준다. 덕분에 불필요한 소스 코드나 개발 의존성을 배포 이미지에 포함하지 않아도 된다.

untitled
TS
import type { NextConfig } from "next";
 
const nextConfig: NextConfig = {
  output: 'standalone',
  /* config options here */
};
 
export default nextConfig;

이제 apps/admin 디렉터리에 Dockerfile을 생성해 보자. 여기서는 멀티 스테이지 빌드(multi-stage build) 방식을 적용하여, 빌드 과정에서는 모노레포 전체를 활용하지만 최종 실행 단계에서는 standalone 결과물과 static, public 같은 정적 자산만 가져오도록 한다. 이런 구조는 빌드 효율성을 높이고 최종 이미지를 가볍고 안전하게 유지하는 데 도움이 된다.

untitled
PY
# Dockerfile for apps/admin
 
# --- Base Stage ---
FROM node:20-alpine AS base
WORKDIR /app
# Install pnpm globally
RUN npm install -g pnpm
 
# --- Dependencies Stage ---
FROM base AS deps
WORKDIR /app
# Copy dependency manifests
COPY pnpm-lock.yaml ./
COPY pnpm-workspace.yaml ./
COPY package.json ./
COPY turbo.json ./
# Copy package.json for each workspace to leverage caching
COPY apps/admin/package.json ./apps/admin/
COPY packages/ui/package.json ./packages/ui/
# Fetch all dependencies
RUN pnpm fetch
 
# --- Builder Stage ---
FROM base AS builder
WORKDIR /app
# Copy dependency cache and manifests from previous stage
COPY --from=deps /app/ ./
# Copy the entire monorepo source code
COPY . .
# Install dependencies using the cache and build the 'admin' app
RUN pnpm install --offline -r
RUN pnpm turbo build --filter=admin
 
# --- Runner Stage ---
FROM node:20-alpine AS runner
WORKDIR /app
 
ENV NODE_ENV=production
# Disable Next.js telemetry
ENV NEXT_TELEMETRY_DISABLED 1
 
# Create a non-root user for security
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
 
# Copy the standalone output
COPY --from=builder --chown=nextjs:nodejs /app/apps/admin/.next/standalone ./
# Copy the static assets
COPY --from=builder --chown=nextjs:nodejs /app/apps/admin/.next/static ./apps/admin/.next/static
# Copy the public assets
COPY --from=builder --chown=nextjs:nodejs /app/apps/admin/public ./apps/admin/public
 
# Set the user
USER nextjs
 
EXPOSE 3000
ENV PORT 3000
 
# Run the server
CMD ["node", "apps/admin/server.js"]

마지막으로, 작성한 Dockerfile을 활용해 로컬 환경에서 이미지를 빌드하고 실행할 수 있다. 중요한 점은 Dockerfile이 하위 디렉터리에 있기 때문에 빌드 컨텍스트를 모노레포 루트(.) 로 지정해야 한다는 것이다. 그래야 pnpm-lock.yaml이나 packages 폴더 같은 공용 자원을 정상적으로 참조할 수 있다.

untitled
SH
# 모노레포 루트에서 도커파일 실행
docker build -t admin-app:latest -f apps/admin/Dockerfile .
 
# 도커이미지로 컨테이너 실행
docker run -d -p 3000:3000 --name admin-container admin-app:latest

이 과정을 통해 apps/admin 애플리케이션을 독립적으로 배포할 수 있으며, 다른 애플리케이션 역시 동일한 패턴을 적용해 개별 배포 단위로 관리할 수 있다. 이렇게 하면 모노레포의 장점은 유지하면서도, 각 앱을 최적화된 상태로 효율적으로 배포할 수 있게 된다.