블로그 단일 레포를 모노레포로 전환했습니다.
Next.js 블로그에 어드민과 포트폴리오를 얹기까지
로그를 만들고, 고치고, 또 고치고.
이 블로그는 벌써 세 번째 리뉴얼입니다.
근데 이번엔 좀 달랐습니다.
블로그만 손보는 게 아니라, 어드민 대시보드와 포트폴리오 사이트를 같은 레포에 올리는 작업이었거든요.
결론부터 말하면, Turborepo 기반 모노레포로 전환했고 만족하고 있습니다.
왜 모노레포였나
기존 구조의 문제
원래 블로그는 단일 Next.js 레포였습니다.
hyunwoo-blog-nextjs/
├── src/
│ ├── app/
│ ├── components/
│ ├── lib/
│ ├── posts/ # MDX 파일
│ └── types/
├── public/
└── package.json
여기서 두 가지가 필요해졌습니다.
- 어드민: 블로그 글 관리, 포트폴리오 콘텐츠 관리, 설정 등을 위한 대시보드
- 포트폴리오: 이력서 대용 인터랙티브 포트폴리오 사이트
처음엔 레포를 따로 팔까 했습니다.
근데 공통으로 쓸 게 너무 많았습니다.
- UI 컴포넌트 (Button, Card, Dialog 등)
- 유틸리티 함수 (
cn(), 날짜 포맷 등) - 타입 정의 (블로그 포스트, 프로필 등)
- MDX 렌더링 로직
이걸 3개 레포에 복붙하면 나중에 관리가 안 될 게 뻔했습니다.
예전에 멀티레포로 고생한 경험이 있어서, 이번엔 처음부터 모노레포로 가기로 했습니다.
전환 전략
기존 블로그를 apps/blog로 이동
가장 고민했던 부분입니다.
이미 Vercel에 배포되어 있고, 커밋 히스토리도 살리고 싶었거든요.
방법은 단순했습니다.
- 루트에
apps/디렉터리 생성 - 기존 블로그 코드를
apps/blog/로 이동 package.json의 name을@hyunwoo/blog로 변경- 경로 참조 수정
hyunwoo-monorepo/
├── apps/
│ ├── blog/ # Next.js 16 — 기존 블로그
│ ├── admin/ # Vite + React 19 — 새로 만든 어드민
│ └── portfolio/ # Next.js 16 — 새로 만든 포트폴리오
├── packages/
│ ├── ui/ # shadcn/ui 기반 공통 컴포넌트
│ ├── shared/ # 유틸, 타입, 상수, API 클라이언트
│ └── mdx/ # MDX 렌더링 + 커스텀 컴포넌트
├── turbo.json
├── biome.json
└── package.json
3개 앱이 각각 다른 프레임워크
여기서 재미있는 건, 앱마다 기술 스택이 다르다는 겁니다.
| 앱 | 프레임워크 | 라우터 | 용도 |
|---|---|---|---|
| blog | Next.js 16 (App Router) | 파일 기반 | SSG 블로그 |
| admin | Vite + React 19 (SPA) | TanStack Router | 관리자 대시보드 |
| portfolio | Next.js 16 (App Router) | 파일 기반 | 포트폴리오 사이트 |
어드민은 SSR이 필요 없어서 Vite SPA로 갔습니다.
SEO가 필요 없는 인증 기반 대시보드니까, SPA가 오히려 맞았습니다.
블로그와 포트폴리오는 SEO가 중요해서 Next.js를 유지했습니다.
Turborepo 설정
turbo.json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {},
"lint:ci": {},
"test": {
"cache": false
},
"test:run": {}
}
}outputs에 .next/**와 dist/**를 둘 다 넣은 건, Next.js 앱과 Vite 앱의 빌드 결과물 경로가 다르기 때문입니다.
pnpm workspace
packages:
- "apps/*"
- "packages/*"패키지 매니저는 pnpm을 썼습니다.
디스크 효율도 좋고, workspace:* 프로토콜로 내부 패키지 참조가 깔끔합니다.
루트 package.json
{
"name": "hyunwoo-monorepo",
"private": true,
"packageManager": "pnpm@10.28.0",
"scripts": {
"dev": "turbo dev",
"dev:blog": "turbo dev --filter=@hyunwoo/blog",
"dev:admin": "turbo dev --filter=@hyunwoo/admin",
"dev:portfolio": "turbo dev --filter=@hyunwoo/portfolio",
"build": "turbo build",
"lint": "turbo lint",
"prepare": "husky"
},
"devDependencies": {
"@biomejs/biome": "^2.4.8",
"husky": "^9.1.7",
"lint-staged": "^16.4.0",
"turbo": "^2",
"typescript": "^5"
}
}앱별 dev:blog, dev:admin, dev:portfolio 스크립트를 따로 뒀습니다.
pnpm dev로 전체를 띄울 수도 있지만, 보통은 작업 중인 앱만 띄우는 게 빠릅니다.
공통 패키지 설계
packages/ui — 공통 UI 컴포넌트
shadcn/ui를 packages/ui에 통합했습니다.
Button, Card, Dialog, Input 등 3개 앱에서 공통으로 쓰는 컴포넌트가 여기 있습니다.
{
"name": "@hyunwoo/ui",
"exports": {
".": "./src/index.ts",
"./globals.css": "./src/globals.css"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-select": "^2.1.6",
"class-variance-authority": "^0.7.1",
"sonner": "^2.0.3"
}
}앱에서 사용할 때:
import { Button, Card, toast } from '@hyunwoo/ui'barrel export로 한 번에 import하는 방식입니다.
트리쉐이킹은 번들러가 알아서 해줍니다.
packages/shared — 유틸, 타입, 상수
{
"name": "@hyunwoo/shared",
"exports": {
".": "./src/index.ts",
"./api": "./src/api/index.ts",
"./config": "./src/config/index.ts",
"./lib": "./src/lib/index.ts",
"./types": "./src/types/index.ts"
}
}서브패스 export를 활용해서 용도별로 import 경로를 분리했습니다.
import { cn } from '@hyunwoo/shared/lib'
import type { BlogPost } from '@hyunwoo/shared/types'
import { CACHE_TAGS } from '@hyunwoo/shared/config'
import { apiFetch } from '@hyunwoo/shared/api'여기서 apiFetch는 블로그, 어드민, 포트폴리오 모두가 같은 백엔드 API를 호출하기 때문에 공통으로 뺀 겁니다.
packages/mdx — MDX 렌더링
블로그와 포트폴리오 둘 다 MDX 콘텐츠를 렌더링합니다.
next-mdx-remote, rehype 플러그인, 커스텀 MDX 컴포넌트(Callout, Highlight, MdxImage 등)를 여기에 모았습니다.
// apps/blog에서
import { MdxRenderer } from '@hyunwoo/mdx/renderer'
import { mdxComponents } from '@hyunwoo/mdx/components'
// apps/portfolio에서도 동일
import { MdxRenderer } from '@hyunwoo/mdx/renderer'MDX 관련 의존성이 꽤 많은데, 이걸 앱마다 따로 설치하면 버전이 틀어질 수 있습니다.
패키지로 묶어놓으니 한 곳에서 관리됩니다.
CI/CD 파이프라인
GitHub Actions
name: CI
on:
pull_request:
branches: [main, dev]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
ci:
name: Lint, Type Check, Test & Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
with:
node-version: 20
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm lint:ci
- name: Build
run: pnpm build
- name: Test
run: pnpm test:runPR을 열면 자동으로 lint, build, test가 돌아갑니다.
concurrency로 같은 PR에서 여러 번 push하면 이전 빌드를 취소시킵니다.
pnpm lint:ci와 pnpm lint는 다릅니다.
lint는 biome check --write로 자동 수정까지 하고,
lint:ci는 biome check로 체크만 합니다.
CI에서 코드를 수정하면 안 되니까요.
Husky + lint-staged
{
"lint-staged": {
"*.{ts,tsx}": ["biome check --write --no-errors-on-unmatched"],
"*.{json,css}": ["biome format --write --no-errors-on-unmatched"]
}
}커밋할 때 staged 파일만 Biome으로 체크합니다.
전체 린트를 돌리면 느리지만, staged 파일만 돌리면 1-2초면 끝납니다.
Branch Protection
main 브랜치에는 protection rule을 걸어놨습니다.
Lint, Type Check, Test & Build체크 필수 통과strict: true— PR 브랜치가 main과 동기화되어 있어야 머지 가능
혼자 하는 프로젝트인데 왜 이렇게까지 하냐면, 습관입니다.
현업에서도 이렇게 하니까 개인 프로젝트에서도 동일하게 가져갑니다.
Vercel 배포 구조
모노레포를 Vercel에 배포할 때 한 가지 알아야 할 게 있습니다.
하나의 GitHub 레포에서 여러 Vercel 프로젝트를 생성할 수 있습니다.
| Vercel 프로젝트 | Root Directory | 도메인 |
|---|---|---|
| hyunwoo-blog | apps/blog | chahyunwoo.dev |
| hyunwoo-admin | apps/admin | admin.chahyunwoo.dev |
| hyunwoo-portfolio | apps/portfolio | portfolio.chahyunwoo.dev |
Vercel에서 "Import Git Repository"할 때 Turborepo를 자동 감지합니다.
어떤 앱을 배포할지 선택하면 Root Directory가 자동으로 설정됩니다.
Build Command
각 Vercel 프로젝트의 Build Command는 turbo run build입니다.
Turborepo가 의존성 그래프를 보고, 해당 앱에 필요한 패키지만 빌드합니다.
예를 들어 portfolio를 빌드하면:
@hyunwoo/shared:build → @hyunwoo/portfolio:build
shared를 먼저 빌드하고, 그 다음 portfolio를 빌드합니다.
변경이 없으면 캐시를 사용하니까, 실제로는 몇 초 만에 끝나는 경우도 많습니다.
환경변수
앱마다 필요한 환경변수가 다릅니다.
- blog: 특별한 환경변수 없음 (MDX 파일 기반)
- admin:
NEXT_PUBLIC_API_URL,NEXT_PUBLIC_API_KEY - portfolio:
NEXT_PUBLIC_API_URL,NEXT_PUBLIC_API_KEY
Vercel에서 프로젝트별로 따로 설정합니다.
루트의 .env는 로컬 개발용이고, 프로덕션 환경변수는 Vercel Dashboard에서 관리합니다.
헤맸던 부분들
1. Tailwind CSS 4 + 모노레포
Tailwind 4는 CSS-first 설정 방식이라, 모노레포에서 다른 패키지의 클래스를 인식하려면 @source가 필요합니다.
@import "tailwindcss";
@source "../../../../packages/ui/src/**/*.tsx";
@source "../../../../packages/mdx/src/**/*.tsx";경로가 좀 지저분한데, 이건 Tailwind 4의 한계입니다.
패키지 이름으로 참조하는 방법이 아직 없습니다.
2. shared 패키지 빌드 순서
처음에 packages/shared를 빌드 없이 소스 직접 참조 방식으로 했습니다.
근데 Next.js에서 서버 컴포넌트가 shared 패키지의 타입을 제대로 못 읽는 이슈가 있었습니다.
결국 shared 패키지는 tsc로 빌드해서 dist/를 생성하는 방식으로 바꿨습니다.
turbo.json의 dependsOn: ["^build"]가 빌드 순서를 보장합니다.
3. Vercel에서 모노레포 첫 배포
처음 Vercel에 연결했을 때, Root Directory 설정을 빼먹어서 루트에서 빌드를 시도했습니다.
당연히 실패.
Vercel Dashboard > Settings > General > Root Directory에서 apps/blog 같이 앱 경로를 지정해야 합니다.
4. next.config 설정 차이
Next.js 16에서 experimental.reactCompiler가 reactCompiler로 이동했는데, 포트폴리오 앱에서 이전 설정을 그대로 가져가서 빌드 warning이 떴습니다.
// before
experimental: { reactCompiler: true }
// after
reactCompiler: trueNext.js 메이저 업그레이드할 때마다 설정 위치가 바뀌는 건 좀 짜증나긴 합니다.
근데 빌드 로그에 친절하게 알려주니까 금방 고칠 수 있습니다.
Biome으로 린팅 통합
ESLint 대신 Biome을 쓰고 있습니다.
{
"formatter": {
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 120
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"semicolons": "asNeeded",
"trailingCommas": "all"
}
},
"linter": {
"rules": {
"recommended": true,
"correctness": {
"noUnusedImports": "error",
"useExhaustiveDependencies": "warn"
},
"suspicious": {
"noExplicitAny": "error"
}
}
}
}루트에 biome.json 하나만 두면 모든 앱과 패키지에 적용됩니다.
ESLint처럼 앱마다 설정 파일을 따로 만들 필요가 없습니다.
Biome은 CSS 린팅도 지원합니다.
Tailwind의 @theme, @source 같은 디렉티브는 tailwindDirectives: true로 인식시켜야 합니다.
정리
개인 블로그를 모노레포로 전환하면서 느낀 점입니다.
좋은 점
- 공통 코드를 한 곳에서 관리하니 일관성이 생겼습니다
- UI 컴포넌트 수정하면 3개 앱에 바로 반영됩니다
- Biome 설정 하나로 전체 린팅이 통일됩니다
- Turborepo 캐싱으로 CI가 빨라졌습니다
- Vercel 배포가 생각보다 간단합니다
어려웠던 점
- Tailwind 4 + 모노레포 조합에서
@source경로 관리 - 패키지 간 빌드 순서와 타입 해석 이슈
- 처음에 구조 잡는 데 시간이 좀 걸립니다
팁
- 처음부터 완벽한 패키지 분리를 하려 하지 말고, 중복이 보일 때 점진적으로 추출하는 게 좋습니다
pnpm dev --filter=앱이름으로 필요한 앱만 띄우면 개발이 훨씬 빠릅니다- Vercel 환경변수는 프로젝트별로 따로 관리해야 합니다
혼자 하는 사이드 프로젝트라도, 앱이 2개 이상이면 모노레포를 추천합니다.
초기 비용은 있지만, 그 이후로는 확실히 편해집니다.
이외에 모노레포에 함께 들어있는 제 포트폴리오는 Portfolio에서 확인하실 수 있습니다.
추후 admin 개발기와 portfollio 개발기를 업로드할 예정입니다.
뿌듯하네요!
참고 자료
연관 게시글