Metro 설정 · 공유 패키지 소비 · 의존성 함정
apps/native-app은 turborepo 모노레포 안의 Expo/RN 프로젝트로, web(apps/app, Next.js)과 같은 공유 패키지(@howmuchhome-web/*)를 무수정으로 재사용합니다. 그 공유 패키지들은 web/node 전용 모듈(crypto, datadog 브라우저 SDK, @sentry/nextjs 등)을 transitive dependency로 끌고 오는데, RN 번들에는 이것들이 없거나 동작하지 않습니다. 이 간극을 메우는 곳이 Metro resolver·Babel 플러그인·patch-package입니다.
이 문서는 다음 파일을 근거로 합니다:
apps/native-app/metro.config.jsapps/native-app/babel.config.js,apps/native-app/plugins/babel-plugin-rn-svg-icons.jsapps/native-app/src/shims/(datadogMobile.ts,tty.js,os.js)- 루트
patches/(patch-package) apps/native-app/tsconfig.json
관련 문서: 크로스플랫폼 UI · native 컨벤션 · 로깅
1. 모노레포 Metro 구성
metro.config.js의 기본 골격입니다.
const projectRoot = __dirname; // apps/native-app
const workspaceRoot = path.resolve(projectRoot, '../..'); // 레포 루트
const config = getSentryExpoConfig(projectRoot);
config.watchFolders = [workspaceRoot];
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, 'node_modules'),
path.resolve(workspaceRoot, 'node_modules'),
];
config.resolver.disableHierarchicalLookup = true;| 설정 | 이유 |
|---|---|
getSentryExpoConfig (getDefaultConfig 대체) | 번들·소스맵에 Sentry debug ID 를 주입하는 serializer 를 추가. OTA(R2)로 배포된 JS 의 Sentry 이벤트가 release/dist 매칭 없이 symbolicate 된다. |
watchFolders = [workspaceRoot] | Metro 가 앱 밖(packages/*, 루트 node_modules)의 파일 변경을 감지·번들. 공유 패키지를 그대로 import 하려면 필수. |
nodeModulesPaths = 앱 + 루트 | yarn workspace hoisting 때문에 의존성이 앱 node_modules 또는 루트 node_modules 둘 중 하나에 있다. 둘 다 탐색. |
disableHierarchicalLookup = true | Node 식 상위 디렉토리 순회(../node_modules, ../../node_modules …)를 끈다. 모노레포에서 중복/잘못된 버전 해석을 막고 위 두 경로만 쓰도록 강제. 단, nested-only 로 설치된 패키지를 못 보게 되는 부작용이 있다(§6 entities 참고). |
cacheVersion 은 EXPO_PUBLIC_STORYBOOK 값('storybook'/'app')으로 분리합니다. 앱 Metro 와 on-device Storybook Metro 를 동시에 띄울 때 캐시가 서로를 오염시키지 않도록 하기 위함입니다.
2. resolveRequest shim 표
공유 패키지가 끌고 오는 web/node 전용 의존을 커스텀 resolver.resolveRequest 에서 native 호환으로 처리합니다. 공유 패키지와 web 소스는 무수정입니다.
resolveRequest 는 다음 순서로 분기하고, 어디에도 걸리지 않으면 기본 resolver 로 위임합니다.
| 대상 (moduleName) | 처리 | 왜 |
|---|---|---|
.rnstorybook / storybook.requires (Storybook 비활성 시) | {type: 'empty'} | index.js 의 require('./.rnstorybook') 는 런타임상 dead-branch 지만 Metro 는 정적 수집 단계에서 조건 무관하게 해석 → gitignore 라 미생성인 storybook.requires 해석 실패. 진입을 끊어 Storybook 코드를 앱 번들 그래프에서 완전히 제외. |
crypto | {type: 'empty'} (EMPTY_STUBS) | utils-ts 배럴의 aes-encryption 이 import. native 에서 미사용 → 빈 모듈 stub. |
@tanstack/react-query-next-experimental | {type: 'empty'} (EMPTY_STUBS) | utils-react-query 배럴 → TanstackQueryProvider 가 import 하는 Next 전용 패키지. native 는 QueryClientProvider 를 직접 쓰므로 스트리밍 하이드레이션 미사용 → 빈 모듈 stub (렌더되지 않아 안전). |
tty, os (NODE_STDLIB_SHIMS) | src/shims/tty.js · src/shims/os.js | on-device Storybook core(tinyrainbow/색상 감지)가 node stdlib 를 import 하는데 RN 번들엔 없다. isatty/EOL 등만 no-op 로 shim. Storybook 빌드에서만 끌려옴. |
@datadog/browser-logs, @datadog/browser-rum (DATADOG_PKGS) | src/shims/datadogMobile.ts | 브라우저 전용 SDK 라 RN 미지원. logger 가 호출하는 datadogLogs/datadogRum API 를 @datadog/mobile-react-native(DdLogs/DdRum/DdSdkReactNative)로 브리지. init 전엔 isDatadogReady() 가 false 라 전부 no-op. → packages/logger 무수정. |
@sentry/nextjs (REDIRECTS) | @sentry/react-native 로 redirect | logger 의 Sentry.setUser/withScope/captureException 는 양쪽 공통 API 라 그대로 동작. native 에러 리포팅이 RN Sentry(앱에서 이미 init)로 들어간다. |
@sentry/core, @sentry/react, @sentry-internal/* 등 (isSentryJsSdk) | require.resolve(target, {paths: [sentryReactNativeDir]}) 로 실제 파일 해석 | @sentry/browser@10 이 import 하는 @sentry/core/browser 는 package.json exports 서브패스인데, Metro 는 unstable_enablePackageExports 없이는 서브패스를 해석 못 해 Unable to resolve @sentry/core/browser 가 난다(§5 참고). Node 해석(exports 처리)으로 실제 파일을 찾아 준다. @sentry/react-native 자체는 제외. |
entities, entities/* | require.resolve(moduleName, {paths: [markdownItDir]}) | markdown-it(← react-native-markdown-display의 transitive)가 require('entities/lib/maps/entities.json'). entities@2 는 markdown-it 아래 nested 로만 설치되고 root 에 hoist 안 되는데, disableHierarchicalLookup=true 라 Metro 가 nested 를 못 본다. Node 해석(hierarchical)으로 찾아 준다. |
주의(적용 순서):
REDIRECTS를 먼저 적용한 뒤(const target = REDIRECTS[moduleName] ?? moduleName)isSentryJsSdk(target)를 검사합니다.require.resolve가 실패하는 경우(예: v10 트리에 없는 구버전 모듈)는try/catch로 삼켜 기본 해석으로 fallback 합니다.
shim 계층은 왜 이 구조인가
- 버전 스큐가 아니라 Metro 한계. 모노레포의
@sentryJS SDK 는 web@sentry/nextjs·native@sentry/react-native모두 v10 으로 통일돼 있습니다.@sentryexports 보정은 버전 충돌 해결이 아니라 순전히 Metro 의 exports-서브패스 미해석 때문입니다. - stub vs 어댑터.
crypto/@tanstack/react-query-next-experimental는 native 에서 실제로 호출되지 않아 빈 모듈로 충분합니다. datadog 은 native 에서도 실제 로깅이 필요하므로 동작하는 어댑터(datadogMobile.ts)로 브리지합니다.
3. 아이콘 Babel 치환 (babel-plugin-rn-svg-icons.js)
@howmuchhome-web/icon(packages/icon/libs)의 아이콘은 web DOM SVG(<svg>/<path> …)로 작성돼 있어 RN 에서 그대로 렌더되지 않습니다(Unimplemented component: svg). native 빌드에서만 동작하는 커스텀 Babel 플러그인이 이를 react-native-svg 컴포넌트로 치환합니다.
babel.config.js 에 등록:
plugins: [
require('./plugins/babel-plugin-rn-svg-icons.js'),
'react-native-worklets/plugin', // Reanimated 4 worklet 변환 — 반드시 배열 마지막
],플러그인 동작:
- 파일 스코프:
(^|/)(packages/icon|@howmuchhome-web/icon)/libs/정규식으로 매칭되는 파일에만 적용(소스 경로 + node_modules 심링크 경로 모두). web 소스·타 파일은 무영향. - 엘리먼트 치환:
ELEMENT_MAP으로 소문자 SVG 엘리먼트를 컴포넌트명으로 (svg→Svg,path→Path,linearGradient→LinearGradient…). 사용된 컴포넌트명으로import { … } from 'react-native-svg'를 파일 상단에 주입. - 속성 처리:
fill/d/viewBox/fillRule/stopColor등은react-native-svg가 동일 camelCase 로 받으므로 그대로.xmlns*만 제거(미지원 경고 방지).
결과적으로 web 소스는 DOM svg 그대로 두고, native 빌드에서만 아이콘 파일이 react-native-svg 로 렌더됩니다.
react-native-svg는 아이콘을 소비하는 공유 패키지(packages/ui)의package.json에 선언되어 있습니다(§5의 “RN 전용 dep 는 hoist 안 됨” 규칙).
4. patch-package 로 처리하는 것들
패치는 루트 patches/ 에 있고 patch-package 로 적용됩니다(앱 하위 patches/ 가 아님). native 에 영향 있는 항목:
| 패치 | 내용 | 이유 |
|---|---|---|
react-native-css+3.0.7.patch | ① 기본 rem 을 14→16 으로(compiler.js firstPass, declarations.js parseLength, native-internal/root.js __rn-css-rem) ② components/Text.js·TextInput.js 에 className 앞에 "font-pretendard " 주입 ③ metro/injection-code.js 의 주입 CSS 모듈에 module.hot.accept() 추가 | ① withNativewind 의 inlineRem 옵션이 이 Expo/Metro 버전에서 transform 까지 전달되지 않아 무효였고, rem→px 변환 경로가 두 곳이라 컴파일러 기본값을 직접 16 으로 패치하는 게 확실. ② RN 0.85 Text=순수 함수형 + React 19 라 전역 기본 폰트를 몽키패치할 수 없어 react-native-css Text 에 font-pretendard className 주입(UI 문서 참고). ③ NativeWind v5 의 주입 CSS 모듈엔 module.hot.accept 가 없어 저장마다 full reload 되던 것을 fast-refresh 로. |
@use-funnel+core+0.0.13.patch | package.json exports 에 ./dist/core 서브패스 추가 | native funnel(memory 라우터)이 core 진입점을 서브패스로 import 하는데 패키지가 노출하지 않아서. |
@react-native-cookies+cookies+6.2.1.patch | Android build.gradle 의 jcenter() → mavenCentral() | jcenter 폐지로 의존성 해석 실패 방지. |
react-native-view-shot+5.1.0.patch | package.json codegenConfig 에 iOS modulesProvider 추가 | New Architecture 에서 iOS 네이티브 모듈(RNViewShot) 프로바이더 등록. |
next+15.4.10.patch | SWC 데코레이터 옵션(decorators: true, decoratorVersion: "2022-03") | web 빌드용 — @howmuchhome-web/api 의 표준 데코레이터. native 는 babel-preset-expo 의 decorators: {version: '2023-11'} 로 별도 처리(babel.config.js). |
expo-realtime-ivs-broadcast+0.2.8.patch도 루트에 있으나 방송 기능 전용입니다.
5. 공유 패키지를 native 가 소비하는 규칙
.native.tsx / .shared.ts 분리
공유 UI 는 컴포넌트별로 native 구현(*.native.tsx)과 web 구현, 그리고 공유 로직(*.shared.ts)을 따로 둡니다.
packages/ui/v2/RadioGroup/
RadioGroup.tsx # web
RadioGroup.native.tsx # native (Metro 가 .native 우선 해석)
packages/ui/v2/Button/
Button.shared.ts # 양쪽 공유 로직/타입/native 배럴 진입점
native 는 전용 배럴에서 import 합니다. 각 패키지 package.json exports 에 ./native 가 있습니다.
import {Button, Form} from '@howmuchhome-web/ui/native';
import {Funnel, useFunnel} from '@howmuchhome-web/funnel/native';packages/ui/package.json→"./native": "./native/index.ts"packages/funnel/package.json→"./native": "./native/index.ts"(web funnel 은@use-funnel/browser+next+react-dom+window.history의존이라 RN 불가 → native 는 memory 라우터 기반 구현)
customConditions: ["react-native"] (tsconfig)
apps/native-app/tsconfig.json 은 customConditions: ["react-native"] 로, tsc 도 Metro 와 동일하게 package.json exports 의 "react-native" 조건을 해석합니다(예: react-native-css/components). 이게 없으면 타입체크와 런타임 해석이 어긋납니다.
RN 전용 dep 는 루트에 hoist 되지 않는다
RN 전용 패키지가 앱(apps/native-app)의 package.json 에만 선언돼 있으면, 공유 패키지(packages/*)에서 import 할 때 tsc·Metro 가 못 찾습니다. 공유 패키지가 쓰는 RN dep 는 그 패키지의 package.json 에 선언해야 합니다.
- 예:
react-native-svg,react-native-css,react-native-reanimated등은packages/ui/package.json에 선언됨. - 반대로 앱 레벨에서만 필요한 것(
@datadog/mobile-react-native,@sentry/react-native)은 앱에서 주입/설정하고, 공유 패키지는 Metro shim 을 통해 간접적으로 연결됩니다(§2).
6. 문제 진단 지도 (native 모듈 해석/의존성)
native 빌드에서 모듈 해석·의존성 오류를 만났을 때 참고합니다.
| 증상 | 원인 가능성 | 확인/처리 |
|---|---|---|
Unable to resolve <web/node 패키지> (예: crypto, datadog, @sentry/nextjs) | 공유 패키지의 web/node 전용 transitive dep 가 shim 처리 안 됨 | metro.config.js 의 EMPTY_STUBS/DATADOG_PKGS/REDIRECTS/NODE_STDLIB_SHIMS 에 추가(§2). 실제 호출되면 어댑터, 안 되면 empty stub. |
Unable to resolve @sentry/core/browser 류 (exports 서브패스) | Metro 가 exports 서브패스를 물리 파일 없이 해석 못 함 | isSentryJsSdk 처럼 require.resolve(...) 로 실제 파일 해석하는 분기 추가(§2, §5). |
nested-only 패키지 미해석 (예: entities) | disableHierarchicalLookup=true 라 nested 를 못 봄 | 해당 dep 의 부모 dir 을 paths 로 준 require.resolve 분기(§2 entities). |
Unimplemented component: svg / 아이콘 안 보임 | 아이콘 파일에 Babel svg 치환 미적용 | 파일 경로가 ICON_FILE_RE(packages/icon|@howmuchhome-web/icon)/libs/)에 매칭되는지(§3). Metro 캐시 클리어 필요할 수 있음. |
| 공유 패키지에서 RN dep import 시 tsc/Metro 실패 | RN dep 가 앱에만 선언돼 루트 hoist 안 됨 | 그 dep 를 공유 패키지 package.json 에 선언(§5). |
exports 조건이 tsc 와 런타임에서 다르게 해석 | customConditions 누락 | tsconfig.json customConditions: ["react-native"] 확인(§5). |
.native.tsx 대신 web 파일이 번들됨 | 파일명/배럴 문제 | .native.tsx 존재·/native 배럴 경유 import 확인(§5). |
| 라이브러리 버그를 소스 수정 없이 고쳐야 함 | 서브패스 미노출·기본값·fast-refresh 등 | 루트 patches/ 에 patch-package(§4). 새 패치 후 postinstall 재적용 필요. |
| 워크스페이스 패키지만 고쳤는데 CI 에서 stale | 새 dep 없음 → yarn.lock 불변 → CI node_modules 캐시 재사용 | yarn.lock 이 바뀌지 않으면 lockfile 해시 기반 캐시가 그대로 hit 한다. Metro/번들 캐시나 CI 캐시 무효화가 필요할 수 있음(빌드 파이프라인과 연계). |
CI 캐시 stale 함정(재강조): 공유 패키지의 소스만 바꾸고 의존성은 안 추가하면
yarn.lock이 그대로라, lockfile 해시로 키를 만드는node_modules/설치 캐시가 이전 상태를 그대로 복원합니다. 소스 변경은 watch/번들 그래프에는 반영되지만, “설치물 자체가 stale” 한 상황(patch-package 재적용 누락 등)은 lockfile 로 감지되지 않으니 주의합니다.