프로젝트 스택 · 4장 상태관리 라이브러리

4장 · 상태관리 라이브러리

이 프로젝트의 두 기둥 — Zustand?(로그인·권한 같은 내 앱의 상태)와 React Query?(서버에서 받아오는 데이터)를 나눠서 익힙니다. 둘은 경쟁 관계가 아니라 역할이 다른 도구예요.

이 장의 코드는 대부분 "읽는 코드"예요

Zustand·React Query 라이브러리는 오른쪽 연습장(iframe? 미설치)에서 실행할 수 없어요. 그래서 라이브러리 코드는 전부 정적 코드블록으로 보여 줍니다. 직접 실행해 보는 플레이그라운드는 "왜 이런 도구가 필요한가"를 순수 React로 시연할 때만 나옵니다.

4.0 왜 "전역 상태"가 필요한가

useState? 로 만든 상태?그 컴포넌트 안에서만 삽니다. 그런데 로그인한 사용자 이름은 헤더에도, 사이드바에도, 본문에도 필요해요. 위쪽 컴포넌트에 두고 아래로 계속 props? 로 넘기면 중간 컴포넌트들은 자기는 안 쓰면서 그냥 배달만 해야 합니다. 이걸 props 내려꽂기(prop drilling) 라고 불러요.

props 내려꽂기 = 3층 사람이 1층 사람에게 물건을 주려고 2층 사람 손을 거치는 것. 2층은 받을 필요도 없는데 계속 들고 내려가야 합니다.

2장에서 본 useContext? 가 그 "통로"의 React 기본 해법입니다. 아래에서 한 번 더 시연해 볼게요. Toolbar 가 props 없이도 위에서 정한 사용자 이름을 바로 꺼내 씁니다.

그럼 Context 면 충분하지 않나요?

Context 는 "통로"만 줍니다. 값을 어떻게 저장하고 바꿀지는 직접 짜야 하고, 값이 조금만 바뀌어도 그 통로를 쓰는 컴포넌트가 전부 리렌더? 되기 쉬워요. Zustand? 는 이 "저장 + 바꾸기 + 필요한 부분만 리렌더"를 깔끔하게 해 줍니다.

4.1 Zustand — 가장 단순한 전역 store

store? 는 여러 컴포넌트가 함께 보는 공용 보관소예요. create 에 함수를 하나 넘기면 끝납니다. 그 함수는 상태상태를 바꾸는 함수를 한 객체로 돌려줘요.

store 는 교실 앞 공용 칠판. 누가 칠판 숫자를 고치면(set) 그 숫자를 보고 있던 사람 모두의 화면이 갱신됩니다.

가장 단순한 카운터 store. set 으로 값을 바꾸고, 컴포넌트는 셀렉터로 필요한 조각만 꺼냅니다.

import { create } from "zustand"

// ① store 만들기 — 상태 + 그 상태를 바꾸는 함수
const useCounter = create((set) => ({
  count: 0,
  inc: () => set((s) => ({ count: s.count + 1 })), // 이전 값 기반으로 변경
  reset: () => set({ count: 0 }),                   // 통째로 지정도 가능
}))

// ② 컴포넌트에서 "셀렉터"로 필요한 조각만 꺼내기
function Display() {
  const count = useCounter((s) => s.count) // count 만 구독
  return <p>값: {count}</p>
}

function Buttons() {
  const inc = useCounter((s) => s.inc)     // 함수만 구독
  return <button onClick={inc}>+1</button>
}
셀렉터 (s) => s.count 가 핵심

store 전체가 아니라 내가 쓰는 조각만 골라 구독하세요. 그러면 count 가 바뀔 때 count 를 꺼낸 컴포넌트만 리렌더되고, 다른 값만 쓰는 컴포넌트는 가만히 있습니다.

