📚 모노레포 아키텍처 결정기 #1

NestJS + React 모노레포 구성법 — 1인 개발자의 pnpm workspace + Turborepo 실전기

1인 개발로 NestJS 백엔드 + React 프론트엔드를 모노레포로 구성한 이유와 pnpm workspace + Turborepo 세팅 과정을 정리했다. 삽질 포인트와 구조 결정 기록. 초보자도 따라할 수 있게 정리했어요.

NestJS + React 모노레포 구성법 — 1인 개발자의 pnpm workspace + Turborepo 실전기

멀티레포에서 모노레포로 전환한 순간

모노레포로 전환하기 전까지 2주를 날렸다.

1인 개발로 백엔드(NestJS + Prisma)와 프론트엔드(React + Vite) 여러 개를 동시에 개발하는 상황이었다. 처음엔 레포를 따로 만들었다. API 서버 하나, 어드민 포털 하나, 사용자 포털 하나.

2주 만에 후회했다.

타입 하나 바꾸면 3개 레포에서 PR을 열어야 했다. 공유 타입은 npm에 배포하거나 복붙해야 했고, 버전 맞추다가 하루가 날아갔다.

결론부터 말하면 — 1인 개발에서 모노레포는 사치가 아니라 생존 전략이었다.


🔍 증상: 멀티레포에서 터진 문제들

멀티레포 체제를 2주 쓰면서 세 가지 문제가 반복됐다. 각각 단독으로도 피곤했는데, 세 개가 겹치니 개발 속도가 반토막 났다.

❌ 타입 동기화 지옥

API 응답 타입을 바꾸면 이런 일이 벌어졌다:

// API 서버 — 응답 타입 변경
interface UserResponse {
  id: number;

---

  name: string;
  level: number; // 새로 추가됨
}
// 프론트엔드 — 아직 예전 타입
interface UserResponse {
  id: number;

---

  name: string;
  // level? 뭐가 추가됐는지 모름
}

프론트에서 빌드는 됐다. 런타임에서 undefined가 터졌다. TypeScript를 쓰는 이유가 타입 안전성인데,


레포 간 타입이 따로 노니까 그 장점이 절반으로 줄어드는 거다.

핵심: 멀티레포에서 타입 안전성은 레포 경계 앞에서 멈춘다. 빌드가 통과해도 런타임 버그는 막지 못한다.

❌ 의존성 버전 불일치

# API 서버
"typescript": "^5.3.0"

# 어드민 포털
"typescript": "^5.7.0"

# 사용자 포털
"typescript": "^5.5.0"

같은 TypeScript인데 버전이 달랐다. 타입 호환성에서 미묘한 차이가 생겼고, @types/node 버전 충돌은 덤이었다.

exactOptionalPropertyTypes 같은 strict 옵션은 버전마다 동작이 달라서 한 레포에서 통과한 타입이 다른 레포에선 에러가 났다 💀

주의: 버전 불일치에서 비롯된 버그는 빌드 에러보다 무섭다. 에러 메시지가 뜨지 않아서 원인 추적이 특히 어렵다.

❌ 코드 리뷰 맥락 분산

API 변경과 프론트 변경이 다른 레포에 있으니 문제가 생겼다. “이 API 변경이 프론트에 어떤 영향을 주지?”를 확인하려면 탭을 왔다 갔다 해야 했다.

1인 개발이라 리뷰어가 나뿐인데도 힘들었다. 2명 이상 팀이었다면 리뷰 맥락을 글로 설명하는 데만 시간이 더 들었을 거다.

멀티레포 타입 동기화할 때의 내 표정


🔎 원인: 왜 처음부터 모노레포를 안 했나

솔직히 말하면, 오버 엔지니어링이라고 생각했다.

“모노레포는 구글, 메타 같은 대기업이나 쓰는 거 아냐?”

이게 편견이었다.

구글이 단일 모노레포에 수십만 개 파일을 넣고 관리한다는 사례가 알려지면서, 모노레포 = 대규모 = 복잡하다는 인식이 퍼진 것 같다. 근데 규모와 도구는 별개다.


1인 개발자가 Turborepo로 앱 3개를 묶는 건 오버 엔지니어링이 아니라 워크플로우 최적화다.

현실을 표로 비교하면 차이가 명확해진다:

상황멀티레포모노레포
타입 공유npm 배포 or 복붙import 한 줄
의존성 관리레포별 독립루트에서 통합
빌드 순서수동 관리Turborepo 자동
코드 리뷰탭 3개 열기PR 1개
CI/CD파이프라인 3개파이프라인 1개

