Monorepo 구조로 코드픽 풀스택 개발하기
안녕하세요, 코드픽 기술 블로그 독자 여러분!
코드픽(codepick.kr)은 개발자들을 위한 혁신적인 학습 및 코드 공유 플랫폼을 지향하며, 사용자에게 최고의 경험을 제공하기 위해 끊임없이 기술 스택과 아키텍처를 고민하고 발전시켜나가고 있습니다. 특히 풀스택 애플리케이션 개발에 있어 복잡한 의존성 관리, 일관되지 않은 개발 환경, 그리고 비효율적인 빌드 및 배포 과정은 많은 개발 팀의 고질적인 문제로 손꼽힙니다. 이러한 문제들을 해결하고 개발 생산성을 극대화하기 위해 코드픽은 Monorepo(모노레포) 구조를 채택했습니다.
이 글에서는 Monorepo가 무엇인지, 왜 코드픽과 같은 풀스택 프로젝트에 Monorepo가 강력한 솔루션이 될 수 있는지, 그리고 Turborepo와 pnpm을 활용하여 실제 Monorepo 환경을 어떻게 구축하고 운영하는지에 대한 실전 가이드를 제공하고자 합니다. 코드 예제와 함께 Monorepo의 장점을 최대한 활용하여 개발 워크플로우를 최적화하는 방법을 함께 탐구해봅시다.
Monorepo란 무엇이며, 왜 코드픽에 필요한가?
소프트웨어 개발에서 **저장소(Repository)**는 코드와 관련 자산을 관리하는 핵심 공간입니다. 일반적으로 하나의 프로젝트는 하나의 저장소에 담기는 Multirepo(멀티레포) 방식을 사용합니다. 하지만 Monorepo는 이와 달리, 단일 Git 저장소 안에 여러 개의 독립적인 프로젝트나 패키지를 함께 관리하는 방식을 의미합니다.
Monorepo의 정의
Monorepo는 "Mono" (하나)와 "Repo" (저장소)의 합성어로, 이름 그대로 모든 코드가 하나의 저장소에 집중되어 있다는 것을 뜻합니다. 코드픽의 경우, 사용자에게 보이는 프론트엔드 웹 애플리케이션, 백엔드 API 서버, 관리자 페이지, 백그라운드 워커 등 다양한 서비스와 이들이 공유하는 UI 컴포넌트, 유틸리티 함수, 타입 정의 등이 모두 하나의 Git 저장소 내에 존재합니다.
Monorepo의 장점
코드픽이 Monorepo를 선택한 이유는 다음과 같은 명확한 이점들 때문입니다.
코드 공유 및 재사용성 증대:
- 공통 UI 컴포넌트: 코드픽 웹과 관리자 페이지에서 동일한 디자인 시스템을 사용한다면, 이 UI 컴포넌트들을
packages/ui와 같은 단일 패키지로 관리하여 모든 프론트엔드 애플리케이션에서 쉽게 재사용할 수 있습니다. - 공유 유틸리티 함수: 날짜 포맷팅, 데이터 검증, 인증 관련 로직 등 여러 서비스에서 공통적으로 사용되는 유틸리티 함수들을
packages/utils에 모아두면 중복 코드를 줄이고 유지보수를 용이하게 합니다. - 타입 정의: TypeScript 기반의 프로젝트에서 백엔드와 프론트엔드가 공유하는 데이터 모델(예: 사용자 정보, 게시글 정보)의 타입 정의를
packages/types에 한 번만 정의하여 일관성을 유지하고 휴먼 에러를 방지할 수 있습니다. - 데이터베이스 스키마 및 ORM 클라이언트: Prisma와 같은 ORM을 사용할 경우,
packages/db에 스키마와 클라이언트를 정의하여 백엔드 API와 워커 등 모든 데이터 접근 계층에서 동일한 설정을 공유할 수 있습니다.
- 공통 UI 컴포넌트: 코드픽 웹과 관리자 페이지에서 동일한 디자인 시스템을 사용한다면, 이 UI 컴포넌트들을
단일 버전 관리 및 의존성 일관성:
- 모든 프로젝트가 동일한 저장소에 있으므로, 모든 패키지가 동일한 Git 커밋 해시를 기준으로 버전을 관리하게 됩니다. 이는 특정 라이브러리의 버전 불일치로 인해 발생하는 문제를 방지하고, 전체 시스템의 안정성을 높입니다.
react,typescript와 같은 핵심 의존성을 Monorepo 루트에서 관리하면, 모든 서브 프로젝트가 동일한 버전을 사용하도록 강제하여 예측 불가능한 버그를 줄일 수 있습니다.
일관된 개발 환경 및 도구:
- ESLint, Prettier, TypeScript 설정 등 개발 환경에 필요한 공통 설정을
packages/config와 같은 단일 패키지로 관리할 수 있습니다. 이를 통해 모든 개발자가 동일한 코드 스타일과 검사 규칙을 적용하여 코드 품질을 일관되게 유지할 수 있습니다. - 새로운 개발자를 온보딩할 때도, 단일 저장소와 일관된 설정으로 인해 학습 곡선을 줄일 수 있습니다.
- ESLint, Prettier, TypeScript 설정 등 개발 환경에 필요한 공통 설정을
간소화된 CI/CD 및 효율적인 빌드:
- 단일 저장소는 CI/CD 파이프라인을 단순화합니다. 모든 변경 사항이 한 곳에 모이므로, 변경된 부분만 감지하여 빌드/테스트/배포하는 전략을 세우기 용이합니다.
- Turborepo와 같은 Monorepo 도구는 캐싱과 증분 빌드 기능을 제공하여, 이전에 빌드된 적이 없는 코드만 다시 빌드하고 변경되지 않은 코드는 캐시된 결과를 재사용함으로써 빌드 시간을 획기적으로 단축시킵니다.
팀 간 협업 용이:
- 백엔드 개발자가 프론트엔드 코드를, 프론트엔드 개발자가 백엔드 코드를 쉽게 탐색하고 이해할 수 있습니다. 이는 팀원 간의 지식 공유를 촉진하고, 풀스택 개발 역량을 강화하는 데 기여합니다.
- 코드 리뷰 시에도 전체 시스템의 맥락을 이해하기 쉬워집니다.
Monorepo의 단점 및 고려사항
물론 Monorepo가 만능은 아니며, 몇 가지 단점과 고려해야 할 사항도 있습니다.
초기 설정의 복잡성:
- Multirepo에 비해 Monorepo를 처음 설정하는 과정은 더 많은 지식과 노력을 요구합니다. 적절한 도구(Turborepo, Nx, Lerna 등)를 선택하고, 워크스페이스를 구성하며, 패키지 간 의존성을 올바르게 설정해야 합니다.
빌드 및 테스트 시간 증가 가능성:
- 모든 코드가 한곳에 모여 있기 때문에, 전체 프로젝트를 빌드하거나 테스트할 경우 시간이 오래 걸릴 수 있습니다. 하지만 Turborepo와 같은 도구의 캐싱 및 증분 빌드 기능을 잘 활용하면 이 문제를 효과적으로 완화할 수 있습니다.
도구 선택의 중요성:
- Monorepo의 이점을 최대한 활용하려면 적절한 워크스페이스 관리 도구와 빌드 도구를 선택하는 것이 매우 중요합니다. 선택에 따라 개발 경험이 크게 달라질 수 있습니다.
학습 곡선:
- Monorepo에 익숙하지 않은 개발자에게는 새로운 개념과 도구에 대한 학습이 필요합니다.
코드픽은 이러한 장단점을 면밀히 검토한 결과, 풀스택 개발의 복잡성을 관리하고 개발 생산성을 높이는 데 Monorepo가 훨씬 더 유리하다고 판단했습니다. 특히 Turborepo의 강력한 빌드 캐싱과 pnpm의 효율적인 의존성 관리는 Monorepo의 단점을 상쇄하고 장점을 극대화하는 데 핵심적인 역할을 합니다.
코드픽의 Monorepo 아키텍처 설계
코드픽은 Monorepo의 이점을 최대한 활용하기 위해 다음과 같은 아키텍처를 설계했습니다. 핵심은 apps와 packages 디렉토리를 명확히 분리하고, 각자의 역할에 맞는 기술 스택을 선택하는 것입니다.
전체 구조 개요
코드픽 Monorepo는 크게 두 개의 최상위 디렉토리로 구성됩니다.
apps/: 실제 사용자에게 서비스를 제공하거나 백그라운드에서 동작하는 "애플리케이션" 프로젝트들을 포함합니다. 각 애플리케이션은 독립적으로 빌드되고 배포될 수 있습니다.packages/: 여러apps에서 공유되는 "패키지" 또는 "라이브러리"들을 포함합니다. 이들은 독립적으로 배포되기보다는apps의 의존성으로 사용됩니다.
기술 스택 선택
코드픽 Monorepo의 핵심 기술 스택은 다음과 같습니다.
- 프론트엔드 (Frontend): Next.js (React, TypeScript)
- 사용자 웹 서비스(
apps/web)와 관리자 페이지(apps/admin) 모두 Next.js를 사용하여 SSR(Server-Side Rendering) 및 SSG(Static Site Generation)의 이점을 활용하고 개발 경험을 통일했습니다.
- 사용자 웹 서비스(
- 백엔드 (Backend): NestJS (Node.js, TypeScript)
- 확장 가능하고 견고한 API 서버(
apps/api)와 백그라운드 작업을 처리하는 워커(apps/worker)를 NestJS로 구축하여 엔터프라이즈급 백엔드 개발 생산성을 확보했습니다.
- 확장 가능하고 견고한 API 서버(
- 데이터베이스 (Database): PostgreSQL with Prisma ORM
- 안정적이고 강력한 관계형 데이터베이스인 PostgreSQL을 사용하고, 타입 세이프티와 생산성을 높이기 위해 Prisma ORM을 채택했습니다. Prisma 스키마와 클라이언트는
packages/db에서 통합 관리됩니다.
- 안정적이고 강력한 관계형 데이터베이스인 PostgreSQL을 사용하고, 타입 세이프티와 생산성을 높이기 위해 Prisma ORM을 채택했습니다. Prisma 스키마와 클라이언트는
- Monorepo 도구: Turborepo
- 빠른 빌드 속도, 캐싱, 증분 빌드, 원격 캐시 지원 등 Monorepo 환경에 최적화된 기능을 제공하여 개발 생산성을 극대화합니다.
- 패키지 매니저: pnpm
- 심볼릭 링크를 활용하여 디스크 공간을 절약하고 설치 속도를 향상시킵니다. Monorepo 환경에서 의존성 호이스팅 문제를 최소화하고 안정적인 워크스페이스 관리를 가능하게 합니다.
Monorepo 디렉토리 구조 예시
다음은 코드픽 Monorepo의 간략화된 디렉토리 구조 예시입니다.
/
├── apps/
│ ├── web/ # Next.js 기반 사용자 웹 프론트엔드 애플리케이션
│ │ ├── public/
│ │ ├── src/
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── api/ # NestJS 기반 백엔드 REST API 서버 애플리케이션
│ │ ├── src/
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── admin/ # Next.js 기반 관리자 페이지 프론트엔드 애플리케이션
│ │ ├── public/
│ │ ├── src/
│ │ ├── package.json
│ │ └── tsconfig.json
│ └── worker/ # Node.js 기반 백그라운드 작업 처리 워커 애플리케이션
│ ├── src/
│ ├── package.json
│ └── tsconfig.json
├── packages/
│ ├── ui/ # 공유 UI 컴포넌트 라이브러리 (React, Tailwind CSS 등)
│ │ ├── src/
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── utils/ # 공통 유틸리티 함수 및 헬퍼 모듈
│ │ ├── src/
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── types/ # 백엔드와 프론트엔드 간 공유되는 타입 정의
│ │ ├── src/
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── config/ # ESLint, Prettier, TypeScript 등 공통 개발 설정
│ │ ├── eslint-preset/
│ │ ├── prettier-config/
│ │ ├── tsconfig/
│ │ └── package.json
│ └── db/ # Prisma ORM 스키마 정의 및 클라이언트 모듈
│ ├── prisma/
│ ├── src/
│ ├── package.json
│ └── tsconfig.json
├── pnpm-workspace.yaml # pnpm 워크스페이스 설정 파일
├── package.json # Monorepo 루트의 패키지 정의 (공통 의존성, 스크립트)
├── turbo.json # Turborepo 빌드 설정 파일
├── tsconfig.json # Monorepo 루트의 공통 TypeScript 설정
└── .gitignore # Git 무시 파일
이 구조는 각 프로젝트의 독립성을 유지하면서도, 공유 가능한 로직을 packages로 분리하여 효율적인 재사용을 가능하게 합니다.
Turborepo와 pnpm을 활용한 Monorepo 구축 실전 가이드
이제 실제로 Turborepo와 pnpm을 사용하여 Monorepo를 구축하는 과정을 단계별로 살펴보겠습니다.
1. 초기 프로젝트 설정
먼저 빈 디렉토리를 생성하고 pnpm 워크스페이스를 초기화합니다.
# 프로젝트 디렉토리 생성
mkdir codepick-monorepo
cd codepick-monorepo
# pnpm 워크스페이스 초기화
pnpm init -w
# Turborepo 설치 (루트 의존성으로)
pnpm add -w turbo typescript
pnpm init -w 명령어는 루트에 package.json 파일을 생성하고 pnpm-workspace.yaml 파일을 초기화합니다. pnpm-workspace.yaml은 Monorepo 내에서 어떤 디렉토리를 패키지로 인식할지 정의합니다.
pnpm-workspace.yaml 예시:
packages:
- apps/*
- packages/*
이 설정은 apps와 packages 디렉토리 내의 모든 서브 디렉토리를 pnpm 워크스페이스의 패키지로 인식하도록 합니다.
2. packages 디렉토리 만들기
공유될 패키지들을 packages 디렉토리 안에 생성합니다. 여기서는 ui, utils, types, db, config 패키지를 예시로 들겠습니다.
packages/ui (React 컴포넌트 라이브러리):
mkdir -p packages/ui
cd packages/ui
pnpm init
# React, Tailwind CSS 등 UI 관련 의존성 설치
pnpm add react react-dom tailwindcss postcss autoprefixer @types/react @types/react-dom
# package.json 수정
# packages/ui/package.json
{
"name": "@codepick/ui", # 워크스페이스 내에서 참조할 이름
"version": "0.0.0",
"main": "./src/index.ts", # 메인 진입점
"types": "./src/index.ts", # 타입 정의 파일
"license": "MIT",
"scripts": {
"lint": "eslint .",
"generate:component": "turbo gen react-component" # Turborepo의 제너레이터와 연동 가능
},
"devDependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"typescript": "^5.3.3",
"@types/react": "^18.2.46",
"@types/react-dom": "^18.2.18",
"@codepick/eslint-config": "workspace:*", # 내부 config 패키지 참조
"@codepick/tsconfig": "workspace:*" # 내부 config 패키지 참조
},
"peerDependencies": { # UI 컴포넌트는 React를 피어 의존성으로 가짐
"react": "^18.2.0"
}
}
"name": "@codepick/ui"는 이 패키지를 다른 워크스페이스 패키지에서 참조할 때 사용될 이름입니다. "workspace:*"는 pnpm이 이 의존성을 Monorepo 내의 다른 패키지로 해석하도록 지시합니다.
packages/utils (공통 유틸리티 함수):
mkdir -p packages/utils
cd packages/utils
pnpm init
# packages/utils/package.json
{
"name": "@codepick/utils",
"version": "0.0.0",
"main": "./src/index.ts",
"types": "./src/index.ts",
"license": "MIT",
"devDependencies": {
"typescript": "^5.3.3",
"@codepick/tsconfig": "workspace:*"
}
}
packages/db (Prisma ORM):
mkdir -p packages/db
cd packages/db
pnpm init
pnpm add prisma @prisma/client
# packages/db/package.json
{
"name": "@codepick/db",
"version": "0.0.0",
"main": "./src/index.ts",
"types": "./src/index.ts",
"license": "MIT",
"scripts": {
"db:push": "prisma db push",
"db:generate": "prisma generate"
},
"devDependencies": {
"prisma": "^5.8.1",
"typescript": "^5.3.3",
"@codepick/tsconfig": "workspace:*"
}
}
packages/db/prisma/schema.prisma 파일에 데이터베이스 스키마를 정의하고, src/index.ts에서 Prisma 클라이언트를 export하여 다른 앱에서 사용할 수 있도록 합니다.
packages/config (공통 설정):
ESLint, Prettier, TypeScript 설정 등을 한곳에 모읍니다.
mkdir -p packages/config/eslint-preset
mkdir -p packages/config/prettier-config
mkdir -p packages/config/tsconfig
cd packages/config
pnpm init
# packages/config/package.json
{
"name": "@codepick/config",
"version": "0.0.0",
"private": true, # 이 패키지는 외부에 배포되지 않음을 명시
"license": "MIT",
"devDependencies": {
"eslint": "^8.56.0",
"eslint-config-next": "^14.0.4",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"prettier": "^3.1.1",
"typescript": "^5.3.3"
}
}
그리고 각 설정 파일을 해당 디렉토리에 생성합니다. 예를 들어, packages/config/eslint-preset/index.js에 ESLint 설정을 정의하고, packages/config/tsconfig/base.json에 기본 TypeScript 설정을 정의합니다.
3. apps 디렉토리 만들기
이제 실제 애플리케이션들을 apps 디렉토리 안에 생성합니다.
apps/web (Next.js 프론트엔드):
# Next.js 앱 생성 (pnpm create 사용)
pnpm create next-app web --ts --eslint --tailwind --app --src-dir --import-alias "@/*"
cd apps/web
# 생성된 앱의 package.json에 내부 패키지 의존성 추가
pnpm add @codepick/ui @codepick/utils @codepick/db
# (선택 사항) 공통 config 패키지 활용
pnpm add -D @codepick/eslint-config @codepick/tsconfig
apps/web/package.json은 다음과 같이 보일 수 있습니다.
// apps/web/package.json
{
"name": "web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"react": "^18",
"react-dom": "^18",
"next": "14.0.4",
"@codepick/ui": "workspace:*", // 내부 UI 패키지 참조
"@codepick/utils": "workspace:*", // 내부 유틸리티 패키지 참조
"@codepick/db": "workspace:*" // 내부 DB 패키지 참조
},
"devDependencies": {
"typescript": "^5.3.3",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"@codepick/eslint-config": "workspace:*",
"@codepick/tsconfig": "workspace:*",
"autoprefixer": "^10.0.1",
"postcss": "^8",
"tailwindcss": "^3.3.0"
}
}
그리고 apps/web/tsconfig.json에서 paths를 사용하여 내부 패키지들을 쉽게 임포트할 수 있도록 설정하고, extends를 사용하여 공통 tsconfig를 상속받습니다.
// apps/web/tsconfig.json
{
"extends": "@codepick/tsconfig/next.json", // 공통 Next.js TS 설정 상속
"compilerOptions": {
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"],
"references": [ // Monorepo 내 패키지 참조
{ "path": "../../packages/ui" },
{ "path": "../../packages/utils" },
{ "path": "../../packages/db" },
{ "path": "../../packages/config/tsconfig" }
]
}
apps/api (NestJS 백엔드):
# NestJS 앱 생성
pnpm create nest-app api
cd apps/api
# 내부 패키지 의존성 추가
pnpm add @codepick/utils @codepick/db
pnpm add -D @codepick/eslint-config @codepick/tsconfig
마찬가지로 apps/api/package.json과 apps/api/tsconfig.json을 적절히 수정합니다.
4. Turborepo 설정 (turbo.json)
Monorepo 루트에 turbo.json 파일을 생성하여 Turborepo의 빌드 파이프라인과 캐싱 전략을 정의합니다.
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"build": {
"dependsOn": ["^build"], // 상위 의존성(packages)의 build 작업이 먼저 완료되어야 함
"outputs": ["dist/**", ".next/**", "!.next/cache/**"] // 빌드 결과물 캐싱
},
"test": {
"dependsOn": ["^build"],
"outputs": [] // 테스트 결과는 캐싱하지 않음
},
"lint": {
"outputs": []
},
"dev": {
"cache": false, // 개발 모드는 캐싱하지 않음
"persistent": true // 프로세스가 계속 실행되도록 함
}
},
"globalDependencies": ["**/.env", ".env.*"] // 환경 변수 파일 변경 시 전체 재빌드
}
pipeline: Monorepo 내의 각 패키지/앱에서 실행될 스크립트(예:build,test,lint,dev)에 대한 설정을 정의합니다.dependsOn: 특정 작업이 실행되기 전에 완료되어야 하는 다른 작업들을 지정합니다.^build는 현재 패키지가 의존하는packages들의build스크립트가 먼저 실행되어야 함을 의미합니다.outputs: 해당 작업의 결과물 중 캐시할 파일들을 정의합니다.dist/**,.next/**등 빌드 결과 디렉토리를 지정하여 다음 빌드 시 변경 사항이 없으면 캐시된 결과를 재사용합니다.cache: false: 개발 서버(dev)와 같이 지속적으로 실행되는 작업은 캐싱하지 않습니다.persistent: true: 해당 작업이 완료된 후에도 프로세스를 유지합니다 (예:next dev,nest start --watch).globalDependencies: 이 파일들이 변경되면 모든 작업이 캐시를 무시하고 다시 실행되도록 합니다..env파일 등이 대표적입니다.
5. 공통 설정 관리 (ESLint, Prettier, TypeScript)
packages/config 디렉토리에 공통 설