모노레포를 위한 패키지매니저는 제 개인적인 생각이지만 pnpm으로 귀결되고 있다고 생각합니다.
npm, yarn은 구조적인 한계가 뚜렷하고 bun은 주목할만하지만 아직 프로덕션에서 사용하기에는 섣부른 감이 있습니다.
yarn 2는 PnP 방식이 갖는 태생적인 문제와 자잘하게 떨어지는 DX가 거슬리는 면이 있죠..
결국 소거법을 하고나면 남는게 pnpm 밖에 없는데 정작 React Native 개발을 하는 경우에는 Pnpm이 골칫거리로 다가옵니다.
Pnpm의 핵심 기능인 심볼릭 링크가 RN 생태계와 제대로 호환되지 못하고 있었다는 점 때문인데요
RN 0.72 버전부터 심볼릭 링크를 지원한다고는 했지만 순순히 그냥은 동작해주지 않는다는 점과 빈약한 문서로 인해 사실상 못써먹었습니다. 아마 저만 못써먹은 건 아닌 것 같아요..
그렇다보니 RN을 포함한 모노레포를 구성하기 위해서는 node-linker=hoisted를 사용하거나 또는 shamefully-hoisted 옵션까지 사용하며 pnpm의 장점을 내다버리는 선택을 강요받아왔습니다.
이 문제를 처음 고민한 후로 약 세달이 지난 지금 드디어 동작하는 솔루션을 얻어내어 공유드립니다.
마이크로 소프트에서 공개한 오픈소스 rnx-kit을 이용하는 방법입니다.
https://gist.github.com/Zn4rK/ed60c380e7b672e3089074f51792a2b8
이 해결방법의 영감은 위 github에서 얻었습니다. (안타깝게도 저 글의 솔루션이 제게는 동작하지는 않았습니다.)
rnx-kit?
rnx-kit의 짧은 소개를 읽어보면 RN 개발자 경험을 최적화하기 위해 마이크로 소프트 엔지니어가 만든 실전에서 검증된 도구 모음이라고 소개합니다.
https://microsoft.github.io/rnx-kit/
실제로 이번 포스트의 핵심이 될 심볼릭링크 해결 문제 뿐만 아니라 RN 프로젝트 관리에서 항상 까다로운 부분이었던 미묘한 의존성 버전 관리 같은 부분들을 자동화할 수 있게 도와주는 기능도 상당히 킬러기능으로 느껴졌습니다.
관심이 생기신 분들은 위 문서를 읽어보시는 것을 추천드리겠습니다.
이번에 중점적으로 사용할 기능은 이 metro resolver입니다.
https://microsoft.github.io/rnx-kit/docs/tools/metro-resolver-symlinks
코딩을 시작합시다.
저는 turbo repo , pnpm을 주로 이용합니다. 다만 turbo repo를 사용하지 않더라도 크게 문제가 되는 점은 없습니다.
apps 폴더에서 다음과 같이 rn 프로젝트를 생성하겠습니다.
pnpm create expo-app --template blank-typescript
https://docs.expo.dev/more/create-expo/
template를 지정하지 않고 expo app을 생성하는 경우 이글을 쓰고있는 2024년 기준으로 엑스포 라우터가 기본적으로 설치됩니다.
저는 엑스포 라우터에서 동작시키지 못했습니다. 따라서 엑스포 라우터를 사용하지 않고 최소한의 기능만 포함된 보일러플레이트를 설치합니다.
사용가능한 다른 옵션은 위 문서에서 참고하세요
생성하고 나면 프로젝트 내부에 .npmrc가 생성되어있습니다.
내부가 다음과 같이 작성되어있다면 .npmrc를 지웁니다.
node-linker=hoisted
.npmrc가 프로젝트 내부에 존재하는 경우 install을 시도하는 위치에 따라 node_modules이 다르게 설치되기 때문에 삭제합니다.
이제 의존성을 추가합니다.
pnpm i -D @rnx-kit/metro-config @rnx-kit/metro-resolver-symlinks
React Native 0.72 버전 이상을 사용하고있는 경우 metro.config.js가 기본적으로 생성되지 않을 수 있습니다.
metro.config.js를 따로 생성하려는 경우 0.72버전 이상부터는 이 의존성을 설치해야합니다.
pnpm i -D @react-native/metro-config
여기까지 잘 설치했다면 이제 metro.config.js를 작성하겠습니다.
const path = require("node:path");
const { FileStore } = require("metro-cache");
const { makeMetroConfig } = require("@rnx-kit/metro-config");
const { getDefaultConfig } = require("expo/metro-config");
const MetroSymlinksResolver = require("@rnx-kit/metro-resolver-symlinks");
const projectDir = __dirname;
const workspaceRoot = path.resolve(projectDir, "../..");
const symlinksResolver = MetroSymlinksResolver();
/** @type {import('expo/metro-config').MetroConfig} */
const expoConfig = getDefaultConfig(projectDir);
/** @type {import('expo/metro-config').MetroConfig} */
module.exports = makeMetroConfig({
...expoConfig,
resolver: {
...expoConfig.resolver,
resolveRequest: (context, moduleName, platform) => {
try {
// Symlinks resolver throws when it can't find what we're looking for.
const res = symlinksResolver(context, moduleName, platform);
if (res) {
return res;
}
} catch {
// If we have an error, we pass it on to the next resolver in the chain,
// which should be one of expos.
// https://github.com/expo/expo/blob/9c025ce7c10b23546ca889f3905f4a46d65608a4/packages/%40expo/cli/src/start/server/metro/withMetroResolvers.ts#L47
return context.resolveRequest(context, moduleName, platform);
}
},
},
watchFolders: [workspaceRoot],
cacheStores: [
new FileStore({
root: path.join(projectDir, "node_modules", ".cache", "metro"),
}),
],
});
rnx-kit의 metro-resolver-symlinks를 통해 심링크를 해결해주는 것과 모노레포를 위한 설정을 수행합니다.
여기까지만 수행하여도 최신 버전의 보일러플레이트를 사용했다면 node-linker=hoisted 없이도 정상적으로 동작하는 것을 확인할 수 있습니다.
기존 프로젝트를 마이그레이션하는 경우에는 package.json의 엔트리포인트를 새로 작성해야합니다.
rn 프로젝트의 루트 부분에 index.ts를 작성합니다.
import { registerRootComponent } from "expo";
import App from "./App";
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately
registerRootComponent(App);
이 코드는 최신 버전의 보일러플레이트에서는 기본적으로 포함되어있으니 마이그레이션하는 경우가 아니라면 무시해도 좋습니다.
"main": "index.ts",
package.json의 main 부분을 확인합니다. 다음과 같이 작성되어있으면 ok입니다.
즉 가이드를 잘 따라왔다면 최종적으로는 이런 폴더구조가 완성됩니다.
apps/rn/index.ts
apps/rn/metro.config.js
apps/rn/package.json
apps/rn/tsconfig.json
apps/rn/App.tsx
이런 구조가 되었다면 정상적으로 설정하신 게 맞습니다.
이제 hoisted 없이 모노레포 개발을 즐기세요..
마치며
언젠간 React Native의 DX도 리액트 못지않은 세상이 오겠죠..?
힘겨웠지만 그래도 지금 당장 해결할 수 있게 도와준 마이크로 소프트에게 감사인사 올리겠읍니다.
고마워요! 마이크로 소프트! 😭
'frontend' 카테고리의 다른 글
코드에 대해서 그냥 요즘 생각하고 있는 주제 (4) | 2025.01.19 |
---|---|
storybook, chromatic 대신 vercel로 배포하기 (1) | 2024.09.29 |
모노레포에서 tailwindcss를 쓰는 경우엔 설정을 어떻게 해야할까? (1) | 2024.06.20 |
모노레포에서 Internal Packages를 관리하는 3가지 방법 (0) | 2024.06.13 |
Feature-Sliced Design을 직접 사용하면서 느낀 장점과 단점 (4) | 2024.05.29 |