포스트 검색

제목, 태그, 카테고리로 검색합니다

목록으로
Frontend

블로그 단일 레포를 모노레포로 전환했습니다.

Next.js 블로그에 어드민과 포트폴리오를 얹기까지

Mar 11, 2026
|
9 min read

로그를 만들고, 고치고, 또 고치고.
이 블로그는 벌써 세 번째 리뉴얼입니다.

근데 이번엔 좀 달랐습니다.
블로그만 손보는 게 아니라, 어드민 대시보드와 포트폴리오 사이트를 같은 레포에 올리는 작업이었거든요.

결론부터 말하면, Turborepo 기반 모노레포로 전환했고 만족하고 있습니다.

왜 모노레포였나

기존 구조의 문제

원래 블로그는 단일 Next.js 레포였습니다.

hyunwoo-blog-nextjs/
├── src/
│   ├── app/
│   ├── components/
│   ├── lib/
│   ├── posts/       # MDX 파일
│   └── types/
├── public/
└── package.json

여기서 두 가지가 필요해졌습니다.

  1. 어드민: 블로그 글 관리, 포트폴리오 콘텐츠 관리, 설정 등을 위한 대시보드
  2. 포트폴리오: 이력서 대용 인터랙티브 포트폴리오 사이트

처음엔 레포를 따로 팔까 했습니다.
근데 공통으로 쓸 게 너무 많았습니다.

  • UI 컴포넌트 (Button, Card, Dialog 등)
  • 유틸리티 함수 (cn(), 날짜 포맷 등)
  • 타입 정의 (블로그 포스트, 프로필 등)
  • MDX 렌더링 로직

이걸 3개 레포에 복붙하면 나중에 관리가 안 될 게 뻔했습니다.
예전에 멀티레포로 고생한 경험이 있어서, 이번엔 처음부터 모노레포로 가기로 했습니다.

전환 전략

기존 블로그를 apps/blog로 이동

가장 고민했던 부분입니다.
이미 Vercel에 배포되어 있고, 커밋 히스토리도 살리고 싶었거든요.

방법은 단순했습니다.

  1. 루트에 apps/ 디렉터리 생성
  2. 기존 블로그 코드를 apps/blog/로 이동
  3. package.json의 name을 @hyunwoo/blog로 변경
  4. 경로 참조 수정
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개 앱이 각각 다른 프레임워크

여기서 재미있는 건, 앱마다 기술 스택이 다르다는 겁니다.

앱프레임워크라우터용도
blogNext.js 16 (App Router)파일 기반SSG 블로그
adminVite + React 19 (SPA)TanStack Router관리자 대시보드
portfolioNext.js 16 (App Router)파일 기반포트폴리오 사이트

어드민은 SSR이 필요 없어서 Vite SPA로 갔습니다.
SEO가 필요 없는 인증 기반 대시보드니까, SPA가 오히려 맞았습니다.

블로그와 포트폴리오는 SEO가 중요해서 Next.js를 유지했습니다.

Turborepo 설정

turbo.json

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

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

패키지 매니저는 pnpm을 썼습니다.
디스크 효율도 좋고, workspace:* 프로토콜로 내부 패키지 참조가 깔끔합니다.

루트 package.json

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개 앱에서 공통으로 쓰는 컴포넌트가 여기 있습니다.

packages/ui/package.json
{
  "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 — 유틸, 타입, 상수

packages/shared/package.json
{
  "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

.github/workflows/ci.yml
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:run

PR을 열면 자동으로 lint, build, test가 돌아갑니다.
concurrency로 같은 PR에서 여러 번 push하면 이전 빌드를 취소시킵니다.

pnpm lint:ci와 pnpm lint는 다릅니다.
lint는 biome check --write로 자동 수정까지 하고,
lint:ci는 biome check로 체크만 합니다.
CI에서 코드를 수정하면 안 되니까요.

Husky + lint-staged

package.json (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-blogapps/blogchahyunwoo.dev
hyunwoo-adminapps/adminadmin.chahyunwoo.dev
hyunwoo-portfolioapps/portfolioportfolio.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가 필요합니다.

apps/blog/src/styles/globals.css
@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이 떴습니다.

apps/portfolio/next.config.mjs
// before
experimental: { reactCompiler: true }
 
// after
reactCompiler: true

Next.js 메이저 업그레이드할 때마다 설정 위치가 바뀌는 건 좀 짜증나긴 합니다.
근데 빌드 로그에 친절하게 알려주니까 금방 고칠 수 있습니다.

Biome으로 린팅 통합

ESLint 대신 Biome을 쓰고 있습니다.

biome.json
{
  "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 개발기를 업로드할 예정입니다.
뿌듯하네요!

참고 자료