2026년, React Native 상태 관리 지형은 어떻게 바뀌었을까?
솔직히 말하자면, React Native 상태 관리 라이브러리 선택은 개발자들 사이에서 끝나지 않는 논쟁거리입니다. 2026년 현재, React Native는 New Architecture(Fabric 렌더러, JSI 기반 TurboModules)가 전면 도입되면서 성능이 비약적으로 좋아졌죠. 하지만 런타임이 아무리 빨라져도, 상태 관리 전략이 엉망이면 불필요한 리렌더링, 메모리 누수, 끔찍한 사용자 경험으로 이어집니다.
상태 관리 라이브러리를 고르는 건 단순한 기술적 결정이 아닙니다. 프로젝트의 확장성, 유지보수성, 팀 생산성에 직접적인 영향을 미치는 아키텍처 결정이에요.
2024~2025년을 거치면서 생태계가 정말 많이 바뀌었습니다. Redux가 독점하던 시대는 끝났고, Zustand, Jotai, Legend State 같은 경량 라이브러리들이 급부상했어요. TanStack Query는 서버 상태 관리의 사실상 표준이 됐고요. 2026년의 핵심 키워드는 하이브리드 접근법입니다. 서버 데이터는 TanStack Query로, 클라이언트 상태는 Zustand나 Jotai로 조합하는 패턴이 업계 전반에서 보편화됐습니다.
자, 그럼 본격적으로 들어가 볼까요? 이 글에서는 2026년에 React Native 개발자가 꼭 알아야 할 주요 상태 관리 솔루션들을 깊이 있게 비교하고, 프로젝트 상황에 맞는 최적의 선택을 실전 코드 예제와 함께 안내해 드리겠습니다.
상태의 종류부터 제대로 이해하기
상태 관리 라이브러리를 고르기 전에, 먼저 우리가 다루는 상태의 종류를 명확히 구분해야 합니다. 모든 상태를 하나의 전역 스토어에 때려 넣는 건 2026년 기준으로는 확실한 안티패턴이에요.
서버 상태 (Server State)
서버 상태는 원격 서버에서 가져오는 비동기 데이터입니다. API 응답, DB 조회 결과, 실시간 웹소켓 데이터 등이 여기에 해당하죠. 서버 상태는 본질적으로 캐시이고, 소유권이 클라이언트에 있지 않습니다. 그래서 캐싱, 무효화, 백그라운드 갱신, 낙관적 업데이트 같은 고유한 문제들을 다뤄야 해요. TanStack Query(React Query)가 이 영역의 표준 솔루션입니다.
클라이언트 상태 (Client State)
클라이언트 상태는 서버와 동기화할 필요 없이 클라이언트 측에서만 존재하는 전역 상태예요. 사용자의 테마 설정, 인증 토큰, 다크모드/라이트모드, 내비게이션 상태, 장바구니 데이터 같은 것들이 여기 속합니다. Zustand, Jotai, Redux Toolkit, Legend State가 이 영역을 담당하고요.
로컬 상태 (Local/Component State)
로컬 상태는 특정 컴포넌트나 소수의 컴포넌트 트리 안에서만 쓰이는 상태입니다. 폼 입력값, 토글, 모달 표시 여부 같은 것들이죠. React의 내장 useState와 useReducer로 충분합니다. 굳이 외부 라이브러리를 쓸 필요가 없어요.
- 원칙 1: 상태를 가능한 한 사용되는 곳 가까이에 두세요 (코로케이션 원칙)
- 원칙 2: 서버 상태와 클라이언트 상태를 분리하세요
- 원칙 3: 전역 상태는 정말로 전역이어야 할 때만 전역으로 만드세요
React Context API: 내장 솔루션의 한계와 적절한 사용처
React Context API는 React에 내장된 상태 공유 메커니즘입니다. 별도 라이브러리 설치 없이 바로 사용할 수 있다는 건 확실한 장점이에요. 하지만 React Native 앱의 본격적인 상태 관리 솔루션으로 쓰기엔 분명한 한계가 있습니다.
Context API의 핵심 문제: 리렌더링
Context의 값이 변경되면, 해당 Context를 구독하는 모든 컴포넌트가 리렌더링됩니다. 상태의 일부분만 필요한 컴포넌트도 전부 다시 그려지는 거죠. React Native에서 이 문제가 특히 심각한 이유는 네이티브 브릿지를 통한 UI 업데이트 비용이 웹보다 크기 때문입니다.
// Context API 기본 예제 - React Native
import React, { createContext, useContext, useState, useMemo } from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
interface AppState {
theme: 'light' | 'dark';
language: string;
toggleTheme: () => void;
}
const AppContext = createContext<AppState | undefined>(undefined);
export function AppProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const [language] = useState('ko');
const value = useMemo(
() => ({
theme,
language,
toggleTheme: () => setTheme((prev) => (prev === 'light' ? 'dark' : 'light')),
}),
[theme, language]
);
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}
export function useAppContext() {
const context = useContext(AppContext);
if (!context) {
throw new Error('useAppContext must be used within AppProvider');
}
return context;
}
// 사용 예시
function ThemeToggleButton() {
const { theme, toggleTheme } = useAppContext();
// language가 변경되어도 이 컴포넌트가 리렌더링됩니다!
return (
<TouchableOpacity onPress={toggleTheme} style={styles.button}>
<Text>현재 테마: {theme}</Text>
</TouchableOpacity>
);
}
Context API를 써야 할 때
- 변경 빈도가 매우 낮은 값 (테마, 로케일 등)
- 의존성 주입(DI) 패턴을 구현할 때
- 소규모 앱에서 별도 라이브러리 도입이 과할 때
- 라이브러리 내부에서 설정을 전달할 때
Context API를 피해야 할 때
- 자주 변경되는 상태 (입력 폼, 애니메이션, 실시간 데이터)
- 많은 컴포넌트가 상태의 서로 다른 부분을 구독할 때
- 성능이 중요한 리스트(FlatList 등)의 아이템 데이터
Zustand 심층 분석
Zustand는 2026년 현재 React Native에서 가장 인기 있는 클라이언트 상태 관리 라이브러리입니다. 개인적으로도 가장 많이 쓰는 라이브러리인데, 번들 크기가 약 3KB밖에 안 되면서도 기능이 강력하거든요. 보일러플레이트가 거의 없어서 개발 생산성도 훌륭합니다. 함수형 프로그래밍 패러다임 기반이라 Provider 래핑 없이 전역 상태를 바로 쓸 수 있다는 점도 큰 매력이에요.
설치 및 기본 스토어 생성
// 설치
// npm install zustand
import { create } from 'zustand';
// 인증 스토어 생성
interface User {
id: string;
name: string;
email: string;
avatarUrl: string;
}
interface AuthState {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
updateProfile: (updates: Partial<User>) => void;
}
const useAuthStore = create<AuthState>((set, get) => ({
user: null,
isAuthenticated: false,
isLoading: false,
login: async (email: string, password: string) => {
set({ isLoading: true });
try {
const response = await fetch('https://api.example.com/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const user = await response.json();
set({ user, isAuthenticated: true, isLoading: false });
} catch (error) {
set({ isLoading: false });
throw error;
}
},
logout: () => {
set({ user: null, isAuthenticated: false });
},
updateProfile: (updates) => {
const currentUser = get().user;
if (currentUser) {
set({ user: { ...currentUser, ...updates } });
}
},
}));
셀렉터를 활용한 리렌더링 최적화
Zustand의 가장 강력한 기능 중 하나가 바로 셀렉터를 통한 세밀한 구독입니다. 컴포넌트가 스토어의 특정 부분만 구독하면, 딱 그 부분이 변경될 때만 리렌더링돼요. 이게 왜 중요한지 예제로 살펴보겠습니다.
import React from 'react';
import { View, Text, Image, TouchableOpacity, ActivityIndicator } from 'react-native';
// 나쁜 예: 전체 스토어 구독 (불필요한 리렌더링 발생)
function ProfileScreenBad() {
const store = useAuthStore(); // isLoading이 변해도 리렌더링
return <Text>{store.user?.name}</Text>;
}
// 좋은 예: 셀렉터로 필요한 값만 구독
function ProfileScreen() {
const userName = useAuthStore((state) => state.user?.name);
const avatarUrl = useAuthStore((state) => state.user?.avatarUrl);
return (
<View style={{ padding: 20 }}>
{avatarUrl && (
<Image
source={{ uri: avatarUrl }}
style={{ width: 80, height: 80, borderRadius: 40 }}
/>
)}
<Text style={{ fontSize: 20, fontWeight: 'bold', marginTop: 12 }}>
{userName}
</Text>
</View>
);
}
// 로그인 버튼 컴포넌트 - isLoading만 구독
function LoginButton() {
const isLoading = useAuthStore((state) => state.isLoading);
const login = useAuthStore((state) => state.login);
if (isLoading) {
return <ActivityIndicator size="large" color="#007AFF" />;
}
return (
<TouchableOpacity
onPress={() => login('[email protected]', 'password')}
style={{
backgroundColor: '#007AFF',
padding: 16,
borderRadius: 12,
alignItems: 'center',
}}
>
<Text style={{ color: 'white', fontWeight: '600' }}>로그인</Text>
</TouchableOpacity>
);
}
AsyncStorage를 활용한 상태 영속화 (Persist 미들웨어)
React Native에서 앱을 종료해도 상태를 유지해야 하는 경우가 정말 많죠. 장바구니 데이터, 로그인 토큰, 사용자 설정... Zustand의 persist 미들웨어와 AsyncStorage를 조합하면 이걸 놀랍도록 간단하게 구현할 수 있습니다.
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
imageUrl: string;
}
interface CartState {
items: CartItem[];
addItem: (item: Omit<CartItem, 'quantity'>) => void;
removeItem: (id: string) => void;
updateQuantity: (id: string, quantity: number) => void;
clearCart: () => void;
totalPrice: () => number;
totalItems: () => number;
}
const useCartStore = create<CartState>()(
persist(
(set, get) => ({
items: [],
addItem: (item) => {
set((state) => {
const existingItem = state.items.find((i) => i.id === item.id);
if (existingItem) {
return {
items: state.items.map((i) =>
i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
),
};
}
return { items: [...state.items, { ...item, quantity: 1 }] };
});
},
removeItem: (id) => {
set((state) => ({
items: state.items.filter((item) => item.id !== id),
}));
},
updateQuantity: (id, quantity) => {
set((state) => ({
items: state.items.map((item) =>
item.id === id ? { ...item, quantity: Math.max(0, quantity) } : item
).filter((item) => item.quantity > 0),
}));
},
clearCart: () => set({ items: [] }),
totalPrice: () => {
return get().items.reduce(
(total, item) => total + item.price * item.quantity,
0
);
},
totalItems: () => {
return get().items.reduce((total, item) => total + item.quantity, 0);
},
}),
{
name: 'cart-storage',
storage: createJSONStorage(() => AsyncStorage),
// 영속화할 필드만 선택 (메서드 제외)
partialize: (state) => ({ items: state.items }),
}
)
);
React Native에서의 실전 활용: FlatList와 함께
실제 프로젝트에서 Zustand를 FlatList와 함께 사용하는 패턴을 보여드릴게요. 이 조합은 제가 커머스 앱을 만들 때 거의 매번 쓰는 패턴이기도 합니다.
import React, { useCallback } from 'react';
import { View, Text, FlatList, TouchableOpacity, Image, StyleSheet } from 'react-native';
function CartScreen() {
const items = useCartStore((state) => state.items);
const clearCart = useCartStore((state) => state.clearCart);
const renderItem = useCallback(({ item }: { item: CartItem }) => (
<CartItemRow item={item} />
), []);
return (
<View style={styles.container}>
<FlatList
data={items}
renderItem={renderItem}
keyExtractor={(item) => item.id}
ListEmptyComponent={
<Text style={styles.emptyText}>장바구니가 비어있습니다</Text>
}
ListFooterComponent={items.length > 0 ? <CartFooter /> : null}
/>
</View>
);
}
// 개별 아이템 컴포넌트 - 메모이제이션으로 최적화
const CartItemRow = React.memo(({ item }: { item: CartItem }) => {
const updateQuantity = useCartStore((state) => state.updateQuantity);
const removeItem = useCartStore((state) => state.removeItem);
return (
<View style={styles.itemRow}>
<Image source={{ uri: item.imageUrl }} style={styles.itemImage} />
<View style={styles.itemInfo}>
<Text style={styles.itemName}>{item.name}</Text>
<Text style={styles.itemPrice}>
{item.price.toLocaleString()}원
</Text>
<View style={styles.quantityRow}>
<TouchableOpacity
onPress={() => updateQuantity(item.id, item.quantity - 1)}
>
<Text style={styles.quantityButton}>-</Text>
</TouchableOpacity>
<Text style={styles.quantityText}>{item.quantity}</Text>
<TouchableOpacity
onPress={() => updateQuantity(item.id, item.quantity + 1)}
>
<Text style={styles.quantityButton}>+</Text>
</TouchableOpacity>
</View>
</View>
<TouchableOpacity onPress={() => removeItem(item.id)}>
<Text style={styles.removeButton}>삭제</Text>
</TouchableOpacity>
</View>
);
});
function CartFooter() {
const totalPrice = useCartStore((state) => state.totalPrice());
const totalItems = useCartStore((state) => state.totalItems());
return (
<View style={styles.footer}>
<Text style={styles.footerText}>
총 {totalItems}개 상품
</Text>
<Text style={styles.footerPrice}>
합계: {totalPrice.toLocaleString()}원
</Text>
</View>
);
}
Jotai 심층 분석
Jotai는 "원자(atom)" 기반의 상태 관리 라이브러리로, 상향식(bottom-up) 접근법을 취합니다. 각 상태 조각을 독립적인 아톰으로 정의하고, 컴포넌트는 필요한 아톰만 구독하는 방식이에요. 이 구조 덕분에 불필요한 리렌더링이 근본적으로 방지되고, React Native의 Fabric 렌더러와 결합하면 성능이 정말 좋습니다.
Zustand와 뭐가 다르냐고요? 간단히 말하면, Zustand는 하나의 스토어에 관련 상태를 모아두는 "하향식" 접근이고, Jotai는 작은 아톰들을 조합하는 "상향식" 접근입니다. 취향 차이라고 볼 수도 있지만, 복잡한 파생 상태가 많은 앱에선 Jotai가 더 자연스러울 때가 있어요.
아톰의 기본 개념과 생성
// npm install jotai
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';
// 원시 아톰 (Primitive Atom) - 읽기/쓰기 가능
const userNameAtom = atom<string>('');
const darkModeAtom = atom<boolean>(false);
const fontSizeAtom = atom<number>(16);
const notificationsEnabledAtom = atom<boolean>(true);
// 읽기 전용 파생 아톰 (Derived Atom)
const themeAtom = atom((get) => {
const isDark = get(darkModeAtom);
return {
backgroundColor: isDark ? '#1a1a2e' : '#ffffff',
textColor: isDark ? '#e0e0e0' : '#1a1a2e',
primaryColor: isDark ? '#4da6ff' : '#007AFF',
cardColor: isDark ? '#16213e' : '#f5f5f5',
};
});
// 읽기/쓰기 파생 아톰
const fontSizeWithLabelAtom = atom(
(get) => {
const size = get(fontSizeAtom);
const label = size <= 14 ? '작게' : size <= 18 ? '보통' : '크게';
return { size, label };
},
(get, set, newSize: number) => {
set(fontSizeAtom, Math.min(28, Math.max(12, newSize)));
}
);
React Native에서의 세밀한 리렌더링 최적화
Jotai의 아톰 기반 구조가 빛을 발하는 건 바로 이 부분입니다. 컴포넌트가 정확히 필요한 상태만 구독하도록 자동으로 보장해주거든요. 긴 리스트나 복잡한 UI에서 성능 차이가 눈에 띄게 납니다.
import React from 'react';
import { View, Text, Switch, TouchableOpacity, StyleSheet, ScrollView } from 'react-native';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
// 설정 화면 - 각 설정 항목이 독립적으로 리렌더링됩니다
function SettingsScreen() {
return (
<ScrollView style={{ flex: 1 }}>
<ThemeSection />
<FontSizeSection />
<NotificationSection />
</ScrollView>
);
}
// darkModeAtom이 변경되면 이 컴포넌트만 리렌더링
function ThemeSection() {
const [isDark, setIsDark] = useAtom(darkModeAtom);
const theme = useAtomValue(themeAtom);
return (
<View style={[styles.section, { backgroundColor: theme.cardColor }]}>
<Text style={[styles.sectionTitle, { color: theme.textColor }]}>
테마 설정
</Text>
<View style={styles.row}>
<Text style={{ color: theme.textColor }}>다크 모드</Text>
<Switch value={isDark} onValueChange={setIsDark} />
</View>
</View>
);
}
// fontSizeAtom이 변경되면 이 컴포넌트만 리렌더링
function FontSizeSection() {
const [fontSizeInfo, setFontSize] = useAtom(fontSizeWithLabelAtom);
const theme = useAtomValue(themeAtom);
return (
<View style={[styles.section, { backgroundColor: theme.cardColor }]}>
<Text style={[styles.sectionTitle, { color: theme.textColor }]}>
글꼴 크기: {fontSizeInfo.label} ({fontSizeInfo.size}pt)
</Text>
<View style={styles.row}>
<TouchableOpacity onPress={() => setFontSize(fontSizeInfo.size - 2)}>
<Text style={styles.adjustButton}>A-</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => setFontSize(fontSizeInfo.size + 2)}>
<Text style={[styles.adjustButton, { fontSize: 22 }]}>A+</Text>
</TouchableOpacity>
</View>
</View>
);
}
// notificationsEnabledAtom이 변경되면 이 컴포넌트만 리렌더링
function NotificationSection() {
const [enabled, setEnabled] = useAtom(notificationsEnabledAtom);
const theme = useAtomValue(themeAtom);
return (
<View style={[styles.section, { backgroundColor: theme.cardColor }]}>
<Text style={[styles.sectionTitle, { color: theme.textColor }]}>
알림 설정
</Text>
<View style={styles.row}>
<Text style={{ color: theme.textColor }}>푸시 알림</Text>
<Switch value={enabled} onValueChange={setEnabled} />
</View>
</View>
);
}
비동기 아톰과 AsyncStorage 연동
Jotai에서 AsyncStorage 기반의 영속 아톰을 만드는 것도 꽤 직관적입니다. 비동기 파생 아톰을 활용하면 토큰 기반 인증 흐름도 깔끔하게 구현할 수 있어요.
import { atom } from 'jotai';
import { atomWithStorage, createJSONStorage } from 'jotai/utils';
import AsyncStorage from '@react-native-async-storage/async-storage';
// AsyncStorage 기반 영속 아톰
const asyncStorage = createJSONStorage<string>(() => AsyncStorage);
const accessTokenAtom = atomWithStorage<string | null>(
'access-token',
null,
asyncStorage
);
const onboardingCompleteAtom = atomWithStorage<boolean>(
'onboarding-complete',
false,
asyncStorage
);
// 비동기 파생 아톰 - 사용자 프로필 가져오기
const userProfileAtom = atom(async (get) => {
const token = get(accessTokenAtom);
if (!token) return null;
const response = await fetch('https://api.example.com/user/profile', {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) return null;
return response.json();
});
Redux Toolkit: 2026년에도 여전히 강력한 이유
"Redux는 구식이다"라는 말, 많이 들어보셨죠? 솔직히 저도 한때 그렇게 생각했습니다. 하지만 Redux Toolkit(RTK)은 대규모 엔터프라이즈 앱에서 여전히 가장 신뢰받는 상태 관리 솔루션이에요. 엄격한 단방향 데이터 흐름, 뛰어난 DevTools, 광대한 생태계, RTK Query를 통한 서버 상태 관리 통합까지... 100명 이상의 개발자가 협업하는 프로젝트에선 아직도 대체하기 어렵습니다.
슬라이스 기반의 모듈화된 스토어
// npm install @reduxjs/toolkit react-redux
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
// 타입 정의
interface Product {
id: string;
name: string;
price: number;
category: string;
imageUrl: string;
rating: number;
}
interface ProductsState {
items: Product[];
filteredItems: Product[];
selectedCategory: string | null;
searchQuery: string;
status: 'idle' | 'loading' | 'succeeded' | 'failed';
error: string | null;
}
const initialState: ProductsState = {
items: [],
filteredItems: [],
selectedCategory: null,
searchQuery: '',
status: 'idle',
error: null,
};
// 비동기 Thunk
export const fetchProducts = createAsyncThunk(
'products/fetchProducts',
async (_, { rejectWithValue }) => {
try {
const response = await fetch('https://api.example.com/products');
if (!response.ok) {
throw new Error('상품 목록을 불러올 수 없습니다');
}
return (await response.json()) as Product[];
} catch (error) {
return rejectWithValue(
error instanceof Error ? error.message : '알 수 없는 오류'
);
}
}
);
// 슬라이스 생성
const productsSlice = createSlice({
name: 'products',
initialState,
reducers: {
setCategory: (state, action: PayloadAction<string | null>) => {
state.selectedCategory = action.payload;
state.filteredItems = applyFilters(
state.items,
action.payload,
state.searchQuery
);
},
setSearchQuery: (state, action: PayloadAction<string>) => {
state.searchQuery = action.payload;
state.filteredItems = applyFilters(
state.items,
state.selectedCategory,
action.payload
);
},
},
extraReducers: (builder) => {
builder
.addCase(fetchProducts.pending, (state) => {
state.status = 'loading';
})
.addCase(fetchProducts.fulfilled, (state, action) => {
state.status = 'succeeded';
state.items = action.payload;
state.filteredItems = applyFilters(
action.payload,
state.selectedCategory,
state.searchQuery
);
})
.addCase(fetchProducts.rejected, (state, action) => {
state.status = 'failed';
state.error = action.payload as string;
});
},
});
function applyFilters(
items: Product[],
category: string | null,
query: string
): Product[] {
return items.filter((item) => {
const matchesCategory = !category || item.category === category;
const matchesQuery =
!query || item.name.toLowerCase().includes(query.toLowerCase());
return matchesCategory && matchesQuery;
});
}
export const { setCategory, setSearchQuery } = productsSlice.actions;
export default productsSlice.reducer;
RTK Query: API 캐싱의 최적해
RTK Query는 Redux Toolkit에 통합된 데이터 페칭 솔루션인데, 솔직히 이거 하나만으로도 Redux Toolkit을 쓸 이유가 됩니다. 캐시 태그 기반의 자동 무효화가 특히 편해요.
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
interface Order {
id: string;
items: Array<{ productId: string; quantity: number }>;
totalAmount: number;
status: 'pending' | 'confirmed' | 'shipped' | 'delivered';
createdAt: string;
}
export const orderApi = createApi({
reducerPath: 'orderApi',
baseQuery: fetchBaseQuery({
baseUrl: 'https://api.example.com/',
prepareHeaders: (headers, { getState }) => {
// 인증 토큰을 헤더에 자동 추가
const token = (getState() as RootState).auth.token;
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
return headers;
},
}),
tagTypes: ['Order'],
endpoints: (builder) => ({
getOrders: builder.query<Order[], void>({
query: () => 'orders',
providesTags: (result) =>
result
? [
...result.map(({ id }) => ({ type: 'Order' as const, id })),
{ type: 'Order', id: 'LIST' },
]
: [{ type: 'Order', id: 'LIST' }],
}),
getOrderById: builder.query<Order, string>({
query: (id) => `orders/${id}`,
providesTags: (result, error, id) => [{ type: 'Order', id }],
}),
createOrder: builder.mutation<Order, Omit<Order, 'id' | 'createdAt' | 'status'>>({
query: (newOrder) => ({
url: 'orders',
method: 'POST',
body: newOrder,
}),
invalidatesTags: [{ type: 'Order', id: 'LIST' }],
}),
}),
});
export const {
useGetOrdersQuery,
useGetOrderByIdQuery,
useCreateOrderMutation,
} = orderApi;
스토어 구성 및 React Native 연동
import { configureStore } from '@reduxjs/toolkit';
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import productsReducer from './productsSlice';
import authReducer from './authSlice';
import { orderApi } from './orderApi';
export const store = configureStore({
reducer: {
products: productsReducer,
auth: authReducer,
[orderApi.reducerPath]: orderApi.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: ['persist/PERSIST'],
},
}).concat(orderApi.middleware),
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// 타입 안전한 커스텀 훅
export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
// App.tsx에서 Provider 래핑
// import { Provider } from 'react-redux';
// <Provider store={store}><App /></Provider>
Legend State: 시그널 기반 초고성능 상태 관리
Legend State는 이 글에서 소개하는 라이브러리 중 가장 "미래지향적"이라고 할 수 있습니다. Observable/시그널 기반의 상태 관리 라이브러리로, 번들 크기가 약 4KB밖에 안 되면서 성능은 놀라울 정도예요. 특히 React Native에서 MMKV 기반의 초고속 영속화를 내장 지원하고, 세밀한 반응성(fine-grained reactivity)을 통해 컴포넌트 단위가 아닌 개별 뷰 단위의 업데이트를 가능하게 합니다.
Observable 기반의 상태 정의
// npm install @legendapp/state @legendapp/state/react
// npm install react-native-mmkv (MMKV 영속화용)
import { observable, computed } from '@legendapp/state';
import { observer, useObservable } from '@legendapp/state/react';
import { synced } from '@legendapp/state/sync';
import { ObservablePersistMMKV } from '@legendapp/state/persist-plugins/mmkv';
// 전역 상태 정의 - observable로 감싸면 자동으로 반응성 부여
interface TodoItem {
id: string;
title: string;
completed: boolean;
priority: 'low' | 'medium' | 'high';
createdAt: number;
}
const todos$ = observable<TodoItem[]>(
synced({
initial: [],
persist: {
name: 'todos',
plugin: ObservablePersistMMKV,
},
})
);
// 앱 설정 - MMKV로 자동 영속화
const appSettings$ = observable(
synced({
initial: {
theme: 'light' as 'light' | 'dark',
language: 'ko',
hapticFeedback: true,
fontSize: 16,
},
persist: {
name: 'app-settings',
plugin: ObservablePersistMMKV,
},
})
);
// computed를 통한 파생 상태 (자동 메모이제이션)
const completedCount$ = computed(() =>
todos$.get().filter((todo) => todo.completed).length
);
const pendingTodos$ = computed(() =>
todos$.get().filter((todo) => !todo.completed)
);
const highPriorityPending$ = computed(() =>
pendingTodos$.get().filter((todo) => todo.priority === 'high')
);
observer를 활용한 세밀한 반응성
Legend State의 observer HOC는 컴포넌트 내에서 실제로 접근한 observable만 추적합니다. 셀렉터를 수동으로 작성할 필요 없이 최적의 리렌더링이 자동으로 보장되는 거죠. 이건 정말 편합니다.
import React from 'react';
import { View, Text, FlatList, TouchableOpacity, TextInput, StyleSheet } from 'react-native';
import { observer } from '@legendapp/state/react';
import { generateId } from '../utils';
// observer로 감싸면 사용하는 observable이 변경될 때만 리렌더링
const TodoListScreen = observer(function TodoListScreen() {
const todos = todos$.get();
const completed = completedCount$.get();
const theme = appSettings$.theme.get();
const backgroundColor = theme === 'dark' ? '#1a1a2e' : '#ffffff';
const textColor = theme === 'dark' ? '#e0e0e0' : '#1a1a2e';
return (
<View style={[styles.container, { backgroundColor }]}>
<Text style={[styles.header, { color: textColor }]}>
할 일 목록 ({completed}/{todos.length} 완료)
</Text>
<AddTodoInput />
<FlatList
data={todos}
renderItem={({ item, index }) => <TodoItemRow index={index} />}
keyExtractor={(item) => item.id}
/>
</View>
);
});
// 개별 아이템 - 해당 아이템의 observable만 구독
const TodoItemRow = observer(function TodoItemRow({ index }: { index: number }) {
const todo = todos$[index];
const toggleComplete = () => {
todo.completed.set(!todo.completed.get());
// MMKV에 자동으로 영속화됩니다!
};
const deleteTodo = () => {
todos$.set((prev) => prev.filter((_, i) => i !== index));
};
return (
<TouchableOpacity onPress={toggleComplete} style={styles.todoItem}>
<View style={styles.todoContent}>
<Text
style={[
styles.todoTitle,
todo.completed.get() && styles.completedTitle,
]}
>
{todo.title.get()}
</Text>
<Text style={styles.priorityBadge}>
{todo.priority.get()}
</Text>
</View>
<TouchableOpacity onPress={deleteTodo}>
<Text style={styles.deleteButton}>삭제</Text>
</TouchableOpacity>
</TouchableOpacity>
);
});
const AddTodoInput = observer(function AddTodoInput() {
const [text, setText] = React.useState('');
const addTodo = () => {
if (text.trim()) {
todos$.set((prev) => [
...prev,
{
id: generateId(),
title: text.trim(),
completed: false,
priority: 'medium',
createdAt: Date.now(),
},
]);
setText('');
}
};
return (
<View style={styles.inputRow}>
<TextInput
value={text}
onChangeText={setText}
placeholder="새 할 일 입력..."
style={styles.input}
onSubmitEditing={addTodo}
/>
<TouchableOpacity onPress={addTodo} style={styles.addButton}>
<Text style={styles.addButtonText}>추가</Text>
</TouchableOpacity>
</View>
);
});
한 가지 더 언급하자면, Legend State의 MMKV 영속화는 AsyncStorage보다 약 30배 빠릅니다. MMKV는 C++로 작성된 키-값 저장소로, JSI를 통해 JavaScript와 직접 통신하기 때문에 JSON 직렬화 브릿지 오버헤드가 없어요. React Native New Architecture의 JSI 기반 네이티브 모듈 시스템과도 찰떡궁합이고, 대용량 상태의 읽기/쓰기에서 체감되는 성능 차이가 상당합니다.
TanStack Query (React Query): 서버 상태 관리의 표준
TanStack Query는 서버 상태 관리에 특화된 라이브러리이고, 2026년 현재 사실상 업계 표준이라고 해도 과언이 아닙니다. 데이터 페칭, 캐싱, 동기화, 백그라운드 갱신, 낙관적 업데이트... 서버 상태 관련 복잡한 문제들을 정말 우아하게 해결해 줍니다.
제가 이 라이브러리를 처음 쓰기 시작한 건 2023년쯤인데, 한번 경험하고 나니 다시는 직접 fetch + useState 조합으로 돌아갈 수 없더라고요.
React Native에서의 기본 설정
React Native에서 TanStack Query를 설정할 때는 웹과 좀 다른 부분이 있습니다. 네트워크 상태 감지랑 앱 포커스 관리를 별도로 설정해줘야 해요.
// npm install @tanstack/react-query
// npm install @react-native-async-storage/async-storage
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { AppStateStatus, Platform } from 'react-native';
import { focusManager, onlineManager } from '@tanstack/react-query';
import NetInfo from '@react-native-community/netinfo';
import { useEffect } from 'react';
import { AppState } from 'react-native';
// QueryClient 설정
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5분간 fresh 상태 유지
gcTime: 1000 * 60 * 30, // 30분간 캐시 유지
retry: 3,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
},
},
});
// AsyncStorage 영속화 설정
const asyncStoragePersister = createAsyncStoragePersister({
storage: AsyncStorage,
key: 'REACT_QUERY_CACHE',
throttleTime: 1000, // 1초마다 영속화 (성능 최적화)
});
// 온라인 상태 관리 - React Native에 필수
onlineManager.setEventListener((setOnline) => {
return NetInfo.addEventListener((state) => {
setOnline(!!state.isConnected);
});
});
// 앱 포커스 관리 - 앱이 포그라운드로 돌아올 때 데이터 갱신
function useAppStateRefetch() {
useEffect(() => {
const subscription = AppState.addEventListener(
'change',
(status: AppStateStatus) => {
if (Platform.OS !== 'web') {
focusManager.setFocused(status === 'active');
}
}
);
return () => subscription.remove();
}, []);
}
// 앱 루트에서 Provider 구성
export default function App() {
useAppStateRefetch();
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{ persister: asyncStoragePersister }}
>
<Navigation />
</PersistQueryClientProvider>
);
}
실전 쿼리 및 뮤테이션 예제
TanStack Query의 진가는 실전에서 드러납니다. 무한 스크롤 + 낙관적 업데이트 조합을 한번 살펴볼까요?
import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from '@tanstack/react-query';
import { View, Text, FlatList, RefreshControl, ActivityIndicator } from 'react-native';
// API 함수 분리
const api = {
async getNotifications(page: number = 1): Promise<{
data: Notification[];
nextPage: number | null;
totalCount: number;
}> {
const response = await fetch(
`https://api.example.com/notifications?page=${page}&limit=20`
);
return response.json();
},
async markAsRead(notificationId: string): Promise<void> {
await fetch(`https://api.example.com/notifications/${notificationId}/read`, {
method: 'PATCH',
});
},
};
// 무한 스크롤 알림 목록
function NotificationScreen() {
const queryClient = useQueryClient();
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
isError,
error,
refetch,
isRefetching,
} = useInfiniteQuery({
queryKey: ['notifications'],
queryFn: ({ pageParam }) => api.getNotifications(pageParam),
initialPageParam: 1,
getNextPageParam: (lastPage) => lastPage.nextPage,
staleTime: 1000 * 60 * 2, // 2분
});
// 낙관적 업데이트가 적용된 뮤테이션
const markAsReadMutation = useMutation({
mutationFn: api.markAsRead,
onMutate: async (notificationId) => {
// 진행 중인 쿼리 취소
await queryClient.cancelQueries({ queryKey: ['notifications'] });
// 이전 데이터 스냅샷
const previousData = queryClient.getQueryData(['notifications']);
// 낙관적 업데이트
queryClient.setQueryData(['notifications'], (old: any) => ({
...old,
pages: old.pages.map((page: any) => ({
...page,
data: page.data.map((n: Notification) =>
n.id === notificationId ? { ...n, read: true } : n
),
})),
}));
return { previousData };
},
onError: (err, notificationId, context) => {
// 에러 시 롤백
queryClient.setQueryData(['notifications'], context?.previousData);
},
onSettled: () => {
// 성공/실패 무관하게 캐시 무효화
queryClient.invalidateQueries({ queryKey: ['notifications'] });
},
});
const allNotifications = data?.pages.flatMap((page) => page.data) ?? [];
if (isLoading) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" />
</View>
);
}
if (isError) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text>오류 발생: {error.message}</Text>
</View>
);
}
return (
<FlatList
data={allNotifications}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<NotificationItem
notification={item}
onMarkRead={() => markAsReadMutation.mutate(item.id)}
/>
)}
onEndReached={() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}}
onEndReachedThreshold={0.5}
refreshControl={
<RefreshControl refreshing={isRefetching} onRefresh={refetch} />
}
ListFooterComponent={
isFetchingNextPage ? (
<ActivityIndicator style={{ padding: 16 }} />
) : null
}
/>
);
}
TanStack Query의 AsyncStorage 영속화 기능은 오프라인에서도 이전에 캐시된 데이터를 바로 보여줄 수 있게 해줍니다. 지하철이나 엘리베이터처럼 네트워크가 불안정한 모바일 환경에서 사용자 경험을 확실히 개선해 주는 부분이죠. 캐시된 데이터를 먼저 보여주고 네트워크가 복구되면 백그라운드에서 자동으로 최신 데이터로 갱신하는 "stale-while-revalidate" 전략은 모바일 앱에 정말 잘 맞는 패턴입니다.
라이브러리 비교 표
여기까지 읽으셨으면 각 라이브러리의 특성이 어느 정도 감이 오실 거예요. 한눈에 비교할 수 있도록 표로 정리해 봤습니다.
| 기준 | Context API | Zustand | Jotai | Redux Toolkit | Legend State | TanStack Query |
|---|---|---|---|---|---|---|
| 번들 크기 | 0KB (내장) | ~3KB | ~3.5KB | ~12KB | ~4KB | ~13KB |
| 주요 용도 | 저빈도 전역값 | 클라이언트 상태 | 클라이언트 상태 | 클라이언트 + 서버 | 클라이언트 상태 | 서버 상태 |
| 학습 곡선 | 낮음 | 매우 낮음 | 낮음 | 중간~높음 | 중간 | 중간 |
| 보일러플레이트 | 중간 | 최소 | 최소 | 중간 (RTK 사용 시) | 최소 | 중간 |
| 리렌더링 최적화 | 수동 분리 필요 | 셀렉터 기반 | 아톰 기반 자동 | 셀렉터 기반 | 시그널 기반 자동 | 쿼리 키 기반 |
| TypeScript 지원 | 기본 지원 | 우수 | 우수 | 매우 우수 | 우수 | 매우 우수 |
| DevTools | React DevTools | Redux DevTools 연동 | Jotai DevTools | Redux DevTools | Legend DevTools | RQ DevTools |
| RN 영속화 | 직접 구현 | AsyncStorage | AsyncStorage | redux-persist | MMKV (내장) | AsyncStorage |
| 미들웨어 | 없음 | 내장 지원 | 유틸리티 함수 | 강력한 미들웨어 | 플러그인 시스템 | 플러그인 시스템 |
| 커뮤니티 크기 | 매우 큼 | 큼 | 중간 | 매우 큼 | 성장 중 | 매우 큼 |
| New Architecture 최적화 | 기본 수준 | 좋음 | 매우 좋음 | 좋음 | 탁월함 | 좋음 |
| Provider 필요 | 예 | 아니오 | 선택적 | 예 | 아니오 | 예 |
프로젝트 규모별 추천 조합
모든 프로젝트에 딱 맞는 단일 솔루션은 없습니다. (있었으면 좋겠지만요.) 프로젝트 규모, 팀 구성, 요구사항에 따라 최적의 조합이 달라지죠. 아래는 2026년 기준으로 검증된 조합들입니다.
소규모 프로젝트 (1~3명, MVP/프로토타입)
소규모 프로젝트에서는 복잡한 설정보다 빠른 개발 속도가 중요합니다. 일단 돌아가게 만드는 게 먼저니까요.
- 클라이언트 상태: Zustand (설정 거의 없이 바로 사용 가능)
- 서버 상태: TanStack Query (데이터 페칭 로직을 대폭 단순화)
- 로컬 상태: useState / useReducer
- 영속화: Zustand persist + AsyncStorage
// 소규모 프로젝트의 전형적인 구조
// stores/
// useAuthStore.ts - Zustand
// useSettingsStore.ts - Zustand + persist
// hooks/
// useUser.ts - TanStack Query
// usePosts.ts - TanStack Query
// screens/
// HomeScreen.tsx - useState for local state
// useAuthStore.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
interface AuthState {
token: string | null;
setToken: (token: string | null) => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
token: null,
setToken: (token) => set({ token }),
}),
{
name: 'auth',
storage: createJSONStorage(() => AsyncStorage),
}
)
);
// hooks/usePosts.ts
import { useQuery } from '@tanstack/react-query';
export function usePosts() {
return useQuery({
queryKey: ['posts'],
queryFn: async () => {
const response = await fetch('https://api.example.com/posts');
return response.json();
},
staleTime: 1000 * 60 * 5,
});
}
중규모 프로젝트 (3~10명, 상용 앱)
중규모가 되면 코드 구조화와 팀 간 일관된 패턴이 중요해지기 시작합니다. 이 단계에서 아키텍처를 제대로 잡아놓으면 나중에 고생이 줄어들어요.
- 클라이언트 상태: Zustand 또는 Jotai (팀 선호도에 따라 결정)
- 서버 상태: TanStack Query (이건 사실상 필수)
- 복잡한 폼: React Hook Form + Zod
- 영속화: Legend State의 MMKV 또는 Zustand persist
- 네비게이션 상태: React Navigation의 내장 상태 관리
// 중규모 프로젝트: Jotai + TanStack Query 하이브리드 접근법
// atoms/auth.ts
import { atom } from 'jotai';
import { atomWithStorage, createJSONStorage } from 'jotai/utils';
import AsyncStorage from '@react-native-async-storage/async-storage';
const storage = createJSONStorage(() => AsyncStorage);
export const accessTokenAtom = atomWithStorage<string | null>(
'accessToken',
null,
storage
);
export const isAuthenticatedAtom = atom((get) => get(accessTokenAtom) !== null);
// atoms/ui.ts
export const bottomSheetOpenAtom = atom(false);
export const selectedTabAtom = atom<'home' | 'search' | 'profile'>('home');
// hooks/useProducts.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useAtomValue } from 'jotai';
import { accessTokenAtom } from '../atoms/auth';
export function useProducts(categoryId?: string) {
const token = useAtomValue(accessTokenAtom);
return useQuery({
queryKey: ['products', { categoryId }],
queryFn: async () => {
const url = categoryId
? `https://api.example.com/products?category=${categoryId}`
: 'https://api.example.com/products';
const response = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) throw new Error('상품 조회 실패');
return response.json();
},
enabled: !!token, // 토큰이 있을 때만 실행
});
}
export function useAddToWishlist() {
const queryClient = useQueryClient();
const token = useAtomValue(accessTokenAtom);
return useMutation({
mutationFn: async (productId: string) => {
const response = await fetch('https://api.example.com/wishlist', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ productId }),
});
return response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['wishlist'] });
},
});
}
대규모 프로젝트 (10명 이상, 엔터프라이즈)
대규모 프로젝트에서는 예측 가능성, 디버깅 용이성, 엄격한 타입 안전성이 무엇보다 중요합니다. 여기서 Redux Toolkit이 빛을 발하죠.
- 클라이언트 상태: Redux Toolkit (예측 가능한 단방향 흐름과 강력한 DevTools)
- 서버 상태: RTK Query 또는 TanStack Query
- 성능 크리티컬 영역: Legend State (애니메이션, 실시간 데이터)
- 영속화: redux-persist + MMKV (또는 Legend State MMKV)
- 상태 테스트: Redux의 순수 함수 리듀서는 테스트 작성이 편리
// 대규모 프로젝트: Redux Toolkit + RTK Query + Legend State 조합
// features/products/productsSlice.ts - Redux Toolkit
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface FiltersState {
category: string | null;
priceRange: [number, number];
sortBy: 'price' | 'rating' | 'newest';
searchQuery: string;
}
const initialState: FiltersState = {
category: null,
priceRange: [0, 1000000],
sortBy: 'newest',
searchQuery: '',
};
const filtersSlice = createSlice({
name: 'filters',
initialState,
reducers: {
setCategory(state, action: PayloadAction<string | null>) {
state.category = action.payload;
},
setPriceRange(state, action: PayloadAction<[number, number]>) {
state.priceRange = action.payload;
},
setSortBy(state, action: PayloadAction<FiltersState['sortBy']>) {
state.sortBy = action.payload;
},
setSearchQuery(state, action: PayloadAction<string>) {
state.searchQuery = action.payload;
},
resetFilters() {
return initialState;
},
},
});
export const { setCategory, setPriceRange, setSortBy, setSearchQuery, resetFilters } =
filtersSlice.actions;
export default filtersSlice.reducer;
// features/realtime/realtimePrices.ts - Legend State (성능 크리티컬)
import { observable } from '@legendapp/state';
// 실시간 주식 가격 같은 초고빈도 업데이트에는 Legend State 사용
// Redux에 넣으면 디스패치 오버헤드가 생기거든요
export const realtimePrices$ = observable<Record<string, number>>({});
// WebSocket으로 수신되는 실시간 가격 업데이트
export function connectPriceStream() {
const ws = new WebSocket('wss://stream.example.com/prices');
ws.onmessage = (event) => {
const { symbol, price } = JSON.parse(event.data);
// Observable 직접 업데이트 - 구독 중인 컴포넌트만 리렌더링
realtimePrices$[symbol].set(price);
};
return () => ws.close();
}
조합 선택 가이드 요약
| 프로젝트 규모 | 클라이언트 상태 | 서버 상태 | 영속화 | 특수 요구사항 |
|---|---|---|---|---|
| 소규모 | Zustand | TanStack Query | AsyncStorage | - |
| 중규모 | Zustand / Jotai | TanStack Query | MMKV | React Hook Form |
| 대규모 | Redux Toolkit | RTK Query / TanStack Query | MMKV + redux-persist | Legend State (실시간) |
| 성능 최우선 | Legend State | TanStack Query | MMKV (내장) | JSI 네이티브 모듈 |
하이브리드 접근법: 2026년의 모범 사례
2026년 React Native 상태 관리에서 가장 중요한 트렌드를 하나만 꼽으라면, 저는 "하나의 라이브러리로 모든 걸 해결하려고 하지 않는 것"이라고 답하겠습니다. 서버 상태와 클라이언트 상태를 명확히 분리하고, 각 영역에 맞는 도구를 조합하는 게 이제 표준이 됐어요.
// 하이브리드 접근법의 전형적인 앱 진입점
// App.tsx
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { NavigationContainer } from '@react-navigation/native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
// TanStack Query - 서버 상태 관리
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5,
retry: 2,
},
},
});
// Zustand 스토어들은 Provider 불필요 - import만 하면 사용 가능
// useAuthStore, useUIStore, useCartStore 등
export default function App() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<QueryClientProvider client={queryClient}>
<NavigationContainer>
<RootNavigator />
</NavigationContainer>
</QueryClientProvider>
</GestureHandlerRootView>
);
}
// 화면 컴포넌트에서의 사용
// ProductDetailScreen.tsx
import { useQuery, useMutation } from '@tanstack/react-query';
import { useCartStore } from '../stores/useCartStore';
import { useAuthStore } from '../stores/useAuthStore';
function ProductDetailScreen({ route }: ProductDetailScreenProps) {
const { productId } = route.params;
// 서버 상태: TanStack Query로 관리
const { data: product, isLoading } = useQuery({
queryKey: ['product', productId],
queryFn: () => fetchProduct(productId),
});
// 클라이언트 상태: Zustand로 관리
const addToCart = useCartStore((s) => s.addItem);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
// 로컬 상태: useState로 관리
const [selectedSize, setSelectedSize] = React.useState<string | null>(null);
const [quantity, setQuantity] = React.useState(1);
const handleAddToCart = () => {
if (product && selectedSize) {
addToCart({
id: product.id,
name: product.name,
price: product.price,
imageUrl: product.imageUrl,
});
}
};
// 세 가지 상태 유형이 자연스럽게 공존합니다
// ...
}
이 패턴에서 각 도구는 자기가 가장 잘하는 일에 집중합니다. TanStack Query는 캐싱과 재시도, 백그라운드 갱신 등 서버 데이터의 복잡한 생명주기를 담당하고요. Zustand는 가볍고 직관적인 API로 클라이언트 전용 상태를 관리합니다. React의 내장 useState는 컴포넌트 로컬 상태를 처리하고요. 각자의 역할이 명확하니 코드도 깔끔해집니다.
React Native New Architecture와 상태 관리
React Native의 New Architecture는 Fabric 렌더러와 JSI(JavaScript Interface)를 통해 JavaScript-네이티브 간 통신을 혁신적으로 개선했습니다. 이 변화는 상태 관리 라이브러리 선택에도 영향을 미치는 부분이에요.
- JSI 기반 네이티브 모듈: MMKV 같은 JSI 기반 저장소는 기존 AsyncStorage 대비 30배 이상 빠른 읽기/쓰기를 제공합니다. Legend State가 MMKV를 내장 지원하는 건 이 맥락에서 큰 강점이죠.
- 동기적 네이티브 호출: JSI를 통해 JavaScript에서 네이티브 함수를 동기적으로 호출할 수 있게 되면서, 상태 변경에 따른 UI 업데이트가 더 빠르고 예측 가능해졌습니다.
- Concurrent Features: React 18+의 동시성 기능과 결합하면, 상태 관리 라이브러리의 세밀한 업데이트가 사용자 인터랙션 응답성을 크게 높여줍니다.
- 세밀한 리렌더링의 중요성: Fabric 렌더러에서도 불필요한 리렌더링은 성능 비용을 수반해요. Jotai의 아톰 기반 구독이나 Legend State의 시그널 기반 반응성은 New Architecture의 이점을 극대화하는 데 특히 유리합니다.
결론: 상태 관리, 어떻게 선택할 것인가
2026년 React Native 상태 관리 생태계는 확실히 성숙기에 접어들었습니다. "하나의 정답"은 없고, 프로젝트 맥락에 맞는 올바른 조합을 찾는 게 핵심이에요.
이 글의 핵심을 정리하면 이렇습니다.
- 상태를 분류하세요. 서버 상태, 클라이언트 상태, 로컬 상태를 구분하고 각각에 적합한 도구를 사용하세요.
- 서버 상태에는 TanStack Query를 쓰세요. 데이터 페칭과 캐싱을 직접 구현하지 마세요. TanStack Query가 이미 해결한 문제입니다.
- 클라이언트 상태에는 프로젝트 규모에 맞는 도구를 고르세요. 소~중규모에는 Zustand나 Jotai, 대규모 엔터프라이즈에는 Redux Toolkit, 성능이 극도로 중요하면 Legend State를 고려하세요.
- 영속화 전략을 고민하세요. 모바일 앱에서 상태 영속화는 필수입니다. 성능이 중요하다면 AsyncStorage 대신 MMKV를 선택하세요.
- New Architecture의 이점을 활용하세요. JSI 기반 네이티브 모듈, Fabric 렌더러와 조화되는 상태 관리 전략을 세우세요.
앞으로의 방향을 전망하면, 시그널 기반의 세밀한 반응성이 더 보편화될 겁니다. React 자체도 시그널에서 영감을 받은 컴파일러 최적화(React Compiler)를 도입하고 있고, Legend State 같은 시그널 기반 라이브러리의 접근법이 점점 주류가 될 거예요. 서버 컴포넌트(Server Components)가 React Native에 본격 도입되면, 서버 상태와 클라이언트 상태의 경계는 더 흐려질 수도 있습니다.
마지막으로 가장 중요한 원칙 하나. 단순하게 시작하고, 필요할 때 확장하세요. 처음부터 모든 라이브러리를 한꺼번에 도입하지 마세요. useState로 시작하고, 전역 상태가 필요해지면 Zustand를 추가하고, 서버 데이터가 복잡해지면 TanStack Query를 도입하세요. 점진적인 복잡도 관리야말로 건강한 코드베이스의 비결입니다.
결국 React Native의 상태 관리는 "어떤 라이브러리를 쓸까?"가 아니라 "어떤 종류의 상태를 어떤 도구로 관리할까?"라는 아키텍처적 질문입니다. 이 글이 여러분의 다음 프로젝트에서 최적의 상태 관리 전략을 세우는 데 도움이 됐으면 합니다.