본문으로 건너뛰기
  1. 포스트/

Zustand vs React Query vs AsyncStorage: 어디에 뭘 넣어야 할까

· loading · loading ·
인재덕
작성자
인재덕
서울에 거주하는 리더 겸 소프트웨어 엔지니어
목차

지금 만들고 있는 커튼 견적 앱에는 절대 양보할 수 없는 요구사항이 하나 있습니다. 신호가 없어도 동작해야 한다는 것. 견적은 현장 실측에서 시작되는데, 현장이라는 게 신축 건물이거나 지하라서 전파가 잡힌다는 보장이 없거든요. 이 제약 덕분에 많은 React 프로젝트가 얼버무리고 넘어가는 질문, “어떤 상태를 어디에 둘 것인가"를 정면으로 마주하게 됐습니다.

흔한 실패 패턴은 스토어 하나에 전부 밀어 넣는 겁니다. API 캐싱, 사용자 설정, 폼 상태, 인증 토큰까지 같은 Redux 덩어리 안에 뒤엉켜 있죠. 하지만 이것들은 성격도 수명도 다른 상태이고, 각자에게 맞는 도구가 따로 있습니다. Zustand, React Query, AsyncStorage는 각각 딱 하나의 문제를 제대로 풀어줍니다. 경계선만 잘 그으면 아키텍처는 거의 알아서 정리됩니다.

상태는 세 종류다
#

라이브러리 얘기를 하기 전에, 지금 관리하려는 게 뭔지부터 분명히 해두는 게 좋습니다.

클라이언트 상태는 앱 런타임에만 존재하는 데이터입니다. UI 토글, 선택된 탭, 폼 입력, 모달 열림 여부. 상위에 소유자가 없고, 프로세스가 끝나면 같이 사라집니다.

서버 상태는 사정이 다릅니다. 소유자는 서버이고, 앱이 들고 있는 건 로컬 복사본일 뿐이죠. 사용자 프로필, 상품 목록, 알림, 피드. 내 복사본은 낡아가고, 다시 가져와야 하고, 지금 이 순간 다른 클라이언트는 다른 버전을 보고 있을지도 모릅니다.

영속 상태는 재시작을 버텨야 하는 데이터입니다. 인증 토큰, 온보딩 완료 플래그, 캐시된 설정, 오프라인 데이터. 기기 자체에 저장됩니다.

대부분의 앱에는 셋 다 있습니다. 그리고 대부분의 사고는 셋을 도구 하나로 처리하려다 생깁니다.

Zustand: 클라이언트 상태 담당
#

Zustand는 격식 차릴 게 없는 작은 상태 라이브러리입니다. Provider도, 보일러플레이트도, 몇 겹씩 쌓인 Context 래퍼도 없습니다. 스토어 만들고 컴포넌트에서 쓰면 끝. 배울 게 사실상 그게 전부입니다.

여러 컴포넌트가 같은 UI 상태를 공유할 때(사이드바 열림/닫힘, 활성 필터, 선택된 항목), API에서 오지 않는 앱 레벨 상태가 있을 때(테마, 언어, 시작 시 읽는 기능 플래그), 장바구니 계산이나 멀티 스텝 위저드처럼 진짜 클라이언트 로직이 있을 때가 Zustand의 자리입니다. 동기적이고 예측 가능하게 업데이트돼야 하는 것들이죠.

기본 스토어는 이렇게 생겼습니다.

import { create } from 'zustand'

interface AppState {
  theme: 'light' | 'dark'
  sidebarOpen: boolean
  selectedFilters: string[]
  setTheme: (theme: 'light' | 'dark') => void
  toggleSidebar: () => void
  setFilters: (filters: string[]) => void
}

const useAppStore = create<AppState>((set) => ({
  theme: 'light',
  sidebarOpen: false,
  selectedFilters: [],
  setTheme: (theme) => set({ theme }),
  toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
  setFilters: (filters) => set({ selectedFilters: filters }),
}))

컴포넌트에서는 이렇게 씁니다.

