React Native 모노레포 완벽 가이드 2026: pnpm workspace · Nx · Turborepo · Metro 설정

pnpm workspaces + Turborepo/Nx 조합으로 React Native 모노레포를 구축하는 실전 가이드. Metro watchFolders, 심볼릭 링크, iOS CocoaPods, Expo EAS Build까지 프로덕션에서 검증된 설정을 코드로 정리했습니다.

React Native 모노레포 가이드 2026

업데이트: 2026년 8월 6일

React Native 모노레포는 두 개 이상의 앱과 공유 패키지(UI 시스템, 도메인 로직, 타입, API 클라이언트)를 한 저장소에서 관리하는 아키텍처이며, 2026년 기준 권장 스택은 pnpm workspaces 위에 Turborepo 또는 Nx를 얹고 Metro의 watchFolders·nodeModulesPaths를 명시적으로 설정하는 조합입니다. 이 글에서는 fintech에서 iOS · Android · Web 세 클라이언트를 한 저장소로 운영해 온 경험을 바탕으로, 심볼릭 링크 · Hermes · Codegen · CocoaPods · EAS까지 실제 프로덕션에서 마주치는 함정을 코드로 정리합니다.

  • pnpm workspaces + Turborepo가 2026년 React Native 팀의 사실상 표준입니다. 디스크 사용량은 Yarn 대비 ~40% 줄고 CI 캐시 히트 시 빌드가 60~80% 짧아집니다.
  • Metro는 심볼릭 링크를 공식적으로 지원(resolver.unstable_enableSymlinks: true)하지만, watchFoldersnodeModulesPaths둘 다 지정하지 않으면 워크스페이스 패키지를 못 찾습니다.
  • Nx는 코드 생성기·의존성 그래프·영향 분석(nx affected)이 강점이라 3개 이상 앱을 병렬로 운영하는 플랫폼 팀에 적합합니다.
  • Turborepo는 설정이 최소한이라 2~3개 앱 · 10개 미만 패키지 규모에서 가장 빠르게 안정화됩니다.
  • iOS는 앱 디렉터리에서 pod install을 실행하되 Podfileuse_native_modules!를 통해 워크스페이스 패키지를 스캔하도록 경로를 조정해야 합니다.
  • Expo 프로젝트도 SDK 51 이후 모노레포를 1급으로 지원합니다. @expo/metro-config가 workspace root를 자동 감지합니다.

왜 React Native에서 모노레포인가

fintech에서 저는 iOS · Android · Web 세 클라이언트가 결제 SDK · 사기 탐지 규칙 · 로컬라이제이션 리소스를 공유해야 하는 환경을 운영했습니다. polyrepo 시절에는 공용 라이브러리 하나 수정할 때마다 npm publish → 세 저장소에서 버전 올리기 → PR 세 개 리뷰라는 4~6시간짜리 왕복이 필요했고, 스키마가 어긋난 채 배포되어 결제가 실패하는 사고가 분기마다 반복됐습니다.

모노레포는 단일 소스 오브 트루스를 통해 이 왕복을 없앱니다. Google · Meta · Shopify · Discord가 React Native를 모노레포로 운영하는 이유도 동일합니다. 2026년 현재 세 가지 압력이 모노레포 채택을 가속하고 있습니다. 첫째, React Native 0.79의 New Architecture가 Codegen 산출물을 여러 패키지로 확산시켜 로컬 링크가 필수가 됐습니다. 둘째, Expo Router의 파일 기반 라우팅이 웹·모바일 코드 공유를 실용화했습니다. 셋째, Turborepo의 원격 캐시와 pnpm의 콘텐츠 어드레서블 저장소가 모노레포의 전통적 약점이었던 CI 시간을 극적으로 줄였습니다.

물론 모든 팀에 필요한 건 아닙니다. 앱 하나에 엔지니어가 2~3명이라면 polyrepo가 여전히 단순합니다. 하지만 앱이 2개를 넘거나, UI 시스템을 별도 팀이 소유하거나, 백엔드와 타입을 공유하고 싶다면 모노레포가 유일한 실질적 답입니다.

pnpm vs Yarn vs npm, Nx vs Turborepo 비교

패키지 매니저와 오케스트레이터를 각각 하나씩 골라야 합니다. 2026년 기준 저는 pnpm + Turborepo를 기본값으로 추천하고, 앱 개수가 4개를 넘어가는 시점에 Nx로 전환하거나 처음부터 Nx로 시작합니다.

