애플리케이션의 "상태"라고 하면 전부 useState나 Redux에 넣어야 할까요? 상태의 성격에 따라 관리 방식이 달라져야 하는 건 아닐까요?

개념 정의

React 애플리케이션의 상태는 크게 세 가지로 분류할 수 있습니다.

  • 서버 상태(Server State): 서버에 원본이 존재하는 비동기 데이터
  • ** 클라이언트 상태(Client State)**: 클라이언트에서만 존재하는 UI 상태
  • URL 상태(URL State): 브라우저 URL에 인코딩된 상태

왜 필요한가

상태의 종류를 구분하지 않으면 다음과 같은 문제가 발생합니다.

  • 서버 데이터를 Redux에 넣고 수동으로 동기화하느라 코드가 복잡해짐
  • 캐시, 재검증, 로딩/에러 처리를 매번 직접 구현
  • URL로 공유 가능해야 하는 상태가 공유되지 않음
  • 모든 상태를 전역으로 관리하여 불필요한 복잡성 증가

서버 상태 (Server State)

특성

  • ** 원본이 서버에 존재 **: 클라이언트의 데이터는 캐시(사본)
  • ** 비동기 **: 네트워크를 통해 가져와야 함
  • ** 공유 가능 **: 다른 사용자가 같은 데이터를 수정할 수 있음
  • ** 오래될 수 있음(Stale)**: 가져온 순간부터 최신이 아닐 수 있음

예시

PLAINTEXT
- 사용자 프로필 정보
- 상품 목록
- 댓글, 게시글
- 알림 데이터
- 대시보드 통계

적합한 도구

JSX
// TanStack Query (React Query)
function UserProfile({ userId }) {
  const { data: user, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
    staleTime: 5 * 60 * 1000, // 5분간 신선한 것으로 간주
  });

  if (isLoading) return <Spinner />;
  if (error) return <Error message={error.message} />;
  return <div>{user.name}</div>;
}

서버 상태 관리 라이브러리가 제공하는 것들입니다.

  • 캐싱과 자동 재검증
  • 로딩/에러/성공 상태
  • 중복 요청 제거
  • 백그라운드 리페칭
  • 낙관적 업데이트

클라이언트 상태 (Client State)

특성

  • **클라이언트에서만 존재 **: 서버에 저장되지 않음
  • ** 동기적 **: 즉시 사용 가능
  • ** 일시적 **: 페이지 새로고침 시 초기화 (localStorage 제외)
  • ** 단일 소유 **: 현재 사용자만 사용

예시

PLAINTEXT
- 모달 열림/닫힘
- 사이드바 접힘 상태
- 선택된 탭
- 폼 입력 중인 값
- 다크모드 설정
- 드래그 상태

적합한 도구

JSX
// 로컬 상태: useState
function Accordion({ items }) {
  const [openIndex, setOpenIndex] = useState(null);
  // ...
}

// 복잡한 로컬 상태: useReducer
function FormWizard() {
  const [state, dispatch] = useReducer(formReducer, initialState);
  // ...
}

// 여러 컴포넌트 공유: Zustand, Jotai
const useUIStore = create((set) => ({
  sidebarOpen: true,
  modalType: null,
  toggleSidebar: () => set(s => ({ sidebarOpen: !s.sidebarOpen })),
  openModal: (type) => set({ modalType: type }),
  closeModal: () => set({ modalType: null }),
}));

URL 상태 (URL State)

특성

  • ** 공유 가능 **: URL을 통해 같은 화면을 재현
  • ** 북마크 가능 **: 사용자가 저장하고 나중에 돌아올 수 있음
  • ** 뒤로가기 지원 **: 브라우저 히스토리와 연동
  • ** 직렬화 **: 문자열로 표현 가능해야 함

예시

PLAINTEXT
- 검색 키워드: ?q=react
- 필터 조건: ?category=frontend&level=advanced
- 페이지네이션: ?page=3&size=20
- 정렬: ?sort=date&order=desc
- 선택된 탭: ?tab=settings

적합한 도구

JSX
// React Router의 URL 파라미터
import { useSearchParams } from 'react-router-dom';