1인 개발자에게 모노레포는 오버 엔지니어링이 아니라 공수 절감 도구였다.

팁: 초기 세팅에 약 30분을 투자하면, 이후 타입 동기화 삽질에 낭비할 수백 시간을 절약할 수 있다. ROI가 압도적으로 좋다.


✅ 해결: pnpm workspace + Turborepo 구성

디렉토리 구조 결정

최종 구조는 이렇게 잡았다:

project-root/
├── apps/
│   ├── api/              # NestJS 백엔드

---

│   ├── admin-portal/     # React 어드민
│   └── user-portal/      # React 사용자용
├── packages/

---

│   ├── shared-types/     # BE↔FE 공유 타입
│   ├── eslint-config/    # 린트 설정 공유
│   └── ui/               # 공유 UI 컴포넌트

---

├── pnpm-workspace.yaml
├── turbo.json
└── package.json

핵심은 apps/packages/의 분리다.

  • apps/ — 독립 실행 가능한 애플리케이션
  • packages/ — 앱에서 공유하는 라이브러리

앱이 패키지를 참조하는 단방향 의존성을 지키면 순환 의존 문제가 생기지 않는다. BE(apps/api)가 packages/ui를 참조하기 시작하는 순간 복잡도가 급격히 올라간다. 의존성 방향은 처음부터 명확하게 잡는 게 맞다.

pnpm workspace 설정

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

이 2줄이면 끝이다. pnpm이 각 디렉토리를 워크스페이스 패키지로 인식하고, 로컬 패키지 간 의존성을 자동으로 연결해준다.

# 앱에서 로컬 패키지 의존성 추가
pnpm --filter @project/admin-portal add @project/shared-types

node_modules 안에 심링크(symlink)가 생성된다. npm link처럼 수동으로 링크를 관리할 필요가 없다.

팁: pnpm --filter 플래그로 특정 패키지에만 명령을 실행할 수 있다. pnpm --filter @project/api dev 하면 API 서버만 단독으로 실행된다.

공유 타입 패키지 구성

세 변경 중 임팩트가 가장 컸던 부분이다.

// packages/shared-types/package.json
{
  "name": "@project/shared-types",

---

  "version": "0.0.1",
  "private": true,
  "main": "./src/index.ts",

---

  "types": "./src/index.ts"
}

"private": true는 필수다. 실수로 pnpm publish를 실행해도 npm에 올라가지 않는다.

// packages/shared-types/src/index.ts
export interface UserResponse {
  id: number;

---

  name: string;
  level: number;
}

export type PaginatedResponse<T> = {
  data: T[];
  meta: {

---

    page: number;
    pageSize: number;
    total: number;

---

  };
};

이제 프론트에서 npm 배포 없이 바로 참조된다:

// apps/admin-portal/src/types.ts
import type { UserResponse,
PaginatedResponse } from '@project/shared-types';

// 타입 바꾸면 모든 앱에 즉시 반영 🚀

UserResponse에 필드 하나 추가하면 TypeScript가 모든 앱에서 동시에 타입 오류를 잡아준다. 멀티레포에서는 상상도 못했던 경험이다.

Turborepo 빌드 파이프라인

