
우선, 나는 모노레포 프로젝트를 실무에서 경험해보지 못했다. 회사에서 진행했던 프로젝트들은 관련 있는 프로젝트들도 모두 개별 Git에서 관리되고 있었고, 나 또한 프로젝트들을 진행할 때 프론트엔드, 백엔드를 개별 Git으로 나눠서 관리하고 있었다. "Google은 모노레포로 프로젝트를 관리한다." 등의 이야기를 모노레포 관련 글에서 보았지만 나에게 있어서 모노레포가 가져다 주는 장점은 딱히 없어 보였다.
최근 프로젝트를 진행할 때 Gemini CLI를 이용해서 프로젝트를 설계하거나, 코드 오류 시 원인 파악을 할 때 사용하면서 AI를 활용하기 시작했다. 그런데 문제가 있었다. Gemini CLI는 자신이 실행된 폴더의 상위 폴더를 읽지 못했다. 오류의 원인을 찾으려 할 때 백엔드와 프론트엔드를 연결해서 확인하지 못하니 오류의 원인을 잘못 파악하는 경우가 많았다. 그렇다고 단순히 같은 프로젝트들을 하나의 폴더에 놓고 Gemini를 실행시켜서 파악하기는 비효율적이었다. 관련된 프로젝트 하나가 추가될 때마다 그때그때 폴더에 넣어서 파악할 수는 없지 않은가.
그래서 생각했다. 연관된 프로젝트끼리 묶어서 하나의 Git으로 관리하자. 원래 모노레포로 관리하는 목적은 프로젝트 별로 연관성 있는 코드를 한 곳에 관리함으로써 효율성을 높이는 것이 목적이지만, 나는 AI가 프로젝트의 맥락을 더 쉽게 파악할 수 있도록 하기 위해 모노레포를 사용하기로 했다. 만약 백엔드가 node.js 였다면 코드 공유 면에서도 이점을 얻을 수 있었을 거다.
그렇다면 모노레포는 어떻게 만들어야 하는 거지? 그냥 폴더 하나 만들어서 git init 해서 프로젝트들을 집어넣으면 되나? 조사해보았다. 보니까 모노레포는 하나의 워크스페이스를 만들어서 그 안에서 프로젝트들을 관리하면 된다고 한다. 그러기 위해선 패키지 매니저가 필요한데, 가장 많이 쓰이는 것이 pnpm이었고, 그 다음이 yarn이었다.
조사 후, pnpm을 패키지 매니저로 사용하기로 결정했다. 백엔드와 프론트엔드 경로를 각각 만들어서 프로젝트를 구축했다. 백엔드는 Python 기반 FastAPI 프로젝트이기 때문에, pnpm의 영향을 받지는 않아서 괜찮았다. 문제는 프론트였다.
커맨드를 통해 ios로 빌드를 하자마자 바로 오류가 발생했다. 역시 한번에 실행되면 React Native가 아니지. 곧바로 문제 상황을 살피기 시작했다.
당시 내 프로젝트의 경로는 다음과 같이 되어 있었다.
root
├── apps
│   ├── mobile # React Native 프로젝트 (프론트엔드)
│   │   └── node_modules
│   └── server # FastAPI 백엔드 프로젝트
└── node_modules # 루트(모노레포) 레벨의 공용 node_modules
pnpm은 모노레포 환경을 만들기 용이하며, npm이나 yarn에 비해 빠른 성능을 제공한다.
라고 하는데, 이 빠른 성능을 제공하는 이유는 pnpm의 작동 방식 때문이다.
npm이 프로젝트 별로 node_modules가 새롭게 설치되는 방식이라면, pnpm은 node_modules을 전역적으로 저장하고, 각 프로젝트에는 symbolic link를 제공하여 패키지를 참조하게 한다.
여기서 문제가 발생한다. React Native의 Metro Bundler는 기본적으로 심볼릭 링크를 따라가지 않기 때문에, node_modules 외부 경로에 연결된 패키지를 제대로 탐색하지 못한다. 그래서 오류가 발생하는 것이다.
다행히 React Native에서도 실험적으로 심볼릭 링크를 읽을 수 있는 옵션을 제공하기 시작했다. metro.config.js 파일의 unstable_enableSymlinks 옵션을 활성화하고 watchFolders에 루트 디렉토리를 추가했다. 심링크를 정상적으로 읽고 루트 프로젝트의 파일 변화를 감지하도록 처리했다. 그렇게 다시 iOS를 빌드 했고, 앱이 정상적으로 실행되는 것을 확인할 수 있었다.
그렇게 iOS로 열심히 개발하다가 문득, android로도 실행해봐야겠다는 생각이 들었다. 개발을 시작한지 얼마 되지 않아 이런 생각을 한게 다행이었다. 그러지 않았으면 더 깊은 멘붕에 빠졌을 수도 있다.
하지만 안드로이드는 좀 달랐다. 안드로이드도 당연히 바로 실행되지 않을까 생각했던 나의 생각을 비웃듯, 안드로이드는 커맨드를 입력하자마자 오류를 뿜어냈다.
Failed to install the app.
...
Error resolving plugin [id: 'com.facebook.react.settings']
그래, 놀랍지도 않은 일이다…라고 생각하며 오류 코드를 자세히 보았다.
Error resolving plugin [id: 'com.facebook.react.settings']
안드로이드의 settings.gradle 파일에서 com.facebook.react.settings를 찾을 수 없다고 한다. 파일을 보았다.
pluginManagement { includeBuild("../node_modules/@react-native/gradle-plugin") }
...
includeBuild('../node_modules/@react-native/gradle-plugin')
여기도 react native 프로젝트 폴더의 node_modules를 바라보고 있었다. 그래서 상위 node_modules를 바라보도록 수정해주고 다시 실행했다.
오류는 그대로였다.
여전히 settings.gradle은 똑같은 오류를 뿜어냈다. node_modules 폴더를 들여다봤다. @react-native/gradle-plugin이 없다. 무엇을 어찌해야하나. AI에게 물어보니 외부 node를 찾을 수 없다며 node를 실행할 수 있도록 build.gradle에 여러 설정을 추가하라고 했다.
하지만 코드를 보니 신뢰가 가지 않았다.
그래서 직접 찾아보기로 했다. 나와 비슷한 오류를 가진 사람들이 있었는데, 다운그레이드를 해보라던가, react-native 패키지를 다시 설치해보라던가 하는 답변만 있었다. 하지만 나는 다운그레이드를 하고 싶지도 않았고, 패키지를 다시 설치해도 문제는 동일했다. 이 사람들이 겪은 문제는 내가 겪고 있는 문제와 원인이 달라 보였다.
곰곰이 생각했다. 필시 이는 pnpm에 의한 문제일 것이었다. 질문을 react native와 pnpm으로 좁혔다. 그러고 나니 조금 도움 될 결과가 나왔다.
프로젝트 루트 경로에 .npmrc 파일을 만들고 node-linker=hoisted 옵션을 주세요.
이 옵션이 무엇이냐. 바로 pnpm의 설치 방식을 고유 저장소에 심볼릭 링크를 활용하는 것이 아닌, npm처럼 플랫한 구조로 설치하는 것이다. 이 설정은 pnpm의 장점인 하드링크 구조를 포기하는 것이지만, React Native 처럼 심볼릭 링크를 지원하지 않는 환경에서는 사실상 유일한 방법이었다. 아직 React Native의 심볼릭 링크는 실험적인 기능이기에, node-linker=hoisted 옵션을 사용해 패키지를 다시 설치해보기로 했다.
node-linker=hoisted 옵션을 주면, pnpm이 모든 패키지를 루트의 node_modules에 플랫하게 설치하도록 강제한다. 이를 통해 React Native가 경로를 일관되게 인식할 수 있게 된다.
React Native와 루트 프로젝트의 node_modules를 모두 지우고, 루트에 .npmrc 파일을 만들어 옵션을 주고 패키지를 다시 설치했다. node_modules를 보니 많이 달라져 있었다. 기존에는 심볼릭 링크를 사용하는 것에서 이제 모두 플랫한 구조로 설치되는 것을 확인했다. 이제 될까? 다시 안드로이드 빌드를 시작했다.
info Installing the app...
드디어 앱을 설치하기 시작하는구나
라는 생각이 들자마자 다시 오류가 발생했다. 그나마 로그 내용이 달라졌다는 것에 위안을 삼았다. 이번에는 뭘까. 이번에는 android/build.gradle 에서 오류가 발생했는데 apps/mobile/node_modules/react-native/ReactAndroid/gradle.properties 파일을 찾을 수 없다는 것이었다. 당연하지, 루트 설정으로 모두 상위 node_modules에 옮겨놨는데!
그러면 이제 안드로이드가 루트 node_modules를 읽게 해야겠지? node_modules로 검색해보니 android/app/build.gradle을 보았다(android/build.gradle이 아니다!). 다음과 같은 코드를 확인할 수 있었다.
react {
  autolinkLibrariesWithApp()
}
그리고 그 안에 수많은 주석들이 있었다. 그 안에 node_modules 관련된 설정들이 주석처리 되어 있는 것을 볼 수 있었다. reactNativeDir, codegenDir, cliFile 등이 모두 React Native 프로젝트의 node_modules를 가리키고 있었다. 해당 부분을 모두 루트 디렉토리의 node_modules를 바라보도록 수정했다. 그리고 다시 안드로이드 빌드를 진행했다.
계속해서 나타나던 오류를 넘어서, 드디어 앱 설치가 진행되었다. 이번엔 제발 오류 발생하지 않길 빌며 콘솔창을 바라보았다. 설치가 100%가 되고, 안드로이드 에뮬레이터에 앱이 실행되었다. 앱의 첫 화면인 로그인 화면을 본 순간, 답답했던 가슴이 뻥 뚫렸다.
이번 경험을 통해 단순히 “패키지 매니저를 무엇으로 쓸까?”의 문제가 아니라, **"패키지 매니저가 프로젝트 구조와 빌드 시스템에 어떤 영향을 미치는가"**를 깊이 이해할 수 있었다. React Native가 플랫폼마다 다르게 작동하는 이유도 몸으로 느꼈다. 시행착오는 고생스러웠지만, 앞으로 같은 문제를 마주하더라도 훨씬 빠르게 해결할 수 있을 것이다.