function ProductList() {
  const [searchParams, setSearchParams] = useSearchParams();

  const category = searchParams.get('category') || 'all';
  const page = Number(searchParams.get('page')) || 1;
  const sort = searchParams.get('sort') || 'newest';

  const handleCategoryChange = (newCategory) => {
    setSearchParams(prev => {
      prev.set('category', newCategory);
      prev.set('page', '1'); // 카테고리 변경 시 첫 페이지로
      return prev;
    });
  };

  // URL 상태를 서버 상태 페칭에 활용
  const { data } = useQuery({
    queryKey: ['products', category, page, sort],
    queryFn: () => fetchProducts({ category, page, sort }),
  });

  return (/* ... */);
}

상태 혼합의 문제

서버 상태를 클라이언트 상태로 복사

JSX
// ❌ 서버 데이터를 useState에 복사
function UserList() {
  const [users, setUsers] = useState([]);

  useEffect(() => {
    fetchUsers().then(data => setUsers(data));
  }, []);

  // 문제:
  // 1. 다른 사용자가 추가한 유저가 반영되지 않음
  // 2. 직접 로딩/에러 처리를 구현해야 함
  // 3. 캐싱이 없어 매번 새로 요청
  // 4. 중복 요청 방지가 안 됨
}

// ✅ 서버 상태 라이브러리 사용
function UserList() {
  const { data: users, isLoading } = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
  });
  // 캐싱, 재검증, 로딩/에러가 자동으로 처리됨
}

상태 분류 의사결정 트리

PLAINTEXT
데이터의 원본이 어디에 있는가?
├── 서버(API) → 서버 상태
│   └── TanStack Query, SWR, RTK Query
├── URL → URL 상태
│   └── useSearchParams, URL 파라미터
└── 클라이언트 → 클라이언트 상태
    ├── 단일 컴포넌트에서만 사용? → useState / useReducer
    ├── 가까운 몇 개 컴포넌트가 공유? → props / 상태 끌어올리기
    └── 멀리 떨어진 여러 컴포넌트가 공유? → Context / Zustand / Redux

실전: 세 가지 상태의 조합

JSX
function ProductSearchPage() {
  // URL 상태: 검색 조건
  const [searchParams, setSearchParams] = useSearchParams();
  const query = searchParams.get('q') || '';
  const category = searchParams.get('category') || 'all';
  const page = Number(searchParams.get('page')) || 1;

  // 서버 상태: 상품 목록
  const { data, isLoading } = useQuery({
    queryKey: ['products', query, category, page],
    queryFn: () => fetchProducts({ query, category, page }),
  });

  // 클라이언트 상태: UI 상태
  const [selectedId, setSelectedId] = useState(null);
  const [isFilterOpen, setIsFilterOpen] = useState(false);

  return (
    <div>
      <SearchBar
        value={query}
        onChange={(q) => setSearchParams({ q, category, page: '1' })}
      />
      <button onClick={() => setIsFilterOpen(!isFilterOpen)}>
        필터 {isFilterOpen ? '닫기' : '열기'}
      </button>
      {isFilterOpen && <FilterPanel category={category} />}
      {isLoading ? <Spinner /> : <ProductGrid products={data} />}
    </div>
  );
}

주의할 점

서버 데이터를 useState에 복사하는 안티패턴

const [users, setUsers] = useState([])에 API 응답을 저장하면, 원본(서버)과 사본(클라이언트)의 동기화를 직접 관리해야 합니다. 다른 사용자가 데이터를 변경해도 화면에 반영되지 않고, 캐시 무효화 로직도 직접 구현해야 합니다. TanStack Query처럼 서버 상태를 전문으로 관리하는 도구를 사용해야 합니다.

URL 상태와 클라이언트 상태를 혼동

검색 필터, 정렬 기준, 페이지 번호처럼 공유 가능해야 하는 값 을 useState로 관리하면, URL을 복사해도 같은 화면을 볼 수 없습니다. 이런 값은 searchParams에 저장하는 것이 올바릅니다.

정리

상태 종류특징적합한 도구
서버 상태캐싱, 재검증, 백그라운드 동기화TanStack Query, SWR
클라이언트 상태로컬 UI, 사용자 입력useState, Zustand
URL 상태공유, 북마크, 히스토리useSearchParams
핵심 질문"이 데이터의 원본은 어디에 있는가?"답에 따라 도구가 결정됨

"이 데이터의 원본은 어디에 있는가?"라는 질문 하나만으로 상태 관리 전략이 명확해집니다.

댓글 로딩 중...