• 포스트
  • 도구
  • 작업
  • 포스트
  • 도구
  • 작업
로그인회원가입

© 2005 kaisa.co.kr. All Rights Reserved.

소개개인정보처리방침7083620@hanmail.net
← 목록으로
2026-02-09

상태 관리·아키텍처·모듈화

상태 관리·아키텍처·모듈화

요약: 전역 상태를 최소화하고, 기능 단위 폴더와 Context·Zustand 선택 기준을 정리한다.


1. 왜 전역을 늦게 쓰나

앱이 커지면 “일단 Redux”로 가기 쉽다. RN 신규 프로젝트에서 가장 흔한 실수다. 대부분은 로컬 상태와 서버 캐시면 된다.

전역은 멀리 떨어진 화면이 같은 클라이언트 값을 써야 할 때만 연다. 장바구니, 테마, 세션이 그 예다. 게시글 목록은 Query에 두고 복제하지 않는다.

폴더를 기능 단위로 나누면 화면 파일이 얇아진다. 라우트는 조립만 한다.


2. 핵심 개념

상태 배치 순서:

  1. 로컬 useState
  2. 라우트 파라미터 (공유·복원 가능한 UI)
  3. React Query (서버 상태)
  4. Context (드물고 천천히 바뀌는 값: theme, auth)
  5. Zustand (클라이언트 전역이 명확할 때)
  6. 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로 분리한다.

연습

  1. posts를 Feature 폴더로 옮긴다.
  2. Zustand로 장바구니 또는 최근 검색어를 persist한다.
  3. Theme Context로 라이트/다크를 토글한다.
  4. 라우트 단위 ErrorBoundary를 추가한다.

댓글

0

아직 댓글이 없습니다. 첫 댓글을 남겨보세요.

이전 글애니메이션·제스처·사용자 경험다음 글테스트·디버깅·성능
← 목록으로