차원pnpm + TurborepoNx (with pnpm)Yarn Berry + Nx
설정 학습 곡선낮음 (root package.json 1개)중간 (project.json + generators)높음 (PnP · zero-installs 이해 필요)
디스크 사용량 (앱 3개 기준)~1.2GB~1.4GB~0.8GB (PnP 사용 시)
Metro 심볼릭 링크 호환성완벽 (0.79+)완벽PnP는 patch 필요
원격 캐시Vercel · self-hostedNx Cloud · self-hostedNx Cloud
영향 분석 (affected)기본 (git 기반)강력 (그래프 기반)강력
코드 제너레이터없음 (plop 등 별도)내장내장
추천 규모앱 1~3개앱 3개+대기업 · 자체 툴링 있음

npm workspaces도 7.0부터 안정화됐지만 pnpm 대비 node_modules 크기가 3~5배 커지고 유령 의존성(phantom dependencies) 문제가 남아 있어 프로덕션에서는 권장하지 않습니다. Yarn Classic(1.x)은 유지보수가 중단됐고, Yarn Berry(4.x)의 PnP는 Metro와 여전히 상성이 나빠 nodeLinker: node-modules로 되돌리는 팀이 대부분입니다.

pnpm workspaces로 저장소 부트스트래핑

저장소 구조는 apps/packages/를 분리하는 컨벤션을 따릅니다. 앱은 배포 가능한 최종 산출물, 패키지는 재사용 가능한 라이브러리입니다.

my-fintech-monorepo/
├── apps/
│   ├── mobile/          # React Native 앱 (iOS + Android)
│   ├── web/             # Next.js 웹
│   └── admin/           # 내부 관리 도구
├── packages/
│   ├── ui/              # 크로스 플랫폼 UI 컴포넌트
│   ├── domain/          # 결제 · 계정 도메인 로직
│   ├── api-client/      # 타입 세이프 API 클라이언트
│   └── config/          # ESLint · TS · Prettier 공유 설정
├── pnpm-workspace.yaml
├── package.json
├── turbo.json
└── tsconfig.base.json

pnpm-workspace.yaml은 극도로 단순합니다.

packages:
  - "apps/*"
  - "packages/*"

루트 package.json에는 워크스페이스 전역에서 실행할 스크립트만 정의합니다. 각 앱·패키지의 의존성은 자기 package.json에 선언합니다. 워크스페이스 내부 참조는 workspace:* 프로토콜을 사용하세요.

// apps/mobile/package.json
{
  "name": "@myco/mobile",
  "dependencies": {
    "@myco/ui": "workspace:*",
    "@myco/domain": "workspace:*",
    "@myco/api-client": "workspace:*",
    "react": "19.0.0",
    "react-native": "0.79.2"
  }
}

설치는 저장소 루트에서 pnpm install 한 번이면 모든 워크스페이스가 부트스트랩됩니다. 저는 CI에서 pnpm install --frozen-lockfile을 강제해 로컬과 CI의 lockfile 드리프트를 원천 차단합니다.

Metro가 모노레포에서 심볼릭 링크를 처리하는 법

여기가 90%의 팀이 넘어지는 지점입니다. Metro는 기본적으로 앱 디렉터리와 그 하위 node_modules만 감시합니다. 워크스페이스 패키지는 apps/mobile/node_modules/@myco/ui로 심볼릭 링크되지만, 실제 소스는 packages/ui에 있으므로 Metro가 파일 변경을 감지하지 못하고 리졸버가 실패합니다.

해결책은 metro.config.js에 두 가지를 명시하는 것입니다. watchFolders는 Metro가 파일 변경을 감시할 절대 경로들, nodeModulesPaths는 리졸버가 모듈을 찾을 경로들입니다.

// apps/mobile/metro.config.js
const { getDefaultConfig } = require('@react-native/metro-config');
const path = require('path');

const projectRoot = __dirname;
const workspaceRoot = path.resolve(projectRoot, '../..');

const config = getDefaultConfig(projectRoot);

// 1) 모노레포 루트까지 파일 시스템 감시 확장
config.watchFolders = [workspaceRoot];

// 2) 리졸버가 앱과 루트의 node_modules를 모두 뒤지도록
config.resolver.nodeModulesPaths = [
  path.resolve(projectRoot, 'node_modules'),
  path.resolve(workspaceRoot, 'node_modules'),
];