실제로 어디에 쓰나 (Zustand store)
  • 로그인 사용자·권한 — 헤더·사이드바·본문 어디서든 같은 로그인 정보를 봐야 할 때. 앱 전체가 공유하는 값이라 store 에 딱 맞아요
  • 페이지별 화면 상태 — 표에서 선택한 행, 검색 필터 값, 펼친 탭처럼 한 페이지 안 여러 컴포넌트가 같이 보는 상태 (use…PageStore)
  • 레이아웃 토글 — 사이드바 열림/접힘, 다크모드 같은 전역 UI 스위치
  • 다이얼로그 제어 — 어느 컴포넌트에서든 열고 닫는 공용 모달/알림

setget — 바꾸기와 읽기

도구하는 일예시
set(부분객체)그 키만 갈아끼움 (나머지는 유지)set({ loading: true })
set((s) => ...)이전 상태 s 를 보고 바꿈set((s) => ({ count: s.count + 1 }))
get()action 안에서 현재 상태 읽기get().user?.roles

useState 와 비교하면: useState컴포넌트 한 개의 상태, create store 는 여러 컴포넌트가 공유하는 상태예요. 쓰는 느낌은 비슷합니다 — useCounter((s)=>s.count)const [count] = useState() 자리에 들어간다고 보면 돼요.

여러 조각을 합치는 패턴도 있다 (지금은 한 줄만)

store 가 커지면 기능별 "슬라이스(조각)"로 나눴다가 ... 로 합치기도 합니다. 이 프로젝트에도 CRUD 공통 슬라이스를 합치는 코드가 있지만 지금은 몰라도 됩니다 — 6장 화면 패턴에서 다뤄요. 여기선 "store 하나에 다 넣어도 되고, 나눴다 합칠 수도 있다" 정도만 기억하세요.

이 프로젝트의 실제 전역 store (소개만)

학습용 프로젝트에는 앱 전체에서 쓰는 전역 store 가 두 개 있어요. 지금은 "이런 게 있다"만 보고 넘어갑니다.

store담는 것파일
useAuthStore로그인한 사용자, 로그인/로그아웃 함수src/app/auth/use-auth.hook.ts
usePermissionStore메뉴별 권한(읽기/생성/수정…)src/stores/permission-store.ts

예를 들어 권한 store 는 이렇게 생겼습니다. 모양만 보세요 — 위 카운터와 똑같은 구조예요(상태 + action).

export const usePermissionStore = create((set, get) => ({
  permissions: {},          // 상태: 메뉴코드 -> 권한
  isLoaded: false,

  // 비동기 action: 서버에서 권한을 받아 set 으로 저장
  loadPermissions: async () => {
    if (get().isLoaded) return            // get() 으로 현재 상태 확인
    const res = await api.get("/api/menus/accessible")
    set({ permissions: res.permissionMap, isLoaded: true })
  },

  // 읽기 헬퍼: get() 으로 꺼내 가공
  canRead: (menuCode) => get().permissions[menuCode]?.canRead ?? false,
}))

// 컴포넌트 밖에서도 꺼낼 수 있음
usePermissionStore.getState().loadPermissions()
Store.getState()

컴포넌트 바깥(라우터 가드, 다른 store 안 등)에서 상태를 읽거나 action 을 부를 때 씁니다. 위 코드처럼 usePermissionStore.getState().loadPermissions() 식으로요.

4.2 React Query — 서버 데이터 전담

Zustand?내 앱 안에서 만든 상태를 다룬다면, React Query?서버에서 받아오는 데이터를 다룹니다. 서버 데이터는 까다로워요 — 로딩 중 표시, 에러 처리, 한 번 받은 건 캐시? 해서 재사용, 오래되면 다시 받기… 이 귀찮은 일을 전부 자동으로 해 줍니다.

React Query 는 도서관. useQuery = 책 빌리기(읽기), useMutation = 책 기증·수정·폐기(변경), useQueryClient = 사서에게 "그 책 정보 바뀌었으니 다시 갖다 줘"라고 말하기.

useQuery — 서버에서 읽어오기