// turbo.json
{
  "$schema": "https://turbo.build/schema.json",

---

  "tasks": {
    "build": {
      "dependsOn": ["^build"],

---

      "outputs": ["dist/**"]
    },
    "dev": {

---

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

---

    "test": {
      "dependsOn": ["^build"]
    }

---

  }
}

"dependsOn": ["^build"] — 이게 핵심이다.

^(캐럿) 기호의 의미는 “내가 의존하는 패키지를 먼저 빌드해라”다. apps/api를 빌드하기 전에 packages/shared-types를 자동으로 먼저 빌드한다. 수동으로 빌드 순서를 관리할 필요가 없다.

Turborepo는 빌드 결과를 캐시한다. packages/shared-types를 변경하지 않으면 두 번째 빌드부터는 캐시에서 바로 가져온다. CI에서도 캐시가 유효한 패키지는 빌드를 통째로 건너뛴다.


실제로 첫 풀 빌드 대비 이후 빌드 시간이 약 60~70% 단축됐다.

핵심: Turborepo의 Remote Cache를 Vercel과 연결하면 팀 전체가 캐시를 공유할 수 있다. 1인 개발이어도 로컬 캐시만으로 빌드 속도가 체감상 2~3배 빨라진다.

루트 package.json 설정

{
  "private": true,
  "scripts": {

---

    "build": "turbo run build",
    "dev": "turbo run dev",
    "test": "turbo run test",

---

    "clean": "turbo run clean && rm -rf node_modules"
  },
  "devDependencies": {

---

    "turbo": "^2.3.0"
  },
  "packageManager": "[email protected]"

---

}

pnpm build 한 번이면 전체 프로젝트가 의존성 순서에 맞게 빌드된다. pnpm dev는 모든 앱을 병렬로 실행한다.

루트 packageManager 필드에 pnpm 버전을 명시하는 것도 중요하다. 팀원이 다른 버전의 pnpm을 쓰다가 pnpm-lock.yaml이 충돌하는 상황을 막아준다.


🛡️ 예방: 모노레포 전환 시 체크리스트

전환 전 확인

모노레포가 항상 정답은 아니다. 아래 기준으로 판단하면 된다:

항목체크 기준
앱 간 타입 공유가 있는가?없으면 멀티레포도 OK
TypeScript 기반인가?JS면 타입 공유 이점이 약함
팀 규모가 1~5명인가?대규모 팀은 별도 조직 구조 고려 필요
앱들이 같은 도메인인가?전혀 다른 서비스면 분리가 맞음

나는 세 앱 모두 같은 도메인 모델을 공유했다. User, Level, Permission 같은 타입이 BE/FE 모두에서 쓰였기 때문에 모노레포가 명확한 답이었다.

⚠️ tsconfig paths 매핑 누락

멀티레포에서 모노레포로 전환할 때 가장 많이 놓치는 부분이다.

// apps/admin-portal/tsconfig.json
{
  "compilerOptions": {

---

    "paths": {
      "@project/shared-types": ["../../packages/shared-types/src"]
    }

---

  }
}

이 설정 없으면 IDE에서 타입 추론이 안 된다. 빌드는 되는데 VSCode에서 빨간 줄이 사라지지 않는 상태가 된다 😅 TypeScript Language Server가 워크스페이스 심링크를 따라가지 못하기 때문이다.

⚠️ TypeScript 버전 통일 누락

루트 package.json에서 pnpm.overrides로 통일한다:

// package.json (루트)
{
  "pnpm": {

---

    "overrides": {
      "typescript": "5.7.3"
    }

---

  }
}

이 설정 없이 모노레포로 합쳤더니 패키지마다 TypeScript 버전이 달랐다. tsc --build에서 정체불명의 에러가 났고, 원인 찾는 데 30분을 썼다. overrides 한 줄로 해결됐다.

흔한 실수 3가지

  1. packages/ 빌드를 안 하고 apps/ 빌드 시도 @project/shared-types 타입 에러가 쏟아진다. Turborepo의 ^build가 자동으로 순서를 해결해주므로, turbo run build를 쓰면 된다.

  2. private: true 빼먹기 pnpm publish 실수로 실행 시 npm에 배포된다. 모노레포 내부 패키지는 반드시 private: true를 명시할 것.

  3. BE에서 FE 전용 패키지 참조 apps/apipackages/ui를 참조하면 순환 의존 위험이 생긴다. BE는 packages/shared-types만 참조하도록 제한하는 게 낫다.

주의: 모노레포의 최대 함정은 “같은 레포에 있으니까”라는 이유로 패키지를 무분별하게 참조하는 것. 의존성 방향을 처음부터 명시적으로 관리하지 않으면 나중에 엉킨다.


📋 정리

상황안티패턴권장 패턴
타입 공유npm 배포 or 복붙packages/shared-types로 직접 참조
의존성 버전레포별 독립 관리pnpm.overrides로 통일
빌드 순서수동 (“types 먼저 빌드해!”)Turborepo ^build 자동 해결
IDE 타입 추론 오류방치tsconfig.paths 매핑 추가
프로젝트 규모 인식”모노레포는 대기업용”1인 개발에서 오히려 더 효과적
패키지 간 의존BE/FE 무분별한 상호 참조단방향 의존성 (appspackages)

모노레포 전환 후 빌드가 한 번에 될 때

앱 2개 이상 + TypeScript면 모노레포를 고려하라. 초기 세팅 30분이 이후 수백 시간의 타입 동기화 삽질을 없애준다 ✨

관련 글: 모노레포 환경에서의 Docker 빌드 삽질기도 참고해보자.

참고 자료: