>MinYeongLog
  • Posts
  • About

© 2026. Minyeong Kim all rights reserved.

pnpm 기반 React Native 모노레포 구성과 문제 해결

ProjectmonorepopnpmReact NativeExpo
2026.04.24
15분

목차
  • 들어가며
  • Monorepo란?
  • Monorepo vs Multirepo
  • Monorepo의 장단점
  • 동작 방식
  • 1. 기본 디렉토리 구조
  • 2. Workspace란?
  • `workspace:\*` 프로토콜로 로컬 패키지 참조
  • 3. 의존성 관리 방식
  • React Native에서는 hoisting이 필요하다
  • 실전에서 겪은 문제들
  • 1. Gradle 빌드 실패: `No matching variant of project`
  • 2. Metro 타임아웃: `Failed to construct transformer`
  • 3. `Cannot find native module 'ExponentImagePicker'`
  • 더 나아가면: Turborepo
  • 설정
  • 마치며
  • 참고
←이전 포스트구름톤 in JEJU 17기 후기 (대상)

들어가며

사이드 프로젝트로 React Native와 Next.js를 사용해 웹뷰 기반 앱을 만든 지 약 한 달이 되었다.

앱 내부에서 Next.js를 웹뷰로 띄우고, 브리지를 통해 웹과 네이티브가 데이터를 주고받는 구조이다 보니 양쪽에서 사용하는 타입을 일관되게 관리할 필요가 있었다.

이를 공통 패키지로 분리해 재사용하고자 monorepo 구조를 선택했다.

세팅을 하면서 pnpm 설정이 두 앱 사이에서 충돌하거나 Metro가 엉뚱한 경로를 바라보는 문제가 발생했다.

이 문제들을 해결하면서 monorepo와 workspace가 실제로 어떻게 동작하는지 이해하게 되었고, 그 과정을 정리해보려고 한다.


Monorepo란?

하나의 저장소에서 여러 프로젝트를 함께 관리하는 방식이다.

monorepo는 저장소 구조에 관한 개념이며, 실제 의존성 관리 방식은 pnpm, npm, yarn 같은 패키지 매니저의 workspace 기능에 따라 달라진다.

Monorepo vs Multirepo

예를 들어 웹 프론트엔드, 모바일 앱, 백엔드 서버, 공유 유틸리티 라이브러리가 있다고 하면

  • Monorepo: 네 가지가 전부 my-project/ 하나의 레포에 들어있다
  • Multirepo: 각각 별도의 저장소로 분리되어 있다

monorepo-multirepo 비교

구조를 기준으로 보면 두 방식의 차이는 다음과 같다.

구분MonorepoMultirepo
저장소 수1개N개
코드 공유내부에서 직접 import패키지로 publish 후 install
의존성 관리통합 관리 가능각 레포별로 별도 관리
버전 관리한 곳에서 일관되게 관리레포별 버전이 달라질 수 있음
배포통합 또는 부분 배포각 레포별로 독립 배포

그 외에 하이브리드 접근법도 있다.
공유 라이브러리는 monorepo로 관리하고, 독립성이 중요한 서비스는 별도 레포로 분리하는 식이다.

Monorepo의 장단점

장점

  • 코드와 타입을 여러 프로젝트에서 쉽게 공유할 수 있다
  • 변경 사항을 한 번에 반영할 수 있다
  • 의존성과 버전을 일관되게 관리할 수 있다

단점

  • 초기 설정과 구조 이해가 어렵다
  • 프로젝트 규모가 커지면 빌드/테스트 관리가 복잡해진다
  • 특히 React Native 환경에서는 tooling 충돌이 발생하기 쉽다

동작 방식

이 글에서는 프로젝트에서 사용한 pnpm을 기준으로 설명한다.

1. 기본 디렉토리 구조

my-project/
├── apps/
│   ├── web/          # Next.js
│   └── mobile/       # React Native
├── packages/
│   └── shared/       # shared types & utils
├── package.json      # workspace root
└── pnpm-workspace.yaml

apps/*에는 실행 가능한 애플리케이션을, packages/*에는 여러 앱에서 공유하는 코드를 두는 방식이 자주 사용된다.

2. Workspace란?

Workspace는 npm, yarn, pnpm 같은 패키지 매니저가 여러 패키지를 하나의 작업 공간으로 관리하기 위한 기능이다.

npm과 yarn은 package.json의 workspaces 필드를 사용하고, pnpm은 root의 pnpm-workspace.yaml 파일로 분리해 관리한다.

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

이 설정이 있으면 pnpm은 apps/web, apps/mobile, packages/shared를 각각 독립적인 패키지로 인식하고, 서로 참조하거나 의존성을 공유할 수 있다.

workspace:\* 프로토콜로 로컬 패키지 참조

앱에서 같은 레포 안의 패키지를 참조할 때는 workspace:* 프로토콜을 사용한다.

// apps/mobile/package.json
{
  "dependencies": {
    "@my-project/shared": "workspace:*"
  }
}

이 프로토콜은 패키지 레지스트리를 거치지 않고, 현재 workspace 안에 있는 @my-project/shared 패키지를 직접 참조한다.

여기서 *는 버전 범위를 제한하지 않는다는 의미이며, publish 시에는 package.json에 정의된 실제 버전으로 변환된다.

레지스트리(registry)와 publish란?

  • 레지스트리 : 패키지를 업로드하고 다운로드할 수 있는 중앙 서버
  • publish : 패키지를 그 레지스트리에 업로드하는 과정

pnpm, npm(8+), yarn(berry) 모두 workspace: 프로토콜을 지원한다.
workspace:*는 이 과정을 거치지 않고도 로컬 패키지를 직접 참조할 수 있게 해준다.

외부 레지스트리에 publish하지 않는 내부 패키지라면 package.json에 "private": true를 추가해 배포를 방지하는 것이 좋다.

3. 의존성 관리 방식

monorepo에서 pnpm을 선택한 이유 중 하나는 의존성 관계를 명확하게 관리하기 위해서다.

npm과 yarn은 공통 의존성을 root node_modules로 끌어올리는 hoisting을 수행한다. 이 구조에서는 다른 패키지의 의존성으로 설치된 모듈을 직접 선언하지 않아도 참조할 수 있는 phantom dependency(유령 의존성) 문제가 발생할 수 있다.

pnpm은 패키지를 .pnpm 디렉토리에 저장하고 symlink로 연결하며, 각 패키지는 package.json에 선언한 의존성만 접근할 수 있다.

여러 패키지가 함께 구성되는 환경에서 의존성 누락이나 환경에 따른 동작 차이를 방지하고자 pnpm을 선택했다.

React Native에서는 hoisting이 필요하다

하지만 React Native는 Metro bundler, autolinking, native build 과정에서 node_modules를 직접 탐색하기 때문에 pnpm의 구조에서 패키지 경로를 정상적으로 인식하지 못하는 문제를 일으킬 수 있다.

또한 일부 라이브러리의 경우 자신이 선언하지 않은 의존성을 직접 참조해 호환성 문제가 발생한다.

이 문제를 해결하기 위해 .npmrc에 다음 설정을 추가해야 한다.

# .npmrc
node-linker=hoisted
shameful-hoist=true
  • node-linker=hoisted: pnpm의 기본 symlink 구조 대신 npm과 유사한 flat 구조로 동작하게 한다.
  • shameful-hoist=true: 직접 선언하지 않은 의존성까지 root node_modules로 끌어올려 의존성 선언이 불완전한 라이브러리와의 호환성을 확보한다.

    "shameful"이라는 이름이 붙은 이유는 정확히 명시되어 있진 않지만, pnpm의 strict한 구조를 포기하는 옵션이라서 '부끄럽다'는 의미로 이해했다.

이 설정을 적용하면 패키지가 root node_modules를 기준으로 동작하게 되어 각 앱은 별도의 경로 설정 없이도 필요한 패키지를 찾을 수 있다.

React Native 환경만 고려하면 npm이나 yarn을 사용하는 것이 더 단순한 선택일 수 있다.

앞서 언급한 것처럼 monorepo에서의 의존성 관리와 코드 공유를 위해 pnpm을 유지했고, 그로 인해 발생한 호환성 문제는 hoisting 설정으로 해결했다.


실전에서 겪은 문제들

pnpm과 React Native는 node_modules를 탐색하는 방식이 다르다.
이 차이를 고려하지 않고 monorepo를 세팅하면서 Gradle, Metro, autolinking에서 문제가 발생했다.

Gradle, autolinking, Metro가 서로 다른 시점과 방식으로 node_modules를 탐색한다는 점이 핵심 원인이었다.

  • Gradle: Android 빌드를 담당하며, 빌드 시점에 의존성을 분석하고 결과를 캐시로 재사용한다
  • Expo autolinking: native 모듈을 자동으로 등록하기 위해 빌드 전에 node_modules를 스캔한다
  • Metro: JavaScript 번들을 생성하며, 개발 서버 실행 시점부터 파일 변화를 감시한다

1. Gradle 빌드 실패: No matching variant of project

FAILURE: Build failed with an exception.
> No matching variant of project :react-native-safe-area-context was found.

npx expo run:android 실행 시 Gradle 빌드 단계에서 실패했다.

원인
Expo Android 빌드는 react-native config 결과를 캐시 파일에 저장한다.

apps/mobile/android/build/generated/autolinking/autolinking.json

이 파일이 존재하면 Gradle은 node_modules를 다시 탐색하지 않고 캐시를 그대로 사용한다.

pnpm의 기본 구조와 hoisting 설정을 비교하기 위해 node-linker를 변경하는 과정에서 패키지 경로가 변경되었지만, Gradle은 이전 캐시를 기준으로 의존성을 찾으면서 존재하지 않는 경로를 참조하게 되었고 빌드가 실패했다.

해결 방법

rm apps/mobile/android/build/generated/autolinking/autolinking.json
rm apps/mobile/android/build/generated/autolinking/package.json.sha

캐시를 삭제하면 Gradle이 현재 node_modules를 기준으로 다시 스캔한다.

2. Metro 타임아웃: Failed to construct transformer

Failed to construct transformer: Error: Failed to start watch mode.
MAX_WAIT_TIME = 240000ms exceeded.

Metro 번들러가 시작되지 않고 타임아웃으로 종료됐다.

원인
node_modules/.pnpm/ 내부에 남아 있던 broken symlink와 monorepo 전체를 스캔하는 설정이 문제였다.
pnpm 설정을 변경하는 과정에서 패키지 경로가 정리되지 않은 상태였고, Metro가 이를 감시하면서 파일 감시가 멈췄다.

해결 방법

rm -rf node_modules/.pnpm/
module.exports = require("expo/metro-config").getDefaultConfig(__dirname);

기존에 monorepo를 고려해 추가했던 watchFolders 설정을 제거하고, Expo에서 제공하는 기본 Metro 설정으로 되돌렸다.
이후 root node_modules 기준으로 패키지를 정상적으로 인식하면서 Metro가 정상 동작했다.

3. Cannot find native module 'ExponentImagePicker'

이미지 선택 기능 구현을 위해 expo-image-picker를 추가했지만 런타임에서 모듈을 찾지 못했다.

원인
Expo native 모듈은 다음 세 단계를 거쳐 앱에 포함된다.

① app.json plugins — 네이티브 코드 생성
② Expo autolinking — Gradle에 모듈 등록
③ Metro bundler — JS에서 import

각 단계는 독립적으로 node_modules를 탐색한다.

특히 autolinking 단계에서는 기본적으로 현재 프로젝트의 node_modules만 탐색하기 때문에 monorepo에서 root에만 설치된 패키지는 발견하지 못할 수 있다.

이로 인해 native 모듈이 Gradle에 등록되지 않았고, 런타임에서 모듈을 찾지 못했다.

해결 방법

// apps/mobile/package.json
"expo": {
  "autolinking": {
    "searchPaths": ["../../node_modules"],
    "exclude": ["react-native-reanimated", "react-native-worklets"]
  }
}
  • searchPaths: root node_modules까지 탐색 범위를 확장한다
  • exclude: 탐색 범위를 넓히면 직접 사용하지 않는 transitive dependency까지 포함되어 버전 충돌이 생길 수 있어 명시적으로 제외한다

더 나아가면: Turborepo

pnpm -r typecheck를 실행하면 변경과 관계없이 모든 패키지가 매번 실행됐고, 패키지 간 실행 순서도 보장되지 않았다. 예를 들어 packages/api가 packages/shared에 의존하는데, 실행 타이밍이 맞지 않으면 타입 에러가 발생했다.

pnpm은 실행 순서와 캐싱을 직접 케어하지 않기 때문에 이 문제를 해결하기 위해 Turborepo를 도입했다.

설정

루트에 turbo.json을 추가하고, 기존 pnpm -r 스크립트를 turbo로 변경했다.

// turbo.json
{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "!.next/cache/**", "dist/**"]
    },
    "typecheck": {
      "dependsOn": ["^typecheck"]
    },
    "lint": {
      "dependsOn": []
    },
    "dev": {
      "cache": false,
      "persistent"


^typecheck는 의존하는 패키지의 작업이 먼저 실행되어야 한다는 의미다.
이 설정만으로 의존성 그래프에 따라 실행 순서가 자동으로 보장된다.

packages/shared (typecheck)
  ├─ packages/api (typecheck)
  ├─ apps/web (typecheck)
  └─ apps/mobile (typecheck)

의존성이 없는 작업(lint 등)은 병렬로 실행되며, 변경되지 않은 패키지는 캐시를 활용해 실행 자체를 건너뛴다.

현재 프로젝트에서는 코드 생성이나 복잡한 워크플로 관리가 필요하지 않아 Nx는 도입하지 않았다. 원격 캐시를 연결하면 CI 간에도 캐시를 공유할 수 있지만, 현재 규모에서는 필요하지 않아 제외했다.


마치며

monorepo를 쓰겠다는 결정 자체는 금방 내렸는데, 실제 세팅은 생각보다 많이 얽혀 있었다.

특히 Expo를 붙이고 나서 Gradle, autolinking, Metro가 각자 node_modules를 찾는 방식이 다르다는 걸 처음 알았다. pnpm의 링크 구조 설정 하나를 바꿨다 되돌렸을 뿐인데 세 곳에서 동시에 에러가 터졌고, 그때마다 "어느 단계가, 어느 경로를 바라보고 있는가"를 추적하는 게 디버깅의 핵심이었다.

직접 부딪혀보지 않았다면 shameful-hoist 같은 옵션을 그냥 지나쳤을 것 같다. 왜 이 설정이 필요한지, 내부에서 어떤 구조를 만드는지를 이해하고 나니 에러 메시지가 훨씬 읽히기 시작했다.

결국 monorepo 세팅에서의 문제 해결은 '어떤 도구가 어떤 경로를 기준으로 node_modules를 탐색하는지 이해하는 것'이었다.

참고

  • pnpm Workspaces 공식 문서
  • Expo autolinking 공식 문서
  • Metro 공식 문서
  • 모던 프론트엔드 프로젝트 구성 기법 — 모노레포 개념 편 (Naver D2)
  • 모노레포 소개 및 도입기 (한컴테크)
:
true
}
}
}