본문으로 건너뛰기
  1. 게시물/

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

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

React나 React Native 프로젝트에서 가장 흔한 실수 중 하나는 모든 걸 하나의 상태 관리 솔루션에 다 때려 넣는 거다. Redux 스토어 하나에 API 캐싱, 사용자 설정, 폼 상태, 인증 토큰까지 전부 엉켜 있는 경우를 자주 본다. 이것들은 서로 다른 종류의 상태이고, 다른 도구가 필요하다.

Zustand, React Query, AsyncStorage는 각각 다른 문제를 해결한다. 이 셋의 경계를 이해하면 아키텍처가 훨씬 깔끔해진다.

세 가지 상태 유형
#

도구를 고르기 전에, 실제로 뭘 관리하는지부터 이해하자.

클라이언트 상태는 앱 런타임에서만 존재하는 데이터다. UI 토글, 선택된 탭, 폼 입력, 모달 표시 여부, 테마 설정 같은 것들이다. 앱이 실행 중일 때만 존재하고 서버에서 오는 게 아니다.

서버 상태는 원격 서버에 있는 데이터이고, 앱에는 로컬 복사본만 있는 것이다. 사용자 프로필, 상품 목록, 알림, 피드 데이터 같은 것들이다. 오래되면 stale 해지고, 다시 가져와야 하고, 여러 클라이언트가 서로 다른 버전을 보고 있을 수 있다.

영속 상태는 앱을 재시작해도 살아남아야 하는 데이터다. 인증 토큰, 온보딩 완료 플래그, 캐시된 사용자 설정, 오프라인 데이터 등이다. 기기 자체에 저장된다.

대부분의 앱은 세 가지 다 있다. 실수는 하나의 도구로 전부 해결하려는 것이다.

Zustand: 클라이언트 상태를 제대로 다루기
#

Zustand는 React용 경량 상태 관리 라이브러리다. Provider도 없고, 보일러플레이트도 없고, Context 래퍼 지옥도 없다. 스토어를 만들고, 컴포넌트에서 쓰고, 끝이다.

Zustand를 언제 쓸까
#

  • 여러 컴포넌트에서 필요한 UI 상태 (사이드바 열림/닫힘, 활성 필터, 선택된 항목)
  • API에서 오지 않는 앱 레벨 상태 (테마, 언어, 시작 시 로드되는 기능 플래그)
  • 복잡한 클라이언트 사이드 로직 (장바구니 계산, 멀티 스텝 폼 위저드)
  • 동기적이고 예측 가능하게 업데이트해야 하는 상태

기본 예제
#

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 응답을 캐시하려고 Zustand를 쓰지 말자. API 호출할 때마다 Zustand 스토어에 쓰고, 컴포넌트에서 API 대신 스토어에서 읽는 프로젝트를 본 적이 있다. 그러면 캐시 무효화, 로딩 상태, 에러 처리, 리패치, 페이지네이션을 전부 직접 구현하게 되는데 - React Query가 이걸 다 공짜로 해준다.

앱 재시작 후에도 유지돼야 하는 데이터에도 Zustand를 쓰지 말자. Zustand 상태는 메모리에 있다. 앱이 닫히면 사라진다. persist 미들웨어를 추가할 수는 있지만 (아래에서 더 다룸), 그 시점에서는 AsyncStorage 영역으로 들어가는 거니까 의도적으로 해야 한다.

React Query: 고통 없는 서버 상태 관리
#

React Query (TanStack Query)는 원격 데이터의 전체 라이프사이클을 관리한다. 패칭, 캐싱, 동기화, 업데이트, 가비지 컬렉션까지. 서버 데이터를 내가 소유하는 상태가 아니라, 신선하게 유지해야 하는 캐시로 취급한다.

React 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 Query를 쓰지 말자. 데이터가 서버에서 오는 게 아니면 React Query는 불필요한 복잡성만 추가한다. 모달의 열림/닫힘 상태에 캐시 무효화나 리패치 간격은 필요 없다.

React Query를 장기 영속 레이어로 쓰지도 말자. 캐시는 메모리에 있다. 앱이 재시작되면 캐시는 비어 있고 모든 걸 다시 가져온다. 캐시를 영속화할 수는 있고 (React Native 앱이라면 그래야 하고), 하지만 원본 데이터의 출처는 항상 서버다.

AsyncStorage: 플랫폼 간 영속 상태
#

AsyncStorage는 React Native용 키-값 저장소 솔루션이다. 웹에서의 동등한 것은 localStorage (동기) 또는 IndexedDB (비동기, 더 강력함)다. 같은 개념 - 기기에 저장되어 앱 재시작 후에도 살아남는 데이터.

플랫폼별 구현
#

저장소 백엔드는 플랫폼마다 다르지만, @react-native-async-storage/async-storage를 사용하면 API는 동일하다:

Android: 내부적으로 RKStorage를 통해 SQLite를 사용한다. 데이터는 앱의 내부 저장소 디렉터리에 있는 SQLite 데이터베이스에 저장된다. 빠르고, 안정적이며, 실질적인 크기 제한이 없다 (다만 Android가 앱별 저장소 제한을 적용할 수 있다). 데이터는 앱에 샌드박스되어 있어 다른 앱이 접근할 수 없다.

iOS: 작은 값에는 NSUserDefaults를, 큰 값에는 직렬화된 데이터 파일을 사용한다. Android와 마찬가지로 데이터는 앱 컨테이너 내에 샌드박스되어 있다. Apple이 NSUserDefaults에 하드 리미트를 두지는 않지만, 개별 값은 몇백 KB 이하로 유지하는 게 좋다. 더 큰 데이터는 WatermelonDB나 Realm 같은 적절한 데이터베이스를 고려하자.

Web (React Native Web / Expo Web): localStorage로 폴백되며, 브라우저에 따라 ~5-10 MB 제한이 있다. 웹 전용 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

AsyncStorage를 언제 쓸까
#

  • 인증 토큰과 세션 데이터
  • 유지돼야 하는 사용자 설정 (언어, 테마, 알림 설정)
  • 온보딩 완료 플래그
  • 오프라인 우선 경험을 위한 캐시 데이터
  • 앱 재시작 후에도 살아남아야 하는 작은 키-값 데이터

기본 예제
#

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')

AsyncStorage를 쓰면 안 되는 경우
#

AsyncStorage를 주요 데이터 저장소로 쓰지 말자. 키-값 저장소이지, 데이터베이스가 아니다. 관계형 데이터, 수천 개 항목의 배열, 또는 인덱싱과 쿼리가 필요한 데이터를 저장한다면 SQLite (expo-sqlite), WatermelonDB, 또는 Realm을 사용하자.

암호화 없이 민감한 데이터를 AsyncStorage에 저장하지 말자. Android와 iOS에서 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가 자동으로 사용자 프로필을 가져온다
  }
}

예제: 영속성이 있는 테마 설정
#

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가 오프라인 폴백을 제공한다.

모바일 앱을 위한 오프라인 모드 구축
#

모바일에서 오프라인 지원은 선택이 아니다. 사용자는 지하철, 엘리베이터, 비행기, 통신 상태가 불안정한 지역에서 앱을 연다. 네트워크가 끊길 때마다 빈 화면이나 스피너만 보여주면 사용자는 떠난다.

Zustand, 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단계: React Query 캐시 영속화
#

기본적으로 React Query의 캐시는 메모리에 있다. 앱 재시작 = 빈 캐시 = 로딩 스피너 투성이. 오프라인 모드를 위해서는 캐시를 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는 useMutation과 낙관적 업데이트를 위한 onMutate로 이걸 처리한다:

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'가 설정되어 있으면, React Query가 네트워크가 돌아올 때까지 mutationFn을 일시 정지시킨다. 낙관적 업데이트가 UI에 댓글을 즉시 보여주고, 실제 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가 대부분 자동으로 처리한다 - 일시 정지된 뮤테이션이 재개되고, stale 쿼리가 리패치된다. 하지만 엣지 케이스도 처리해야 한다.

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 + custom sync: 완전한 제어가 필요하면 직접 구현

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에 넣기. Zustand 액션에서 setUsers(apiResponse.users)를 쓰고 있다면 멈추자. React Query를 쓰자. 캐시 무효화를 직접 만들게 될 텐데, 잘 안 될 거다.

클라이언트 상태에 React Query 쓰기. queryFn이 네트워크 요청을 하지 않으면 잘못된 도구를 쓰고 있는 거다. React Query는 서버 상태용이다.

AsyncStorage에 큰 데이터 저장하기. AsyncStorage는 키-값 저장소다. 10,000개짜리 배열을 직렬화하고 있다면 데이터베이스를 쓰자.

토큰을 암호화하지 않기. AsyncStorage는 보안 저장소가 아니다. 인증 토큰, API 키, 사용자 자격 증명은 expo-secure-store나 플랫폼의 키체인에 넣어야 한다.

persist 미들웨어 안 쓰기. 마운트할 때 AsyncStorage에서 수동으로 읽고 상태가 바뀔 때마다 수동으로 쓰고 있다면, 그냥 Zustand의 persist 미들웨어를 쓰자. 하이드레이션, 직렬화, 저장소 동기화를 알아서 해준다.


서버 데이터는 React Query를 거친다. 클라이언트 상태는 Zustand에 넣는다. 영속 데이터는 AsyncStorage에 넣는다. 민감한 데이터는 보안 저장소에 넣는다.

내가 경험한 최악의 아키텍처는 모든 게 하나의 거대한 스토어를 통해 흐르는 것들이었다. 최고의 아키텍처는 서버 상태, 클라이언트 상태, 영속이 필요한 것 사이에 명확한 선이 있는 것들이다. 이걸 일찍 정하면 나머지가 다 쉬워진다.