function Sidebar() {
  const { sidebarOpen, toggleSidebar } = useAppStore()

  if (!sidebarOpen) return null

  return (
    <div className="sidebar">
      <button onClick={toggleSidebar}>Close</button>
      {/* sidebar content */}
    </div>
  )
}

Zustand에 넣지 않는 것
#

우선 API 응답입니다. API를 호출할 때마다 Zustand 스토어에 쓰고, 컴포넌트는 쿼리 대신 스토어를 읽는 프로젝트를 본 적이 있습니다. 그 끝은 캐시 무효화, 로딩 상태, 에러 처리, 리패치, 페이지네이션을 전부 손으로 다시 구현하는 것이었죠. 전부 React Query가 공짜로 해주는 일들입니다.

또 하나는 재시작 후에도 남아야 하는 데이터입니다. Zustand 상태는 메모리에 있어서 앱을 닫으면 사라집니다. persist 미들웨어로 AsyncStorage에 다리를 놓을 수는 있지만(뒤에서 다룹니다), 그건 의식적으로 내리는 결정이어야지 습관이어서는 안 됩니다.

React Query: 서버 상태 담당
#

React Query(요즘은 TanStack Query죠)는 원격 데이터의 라이프사이클 전체를 관리합니다. 패칭, 캐싱, 동기화, 업데이트, 가비지 컬렉션까지. 발상의 전환 포인트는 서버 데이터를 “내가 소유한 상태"가 아니라 “신선하게 유지해야 할 캐시"로 취급한다는 점입니다. 소유자는 서버고, 나는 복사본을 맡아둔 것뿐입니다.

API에서 오는 데이터라면 저는 전부 여기에 둡니다. 같은 엔드포인트를 여러 컴포넌트가 쓰는 데이터(요청은 자동으로 중복 제거됩니다), 페이지네이션과 무한 스크롤, 앱에 돌아왔을 때 백그라운드에서 다시 가져와야 하는 데이터, UI를 먼저 바꾸고 서버가 거부하면 되돌리는 낙관적 업데이트까지 전부요.

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'

function useUser(userId: string) {
  return useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json()),
    staleTime: 5 * 60 * 1000, // 5분 동안 fresh로 간주
  })
}

function useUpdateUser() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: (data: { id: string; name: string }) =>
      fetch(`/api/users/${data.id}`, {
        method: 'PATCH',
        body: JSON.stringify(data),
      }),
    onSuccess: (_, variables) => {
      queryClient.invalidateQueries({ queryKey: ['user', variables.id] })
    },
  })
}

컴포넌트에서는:

function UserProfile({ userId }: { userId: string }) {
  const { data: user, isLoading, error } = useUser(userId)
  const updateUser = useUpdateUser()

  if (isLoading) return <Spinner />
  if (error) return <ErrorMessage error={error} />

  return (
    <div>
      <h1>{user.name}</h1>
      <button
        onClick={() => updateUser.mutate({ id: userId, name: 'New Name' })}
        disabled={updateUser.isPending}
      >
        Update Name
      </button>
    </div>
  )
}

안 짜도 되는 코드들
#

서버 데이터를 여기 두라고 제가 고집하는 이유는, 공짜로 딸려오는 장치가 그만큼 많아서입니다. 컴포넌트 다섯 개가 같은 사용자를 요청해도 네트워크 요청은 한 번입니다. 창이 포커스를 되찾거나 네트워크가 재연결되면 알아서 리패치합니다. 오래된 캐시는 알아서 가비지 컬렉션되고요. 모든 쿼리가 isLoading, isError, data 같은 값을 넘겨주니 로딩·에러 UI를 매번 새로 만들 필요가 없습니다. 실패한 요청은 지수 백오프로 재시도되고, 롤백까지 포함된 낙관적 업데이트도 내장 패턴으로 제공됩니다. 직접 만들면 하루가 통째로 날아가는 것들이죠.

어울리지 않는 곳
#

클라이언트 전용 상태입니다. 서버를 거치지 않는 데이터에 React Query를 쓰면 절차만 늘어납니다. 모달 열림 상태에 캐시 무효화나 리패치 간격이 필요할 리 없으니까요.