// 3) 심볼릭 링크 공식 지원 (RN 0.79+)
config.resolver.unstable_enableSymlinks = true;

// 4) pnpm의 중첩 구조에서 중복 React 인스턴스 방지
config.resolver.disableHierarchicalLookup = true;

module.exports = config;

Codegen도 워크스페이스 경계를 따라 실행해야 합니다. packages/native-modules/처럼 자체 Turbo Module을 가진 패키지가 있다면 앱의 Podfilebuild.gradle이 그 경로를 스캔하도록 react-native.config.js에 명시하세요. 자세한 절차는 React Native Turbo Modules 가이드에서 다룬 Codegen 파이프라인과 그대로 이어집니다.

Turborepo 파이프라인과 원격 캐시 구성

Turborepo의 진가는 turbo.json의 파이프라인 정의에서 나옵니다. 각 태스크가 어떤 태스크에 의존하는지, 어떤 파일 입력·출력을 갖는지 선언하면 Turbo가 그래프를 만들어 병렬 실행하고 결과를 해시로 캐싱합니다.

// turbo.json
{
  "$schema": "https://turborepo.com/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["src/**", "package.json", "tsconfig.json"],
      "outputs": ["dist/**"]
    },
    "lint": {
      "dependsOn": ["^build"],
      "inputs": ["src/**", ".eslintrc.*"]
    },
    "test": {
      "dependsOn": ["^build"],
      "inputs": ["src/**", "__tests__/**"],
      "outputs": ["coverage/**"]
    },
    "typecheck": {
      "dependsOn": ["^build"],
      "inputs": ["src/**", "tsconfig.json"]
    }
  }
}

^build의존 패키지build가 먼저 끝나야 함을 의미합니다. @myco/mobile@myco/ui에 의존한다면 UI가 빌드된 뒤 mobile이 시작됩니다. 한 번 실행된 결과는 콘텐츠 해시(입력 파일 · 환경 변수 · 의존성)로 저장되고, 같은 입력이 반복되면 즉시 캐시에서 재생됩니다.

원격 캐시는 npx turbo login · npx turbo link 두 명령으로 Vercel 계정에 연결됩니다. self-hosted가 필요하면 Turborepo 원격 캐싱 문서에 나온 오픈 API를 구현한 자체 서버(예: turborepo-remote-cache OSS)를 붙일 수 있습니다. 저희 팀은 GitHub Actions에서 캐시 히트율을 70%대로 유지하며 평균 CI 시간을 22분 → 6분으로 줄였습니다.

Nx 제너레이터와 nx affected 활용

Nx는 플러그인 기반 프로젝트 그래프가 핵심입니다. @nx/react-native를 설치하면 앱 · 라이브러리 생성이 명령 하나로 끝납니다.

# 새 React Native 앱 생성
pnpm nx g @nx/react-native:app apps/mobile --e2eTestRunner=none

# 새 공유 라이브러리 생성 (buildable · publishable 설정 가능)
pnpm nx g @nx/react-native:library packages/ui \
  --buildable --publishable --importPath=@myco/ui

# 컴포넌트 스캐폴딩
pnpm nx g @nx/react-native:component packages/ui/src/Button

변경 사항이 있는 프로젝트만 다시 빌드·테스트하는 nx affected가 대규모 저장소에서 진짜 킬러 기능입니다.

# main 브랜치와 비교해 영향받는 프로젝트만 테스트
pnpm nx affected -t test --base=origin/main

# 영향받는 앱만 빌드 (그래프 자동 계산)
pnpm nx affected -t build

Nx Cloud를 붙이면 원격 캐시 + 분산 태스크 실행(Distributed Task Execution)이 활성화되어 여러 CI 워커에 태스크를 자동 분산합니다. 앱이 4~5개를 넘어가면 이 기능만으로 CI 비용을 반으로 줄이는 팀이 흔합니다.

UI · 도메인 · 타입 패키지 설계

패키지 경계 설계는 아키텍처의 절반입니다. 저는 다음 세 계층을 항상 분리합니다.

  • @myco/ui — 순수 표현 컴포넌트. React Native와 Web을 모두 지원하려면 react-native-web.native.tsx · .web.tsx 확장자 스위칭을 사용합니다.
  • @myco/domain — 결제 · 사용자 · 규정 관련 비즈니스 규칙. 프레임워크 무관하게 순수 TypeScript로 작성해 모든 클라이언트가 재사용합니다.
  • @myco/api-client — 백엔드 스키마에서 자동 생성된 타입 세이프 클라이언트. 저희는 tRPC를 쓰지만 GraphQL Code Generator · openapi-typescript도 동일 패턴입니다.

각 패키지의 package.jsonexports 필드로 진입점을 명시하고, main · module · types를 잘 매핑해야 Metro와 TypeScript 양쪽에서 리졸브됩니다.

// packages/ui/package.json
{
  "name": "@myco/ui",
  "version": "0.0.0",
  "main": "./src/index.ts",
  "types": "./src/index.ts",
  "react-native": "./src/index.ts",
  "exports": {
    ".": {
      "types": "./src/index.ts",
      "react-native": "./src/index.ts",
      "default": "./src/index.ts"
    }
  },
  "peerDependencies": {
    "react": ">=19",
    "react-native": ">=0.79"
  }
}

주목할 점은 소스 진입점을 그대로 노출한다는 것입니다. 라이브러리를 미리 빌드하지 않고 Metro가 직접 .ts를 트랜스파일하게 하면 개발 중 HMR이 즉각 반영되고 소스맵도 원본을 가리킵니다. 외부 배포용 패키지만 tsc로 빌드해 dist/를 게시하세요.

iOS CocoaPods와 워크스페이스 경로

iOS 빌드는 모노레포에서 가장 자주 깨지는 부분입니다. pod installPodfile이 있는 디렉터리에서만 실행되지만, use_native_modules!가 워크스페이스 전역의 네이티브 의존성을 스캔하도록 경로를 명시해야 합니다.

# apps/mobile/ios/Podfile
require Pod::Executable.execute_command('node', ['-p',
  'require.resolve(
    "react-native/scripts/react_native_pods.rb",
    {paths: [process.argv[1]]},
  )', __dir__]).strip

# 모노레포 루트에서 네이티브 모듈 자동 발견
config = use_native_modules!(
  { :packageRoot => Pathname.new(__dir__).parent.parent.parent }
)

use_react_native!(
  :path => config[:reactNativePath],
  :hermes_enabled => true,
  :fabric_enabled => true,
  :app_path => "#{Pod::Config.instance.installation_root}/.."
)

Xcode 프로젝트의 User-Defined Build SettingsREACT_NATIVE_PATH$(SRCROOT)/../../../node_modules/react-native처럼 명시해두면 심볼릭 링크가 변경되어도 스크립트들이 안정적으로 실행됩니다. Android 쪽은 settings.gradlebuild.gradle이 상대 경로로 node_modules를 참조하므로 상대적으로 문제가 적습니다.

Expo와 EAS Build에서 모노레포 실행하기

Expo는 SDK 51부터 모노레포를 1급으로 지원합니다. @expo/metro-config가 workspace root를 자동 감지하고 위에서 설명한 watchFolders · nodeModulesPaths를 대신 설정해 줍니다. 앱 metro.config.js는 다음 한 줄이면 충분합니다.

// apps/mobile/metro.config.js (Expo)
const { getDefaultConfig } = require('expo/metro-config');
module.exports = getDefaultConfig(__dirname);

EAS Build는 eas.jsoncli.appVersionSource와 함께 --profile별로 워크스페이스 루트를 명시할 수 있습니다. 저희는 다음 두 필드를 항상 함께 씁니다.

// eas.json
{
  "cli": {
    "version": ">= 12.0.0",
    "appVersionSource": "remote"
  },
  "build": {
    "production": {
      "env": { "EAS_BUILD_PACKAGE_MANAGER": "pnpm" },
      "cache": {
        "key": "v1",
        "paths": ["../../node_modules/.cache/turbo"]
      }
    }
  }
}

Expo가 EAS 빌드 워커에서 pnpm install을 실행하려면 EAS_BUILD_PACKAGE_MANAGER=pnpm 환경 변수가 필수입니다. Turbo 캐시 디렉터리를 cache.paths로 지정하면 EAS 사이드에서도 캐시 히트를 얻습니다. 자세한 파이프라인 튜닝은 Expo EAS Build & Update 가이드에서 이어서 확인하세요.

자주 마주치는 함정과 해결책

3년 동안 모노레포를 운영하며 반복적으로 마주친 문제들과 실전 해결책을 정리합니다.

1. 중복 React 인스턴스

증상은 랜덤한 "Invalid hook call". 원인은 pnpm이 여러 패키지의 peer dependency로 React를 중첩 설치한 것입니다. 해결책은 루트 package.jsonpnpm.overrides로 단일 버전을 강제합니다.

// package.json (루트)
{
  "pnpm": {
    "overrides": {
      "react": "19.0.0",
      "react-dom": "19.0.0",
      "react-native": "0.79.2"
    }
  }
}

2. Hermes bytecode 캐시가 자주 무효화된다

Metro 캐시 키가 모노레포 루트의 node_modules를 포함하지 않아 발생합니다. metro.config.jscacheStores에 파일 시스템 저장소를 명시하고 CI에서 node_modules/.cache/metro를 아티팩트로 저장하세요. 이 튜닝을 하면 콜드 스타트 최적화 가이드에서 다룬 Hermes 부트 시간이 실제 프로덕션 CI에서도 재현됩니다.

3. TypeScript 프로젝트 참조가 무거워진다

tsc --build가 느려지면 tsconfig.base.json"incremental": true"composite": true를 켜고 각 패키지가 references로 의존성을 선언하도록 하세요. Nx는 이를 자동 생성해 주지만 Turborepo에서는 수동 관리가 필요합니다.

4. ESLint · Prettier 설정 표류

루트에 @myco/config 패키지를 만들어 ESLint · Prettier · TS 설정을 export하고 모든 앱·패키지가 이를 extends하게 하세요. 팀이 커질수록 이 규율이 코드 리뷰 시간을 크게 줄입니다.

5. New Architecture 마이그레이션 시 Codegen 산출물 충돌

여러 패키지에서 Codegen을 실행하면 build/generated/ 경로가 서로 덮어씁니다. 각 패키지의 codegenConfig에 고유한 outputDir을 지정하고, 앱 레벨에서만 최종 통합을 수행하세요. 이 패턴은 Turbo Modules 가이드에서 자세히 다뤘습니다.

자주 묻는 질문

React Native가 정말 모노레포와 잘 작동하나요?

네, React Native 0.76부터 pnpm과 심볼릭 링크가 공식 지원됩니다. Metro의 watchFolders · nodeModulesPaths · disableHierarchicalLookup 세 옵션만 정확히 설정하면 프로덕션에서 안정적으로 운영할 수 있으며, Meta · Shopify · Discord 등이 실제로 이 구조로 앱을 배포하고 있습니다.

Nx와 Turborepo 중 어느 것이 더 좋나요?

앱이 1~3개고 설정을 단순하게 유지하고 싶다면 Turborepo, 앱이 3개를 넘고 코드 생성기 · 의존성 그래프 · 영향 분석이 필요하다면 Nx가 유리합니다. 두 도구 모두 pnpm 위에서 잘 작동하므로 패키지 매니저는 pnpm으로 고정하고 오케스트레이터만 팀 규모에 맞춰 고르세요.

pnpm workspace로 React Native를 설정하려면 어떻게 하나요?

루트에 pnpm-workspace.yamlpackage.json을 만들고 apps/* · packages/*를 등록한 뒤, 앱의 metro.config.jswatchFoldersnodeModulesPaths를 절대 경로로 지정하면 됩니다. RN 0.79+에서는 resolver.unstable_enableSymlinks: true도 함께 켜세요.

React Native와 웹 사이에서 코드를 공유하는 방법은?

packages/ui처럼 크로스 플랫폼 패키지를 만들고 react-native-web과 파일 확장자 스위칭(.native.tsx · .web.tsx)을 사용하세요. 순수 비즈니스 로직(@myco/domain)은 프레임워크 무관한 TypeScript로 작성해 모바일 · 웹 · 백엔드가 모두 재사용할 수 있습니다.

Metro가 심볼릭 링크를 지원하나요?

네, React Native 0.79부터 resolver.unstable_enableSymlinks: true로 공식 지원됩니다. 이전 버전에서는 pnpm install --shamefully-hoistnode_modules를 평탄화하거나 Yarn Berry의 nodeLinker: node-modules 모드를 사용해야 했지만, 2026년 현재는 pnpm의 기본 구조를 그대로 사용할 수 있습니다.

Yelena Petrov
저자 소개 Yelena Petrov

React Native architect at a fintech. Builds platform teams, type-safe bridges, and runs the upgrade playbook so others don't have to.