상태 관리·아키텍처·모듈화
상태 관리·아키텍처·모듈화
요약: 전역 상태를 최소화하고, 기능 단위 폴더와 Context·Zustand 선택 기준을 정리한다.
1. 왜 전역을 늦게 쓰나
앱이 커지면 “일단 Redux”로 가기 쉽다. RN 신규 프로젝트에서 가장 흔한 실수다. 대부분은 로컬 상태와 서버 캐시면 된다.
전역은 멀리 떨어진 화면이 같은 클라이언트 값을 써야 할 때만 연다. 장바구니, 테마, 세션이 그 예다. 게시글 목록은 Query에 두고 복제하지 않는다.
폴더를 기능 단위로 나누면 화면 파일이 얇아진다. 라우트는 조립만 한다.
2. 핵심 개념
상태 배치 순서:
- 로컬
useState - 라우트 파라미터 (공유·복원 가능한 UI)
- React Query (서버 상태)
- Context (드물고 천천히 바뀌는 값: theme, auth)
- Zustand (클라이언트 전역이 명확할 때)
- Redux Toolkit (팀 표준이거나 복잡한 클라이언트 워크플로)
Context는 value가 바뀔 때마다 구독 컴포넌트가 다시 그린다. 스크롤 위치, 매 키입력은 넣지 않는다.
Zustand는 셀렉터로 필요한 조각만 구독한다. persist는 AsyncStorage로 충분하다. 토큰은 persist 금지. SecureStore + Auth 흐름이다.
에러 경계는 라우트 단위로 나눈다. 한 화면 크래시가 앱 전체 흰 화면이 되지 않게 한다.
환경은 app.config.ts와 EAS 프로파일로 나눈다. 번들 ID와 API URL을 섞지 않는다.
3. 예제
테마처럼 저빈도 값:
const ThemeContext = createContext<{
mode: 'light' | 'dark';
toggle: () => void;
} | null>(null);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [mode, setMode] = useState<'light' | 'dark'>('light');
const value = useMemo(
() => ({ mode, toggle: () => setMode((m) => (m === 'light' ? 'dark' : 'light')) }),
[mode],
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
장바구니처럼 여러 화면이 같은 클라이언트 데이터:
npx expo install zustand @react-native-async-storage/async-storage
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
type CartState = {
items: Record<string, number>;
add: (id: string) => void;
clear: () => void;
};
export const useCart = create<CartState>()(
persist(
(set) => ({
items: {},
add: (id) =>
set((s) => ({ items: { ...s.items, [id]: (s.items[id] ?? 0) + 1 } })),
clear: () => set({ items: {} }),
}),
{
name: 'cart',
storage: createJSONStorage(() => AsyncStorage),
},
),
);
const count = useCart((s) => s.items[id] ?? 0);
기능 단위 폴더:
src/
├── app/ # expo-router (또는 루트 app/)
├── features/
│ ├── auth/
│ │ ├── api.ts
│ │ ├── hooks.ts
│ │ ├── screens/
│ │ └── components/
│ ├── posts/
│ └── cart/
├── components/ # 공용 UI
├── lib/ # api, storage
└── constants/
화면은 훅만 호출한다.
export function usePost(id: string) {
return useQuery({
queryKey: ['posts', id],
queryFn: () => api<Post>(`/posts/${id}`),
});
}
날짜·가격 포맷은 lib/format.ts 한곳. props가 세 단을 넘으면 합성이나 작은 store를 검토한다. Presentational/Container를 기계적으로 나누기보다 훅으로 로직을 뺀다.
import { ErrorBoundary } from 'react-error-boundary';
<ErrorBoundary FallbackComponent={ScreenError} onReset={resetApp}>
<App />
</ErrorBoundary>
// app.config.ts
import { ExpoConfig, ConfigContext } from 'expo/config';
export default ({ config }: ConfigContext): ExpoConfig => ({
...config,
name: 'MyApp',
slug: 'my-app',
extra: {
appEnv: process.env.APP_ENV ?? 'development',
eas: { projectId: '...' },
},
});
품질 기본선: TypeScript strict, ESLint, @/ import. 팀이면 lint-staged.
4. 흔한 실수
| 실수 | 대안 |
|---|---|
| 모든 값을 Redux/Zustand | 로컬 → Query → 전역 순 |
| 서버 목록을 스토어에 복제 | Query가 원본 |
| Context에 고빈도 값 | 셀렉터 있는 store 또는 로컬 |
| 토큰을 AsyncStorage persist | SecureStore |
| 화면 파일에 API+UI 혼재 | features/*/hooks.ts |
| 루트만 ErrorBoundary | 라우트 단위로 나눈다 |
5. 정리
아키텍처는 라이브러리 선택이 아니다. 상태의 원본을 어디에 둘지를 먼저 정하는 일이다.
- 전역은 최소. Feature 폴더가 경계다.
- Context는 테마·세션. Zustand는 장바구니류.
- dev/staging/prod는 설정과 번들 ID로 분리한다.
연습
posts를 Feature 폴더로 옮긴다.- Zustand로 장바구니 또는 최근 검색어를 persist한다.
- Theme Context로 라이트/다크를 토글한다.
- 라우트 단위 ErrorBoundary를 추가한다.
댓글
0아직 댓글이 없습니다. 첫 댓글을 남겨보세요.