장기 영속 레이어도 아닙니다. 캐시는 메모리에 있으니 재시작하면 텅 비고, 전부 다시 가져옵니다. 캐시를 영속화할 수는 있고 React Native에서는 그래야 하지만(아래 오프라인 섹션에서 다룹니다), 원본은 언제나 서버입니다.

AsyncStorage: 재시작을 버티는 것들 담당
#

AsyncStorage는 React Native의 키-값 저장소입니다. 웹에서의 대응물은 localStorage(동기)나 IndexedDB(비동기, 더 강력함)이고요. 어느 쪽이든 개념은 같습니다. 기기에 기록해서 프로세스보다 오래 사는 데이터라는 것.

플랫폼마다 속은 다르다
#

@react-native-async-storage/async-storage를 쓰면 API는 하나지만, 그 아래 백엔드는 플랫폼마다 꽤 다릅니다.

Android에서는 RKStorage를 통한 SQLite입니다. 앱 내부 저장소 디렉터리의 데이터베이스에 저장되고, 빠르고 안정적이며 샌드박스 처리돼 있어 다른 앱이 건드릴 수 없습니다. 다만 아래 표에 있는 기본 용량 제한은 염두에 두세요.

iOS에서는 작은 값은 NSUserDefaults, 큰 값은 직렬화된 파일에 들어갑니다. 역시 앱 컨테이너 안에 샌드박스 처리됩니다. Apple이 NSUserDefaults에 하드 리미트를 두진 않지만, 값 하나를 몇백 KB 이하로 유지하는 게 상식선입니다. 그보다 크다면 애초에 WatermelonDB나 Realm 같은 제대로 된 데이터베이스를 검토하는 게 맞습니다.

웹(React Native Web / Expo Web)에서는 localStorage로 폴백되는데, 브라우저에 따라 5~10MB 정도가 한계입니다. 웹 전용 React 앱이라면 localStorage를 직접 쓰거나, 데이터가 커지면 idb-keyval 같은 래퍼로 IndexedDB를 쓰면 됩니다.

PlatformBackendSize LimitLocation
AndroidSQLite (RKStorage)~6 MB default (configurable)App internal storage
iOSNSUserDefaults / filesNo hard limit (keep values small)App sandbox container
WeblocalStorage~5-10 MB (browser-dependent)Browser origin storage

내가 넣어두는 것들
#

인증 토큰과 세션 데이터, 계속 유지돼야 할 사용자 설정(언어, 테마, 알림 설정), 온보딩 완료 플래그, 오프라인용 캐시 데이터. 요컨대 “재시작을 버텨야 하는 작은 키-값"이 이 카테고리의 전부입니다.

API는 저장소가 가질 수 있는 가장 단순한 모양입니다.

import AsyncStorage from '@react-native-async-storage/async-storage'

// 값 저장
await AsyncStorage.setItem('auth_token', token)

// 값 읽기
const token = await AsyncStorage.getItem('auth_token')

// 객체 저장 (직렬화 필요)
await AsyncStorage.setItem('user_preferences', JSON.stringify({
  theme: 'dark',
  language: 'en',
  notifications: true,
}))

// 객체 읽기
const prefs = JSON.parse(await AsyncStorage.getItem('user_preferences') ?? '{}')

// 값 삭제
await AsyncStorage.removeItem('auth_token')

// 전부 삭제 (주의해서 사용)
await AsyncStorage.clear()

웹에서는
#

웹 전용 React 앱에는 AsyncStorage가 아예 필요 없습니다. 단순한 키-값이면 localStorage로 충분합니다.

// 동기 - 메인 스레드를 블로킹하지만 작은 데이터에는 괜찮다
localStorage.setItem('theme', 'dark')
const theme = localStorage.getItem('theme')

// 구조화된 데이터용
localStorage.setItem('user', JSON.stringify({ name: 'Jared', role: 'admin' }))
const user = JSON.parse(localStorage.getItem('user') ?? '{}')

데이터셋이 커지면 IndexedDB를 씁니다.

import { get, set, del } from 'idb-keyval'

await set('large-dataset', hugeArray)
const data = await get('large-dataset')
await del('large-dataset')

어울리지 않는 곳
#

키-값 저장소지 데이터베이스가 아닙니다. 관계형 데이터, 수천 건짜리 배열, 인덱싱이나 쿼리가 필요한 것들은 SQLite(expo-sqlite), WatermelonDB, Realm의 영역입니다.

보안 저장소도 아닙니다. 루팅되거나 탈옥된 기기에서는 AsyncStorage 내용이 그대로 읽힙니다. 민감한 토큰은 expo-secure-storereact-native-keychain에 두세요.

셋을 엮는 방법
#

실제 앱에서는 셋을 동시에 씁니다. 제가 자주 쓰는 패턴 몇 가지입니다.

인증 흐름
#

// 1. AsyncStorage: 인증 토큰 영속화
import AsyncStorage from '@react-native-async-storage/async-storage'

async function saveToken(token: string) {
  await AsyncStorage.setItem('auth_token', token)
}

async function getToken(): Promise<string | null> {
  return AsyncStorage.getItem('auth_token')
}

// 2. Zustand: 메모리에서 인증 상태 추적
import { create } from 'zustand'

interface AuthState {
  isAuthenticated: boolean
  token: string | null
  setAuth: (token: string) => void
  clearAuth: () => void
}

const useAuthStore = create<AuthState>((set) => ({
  isAuthenticated: false,
  token: null,
  setAuth: (token) => set({ isAuthenticated: true, token }),
  clearAuth: () => set({ isAuthenticated: false, token: null }),
}))

// 3. React Query: 토큰을 사용해 사용자 프로필 가져오기
function useCurrentUser() {
  const token = useAuthStore((s) => s.token)

  return useQuery({
    queryKey: ['currentUser'],
    queryFn: () =>
      fetch('/api/me', {
        headers: { Authorization: `Bearer ${token}` },
      }).then(res => res.json()),
    enabled: !!token, // 토큰이 있을 때만 패치
  })
}

시작할 때는 이렇게요.

// 앱 초기화
async function initializeApp() {
  const token = await getToken() // AsyncStorage에서 읽기
  if (token) {
    useAuthStore.getState().setAuth(token) // 빠른 접근을 위해 Zustand에 넣기
    // React Query가 자동으로 사용자 프로필을 가져온다
  }
}

AsyncStorage가 토큰을 재시작 너머까지 지켜주고, Zustand가 어디서든 싸게 읽을 수 있게 해주고, React Query는 토큰이 생기는 순간 프로필을 가져옵니다. 각자 잘하는 일 하나씩만 하는 거죠.

재시작해도 남는 테마 설정
#

import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
import AsyncStorage from '@react-native-async-storage/async-storage'

// Zustand의 persist 미들웨어가 간극을 메워준다
const useThemeStore = create(
  persist(
    (set) => ({
      theme: 'light' as 'light' | 'dark',
      toggleTheme: () =>
        set((state) => ({
          theme: state.theme === 'light' ? 'dark' : 'light',
        })),
    }),
    {
      name: 'theme-storage',
      storage: createJSONStorage(() => AsyncStorage), // React Native
      // storage: createJSONStorage(() => localStorage), // Web
    }
  )
)

런타임 상태는 Zustand가 관리하고, persist 미들웨어가 뒤에서 AsyncStorage(웹이라면 localStorage)와 동기화합니다. 컴포넌트는 영속 레이어가 있는지도 모르고, 알 필요도 없습니다.

오프라인 우선 읽기
#

import { useQuery } from '@tanstack/react-query'
import AsyncStorage from '@react-native-async-storage/async-storage'

function useProducts() {
  return useQuery({
    queryKey: ['products'],
    queryFn: async () => {
      try {
        const res = await fetch('/api/products')
        const data = await res.json()

        // 오프라인 사용을 위해 AsyncStorage에 캐시
        await AsyncStorage.setItem('cached_products', JSON.stringify(data))

        return data
      } catch (error) {
        // 네트워크 실패 - 캐시 데이터 시도
        const cached = await AsyncStorage.getItem('cached_products')
        if (cached) return JSON.parse(cached)
        throw error
      }
    },
    staleTime: 10 * 60 * 1000,
  })
}

