Skip to Content
Native Appnative 구현 컨벤션 (규칙 체크리스트)

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-nativeView/TextclassName 금지. 전역 className 타입 augmentation(react-native-css/types) 때문에 타입은 통과하지만 런타임엔 조용히 무시된다(react-native-css는 react-native-css/components만 스타일링). 레이아웃이 “가운데로 뭉치는”·“이 화면만 스타일 안 됨” 류 버그의 단골 원인.
    • ⚠️ 흔한 함정: TextInput/Modalreact-native 전용 컴포넌트를 쓰면서 import {TextInput, View} from 'react-native' 처럼 View까지 묶어 import → 그 View의 className이 전부 무시된다. RN 전용만 react-native에서(import {TextInput} from 'react-native'), View/Textreact-native-css/components에서 import한다. (css/components의 Viewstyle prop도 지원 → inline style 병용 OK)
    • 🔍 디버깅: 특정 화면만 스타일 미적용이면 그 파일의 View/Text import 출처부터 확인한다.
  • className 타입 지시자(/// <reference types="react-native-css/types" />) 위치:
    • apps/native-app/src의 화면·feature 파일 → 불필요. nativewind-env.d.ts(tsconfig include)가 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 클래스 + 색은 colorV4 semantic(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가 없어 .svg require 불가.
    • ⚠️ icon 배럴 타입은 느슨해서 없는 이름도 tsc를 통과한다. import 전에 packages/icon/libs에서 실재 여부를 확인한다.
  • png 이미지: expo-imageImage + require('../../../assets/.../x.png'). import x from '....png'은 타입 선언이 없어 TS2307 → 반드시 require.
  • lottie: lottie-react-nativeLottieView + 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.tsuploadFileFromUri/uploadFileFromDataUrlexpo File(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 여부를 직접 분기하지 않는다useScreenNavigationopenScreen/openWebviewScreen만 쓴다(미등록이면 웹뷰 fallback).
  • 아직 native로 미이식된 페이지를 native에서 열 때: routes.webview(id, {uri: `${env.WEB2_URL}${webPath}`, ...}). 홈/탭 영역은 lib/navigation/homeNavpushWebview.
  • “바텀내비 밖 전체 페이지”(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/ModalScrollView의 content(children) 안에 두지 말 것 — 닫혀 있어도 inline으로 높이를 먹어 부모가 부풀고 아래 콘텐츠가 밀린다. 컨트롤러로 열리므로 형제(밖)로 뺀다.
  • RN Modal끼리 z-order는 “표시(visible)된 순서”로만 결정 — 컴포넌트/렌더 순서로 위계를 못 정한다. 동시에 여러 모달이 뜨면 한쪽을 숨기거나(공유 플래그) 라우트로 분리한다.
  • native 토스트는 RN Modal 위에 못 뜬다 — 풀스크린 모달 안에서는 FullScreenModal(ToastHost 내장)을 쓴다.
  • 탭 화면 safe-area는 top edge만(하단은 탭 바가 차지). Scaffold/SafeAreaView의 하단 inset과 중복 금지. Scaffold footer는 하단 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.jsonbaseline 초과분 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(웹뷰). 레이아웃·키보드·딥링크 복귀·토스트/알럿까지 눈으로 확인한다. 실기기/수동 확인이 필요한 변경은 커밋·푸시 전에 테스트를 거친다.
Last updated on