쿼리?queryKey(캐시 이름표)와 queryFn(실제 비동기? 호출 함수) 두 가지만 주면 됩니다. 그러면 data / isLoading / isError 를 자동으로 채워 줘요.

import { useQuery } from "@tanstack/react-query"

function EquipmentList({ filter }) {
  const { data, isLoading, isError } = useQuery({
    // queryKey: 이 데이터의 "이름표". filter 가 바뀌면 키도 바뀜
    queryKey: ["equipment", filter],
    // queryFn: 실제 서버 호출 (Promise 를 돌려줌)
    queryFn: () => EquipmentEndpoint.getList(filter),
  })

  if (isLoading) return <p>불러오는 중…</p>   // 로딩 자동 관리
  if (isError)   return <p>에러가 났어요</p>    // 에러 자동 관리

  return <ul>{data.map((e) => <li key={e.id}>{e.name}</li>)}</ul>
}
queryKey = useEffect 의존성 배열과 같은 멘탈모델

2장에서 "의존성 배열의 값이 바뀌면 useEffect 가 다시 실행된다"고 했죠. queryKey 도 똑같아요: queryKey 안의 값이 바뀌면 React Query 가 알아서 다시 요청(refetch) 합니다. 그래서 filterqueryKey 에 넣어 두면, 검색 조건이 바뀔 때마다 자동으로 새 목록을 받아 와요. 직접 useEffect + fetch 를 짜지 않아도 됩니다.

실제로 어디에 쓰나 (useQuery)
  • 목록 조회 — 사용자 목록, 장비 목록, 재고 현황처럼 표에 뿌리는 데이터 가져오기
  • 상세 조회 — 한 건 펼쳐 보기. 보통 queryKey: ["user", "detail", id] 식으로 id 를 키에 넣어요
  • 드롭다운 채울 데이터 — 공통코드(상태/구분/단위 등) 목록을 받아 셀렉트박스 옵션으로
  • 대시보드 통계 — 건수·합계 같은 집계 값 표시

→ 캐시·무효화 흐름을 단계별로 보기: 개념 시각화 6번 (useQuery 생명주기)

useMutation — 서버 데이터 바꾸기

읽기가 useQuery 라면, 생성·수정·삭제 같은 변경?useMutation 입니다. mutationFn 으로 요청을 보내고, mutate(데이터) 로 실행해요. 변경이 성공하면(onSuccess) 보통 방금 바꾼 데이터와 관련된 목록을 다시 받아 와야 화면이 최신이 됩니다 — 그게 다음 단계입니다.

import { useMutation, useQueryClient } from "@tanstack/react-query"

function AddEquipmentButton() {
  const queryClient = useQueryClient() // "사서"

  const createMutation = useMutation({
    mutationFn: (data) => EquipmentEndpoint.create(data), // 서버에 POST
    onSuccess: () => {
      // 성공! 이제 목록 캐시를 "오래됨"으로 표시해 다시 받게 함
      queryClient.invalidateQueries({ queryKey: ["equipment"] })
    },
  })

  return (
    <button
      onClick={() => createMutation.mutate({ name: "새 장비" })}
      disabled={createMutation.isPending} // 요청 중엔 비활성화
    >
      {createMutation.isPending ? "저장 중…" : "추가"}
    </button>
  )
}
실제로 어디에 쓰나 (useMutation)
  • 저장(생성·수정) — 등록 폼 제출, 상세 화면에서 수정 후 저장
  • 삭제 — 선택한 행 지우기 (확인 후 mutate(id))
  • 상태 변경 — "승인/반려", "사용/미사용" 같은 한 건 토글
  • 일괄 처리 — 체크한 여러 건을 한 번에 수정·삭제

invalidateQueries — 바꿨으니 다시 받아라

데이터를 바꾼 뒤 화면의 옛 목록을 최신으로 만들려면 관련 캐시를 무효화? 합니다. 무효화하면 React Query 가 "이 데이터 오래됐네" 하고 자동으로 다시 요청해요.