패칭과 인메모리 캐싱은 React Query, 네트워크가 없을 때의 폴백은 AsyncStorage. 그런 분담입니다.

오프라인 모드, 단계별로
#

여기가 제 진짜 관심사입니다. 사용자가 지하철, 엘리베이터, 비행기에서 앱을 연다는 일반론도 맞지만, 제 경우는 더 절박합니다. 견적은 현장에서 쓰는 건데, 현장에 신호가 있으리란 보장이 없거든요. 지하에서 스피너만 돌고 있다는 건 견적 한 건을 놓친다는 뜻입니다.

다행히 이 세 라이브러리만으로도, 무거운 프레임워크 없이 꽤 견고한 오프라인 구성을 만들 수 있습니다.

1단계: 오프라인인지 알기
#

먼저 기기가 네트워크 상태를 앱에 알려주고, 앱은 그 답을 어딘가에 담아둬야 합니다. 이벤트는 @react-native-community/netinfo가 쏴주니, 작은 Zustand 스토어에 담아 어디서든 읽을 수 있게 합니다.

import { create } from 'zustand'
import NetInfo from '@react-native-community/netinfo'

interface NetworkState {
  isOnline: boolean
  setOnline: (online: boolean) => void
}

const useNetworkStore = create<NetworkState>((set) => ({
  isOnline: true,
  setOnline: (online) => set({ isOnline: online }),
}))

// 앱 시작 시 한 번만 구독
NetInfo.addEventListener((state) => {
  useNetworkStore.getState().setOnline(state.isConnected ?? false)
})

이제 어떤 컴포넌트든 useNetworkStore((s) => s.isOnline)을 확인해서 오프라인 배너를 띄우거나, 전송 버튼을 비활성화하거나, “다시 연결되면 동기화됩니다"라고 안내할 수 있습니다.

2단계: React Query에 오프라인 알려주기
#

React Query에는 오프라인일 때 쿼리와 뮤테이션이 어떻게 움직일지 정하는 networkMode가 내장돼 있습니다.

import { QueryClient } from '@tanstack/react-query'

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      networkMode: 'offlineFirst',
      // 캐시 데이터를 즉시 반환하고, 온라인일 때 백그라운드에서 리패치
      staleTime: 5 * 60 * 1000,
      gcTime: 24 * 60 * 60 * 1000, // 캐시를 24시간 유지
      retry: (failureCount, error) => {
        // 오프라인이면 재시도하지 않음 - 어차피 또 실패할 뿐
        if (!useNetworkStore.getState().isOnline) return false
        return failureCount < 3
      },
    },
    mutations: {
      networkMode: 'offlineFirst',
    },
  },
})

세 가지 모드는 이렇습니다.

ModeBehavior
online (default)Queries only fire when online. Pauses when offline.
alwaysQueries fire regardless of network. Your queryFn handles failures.
offlineFirstQueries fire once (for cached data), then pause until online to refetch.

대부분의 모바일 앱에는 offlineFirst가 정답입니다. 캐시 데이터는 즉시 보이고, 새 데이터는 네트워크가 허락할 때 도착합니다.

3단계: 쿼리 캐시 영속화
#

기본 상태에서는 캐시가 메모리에만 있어서, 재시작하면 온 화면이 스피너가 됩니다. 오프라인 모드라면 캐시를 AsyncStorage에 영속화해야 합니다.

import { QueryClient } from '@tanstack/react-query'
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister'
import AsyncStorage from '@react-native-async-storage/async-storage'

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      gcTime: 24 * 60 * 60 * 1000, // 24시간 - maxAge 이상이어야 함
    },
  },
})

const asyncStoragePersister = createAsyncStoragePersister({
  storage: AsyncStorage,
  key: 'react-query-cache',
})

