native 구현 컨벤션 (규칙 체크리스트)
apps/native-app(Expo/expo-router)에서 화면·기능을 구현할 때 반드시 지킬 규칙만 모았다. 대부분 실제 버그/재작업으로 확립된 것이라, 어기면 타입은 통과하지만 런타임에서 조용히 깨지거나 재작업이 생긴다.이 문서는 규칙 허브다. 각 주제의 배경·설계·상세 구현은 다음 문서로 링크한다: 네비게이션·라우팅·게이팅·딥링크 · 데이터·상태·쿼리 영속화·Funnel · 크로스플랫폼 UI · Metro·의존성 · OTA · 로깅 · E2E.
0. 기능 검증 — 3분류로 명시 (최우선)
native 화면·기능을 구현할 때, web 등 기존 구현을 참고한다면 모든 로직/분기/early-return/useEffect/side effect/이벤트 로깅/empty·error·loading 상태를 하나씩 짚고 반드시 셋 중 하나로 분류한다. “분류 안 한 채 빠뜨림”이 가장 흔한 결함이다.
- ✅ 그대로 구현 — native에도 필요한 동작. 1:1로 옮긴다.
- 🚫 불가·보류 — native에서 구현 불가하거나 지금은 보류. 코드 주석 + PR/문서에 “무엇을·왜·대안” 을 남긴다.
- ➖ 불필요 — native에는 필요 없는 web 전용 보정 로직. 옮기지 않는다.
➖ 불필요(옮기지 않음)의 대표 예 — web 브라우저 보정 로직:
history.back 조작, 가짜 backdrop, position:fixed safe-area hack, 스크롤 잠금, -webkit-* 프리픽스 등. native는 OS/네비게이터/safe-area가 이 역할을 제공하므로 베끼면 오히려 버그가 된다. 옮기지 않은 이유를 한 줄 남긴다.
1. 스타일·레이아웃 — react-native-css
가장 자주 깨지는 규칙이다. 심화(NativeWind·leading·transform)는 UI 참고.
- ✅ 레이아웃 요소는
react-native-css/components의{View, Text, Pressable, ScrollView}를 쓴다. 이것들만className스타일이 실제로 적용된다. - 🚫
react-native의View/Text에className금지. 전역className타입 augmentation(react-native-css/types) 때문에 타입은 통과하지만 런타임엔 조용히 무시된다(react-native-css는react-native-css/components만 스타일링). 레이아웃이 “가운데로 뭉치는”·“이 화면만 스타일 안 됨” 류 버그의 단골 원인.- ⚠️ 흔한 함정:
TextInput/Modal등 react-native 전용 컴포넌트를 쓰면서import {TextInput, View} from 'react-native'처럼View까지 묶어 import → 그View의 className이 전부 무시된다. RN 전용만react-native에서(import {TextInput} from 'react-native'),View/Text는react-native-css/components에서 import한다. (css/components의View는styleprop도 지원 → inline style 병용 OK) - 🔍 디버깅: 특정 화면만 스타일 미적용이면 그 파일의
View/Textimport 출처부터 확인한다.
- ⚠️ 흔한 함정:
className타입 지시자(/// <reference types="react-native-css/types" />) 위치:apps/native-app/src의 화면·feature 파일 → 불필요.nativewind-env.d.ts(tsconfiginclude)가react-native-css/types를 전역 참조해 패키지 전체에 적용된다.packages/ui의*.native.tsx공유 컴포넌트 → 파일 맨 위에 필요. packages/ui는 자체 tsconfig에 전역 참조가 없고, native가 그 소스를 패키지 경계 너머로 컴파일할 때 native 쪽 augmentation이 packages/ui의react-native해석엔 닿지 않는다(빼면 packages/ui standalone·native tsc 양쪽에서 className 에러).
- ✅ web과 동일한 tailwind 클래스 + 색은
colorV4semantic(colorV4.semantic.icon.normal.neutral...). - ✅ UI는
@howmuchhome-web/ui/native배럴에서 import한다. 없으면 react-native-css로 인라인 구현하거나 native 컴포넌트를 추가한다(UI). 🚫 v2 web 컴포넌트(@howmuchhome-web/ui/v2) 직접 import 금지. - 🚫 native 파일에
'use client'금지.
2. 아이콘·에셋
- 아이콘:
@howmuchhome-web/icon(react-native-svg 기반, native 동작). web 인라인<svg>·.svg파일은react-native-svg(Svg/Path/Rect/G/…)로 포팅한다 — native엔 svg-transformer가 없어.svgrequire 불가.- ⚠️ icon 배럴 타입은 느슨해서 없는 이름도 tsc를 통과한다. import 전에
packages/icon/libs에서 실재 여부를 확인한다.
- ⚠️ icon 배럴 타입은 느슨해서 없는 이름도 tsc를 통과한다. import 전에
- png 이미지:
expo-image의Image+require('../../../assets/.../x.png').import x from '....png'은 타입 선언이 없어 TS2307 → 반드시require. - lottie:
lottie-react-native의LottieView+require(....json). 에셋은apps/native-app/assets/에 둔다.
3. 데이터·API
상세(provider 스택·MMKV 영속·Funnel·게이팅 cache-first)는 데이터·상태.
- native엔 RSC/Hydration 없음 → 각 컴포넌트가
useAPIQuery(suspense)를 직접 호출하고<Suspense>경계로 감싼다(섹션이 많으면 섹션별<Suspense fallback={null}>). - web의 RSC 서버 fetch / Next server action(
'use server') 은 native에서 호출 불가 →apps/api엔드포인트 +@howmuchhome-web/api(@FrontApi) 메서드로 분리한다. 요청 body는 fetcher가 camelCase→snake_case 변환하므로 route는 snake_case로 받는다. - 🚫 app 내부 Next 라우트(
apps/app/api/*)를 native에서 직접 호출 금지 —env.WEB2_URL(웹앱)은 로컬 실기기에서 도달 불가하다. 웹앱 내부 API는apps/api(front-api)로 옮기고frontApiClient/@FrontApi로 호출한다. @howmuchhome-web/domains의 const enum은 native에서 그대로 사용한다(babel-preset-expo가 런타임 enum 객체로 컴파일).as문자열 캐스팅 우회 금지.- RN-safe 의존: 순수 유틸은
utils-react(neutral), web 종속(window/DOM/<span>)은utils-react-web. native는utils-react만 import한다.utils-react에 web 의존을 넣으면 native 빌드가 깨진다. - 파일 업로드: api 패키지 업로드 메서드(
uploadPostFile등)의 fetcher(expo/fetch)는 RN식{uri,name,type}멀티파트를 거부한다(Unsupported FormDataPart implementation).src/lib/upload/toUploadFile.ts의uploadFileFromUri/uploadFileFromDataUrl로 expoFile(Blob) 을 만들어file인자로 넘긴다. (raw axios 직접 업로드는{uri,name,type}그대로 OK) - 쿼리 영속화(MMKV): 기본 전부 영속. 민감(개인정보)하거나 항상 최신이어야 하는 엔드포인트는 API 메서드에
@NoPersist(→meta.persist=false)로 opt-out한다.apps/native-app/src/lib/queryPersist.ts참고. - 무한쿼리(
useAPIInfiniteQuery) +FlashList의 페이지 폭주 주의.onEndReached는 시각적 끝이 아니라 렌더 윈도우(recycler) 기준이라 마운트·초기 레이아웃 중 사용자 스크롤 없이 연쇄 발동해hasNextPage가 true인 동안 전 페이지를 한 번에 당긴다(!isFetchingNextPage가드만으론 부족).onEndReached대신 실제 스크롤 메트릭(onScroll,contentOffset.y > 0확인 후 하단 근접 시fetchNextPage) 으로 판단한다.
4. 네비게이션·라우팅
파일 트리·게이팅·딥링크·webview 브릿지 전반은 네비게이션.
- 🚫 native 코드 안 이동에서 하드코딩
router.push('/native/...')·{pathname, params}직접 작성 금지.routes레지스트리(src/lib/navigation/routes.ts) 만 쓴다: 정적routes.settings(), 동적routes.onlineAssembly(id), 쿼리routes.webview(id, {uri,...}).- 레지스트리 추가 시 반환 타입을 명시적
: Href로 단다.satisfies Href금지 — 반환 타입이string으로 넓어진다. - 캐스팅(
as Href/as never)이 꼭 필요한 예외(typedRoutes.expo/types/router.d.ts미생성 등)는 레지스트리 한 곳에서만 처리해 호출부엔 캐스팅이 없게 한다.
- 레지스트리 추가 시 반환 타입을 명시적
- web 호출부는 expo 여부를 직접 분기하지 않는다 →
useScreenNavigation의openScreen/openWebviewScreen만 쓴다(미등록이면 웹뷰 fallback). - 아직 native로 미이식된 페이지를 native에서 열 때:
routes.webview(id, {uri: `${env.WEB2_URL}${webPath}`, ...}). 홈/탭 영역은lib/navigation/homeNav의pushWebview. - “바텀내비 밖 전체 페이지”(web
(with-bottom-navigation)밖, 예:/tax-chat·/document-box)는 탭 콘텐츠로 띄우지 말고 풀스크린 웹뷰로 push한다. - 그런 웹뷰 페이지의 web 닫기/뒤로 버튼은
router.back()(Next)이 아니라useNavigation().back()(@howmuchhome-web/app) — 웹뷰면NAVIGATE_GO_BACK브릿지로 native 스크린을 pop, 웹이면router.back(). - 강제 전체화면 플로우(필수 온보딩 등)는 홈 위 RN Modal ❌ → 진입 게이팅 라우트 ✅. 모달로 두면 탭바 덮기·뒤 레이아웃 오측정·z-order 경쟁·suspend 격리 복잡도가 줄줄이 붙는다. 상세 네비게이션.
5. 오버레이·애니메이션·레이아웃 (실측 기반)
오버레이 애니메이션 패턴 심화는 UI.
- 🚫 enter/exit 애니메이션에 Reanimated
entering/exiting(및 마운트 시 sharedValue+withTiming) 금지 — 이 앱에서 점프/깜빡임이 난다. react-native-css 트랜지션을 쓴다(transition-all duration-200+ opacity/scale·arbitrary translate). (제스처 보간형 Reanimated sharedValue는 Drawer처럼 RN Modal 뒤에서만 예외) - 애니메이션(트랜지션 className)은 컨테이너가 아니라 “카드” 컴포넌트에서 처리한다(Modal/Toast 동일). 컨테이너에서 처리하면 트랜지션이 안 먹는 케이스가 있다.
- ⚠️ react-native-css의 색 트랜지션(
transition-colors등)은 상태 클래스(selected등)를 껐다 켜면 보간이 깨진다(예: 칩 재선택 시 글자색이 사라지고 배경만 남음). 토글되는 상태 스타일엔 트랜지션을 빼고 즉시 적용한다(web의 transition을 그대로 옮기지 말 것). FlashList(가상리스트)는 부모로부터 바운드 높이를 받아야 viewport·스크롤이 생긴다 → 전체화면 리스트 화면은Scaffold scrollable={false}(children을 flex-1 View로 바운드). Scaffold의 ScrollView 안에 가상리스트를 중첩하면 높이가 무한대가 돼 전 아이템 렌더·onEndReached마운트 오발동·자체 스크롤 깨짐이 발생한다(§3 무한쿼리 폭주와 연결).- 🚫
BottomSheet/Modal을ScrollView의 content(children) 안에 두지 말 것 — 닫혀 있어도 inline으로 높이를 먹어 부모가 부풀고 아래 콘텐츠가 밀린다. 컨트롤러로 열리므로 형제(밖)로 뺀다. - RN Modal끼리 z-order는 “표시(visible)된 순서”로만 결정 — 컴포넌트/렌더 순서로 위계를 못 정한다. 동시에 여러 모달이 뜨면 한쪽을 숨기거나(공유 플래그) 라우트로 분리한다.
- native 토스트는 RN Modal 위에 못 뜬다 — 풀스크린 모달 안에서는
FullScreenModal(ToastHost 내장)을 쓴다. - 탭 화면 safe-area는 top edge만(하단은 탭 바가 차지).
Scaffold/SafeAreaView의 하단 inset과 중복 금지.Scaffoldfooter는 하단 inset을 자체 처리하므로 커스텀 footer에서insets.bottom을 또 더하지 않는다. - 키보드:
Scaffold(native)가 footer를 키보드 위로 올린다 — 재구현 금지. SideDrawer/모달 안에서Scaffold를 쓰면 상단 inset이 2중이 되므로, 키보드 입력이 있는 풀스크린 플로우는 푸시 라우트 + Scaffold로 둔다. - BottomSheet 닫은 뒤 포커스 이동은
onClose에서. BottomSheet=RN Modal이라 iOS dismiss가 포커스를 resign/복원한다 → 열 때Keyboard.dismiss, 다음 필드 focus는 닫힌 뒤에.
6. 뒤로가기 (퍼널)
- 퍼널은
<BackGuardProvider back={...} canGoBack={...}>로 감싸고, 화면은<PreventLeaveConfirm/>하나만 렌더한다 — 하드웨어 백 + AppBar back이 한 채널로 모인다. 🚫 화면 코드에서BackHandler를 직접 다루지 않는다. 중첩 퍼널은 각자BackGuardProvider를 둔다.
7. 검증 (커밋/완료 전 필수)
- 타입: 각 패키지에서
node ../../node_modules/typescript/bin/tsc --noEmit -p tsconfig.json→ baseline 초과분 0. 작업 전 기준값(native/packages/ui/apps 각각의 기존 에러 수)을 먼저 확인하고 신규 에러가 0인지 본다. - 린트:
npx eslint <changed> | grep -vE "Definition for rule|no-explicit-any|alt-text|jsx-a11y"→ 에러 0(위 4종은 native 무관 노이즈라 필터). - 파일 이동/삭제 시 잔존 import 참조를 grep으로 확인한다.
- 기기/시뮬레이터: native 동작 + 구버전 fallback(웹뷰). 레이아웃·키보드·딥링크 복귀·토스트/알럿까지 눈으로 확인한다. 실기기/수동 확인이 필요한 변경은 커밋·푸시 전에 테스트를 거친다.