무효화 매칭 규칙 — 가장 많이 헷갈리는 부분

queryKey 매칭은 배열 요소 단위의 "접두사(prefix)" 비교예요. 글자 단위 비교가 아닙니다.

무효화 호출["equipment", 1]["equipment-list"]
invalidateQueries({ queryKey: ["equipment"] }) ✅ 무효화됨
(첫 요소가 같음)
❌ 안 됨
("equipment-list" 는 다른 문자열 요소)

왜? 배열의 첫 요소를 "equipment"요소 하나 통째로 비교합니다. ["equipment", 1] 은 첫 요소가 "equipment" 라 걸리지만, ["equipment-list"] 의 첫 요소는 "equipment-list" 라는 다른 문자열이라 안 걸려요. "앞 글자가 같으니 되겠지"는 틀린 생각입니다.

그래서 queryKey 설계가 중요합니다. 관련된 데이터는 같은 첫 요소로 묶으세요. 예를 들어 목록은 ["equipment", "list", filter], 단건은 ["equipment", "detail", id] 로 두면 ["equipment"] 한 방에 둘 다 무효화됩니다. 반대로 ["equipment-list"]["equipment-detail"] 처럼 첫 요소를 다른 문자열로 쪼개면 한 번에 못 잡아요.

실제로 어디에 쓰나 (invalidateQueries)
  • 저장 성공 후 목록 갱신 — 등록/수정이 끝나면 onSuccess 에서 목록 키를 무효화해 새 데이터를 자동으로 다시 받기
  • 삭제 성공 후 목록 갱신 — 지운 행이 표에서 바로 사라지도록
  • 관련 화면 동시 갱신 — 상세에서 수정하면 목록 키까지 함께 무효화해 둘 다 최신으로
useQuery 가 주는 것의미
data받아온 데이터
isLoading최초 로딩 중인가
isError / error에러 났는가 / 에러 객체
refetch()수동으로 다시 받기
useMutation 이 주는 것의미
mutate(데이터)변경 실행 (보내고 잊기)
mutateAsync(데이터)변경 실행 (await 가능)
isPending요청 중인가 (버튼 비활성화용)
onSuccess / onError성공·실패 시 콜백

4.3 Zustand vs React Query — 언제 무엇을?

헷갈리면 딱 한 가지만 물어보세요: "이 데이터는 서버에 있는가?" 서버에서 받아오는 거면 React Query, 내 앱이 들고 있는 거면 Zustand 입니다.

ZustandReact Query
다루는 것클라이언트 상태 (내 앱이 만든 값)서버 데이터 (API 응답)
예시로그인 정보, 권한, UI 토글, 다이얼로그 열림장비 목록, 사용자 상세, 통계
핵심 APIcreate, set, get, 셀렉터useQuery, useMutation, invalidateQueries
"오래됨" 개념없음 (내가 바꿀 때만 변함)있음 (캐시·무효화·refetch)
흔한 실수

서버 목록을 받아 useState 나 Zustand 에 복사해 두지 마세요. 그 순간 "서버는 바뀌었는데 내 복사본은 옛날 값"인 문제가 생깁니다. 서버 데이터는 React Query 가 유일한 출처가 되게 두고, 무효화로 최신화하세요.

한눈에 정리

도구한 줄 요약대표 코드
create (Zustand)여러 컴포넌트가 공유하는 storecreate((set) => ({ ... }))
셀렉터store 에서 필요한 조각만 구독useStore((s) => s.count)
useQuery서버 읽기 + 로딩/에러/캐시 자동useQuery({ queryKey, queryFn })
useMutation서버 변경(생성/수정/삭제)mutation.mutate(data)
invalidateQueries캐시 무효화 → 자동 재조회invalidateQueries({ queryKey: ["equipment"] })

다음 장에서는 화면을 이동시키는 라우팅(TanStack Router) 과 입력을 다루는 폼(react-hook-form) 을 배웁니다.