native 데이터 페칭·상태 관리
native(Expo) 앱에서 데이터를 어떻게 불러오고 캐시하는지, provider 스택은 어떻게 구성되는지, Funnel이 native에서 어떻게 동작하는지 정리합니다.
핵심 요약: native는 클라이언트 전용 react-query만 씁니다. web의 RSC prefetch → <Hydration> 패턴이 없고(서버 컴포넌트가 없으므로), 모든 useAPIQuery/useAPIInfiniteQuery가 클라이언트에서 실행됩니다. 대신 캐시를 MMKV에 영속화해 앱 재실행 시 화면을 즉시 그린 뒤 백그라운드에서 재검증합니다.
공유 훅(
useAPIQuery/useAPIMutation/useAPIInfiniteQuery)과 데코레이터(@QueryOptions/@MutationOptions)의 일반 규약은 utils-react-query를 보세요. 이 문서는 native에서 달라지는 점에 집중합니다.
web과 native의 데이터 페칭 차이
| 항목 | web (apps/app) | native (apps/native-app) |
|---|---|---|
| 초기 데이터 | RSC에서 fetchAPIQuery prefetch → <Hydration> | 없음 (서버 컴포넌트 없음) |
| 쿼리 실행 위치 | 서버 prefetch + 클라이언트 재사용 | 클라이언트 전용 |
| ”cache에 없음” 처리 | prefetch 되어 있으므로 정상 | 전역 clientOnly: true로 경고 억제 |
| 캐시 영속 | 없음 (요청마다 SSR) | MMKV 영속 (persistQueryClient) |
| 로딩 경계 | RSC 트리 + Suspense | Suspense 경계로 감싸기 필수 |
useAPIQuery/useAPIInfiniteQuery는 Suspense 기반입니다. web은 prefetch된 캐시가 있어 대개 즉시 resolve되지만, native는 캐시가 비어 있으면 실제로 suspend합니다. 따라서 데이터를 읽는 컴포넌트를 <Suspense>로 감싸는 것이 native의 기본 로딩 패턴입니다.
// native: 클라이언트 전용 + Suspense 경계
<Suspense fallback={<SpinnerLoader />}>
<CafePostList cafeId={cafeId} categoryId={categoryId} />
</Suspense>// CafePostList 내부 — 훅은 web과 동일하게 호출한다
const {data, hasNextPage, isFetchingNextPage, fetchNextPage, refetch} =
useAPIInfiniteQuery(CafeAPI.getPostList, {cafeId, categoryId, pageSize: 10});clientOnly를 전역으로 켜는 이유
useAPIQuery/useAPIInfiniteQuery는 캐시에 데이터가 없고 clientOnly가 아니면 queryKey ... is not found in the cache 경고를 남깁니다(web의 prefetch 누락을 잡기 위한 것). native는 prefetch 자체가 없어 이 경고가 전역 노이즈가 됩니다. 그래서 per-call clientOnly를 다는 대신 루트에서 한 번에 기본값으로 깝니다.
// apps/native-app/src/app/_layout.tsx
<QueryOptionsOverrider
options={{queries: {clientOnly: true}} as unknown as DefaultOptions}>
{/* ...앱 트리... */}
</QueryOptionsOverrider>react-query 영속화 (MMKV)
앱 재실행 시 빈 화면 없이 이전 데이터로 즉시 그리고, 이후 staleTime에 따라 백그라운드에서 refetch하는 stale-while-revalidate를 구현합니다.
- 저장소: MMKV (
react-native-mmkv) — 동기·고속. 부팅 시 동기 복원이라 suspense 쿼리가 돌기 전에 복원을 끝낼 수 있습니다. - persister:
createSyncStoragePersister(@tanstack/query-sync-storage-persister), throttle 1000ms. - 설정 파일:
apps/native-app/src/lib/queryPersist.ts(queryPersister,queryPersistOptions).
⚠️
react-native-mmkv는 네이티브 모듈(+nitro)입니다. 설정 변경 시 EAS/dev client 재빌드가 선행돼야 합니다.
무엇이 영속되고 무엇이 제외되나
| 대상 | 기본 정책 |
|---|---|
일반 쿼리 (@QueryOptions) | 전부 영속 (성공한 쿼리 한정) |
@NoPersist가 붙은 쿼리 | 제외 (meta.persist=false) |
무한 쿼리 (@InfiniteQueryOptions) | 기본 제외 (meta.persist ?? false) |
일반 쿼리는 defaultShouldDehydrateQuery(성공한 쿼리만) + meta.persist !== false 조건으로 영속됩니다.
무한 쿼리는 기본적으로 영속에서 빠집니다. 영속하면 재실행 시 refetch가 캐시된 전 페이지를 전부 다시 요청해 요청이 폭주하고 캐시가 비대해지기 때문입니다. 영속이 정말 필요하면 @InfiniteQueryOptions에서 meta.persist=true를 명시해야 하고, 이때도 @NoPersist가 우선합니다.
// packages/utils-react-query/common/index.ts (InfiniteQueryOptions)
meta: {
...resolvedMeta,
persist:
__apiMetadata?.persist === false
? false // @NoPersist 우선
: (resolvedMeta?.persist ?? false), // 무한쿼리는 기본 false
}@NoPersist — 민감정보 제외
MMKV는 평문 저장입니다. 개인정보 등 민감한 엔드포인트는 @NoPersist로 영속에서 반드시 제외하세요. (packages/api/lib/HowmuchhomeAPI.ts의 NoPersist 데코레이터 → __apiMetadata.persist=false → meta.persist=false.)
@NoPersist
@QueryOptions(...)
getSensitiveData(props) { ... }신선도 정책 (queryPersistOptions)
| 옵션 | 값 | 의미 |
|---|---|---|
maxAge | 24h | 24시간 지난 캐시는 복원하지 않고 폐기 |
buster | ${appVersion}+${appBuildCode} | 앱 버전/빌드가 바뀌면 전체 캐시 무효화 |
기본 staleTime은 1(ms, packages/utils-react-query/common/options.ts)이라, 복원된 캐시로 첫 프레임을 그린 직후 거의 즉시 백그라운드 refetch가 돕니다.
queryClient는 반드시 공유 싱글톤
native의 queryClient(apps/native-app/src/api/queryClient.ts)는 반드시 getQueryClient()(utils-react-query 공유 싱글톤)를 써야 합니다.
// apps/native-app/src/api/queryClient.ts
export const queryClient = getQueryClient();이유: @MutationOptions의 캐시 무효화(getQueryClient().invalidateQueries)와 useAPIQuery가 읽는 캐시가 동일 인스턴스여야 mutate 후 화면이 즉시 갱신됩니다. new QueryClient()를 따로 만들어 Provider에 주입하면 무효화가 빈 캐시에 적용돼 반영되지 않습니다.
부팅 시 복원 순서
_layout.tsx의 bootstrap이 다음을 **suspense 쿼리가 돌기 전(bootstrapReady 전)**에 끝냅니다. 스플래시는 SplashScreen.preventAutoHideAsync()로 붙잡아 빈 화면 깜빡임을 막고, 콘텐츠 onLayout 시점에 내립니다.
purgeSecureStoreOnFreshInstall()— 재설치 시 이전 세션 정리migrateV1SessionIfNeeded()— v1(웹뷰 앱) 세션 이관Promise.all([initializeCookies(), restoreSession()])— 쿠키·세션 복원persistQueryClientRestore({queryClient, persister, ...queryPersistOptions})— MMKV 캐시 복원
한편 캐시 저장(구독)은 모듈 평가 시점에 persistQueryClientSubscribe로 걸어두어 이후 캐시 변경을 throttle 저장합니다. setupOnlineManager()로 react-query의 온라인 상태를 네트워크에 연결해, 오프라인 시 일시중지하고 재연결 시 자동 refetch합니다.
provider 스택 (_layout.tsx)
apps/native-app/src/app/_layout.tsx의 실제 중첩 순서(바깥 → 안쪽):
GestureHandlerRootView // 제스처
└─ KeyboardProvider // 키보드 추적(UI 스레드)
└─ DatadogProvider // (자격증명 있을 때만) RUM init + trackInteractions
└─ QueryClientProvider // 공유 queryClient 주입
└─ QueryOptionsOverrider // 전역 clientOnly: true (직계 자식들은 형제)
├─ (authToken 있으면) <Suspense><UserLoader/><SessionExpired/></Suspense>
├─ ThemeProvider
│ └─ HowmuchhomeErrorBoundary
│ └─ NotificationProvider
│ └─ AppVersionGate
│ └─ MaintenanceGate
│ └─ Stack // expo-router 화면들
├─ OfflineToast
├─ OtaUpdateGate
└─ ToastHost| provider / 게이트 | 역할 |
|---|---|
GestureHandlerRootView | react-native-gesture-handler 루트. onLayout에서 스플래시 hide |
KeyboardProvider | 키보드 추적(채팅 스크롤·sticky 입력창) |
DatadogProvider | Datadog RUM init + 터치 계측. 자격증명 없으면 감싸지 않음(미계측) |
QueryClientProvider | 공유 queryClient 주입 |
QueryOptionsOverrider | 전역 clientOnly: true (prefetch 없음 대응) |
UserLoader / SessionExpired | 로그인 시 getMe를 react-query로 로드해 jotai store와 동기화. Suspense라 별도 경계로 감싸 메인 트리를 막지 않음 |
ThemeProvider | 라이트/다크 테마 |
HowmuchhomeErrorBoundary | 렌더 에러 경계 |
NotificationProvider | 푸시/알림 컨텍스트 → 푸시 알림 |
AppVersionGate / MaintenanceGate | 강제 업데이트·점검 게이트 |
ToastHost | 전역 토스트. 다른 RN Modal 위에도 보이도록 트리 최상단 1회 렌더 → 크로스플랫폼 UI |
bootstrapReady가 false인 동안_layout은null을 반환합니다(스플래시 유지). provider 스택은 부트스트랩 완료 후에만 마운트됩니다.
Funnel의 native 구현
Funnel의 일반 규약은 Funnel 문서를 보세요. 여기서는 native 차이만 다룹니다.
라우터 모드: web은 browser, native는 memory / expo-router
web useFunnel(useFunnel.ts)은 @use-funnel/browser + next/navigation + window.history/popstate로 브라우저 뒤로가기를 오케스트레이션합니다. native(useFunnel.native.ts)는 이 전부가 없어 다른 라우터 모드를 씁니다.
| 모드 | 구현 | 동작 |
|---|---|---|
'memory' (기본) | useFunnelMemory — React state 라우터 | 한 스크린 안에서 step 전환. URL/브라우저 히스토리를 건드리지 않음 |
'expo-router' | useFunnelExpoRouter — 네이티브 스택 | 각 step을 expo-router 스택 스크린으로 push → iOS 스와이프 백/헤더백이 funnel 이전 step으로 동작 |
router 모드는 렌더 간 불변이어야 합니다(조건부 hook 호출 순서 보장).
memory 라우터의 history 메서드
useFunnelMemory(packages/funnel/src/useFunnelMemory.ts)는 @use-funnel/core의 in-memory 라우터입니다.
history.push/replace/back/go: React state 히스토리 배열로 처리 (push는 현재 위치 이후를 잘라내고 append).history.exit(cb): memory는 정리할 브라우저 상태가 없어 pop 하지 않고 콜백만 호출(보통 drawer/screen 닫기). expo-router 모드도 exit은 pop하지 않음 — pop하면 이어지는 콜백이 사라진 스크린에 dispatch돼SET_PARAMS not handled크래시가 남.history.reset(): 초기 step/context로 replace (memory는 forward history 자동 절단). expo-router 모드는 쌓인 step 스크린을 첫 화면까지 pop한 뒤 초기값 재적용.history.exitAndRedirect(href): native엔 패키지 차원 라우터가 없어, 호출부가onExitAndRedirect어댑터(expo-router 등)를 주입해야 실제 이동. 미주입 시 경고만 남김.
memory 모드는 cross-funnel 좌표를 추적하지 않아 index/historyIndex 등은 0/no-op으로 stub됩니다. expo-router 모드는 라우터가 주는 실제 좌표를 유지합니다.
back / 이탈 가드
native는 하드웨어 백버튼 + 화면의 back 버튼을 하나의 채널로 모아 가드를 한 곳에서 걸 수 있게 합니다(web에서 PreventLeaveConfirm 하나가 라우터 뒤로가기를 모두 가로채는 것과 동등).
BackGuardProvider(funnel 화면/호스트 컴포넌트가 funnel 주위에 렌더,packages/funnel/src/backGuard.native.tsx):- memory 모드:
useHardwareBackHandler로 하드웨어 백을 직접back()으로 처리. 가드가 막으면 이동 안 함. - expo-router 모드(
screenStack): 뒤로가기를 네이티브 스택이 처리하고, 이탈 확인 가드만beforeRemove로 가로챔.
- memory 모드:
PreventLeaveConfirm(native,PreventLeaveConfirm.native.tsx):BackGuardProvider안에서 하나만 렌더하면 하드웨어 백과 화면 back 둘 다 이탈 확인 알럿을 거침. 확인 시onLeave ?? Provider.back실행.
memory 라우터 퍼널을 expo-router 스택 스크린으로 push하는 경우, 스와이프 백/헤더백은 step이 아니라 스크린 전체를 닫아 진행 내용이 날아갑니다. 그래서 그런 스크린은
_layout.tsx에서gestureEnabled: false로 제스처 백을 끄고, 앱바 ‘나가기’/CTA ‘이전’으로만 이동합니다(예:native/principal-consent-funnel/[voteId],native/vote-voter-landing/...).
무한 쿼리 / 리스트 주의 (native)
목록은 FlashList(shopify/flash-list v2)로 렌더합니다. 무한 스크롤에서 반복적으로 밟는 함정:
onEndReached가 마운트 시 자동 발동. FlashList v2는 부모로부터 높이를 받아야 viewport(가상화)가 생깁니다.flex: 1래퍼로 바운드하지 않으면 리스트가 전체 높이로 펼쳐져 “모든 아이템이 보임” 상태가 되고, 마운트 직후onEndReached가 즉시 발동해 다음 페이지가 자동 로드됩니다. 리스트를<View style={{flex: 1}}>등으로 반드시 높이 바운드하세요.onEndReached에서는 항상hasNextPage && !isFetchingNextPage가드 후fetchNextPage()를 호출합니다.- 무한 쿼리는 앞서 설명한 대로 기본 영속 제외이므로, 앱 재실행 시 첫 페이지부터 다시 로드됩니다(의도된 동작).
// apps/native-app/src/features/community/cafe/CafePostList.tsx (요약)
<View style={{flex: 1}}>
<FlashList
data={posts}
onEndReached={() => {
if (hasNextPage && !isFetchingNextPage) void fetchNextPage();
}}
onEndReachedThreshold={0.5}
/* ... */
/>
</View>