// App 컴포넌트에서
function App() {
  return (
    <PersistQueryClientProvider
      client={queryClient}
      persistOptions={{
        persister: asyncStoragePersister,
        maxAge: 24 * 60 * 60 * 1000, // 24시간보다 오래된 데이터는 복원하지 않음
        dehydrateOptions: {
          shouldDehydrateQuery: (query) => {
            // 성공한 쿼리만 영속화
            return query.state.status === 'success'
          },
        },
      }}
    >
      <YourApp />
    </PersistQueryClientProvider>
  )
}

이제 신호가 없는 곳에서 앱을 열어도 빈 화면 대신 마지막으로 받아온 데이터가 바로 보이고, 네트워크가 돌아오면 React Query가 조용히 뒤에서 리패치합니다.

4단계: 오프라인 중의 쓰기를 큐에 쌓기
#

읽기는 쉬운 절반입니다. 재미있어지는 건 쓰기 쪽인데, 신호가 없는 곳에서 댓글을 달거나 주문을 넣거나 프로필을 고치면, 그 변경을 큐에 쌓아뒀다가 나중에 재실행해야 하죠.

React Query의 답은 useMutationonMutate의 낙관적 업데이트입니다.

import { useMutation, useQueryClient } from '@tanstack/react-query'
import AsyncStorage from '@react-native-async-storage/async-storage'

interface Comment {
  id: string
  text: string
  postId: string
  createdAt: string
  pending?: boolean
}

function useAddComment(postId: string) {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: async (text: string) => {
      const res = await fetch(`/api/posts/${postId}/comments`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ text }),
      })
      return res.json()
    },

    // 낙관적 업데이트 - 댓글을 즉시 표시
    onMutate: async (text) => {
      await queryClient.cancelQueries({ queryKey: ['comments', postId] })

      const previous = queryClient.getQueryData<Comment[]>(['comments', postId])

      const optimisticComment: Comment = {
        id: `temp-${Date.now()}`,
        text,
        postId,
        createdAt: new Date().toISOString(),
        pending: true, // UI에서 "전송 중..." 표시
      }

      queryClient.setQueryData<Comment[]>(
        ['comments', postId],
        (old) => [...(old ?? []), optimisticComment]
      )

      return { previous }
    },

    // 실패 시 롤백
    onError: (err, text, context) => {
      if (context?.previous) {
        queryClient.setQueryData(['comments', postId], context.previous)
      }
    },

    // 서버에서 실제 데이터를 가져오기 위해 리패치
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['comments', postId] })
    },
  })
}

뮤테이션에 networkMode: 'offlineFirst'가 설정돼 있으면, 네트워크가 돌아올 때까지 mutationFn이 일시 정지됩니다. 낙관적 업데이트 덕에 댓글은 화면에 바로 뜨고, 실제 API 호출은 재연결 시점에 나갑니다.

순서대로 재실행해야 하는 보류 변경이 수십 건씩 쌓이는, 더 본격적인 큐가 필요하다면 뮤테이션 큐 자체도 영속화할 수 있습니다.

import { MutationCache } from '@tanstack/react-query'

// 보류 중인 뮤테이션을 AsyncStorage에 저장
const mutationCache = new MutationCache({
  onError: async (error, variables, context, mutation) => {
    // 디버깅을 위해 실패한 뮤테이션 기록
    const pending = JSON.parse(
      await AsyncStorage.getItem('pending_mutations') ?? '[]'
    )
    pending.push({
      key: mutation.options.mutationKey,
      variables,
      timestamp: Date.now(),
    })
    await AsyncStorage.setItem('pending_mutations', JSON.stringify(pending))
  },
})

5단계: 네트워크가 돌아오면 맞춰주기
#

재연결 처리의 대부분은 React Query가 알아서 합니다. 멈춰 있던 뮤테이션은 재개되고, 낡은 쿼리는 리패치됩니다. 우리가 할 일은 React Query의 매니저들을 React Native 이벤트에 연결해주는 것뿐입니다.

import NetInfo from '@react-native-community/netinfo'
import { onlineManager, focusManager } from '@tanstack/react-query'
import { AppState } from 'react-native'

