Skip to Content
Native Appnative 데이터 페칭·상태 관리

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 트리 + SuspenseSuspense 경계로 감싸기 필수

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.tsNoPersist 데코레이터 → __apiMetadata.persist=falsemeta.persist=false.)

@NoPersist @QueryOptions(...) getSensitiveData(props) { ... }

신선도 정책 (queryPersistOptions)

옵션의미
maxAge24h24시간 지난 캐시는 복원하지 않고 폐기
buster${appVersion}+${appBuildCode}앱 버전/빌드가 바뀌면 전체 캐시 무효화

기본 staleTime1(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 시점에 내립니다.

  1. purgeSecureStoreOnFreshInstall() — 재설치 시 이전 세션 정리
  2. migrateV1SessionIfNeeded() — v1(웹뷰 앱) 세션 이관
  3. Promise.all([initializeCookies(), restoreSession()]) — 쿠키·세션 복원
  4. 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 / 게이트역할
GestureHandlerRootViewreact-native-gesture-handler 루트. onLayout에서 스플래시 hide
KeyboardProvider키보드 추적(채팅 스크롤·sticky 입력창)
DatadogProviderDatadog 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인 동안 _layoutnull을 반환합니다(스플래시 유지). 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로 가로챔.
  • 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>

관련 문서

Last updated on