4장 · 상태관리 라이브러리
이 프로젝트의 두 기둥 — Zustand?(로그인·권한 같은 내 앱의 상태)와 React Query?(서버에서 받아오는 데이터)를 나눠서 익힙니다. 둘은 경쟁 관계가 아니라 역할이 다른 도구예요.
Zustand·React Query 라이브러리는 오른쪽 연습장(iframe? 미설치)에서 실행할 수 없어요. 그래서 라이브러리 코드는 전부 정적 코드블록으로 보여 줍니다. 직접 실행해 보는 플레이그라운드는 "왜 이런 도구가 필요한가"를 순수 React로 시연할 때만 나옵니다.
4.0 왜 "전역 상태"가 필요한가
useState? 로 만든 상태? 는 그 컴포넌트 안에서만 삽니다. 그런데 로그인한 사용자 이름은 헤더에도, 사이드바에도, 본문에도 필요해요. 위쪽 컴포넌트에 두고 아래로 계속 props? 로 넘기면 중간 컴포넌트들은 자기는 안 쓰면서 그냥 배달만 해야 합니다. 이걸 props 내려꽂기(prop drilling) 라고 불러요.
2장에서 본 useContext? 가 그 "통로"의 React 기본 해법입니다. 아래에서 한 번 더 시연해 볼게요. Toolbar 가 props 없이도 위에서 정한 사용자 이름을 바로 꺼내 씁니다.
Context 는 "통로"만 줍니다. 값을 어떻게 저장하고 바꿀지는 직접 짜야 하고, 값이 조금만 바뀌어도 그 통로를 쓰는 컴포넌트가 전부 리렌더? 되기 쉬워요. Zustand? 는 이 "저장 + 바꾸기 + 필요한 부분만 리렌더"를 깔끔하게 해 줍니다.
4.1 Zustand — 가장 단순한 전역 store
store? 는 여러 컴포넌트가 함께 보는 공용 보관소예요.
create 에 함수를 하나 넘기면 끝납니다. 그 함수는 상태와 상태를 바꾸는 함수를 한 객체로 돌려줘요.
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 를 꺼낸 컴포넌트만 리렌더되고, 다른 값만 쓰는 컴포넌트는 가만히 있습니다.
- 로그인 사용자·권한 — 헤더·사이드바·본문 어디서든 같은 로그인 정보를 봐야 할 때. 앱 전체가 공유하는 값이라 store 에 딱 맞아요
- 페이지별 화면 상태 — 표에서 선택한 행, 검색 필터 값, 펼친 탭처럼 한 페이지 안 여러 컴포넌트가 같이 보는 상태 (
use…PageStore) - 레이아웃 토글 — 사이드바 열림/접힘, 다크모드 같은 전역 UI 스위치
- 다이얼로그 제어 — 어느 컴포넌트에서든 열고 닫는 공용 모달/알림
set 과 get — 바꾸기와 읽기
| 도구 | 하는 일 | 예시 |
|---|---|---|
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? 는 서버에서 받아오는 데이터를 다룹니다. 서버 데이터는 까다로워요 — 로딩 중 표시, 에러 처리, 한 번 받은 건 캐시? 해서 재사용, 오래되면 다시 받기… 이 귀찮은 일을 전부 자동으로 해 줍니다.
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>
}2장에서 "의존성 배열의 값이 바뀌면 useEffect 가 다시 실행된다"고 했죠.
queryKey 도 똑같아요: queryKey 안의 값이 바뀌면 React Query 가 알아서 다시 요청(refetch) 합니다.
그래서 filter 를 queryKey 에 넣어 두면, 검색 조건이 바뀔 때마다 자동으로 새 목록을 받아 와요.
직접 useEffect + fetch 를 짜지 않아도 됩니다.
- 목록 조회 — 사용자 목록, 장비 목록, 재고 현황처럼 표에 뿌리는 데이터 가져오기
- 상세 조회 — 한 건 펼쳐 보기. 보통
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>
)
}- 저장(생성·수정) — 등록 폼 제출, 상세 화면에서 수정 후 저장
- 삭제 — 선택한 행 지우기 (확인 후
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"] 처럼 첫 요소를 다른 문자열로 쪼개면 한 번에 못 잡아요.
- 저장 성공 후 목록 갱신 — 등록/수정이 끝나면
onSuccess에서 목록 키를 무효화해 새 데이터를 자동으로 다시 받기 - 삭제 성공 후 목록 갱신 — 지운 행이 표에서 바로 사라지도록
- 관련 화면 동시 갱신 — 상세에서 수정하면 목록 키까지 함께 무효화해 둘 다 최신으로
| useQuery 가 주는 것 | 의미 |
|---|---|
data | 받아온 데이터 |
isLoading | 최초 로딩 중인가 |
isError / error | 에러 났는가 / 에러 객체 |
refetch() | 수동으로 다시 받기 |
| useMutation 이 주는 것 | 의미 |
|---|---|
mutate(데이터) | 변경 실행 (보내고 잊기) |
mutateAsync(데이터) | 변경 실행 (await 가능) |
isPending | 요청 중인가 (버튼 비활성화용) |
onSuccess / onError | 성공·실패 시 콜백 |
4.3 Zustand vs React Query — 언제 무엇을?
헷갈리면 딱 한 가지만 물어보세요: "이 데이터는 서버에 있는가?" 서버에서 받아오는 거면 React Query, 내 앱이 들고 있는 거면 Zustand 입니다.
| Zustand | React Query | |
|---|---|---|
| 다루는 것 | 클라이언트 상태 (내 앱이 만든 값) | 서버 데이터 (API 응답) |
| 예시 | 로그인 정보, 권한, UI 토글, 다이얼로그 열림 | 장비 목록, 사용자 상세, 통계 |
| 핵심 API | create, set, get, 셀렉터 | useQuery, useMutation, invalidateQueries |
| "오래됨" 개념 | 없음 (내가 바꿀 때만 변함) | 있음 (캐시·무효화·refetch) |
서버 목록을 받아 useState 나 Zustand 에 복사해 두지 마세요.
그 순간 "서버는 바뀌었는데 내 복사본은 옛날 값"인 문제가 생깁니다.
서버 데이터는 React Query 가 유일한 출처가 되게 두고, 무효화로 최신화하세요.
한눈에 정리
| 도구 | 한 줄 요약 | 대표 코드 |
|---|---|---|
create (Zustand) | 여러 컴포넌트가 공유하는 store | create((set) => ({ ... })) |
| 셀렉터 | store 에서 필요한 조각만 구독 | useStore((s) => s.count) |
useQuery | 서버 읽기 + 로딩/에러/캐시 자동 | useQuery({ queryKey, queryFn }) |
useMutation | 서버 변경(생성/수정/삭제) | mutation.mutate(data) |
invalidateQueries | 캐시 무효화 → 자동 재조회 | invalidateQueries({ queryKey: ["equipment"] }) |
다음 장에서는 화면을 이동시키는 라우팅(TanStack Router) 과 입력을 다루는 폼(react-hook-form) 을 배웁니다.