// React Query에 네트워크 상태 변경을 알려주기
onlineManager.setEventListener((setOnline) => {
  return NetInfo.addEventListener((state) => {
    setOnline(!!state.isConnected)
  })
})

// 앱이 포그라운드로 돌아올 때 리패치
focusManager.setEventListener((setFocused) => {
  const subscription = AppState.addEventListener('change', (status) => {
    setFocused(status === 'active')
  })
  return () => subscription.remove()
})

앱을 한 시간쯤 백그라운드에 뒀다가 돌아오면 focusManager가 리패치를 돌려 최신 데이터를 보여주고, 터널을 빠져나와 신호가 잡히면 onlineManager가 멈춰 있던 것들을 다시 굴립니다.

6단계: UI로 알려주기
#

뭘 하든, 네트워크 에러를 조용히 삼키는 것만은 하지 마세요. 지금 오프라인이라는 것, 어떤 변경이 보류 중인지 사용자에게 보여줘야 합니다.

import { useNetworkStore } from './stores/network'

function OfflineBanner() {
  const isOnline = useNetworkStore((s) => s.isOnline)

  if (isOnline) return null

  return (
    <View style={styles.banner}>
      <Text>오프라인 상태입니다. 다시 연결되면 변경사항이 동기화됩니다.</Text>
    </View>
  )
}

function CommentItem({ comment }: { comment: Comment }) {
  return (
    <View style={[styles.comment, comment.pending && styles.pending]}>
      <Text>{comment.text}</Text>
      {comment.pending && (
        <Text style={styles.pendingLabel}>전송 ...</Text>
      )}
    </View>
  )
}

전체 그림
#

┌─────────────────────────────────────────────────┐
│                   Components                     │
│  useQuery() for reads    useMutation() for writes│
└──────────┬──────────────────────┬────────────────┘
           │                      │
     ┌─────▼──────┐        ┌─────▼──────┐
     │ React Query │        │ React Query │
     │   Cache     │        │  Mutation   │
     │ (in-memory) │        │   Queue     │
     └─────┬──────┘        └─────┬──────┘
           │                      │
     ┌─────▼──────────────────────▼──────┐
     │     AsyncStorage Persister         │
     │  (survives app restart)            │
     └─────┬──────────────────────┬──────┘
           │                      │
     ┌─────▼──────┐        ┌─────▼──────┐
     │   Zustand   │        │   Network  │
     │ (isOnline,  │◄───────│   NetInfo  │
     │  UI state)  │        │            │
     └────────────┘        └────────────┘
LayerToolRole
Network detectionZustand + NetInfoTrack online/offline, drive UI banners
Data fetchingReact QueryFetch when online, serve cache when offline
Cache persistenceReact Query + AsyncStorageRestore cache on app restart
Offline writesReact Query mutationsQueue mutations, replay on reconnect
Optimistic UIReact Query onMutateShow changes immediately, roll back on failure
App focus syncReact Query focusManagerRefetch stale data when app returns to foreground

진짜 동기화 엔진이 필요해지는 순간
#

지금까지의 구성은 서버가 원본이고 오프라인은 일시적이라는 전제 위에 서 있습니다. 충돌 해결까지 포함된 진짜 오프라인 우선(두 기기가 같은 문서를 오프라인에서 고치는 메모 앱 같은 세계)이 필요하다면, 이 스택으로는 부족하고 전용 동기화 엔진이 필요합니다. WatermelonDB는 React Native용으로 만들어져 SQLite 위에서 돌고, 충돌 해결용 동기화 프로토콜을 갖췄습니다. Realm은 Atlas Device Sync와 묶으면 MongoDB Atlas를 통한 자동 충돌 해결이 되는 완전한 오프라인 우선 데이터베이스가 되고요. PowerSync는 기존 Postgres 백엔드와 붙는 SQLite 기반 동기화 레이어입니다. 완전한 제어가 필요하면 Expo SQLite에 직접 만든 동기화 로직이라는 길도 있습니다.

