모노레포에서 작업하기
Yarn v1 워크스페이스를 사용하여 모노레포에서 Expo 프로젝트를 설정하는 방법을 알아본다.
생성일: 2024-04-11
수정일: 2024-04-11
Monorepo(모노레포)는 "monolithic repository"의 줄임말로, 여러 개의 앱이나 패키지를 단일 저장소에 포함하는 것을 말한다. 대규모 프로젝트의 개발 속도를 높이고, 코드 공유를 용이하게 하며, 단일 진실 공급원(single source of truth) 역할을 할 수 있다. 이 가이드에서는 Expo 프로젝트와 함께 간단한 monorepo를 설정하는 방법을 다룬다. 현재 Yarn 1(Classic) 워크스페이스에 대한 일급 지원이 제공된다. 다른 도구를 사용하려면 해당 도구의 구성 방법을 잘 알고 있어야 한다.
Warning
Monorepo는 만능이 아니다. 사용하는 도구에 대한 심층적인 지식이 필요하고, 복잡성이 증가하며, 종종 특정 도구 설정이 필요하다. 단일 저장소만으로도 충분히 많은 것을 할 수 있다.
Monorepo 예시
이 예시에서는 nohoist 옵션 없이 Yarn 워크스페이스을 사용하여 monorepo를 설정한다. 익숙한 이름을 가정하겠지만 완전히 커스터마이징할 수 있다.
이 가이드를 따르면 기본 구조는 다음과 같다:
apps/
- React Native 앱을 포함한 여러 프로젝트가 포함되어 있다.packages/
- 앱에서 사용하는 다양한 패키지가 포함되어 있다.package.json
- Yarn 워크스페이스 설정을 포함하는 루트 패키지 파일이다.
루트 패키지 파일
모든 Yarn monorepo에는 "루트" package.json
파일이 있어야 한다. 이는 monorepo의 메인 설정 파일이며, 저장소의 모든 내부 프로젝트에 설치된 패키지를 포함할 수 있다.
yarn init
을 실행하여 자동으로 생성하거나 package.json
을 수동으로 생성할 수 있다.
다음과 같은 내용이 포함되어야 한다:
// package.json
{
"name": "monorepo",
"version": "1.0.0"
}
Yarn 워크스페이스 설정
Yarn과 다른 도구에는 워크스페이스라는 개념이 있다. 저장소의 모든 패키지와 앱에는 자체 워크스페이스가 있다. 이를 사용하려면 Yarn이 워크스페이스를 찾을 수 있도록 위치를 지정해야 한다. package.json
에서 glob 패턴을 사용하여 workspaces
프로퍼티를 설정하면 된다:
// package.json
{
"private": true,
"name": "monorepo",
"version": "1.0.0",
"workspaces": ["apps/*", "packages/*"]
}
Warning
Yarn 워크스페이스의 루트 package.json
은 비공개여야 한다. 이를 설정하지 않으면 yarn install
시 이에 대한 메시지와 함께 오류가 발생한다.
첫 번째 앱 생성하기
이제 기본 monorepo 구조가 설정되었으니 첫 번째 앱을 추가해 보자.
앱을 생성하기 전에 apps/
폴더를 생성해야 한다. 이 폴더에는 이 monorepo에 속하는 모든 개별 앱이나 웹사이트가 포함된다. 이 apps/
폴더 내에 React Native 앱을 포함하는 하위 폴더를 생성할 수 있다.
yarn create expo apps/cool-app
Note
기존 앱이 있는 경우 해당 파일을 모두 apps/
안의 폴더에 복사할 수 있다.
앱을 복사하거나 생성한 후 yarn
을 실행하여 일반적인 경고를 확인한다.
Metro 설정 수정
Expo의 Metro는 bun
, npm
, yarn
용 monorepo를 지원한다. expo/metro-config
설정을 사용하는 경우 monorepo에 Metro를 수동으로 설정할 필요가 없다. 그런 경우라면 이 단계를 건너뛸 수 있다.
Metro를 수동으로 monorepo에 설정하려면 두 가지 주요 변경 사항이 있다:
- Metro가
apps/cool-app
뿐만 아니라 monorepo 내의 모든 관련 코드를 감시하고 있는지 확인한다. - Metro가 패키지를 리졸브할 수 있도록 위치를 알려준다. 패키지는
apps/cool-app/node_modules
또는node_modules
에 설치될 수 있다.
다음과 같이 metro.config.js
를 생성하여 설정할 수 있다:
const { getDefaultConfig } = require('expo/metro-config');
const path = require('path');
// 프로젝트 및 워크스페이스 디렉토리 찾기
const projectRoot = __dirname;
// 이는 'find-yarn-workspace-root'로 대체될 수 있음
const monorepoRoot = path.resolve(projectRoot, '../..');
const config = getDefaultConfig(projectRoot);
// 1. monorepo 내의 모든 파일 감시
config.watchFolders = [monorepoRoot];
// 2. Metro에 패키지를 해결할 위치와 순서 알려주기
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, 'node_modules'),
path.resolve(monorepoRoot, 'node_modules'),
];
module.exports = config;
Note
Metro 커스터마이징에 대한 자세한 내용은 문서를 참조한다.
Monorepo에서 왜 모든 파일을 감시해야 할까?
Metro는 번들링 프로세스에서 세 가지 별도의 단계를 거치는데, 자세한 내용은 여기에 문서화되어 있다. 첫 번째 단계인 해석(Resolution) 중에 Metro는 앱에 필요한 파일과 종속성을 해석한다. Metro는 이를 watchFolders
옵션으로 수행하는데, 기본적으로 프로젝트 디렉토리로 설정된다. 이 기본 설정은 monorepo 구조를 사용하지 않는 앱에 매우 적합하다.
monorepo를 사용할 때는 앱의 종속성이 여러 디렉토리로 분할된다. 이 디렉토리들은 각각 watchFolders
의 범위 내에 있어야 한다. 변경된 파일이 해당 범위 밖에 있다면 Metro는 이를 찾을 수 없다. 이 경로를 monorepo의 루트로 설정하면 Metro가 저장소 내의 모든 파일을 감시하도록 강제하여 초기 시작 시간이 느려질 수 있다.
monorepo의 크기가 커질수록 monorepo 내의 모든 파일을 감시하는 것은 점점 더 느려진다. 앱에서 사용하는 패키지만 감시하면 속도를 높일 수 있다. 일반적으로 이는 package.json에서 별표(*)로 설치된 패키지들이다. 예를 들면 다음과 같다:
// metro.config.js
const { getDefaultConfig } = require('expo/metro-config');
const path = require('path');
const projectRoot = __dirname;
const monorepoRoot = path.resolve(projectRoot, '../..');
const config = getDefaultConfig(monorepoRoot);
// 앱에서 사용하는 monorepo 내의 패키지만 나열합니다. 다른 것은 추가할 필요 없다.
// monorepo 도구가 앱 워크스페이스에 연결된 워크스페이스 목록을 제공할 수 있다면,
// 하드코딩 대신 이 목록을 자동화할 수 있다.
const monorepoPackages = {
'@acme/api': path.resolve(monorepoRoot, 'packages/api'),
'@acme/components': path.resolve(monorepoRoot, 'packages/components'),
};
// 1. 로컬 앱 폴더와 공유 패키지만 감시한다(범위를 제한하고 속도를 높임).
// 이를 `monorepoRoot`에서 `projectRoot`로 변경하는것에 주목한다. 이는 최적화의 일부다!
config.watchFolders = [projectRoot, ...Object.values(monorepoPackages)];
// monorepo 워크스페이스를 Metro의 `extraNodeModules`에 추가한다.
// monorepo 도구가 `node_modules` 폴더에 워크스페이스 심볼릭 링크를 생성하는 경우,
// Metro에 심볼릭 링크 지원을 추가하거나 `extraNodeModules`를 설정하여 심볼릭 링크를 피할 수 있다.
// 참조: https://facebook.github.io/metro/docs/configuration/#extranodemodules
config.resolver.extraNodeModules = monorepoPackages;
// 2. Metro에 패키지를 해석할 위치와 순서를 알려준다.
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, 'node_modules'),
path.resolve(monorepoRoot, 'node_modules'),
];
왜 Metro에게 패키지를 해석하는 방법을 알려줘야 할까?
이 옵션은 라이브러리를 올바른 node_modules 디렉토리에서 해석하는 데 중요하다. Yarn과 같은 monorepo 도구는 일반적으로 단일 워크스페이스에 사용되는 두 개의 서로 다른 node_modules 디렉토리를 생성한다.
- apps/mobile/node_modules - "프로젝트" 폴더
- node_modules - "루트" 폴더
Yarn은 루트 폴더를 사용하여 여러 워크스페이스에서 사용되는 패키지를 설치한다. 워크스페이스가 다른 패키지 버전을 사용하는 경우, 해당 다른 버전을 프로젝트 폴더에 설치한다.
우리는 Metro에게 이 두 폴더를 모두 찾아보라고 알려줘야 한다. 여기서 순서가 중요한데, 프로젝트 폴더의 node_modules에는 우리 앱에 사용하는 특정 버전이 포함될 수 있기 때문이다. 패키지가 프로젝트 폴더에 없으면 공유 루트 폴더를 시도해야 한다.
다음과 같이 Metro 설정에서 nodeModulesPaths
옵션을 사용하여 이를 지정할 수 있다:
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, 'node_modules'),
path.resolve(monorepoRoot, 'node_modules'),
];
이렇게 하면 Metro는 먼저 프로젝트 폴더의 node_modules
에서 패키지를 찾고, 거기에 없으면 monorepo 루트의 node_modules
에서 찾는다.
이 설정은 monorepo 환경에서 Metro가 올바른 패키지 버전을 해석하고 사용할 수 있도록 보장한다. 프로젝트별 종속성과 공유 종속성을 적절히 구분하여 처리할 수 있게 해준다.
기본 엔트리포인트 변경
Monorepo에서는 패키지가 어디에 설치되어 있는지 확신할 수 없기 때문에 패키지 경로를 하드코딩할 수 없다.
관리형 프로젝트를 사용하는 경우 node_modules/expo/AppEntry.js
와 같이 기본 엔트리포인트를 직접 지정해야 한다.
앱의 package.json
을 열고 main
프로퍼티를 index.js
로 변경한 다음 아래 내용으로 앱 디렉토리에 새 index.js
파일을 생성한다.
import { registerRootComponent } from 'expo';
import App from './App';
// registerRootComponent는 AppRegistry.registerComponent('main', () => App)를 호출한다.
// 또한 Expo Go 또는 기본 빌드에서 앱을 로드하든 상관없이
// 환경이 적절하게 설정되도록 한다.
registerRootComponent(App);
Note
이 새로운 엔트리포인트는 베어(bare) 프로젝트에는 이미 존재한다. 관리형 프로젝트를 사용하는 경우에만 이를 추가해야 한다.
Expo Router를 사용하는 경우 npx expo start
명령을 실행할 때 EXPO_USE_METRO_WORKSPACE_ROOT
환경 변수를 정의한다. 이는 Metro의 자동 서버 루트 감지 기능을 활성화한다.
EXPO_USE_METRO_WORKSPACE_ROOT=1 npx expo start
이 변수는 .env
파일 내에서도 정의될 수 있다.
패키지 생성하기
Monorepo는 단일 저장소에서 코드를 그룹화하는 데 도움이 된다. 여기에는 앱뿐만 아니라 별도의 패키지도 포함된다. 또한 패키지를 배포할 필요가 없다. Expo 저장소에서도 이러한 방식을 사용한다. 모든 Expo SDK 패키지는 저장소의 packages/
폴더에 있다. 이를 통해 배포하기 전에 apps/
중 하나에서 코드를 테스트할 수 있다.
루트로 돌아가서 packages/
폴더를 생성해 보자. 이 폴더에는 만들고 싶은 모든 별도의 패키지가 포함된다. 이 폴더 안에 새 하위 폴더를 추가해야 한다. 하위 폴더는 앱 내에서 사용할 수 있는 별도의 패키지다. 아래 예시에서는 이름을 cool-package
로 지정했다.
mkdir -p packages/cool-package
cd packages/cool-package
yarn init
패키지 생성에 대해 너무 자세히 다루지는 않겠다. 익숙하지 않다면 monorepo 없이 간단한 앱을 사용하는 것을 고려해 보자. 그러나 예시를 완성하기 위해 다음 내용으로 index.js
파일을 추가해보자:
export const greeting = 'Hello!';
패키지 사용하기
표준 패키지와 마찬가지로 cool-app
에 cool-package
를 종속성으로 추가해야 한다. 표준 패키지와 monorepo의 패키지의 주요 차이점은 버전 대신 항상 "패키지의 현재 상태"를 사용한다는 것이다. 앱 package.json
파일에 "cool-package": "*"
를 추가하여 앱에 cool-package
를 추가한다:
{
"name": "cool-app",
"version": "1.0.0",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web"
},
"dependencies": {
"cool-package": "*",
"expo": "~50.0.0",
"expo-status-bar": "~1.10.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-native": "0.73.0",
"react-native-web": "~0.19.6"
},
"devDependencies": {
"@babel/core": "^7.20.0"
}
}
Note
패키지를 종속성으로 추가한 후 yarn install
을 실행하여 종속성을 앱에 설치하거나 연결한다.
이제 앱 내에서 패키지를 사용할 수 있다! 이를 테스트하기 위해 앱의 App.js
를 편집하고 cool-package
에서 greeting
텍스트를 렌더링해 본다.
import { greeting } from 'cool-package';
import { StatusBar } from 'expo-status-bar';
import React from 'react';
import { Text, View } from 'react-native';
export default function App() {
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<Text>{greeting}</Text>
<StatusBar style="auto" />
</View>
);
}
일반적인 이슈
앞서 언급했듯이 monorepo 사용은 모든 사람에게 적합하지 않다. 복잡성이 증가하고 마주칠 가능성이 높은 문제를 해결해야 한다. 다음은 자주 직면할 수 있는 몇 가지 일반적인 문제다.
Monorepo 내 여러 React Native 버전
Expo SDK 50 이상은 격리된 모듈과 같은 보다 완전한 node_modules 패턴에 대한 지원이 개선되었다. 불행히도 React Native는 단일 monorepo 내에 여러 버전을 설치할 때 여전히 문제를 일으킬 수 있다. 따라서 단일 버전의 React Native만 사용하는 것이 좋다.
사용하는 패키지 관리자를 통해 monorepo에 여러 React Native 버전이 있는지, 그리고 왜 설치되었는지 확인할 수 있다.
bun install --yarn && yarn why react-native
npm why react-native
pnpm why --recursive react-native
yarn why react-native
Yarn 워크스페이스 대신 다른 monorepo 도구를 사용할 수 있는가?
사용 가능한 많은 monorepo 도구가 있으며, 각 도구에는 장단점이 있다. 최신 도구와 방법을 따라잡기 어려우므로 새로운 monorepo 도구를 공식적으로 지원할 수 없습니다. 그렇다고 해도 도구가 다음 세 가지 규칙을 따른다면 잘 작동할 것이다.
- 모든 종속성은 node_modules 디렉토리에 설치되어야 한다.
- 여러 워크스페이스에서 사용되는 종속성은 루트 node_modules 디렉토리에 설치될 수 있다.
- 다른 버전의 종속성은 앱 node_modules 디렉토리에 설치되어야 한다.
pnpm
과 같은 도구의 기본 설정은 이러한 규칙을 따르지 않는다. .npmrc
파일에 node-linker=hoisted
를 추가하여 이를 변경할 수 있다(문서 참조).
'...' 스크립트가 존재하지 않습니다
React Native는 패키지를 사용하여 JavaScript와 네이티브 파일을 모두 제공한다. 이러한 네이티브 파일도 android/app/build.Gradle의 react-native/react.Gradle 파일과 같이 연결되어야 한다. 보통 이 경로는 다음과 같이 하드코딩된다:
Android (출처)
apply from: "../../node_modules/react-native/react.gradle"
iOS (출처)
require_relative '../node_modules/react-native/scripts/react_native_pods'
안타깝게도 hoisting으로 인해 monorepo에서는 이 경로가 다를 수 있다. 또한 Node module resolution을 사용하지 않는다. 이를 하드코딩하는 대신 Node를 사용하여 패키지의 위치를 찾으면 이 문제를 피할 수 있다:
Android (출처)
apply from: new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim(), "../react.gradle")
iOS (출처)
require File.join(File.dirname(`node --print "require.resolve('react-native/package.json')"`), "scripts/react_native_pods")
위의 코드 조각에서 Node의 require.resolve()
메서드를 사용하여 패키지 위치를 찾는 것을 볼 수 있다. 우리는 엔트리포인트의 위치가 아니라 패키지의 루트 위치를 찾고 싶기 때문에 package.json
을 명시적으로 참조한다. 그리고 그 루트 위치를 사용하여 패키지 내의 예상되는 상대 경로를 해석할 수 있다. 자세한 내용은 여기를 참조한다.
SDK 43부터 시작하는 모든 Expo SDK 모듈과 템플릿에는 이러한 동적 참조가 있으며 monorepo와 함께 작동한다. 그러나 가끔 하드코딩된 경로를 여전히 사용하는 패키지가 있을 수 있다. patch-package
로 수동으로 편집하거나 패키지 관리자에게 이를 요청할 수 있다.
expo-yarn-workspaces 제거
SDK 43 이전에는 expo-yarn-workspaces
가 Expo 프로젝트에서 Yarn 워크스페이스를 사용하는 권장 방법이었다. 이는 필요한 모든 종속성을 앱의 node_modules 폴더로 다시 symlink하는 데 사용되었다. 이는 대부분의 앱에서 작동하지만 몇 가지 결함이 있다. 예를 들어 동일한 패키지의 여러 버전에서는 잘 작동하지 않는다.
Expo SDK 43에서 monorepo 지원을 개선하기 위해 몇 가지 중요한 변경 사항을 만들었다. 새로운 Expo 모듈의 자동 링커는 이제 상위 node_modules 폴더에서도 패키지를 찾는다. 템플릿 내부의 네이티브 파일에는 패키지에 대한 하드코딩된 경로가 포함되어 있지 않다.
이 가이드를 따르고 있다면 프로젝트의 종속성에서 해당 패키지를 제거해야 한다.