Skip to Content
Native AppMetro 설정 · 공유 패키지 소비 · 의존성 함정

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.js
  • apps/native-app/babel.config.js, apps/native-app/plugins/babel-plugin-rn-svg-icons.js
  • apps/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 = trueNode 식 상위 디렉토리 순회(../node_modules, ../../node_modules …)를 끈다. 모노레포에서 중복/잘못된 버전 해석을 막고 위 두 경로만 쓰도록 강제. 단, nested-only 로 설치된 패키지를 못 보게 되는 부작용이 있다(§6 entities 참고).

cacheVersionEXPO_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.jsrequire('./.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.json-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 로 redirectloggerSentry.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@2markdown-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 한계. 모노레포의 @sentry JS SDK 는 web @sentry/nextjs·native @sentry/react-native 모두 v10 으로 통일돼 있습니다. @sentry exports 보정은 버전 충돌 해결이 아니라 순전히 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.jsclassName 앞에 "font-pretendard " 주입 ③ metro/injection-code.js 의 주입 CSS 모듈에 module.hot.accept() 추가withNativewindinlineRem 옵션이 이 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.patchpackage.json exports./dist/core 서브패스 추가native funnel(memory 라우터)이 core 진입점을 서브패스로 import 하는데 패키지가 노출하지 않아서.
@react-native-cookies+cookies+6.2.1.patchAndroid build.gradlejcenter()mavenCentral()jcenter 폐지로 의존성 해석 실패 방지.
react-native-view-shot+5.1.0.patchpackage.json codegenConfig 에 iOS modulesProvider 추가New Architecture 에서 iOS 네이티브 모듈(RNViewShot) 프로바이더 등록.
next+15.4.10.patchSWC 데코레이터 옵션(decorators: true, decoratorVersion: "2022-03")web 빌드용 — @howmuchhome-web/api 의 표준 데코레이터. native 는 babel-preset-expodecorators: {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.jsoncustomConditions: ["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.jsEMPTY_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 로 감지되지 않으니 주의합니다.

Last updated on