Zustand + React Query + AsyncStorage로 모바일 앱의 80% 정도는 커버됩니다. 나머지 20%, 그러니까 협업 편집이나 멀티 디바이스 동기화 같은 오프라인 중심 워크플로우에는 전용 동기화 데이터베이스가 필요합니다.

어디에 둘지 헷갈릴 때
#

저는 애매하면 이 질문들을 순서대로 던져봅니다.

QuestionYes → Use
Does it come from a server/API?React Query
Is it client-only UI state shared across components?Zustand
Does it need to survive an app restart?AsyncStorage (+ optionally Zustand persist)
Is it sensitive (tokens, passwords)?expo-secure-store / react-native-keychain
Is it large structured data that needs querying?SQLite / WatermelonDB / Realm
Is it a simple form input used by one component?useState

자주 나오는 패턴
#

StateToolWhy
API response dataReact QueryCaching, dedup, refetch, loading states
Selected tab / active filterZustandClient-only, multiple components care
Auth tokenAsyncStorage + ZustandPersists across restarts, fast in-memory access
Theme preferenceZustand with persist middlewareClient state that should survive restarts
Shopping cartZustand with persist middlewareComplex client logic, should survive restarts
Form inputuseStateSingle component, no need to share
User profile from APIReact QueryServer state, might be stale
Onboarding completed flagAsyncStorageJust a boolean that persists
Offline cached feedReact Query + AsyncStorageFetch from server, fall back to cache

플랫폼별 설치
#

React Native (Android + iOS)
#

셋 다 한 번에 설치합니다.

npm install zustand @tanstack/react-query @react-native-async-storage/async-storage

민감한 데이터용 보안 저장소도 추가하고요.

npx expo install expo-secure-store
# or
npm install react-native-keychain

Expo
#

AsyncStorage는 Expo에서 그대로 동작합니다. 네이티브 링킹도 필요 없습니다.

npx expo install @react-native-async-storage/async-storage

웹 전용 React
#

AsyncStorage는 건너뛰고 localStorageIndexedDB를 직접 쓰면 됩니다.

npm install zustand @tanstack/react-query
# Optional for IndexedDB
npm install idb-keyval

Zustand의 persist 미들웨어는 웹에서 기본으로 localStorage를 씁니다.

persist(storeConfig, {
  name: 'my-store',
  // 웹에서 localStorage가 기본값 - 추가 설정 필요 없음
})

자꾸 보게 되는 실수들
#

API 데이터를 Zustand에 넣는 것. 액션 안에 setUsers(apiResponse.users)가 있다면 일단 손을 멈추세요. 그건 React Query의 일이고, 그 길 끝에는 어설픈 캐시 무효화 재발명이 기다리고 있습니다.

클라이언트 상태에 React Query를 쓰는 것. queryFn이 네트워크 요청을 안 한다면 도구를 잘못 골랐습니다.

AsyncStorage를 데이터베이스처럼 쓰는 것. 10,000건짜리 배열을 키-값 저장소에 직렬화하고 있다면, 그냥 데이터베이스를 쓰는 게 맞습니다.

토큰을 평문으로 두는 것. AsyncStorage는 보안 저장소가 아닙니다. 인증 토큰, API 키, 자격 증명은 expo-secure-store나 플랫폼 키체인으로 보내세요.

영속화를 손으로 만드는 것. 마운트 때 AsyncStorage를 읽고 상태가 바뀔 때마다 쓰는 코드를 짜고 있다면, Zustand의 persist 미들웨어가 하이드레이션과 직렬화까지 이미 다 해줍니다. 코드는 줄고 버그도 줄어들죠.


서버 데이터는 React Query로. 클라이언트 상태는 Zustand에. 재시작을 버텨야 하는 건 AsyncStorage에, 민감한 건 보안 저장소에.

제가 겪어본 최악의 아키텍처들은 하나같이 거대한 스토어 하나로 모든 게 흘러들었습니다. 좋은 아키텍처에는 서버 상태, 클라이언트 상태, 영속화할 것 사이에 또렷한 선이 있었고요. 그 선을 일찍 그어두면 그다음 일이 전부 쉬워집니다.