상태(State)란 무엇인가
상태란 UI를 결정하는 데이터다. 버튼 클릭 여부, 장바구니 목록, 로그인 여부 등 시간에 따라 변하고 그 변화가 화면에 반영되는 모든 데이터가 상태에 해당한다.
상태는 크게 두 가지로 나뉜다.
- 지역 상태(Local State) : 특정 컴포넌트 안에서만 사용되는 상태다. useState로 관리하며, 모달 열림/닫힘이나 입력 폼 값처럼 해당 컴포넌트에서만 의미 있는 데이터가 여기에 해당한다.
- 전역 상태(Global State) : 여러 컴포넌트에서 공유해야 하는 상태다. 로그인한 유저 정보, 장바구니 데이터처럼 컴포넌트 트리의 여러 곳에서 동시에 접근하고 변경해야 할 때 필요하다.
왜 전역 상태 관리가 필요한가
React는 기본적으로 부모에서 자식으로 데이터를 props를 통해 전달한다. 컴포넌트 depth가 얕을 때는 문제없지만, 트리가 깊어질수록 Props Drilling 문제가 발생한다.
Props Drilling이란 중간 컴포넌트는 해당 데이터가 필요하지 않음에도 불구하고 하위 컴포넌트에 전달하기 위해 props를 계속 받아서 넘겨줘야 하는 상황이다.
App (cartItems 보유)
└── Layout (cartItems 전달만 함)
└── Header (cartItems 전달만 함)
└── CartIcon (cartItems 실제 사용)
이 구조에서 Layout과 Header는 cartItems가 필요 없지만 전달을 위해 props를 받아야 한다. 컴포넌트 간 결합도가 높아지고, 중간 컴포넌트를 수정할 때마다 props 전달 코드도 함께 수정해야 해서 유지보수가 어려워진다.
전역 상태 관리는 이 문제를 해결한다. 상태를 컴포넌트 트리 바깥에 두고, 필요한 컴포넌트가 직접 꺼내 쓰는 방식이다.
상태의 종류
상태 관리 도구를 선택하기 전에 관리하려는 상태가 어떤 종류인지 먼저 구분하는 것이 중요하다. 상태의 종류에 따라 적합한 도구가 달라지기 때문이다.
- 클라이언트 상태 : 서버와 무관하게 브라우저 안에서만 존재하는 상태다. 장바구니, 모달 열림 여부, 다크모드 설정 등이 여기에 해당한다. Zustand, Jotai 같은 라이브러리가 적합하다.
- 서버 상태 : 서버에서 가져온 데이터로, 캐싱과 동기화가 핵심 과제다. API 응답 데이터가 대표적이며 TanStack Query, SWR이 이 영역을 전담한다.
- URL 상태 : 주소창의 쿼리 파라미터나 경로에 담긴 상태다. 검색 필터, 현재 페이지 번호 등이 여기에 해당하며 React Router로 관리한다.
- 폼 상태 : 입력값과 유효성 검사 상태로, React Hook Form 같은 전용 라이브러리가 일반적이다.
많은 경우 전역 상태 관리 라이브러리에 서버 상태까지 넣으려다 복잡해진다. 클라이언트 상태와 서버 상태를 분리하는 것만으로도 전역 상태가 훨씬 단순해진다.
주요 패턴
Flux 패턴
Flux는 Facebook이 React와 함께 고안한 데이터 흐름 설계 방식이다. 핵심은 단방향 데이터 흐름이다.
Action → Dispatcher → Store → View
상태를 변경하고 싶으면 반드시 Action을 통해야 한다. View가 직접 상태를 건드리는 것을 허용하지 않는다. 예를 들어 장바구니에 상품을 추가하고 싶다면 "ADD_ITEM"이라는 Action을 발행하고, Store가 그 Action을 받아 상태를 업데이트한 뒤, View가 변경된 상태를 반영하는 순서로 흐른다.
이 구조의 장점은 예측 가능성이다. 상태가 어디서 왜 바뀌었는지 Action 로그만 봐도 파악할 수 있어서 디버깅이 쉽다. 단점은 간단한 상태 변경에도 Action 정의, Reducer 작성 등 준비 코드가 많다는 것이다. Redux가 이 패턴의 대표 구현체고, Zustand는 같은 철학을 훨씬 단순하게 구현한 라이브러리다.
Atomic 패턴
Atomic 패턴은 상태를 하나의 큰 덩어리로 관리하는 대신, atom이라는 최소 단위로 쪼개서 관리하는 방식이다.
Flux 패턴에서는 Store 하나에 여러 상태가 모여 있어서 Store의 일부가 바뀌면 해당 Store를 구독하는 컴포넌트가 모두 리렌더링 될 수 있다. Atomic 패턴은 이 문제를 해결한다. 각 컴포넌트가 필요한 atom만 골라서 구독하기 때문에, 그 atom이 변경될 때만 해당 컴포넌트가 리렌더링 된다.
장점은 리렌더링 최적화가 자연스럽게 이뤄진다는 것이다. 상태를 잘게 쪼갤수록 불필요한 리렌더링이 줄어든다. 단점은 atom이 너무 많아지면 어떤 atom이 어디서 쓰이는지 파악하기 어려워질 수 있다는 것이다. Jotai와 Recoil이 이 패턴을 따른다.
Zustand
Zustand는 독일어로 "상태"를 의미한다. 2019년 Pmndrs 팀이 만들었으며, Redux의 복잡한 보일러플레이트 없이 전역 상태를 간단하게 관리하기 위해 설계됐다. 현재 React 생태계에서 가장 빠르게 성장하고 있는 상태 관리 라이브러리 중 하나다.
create 함수로 store를 정의하고, store 안에 상태와 상태를 변경하는 함수를 함께 선언한다. 컴포넌트에서는 커스텀 훅처럼 꺼내 쓰며 Provider로 감쌀 필요가 없다.
// store/useCartStore.js
import { create } from 'zustand'
const useCartStore = create((set) => ({
cartItems: [],
addItem: (item) => set((state) => {
const exists = state.cartItems.find(i => i.id === item.id)
if (exists) {
return {
cartItems: state.cartItems.map(i =>
i.id === item.id ? { ...i, qty: i.qty + 1 } : i
)
}
}
return { cartItems: [...state.cartItems, { ...item, qty: 1 }] }
}),
removeItem: (id) => set((state) => ({
cartItems: state.cartItems.filter(i => i.id !== id)
})),
}))
컴포넌트에서는 필요한 상태나 함수만 selector로 꺼내 쓴다.
function CartIcon() {
// cartItems만 구독 → 다른 상태 변경 시 리렌더링 안 됨
const cartItems = useCartStore((state) => state.cartItems)
return <span>장바구니 ({cartItems.length})</span>
}
function ProductCard({ product }) {
// addItem 함수만 꺼내옴
const addItem = useCartStore((state) => state.addItem)
return <button onClick={() => addItem(product)}>담기</button>
}
별도의 Provider 없이 바로 사용할 수 있어 설정이 간단하고, 보일러플레이트가 거의 없다. selector를 통해 필요한 상태만 구독할 수 있어 불필요한 리렌더링을 줄일 수 있고 Redux DevTools와도 연동이 가능하다. 다만 자유도가 높은 만큼 팀 내 컨벤션을 별도로 정하지 않으면 store 구조가 사람마다 제각각이 될 수 있다.
Jotai
Jotai는 일본어로 "상태(状態)"를 의미한다. Zustand와 같은 Pmndrs 팀이 만들었으며, Recoil에서 영감을 받아 더 단순하게 설계한 Atomic 패턴 라이브러리다. Recoil이 사실상 관리 중단 상태가 되면서 그 후계자로 주목받고 있다.
상태를 atom 단위로 쪼개서 정의하고, 각 컴포넌트는 필요한 atom만 구독한다. useState와 사용법이 거의 동일해서 러닝커브가 낮다.
// store/cartAtoms.js
import { atom } from 'jotai'
// 기본 atom — 장바구니 목록
export const cartAtom = atom([])
// derived atom — cartAtom에서 파생된 총 금액
export const totalPriceAtom = atom(
(get) => get(cartAtom).reduce((sum, item) => sum + item.price * item.qty, 0)
)
function CartIcon() {
const [cartItems] = useAtom(cartAtom)
return <span>장바구니 ({cartItems.length})</span>
}
function TotalPrice() {
// 읽기만 할 때는 useAtomValue
const total = useAtomValue(totalPriceAtom)
return <span>총 합계: ₩{total.toLocaleString()}</span>
}
useState와 사용법이 비슷해서 React에 익숙한 개발자라면 바로 적응할 수 있다. atom 단위 구독으로 리렌더링 최적화가 자연스럽게 이뤄지고, derived atom으로 계산된 값을 깔끔하게 관리할 수 있다. 다만 atom이 많아지면 관리가 복잡해질 수 있고, Zustand에 비해 국내 레퍼런스가 적다.
선택 기준
| Zustand | Jotai | |
| 패턴 | Flux | Atomic |
| 상태 구조 | 하나의 store | 잘게 쪼갠 atom |
| 사용법 | 커스텀 훅 | useState와 유사 |
| 리렌더링 최적화 | selector로 직접 설정 | atom 단위로 자동 |
| 적합한 상황 | 중대규모, 명확한 store 구조 | 리렌더링 최적화가 중요할 때 |
어떤 라이브러리가 절대적으로 좋은 것은 없다. 중요한 것은 도구 자체보다 어떤 상태를, 어디서, 어떻게 관리할지를 먼저 설계하는 것이다.
'💻 STUDY > Frontend' 카테고리의 다른 글
| Next.js App Router의 렌더링 방식 이해하기 (0) | 2026.06.11 |
|---|---|
| Next.js는 React의 어떤 문제를 해결할까? (0) | 2026.06.08 |
| Storybook이란? — props가 많아질수록 개발이 느려진다고 느꼈다면 (0) | 2026.06.01 |
| React는 왜 Suspense를 만들었을까? (1) | 2026.05.21 |
| useState의 snapshot과 batching (0) | 2026.05.17 |