React

Zustand 사용 기준: React 상태 관리가 복잡해질 때 선택하기

2026.05.03·수정 2026.05.11·약 14분

이 글에서 정리하는 내용

Zustand를 사용하는 이유를 React 상태 관리 흐름 안에서 정리합니다. 단순히 “Redux보다 쉽다”는 장점 나열이 아니라, 상태가 여러 컴포넌트로 퍼질 때 props, Context, TanStack Query와 어떤 기준으로 역할을 나눠야 하는지를 중심으로 다룹니다.

React 상태는 처음에는 작게 시작합니다

useState로 시작한 React 상태가 여러 컴포넌트로 퍼지는 흐름을 설명하는 다이어그램

React에서 상태를 처음 다룰 때는 대부분 useState로 시작합니다. 버튼을 눌렀는지, input에 어떤 값이 들어왔는지, 모달이 열렸는지처럼 한 컴포넌트 안에서 끝나는 값은 굳이 전역 상태로 빼지 않아도 됩니다.

초반에는 이 구조가 가장 읽기 쉽습니다. 상태가 컴포넌트 안에 있으니 값을 바꾸는 코드도 가까이 있고, 화면이 왜 바뀌는지도 바로 따라갈 수 있습니다. Zustand를 배울 때 먼저 잡아야 하는 기준도 여기서 출발합니다. 모든 상태를 store로 옮기는 것이 목적이 아니라, 컴포넌트 안에 두기 어려워진 상태만 밖으로 빼는 것입니다.

상태 관리가 복잡해지는 순간은 상태 문법 자체가 어려워질 때가 아닙니다. 같은 값을 여러 컴포넌트가 동시에 필요로 할 때 구조 문제가 드러납니다. 예를 들어 상품 카드에서 장바구니 버튼을 누르면 헤더의 장바구니 수량도 바뀌어야 하고, 오른쪽 장바구니 패널도 같은 값을 기준으로 표시될 수 있습니다. 이때부터 상태는 단순한 값이 아니라 화면 구조를 연결하는 기준이 됩니다.

컴포넌트 안에서 끝나는 상태는 그대로 둬도 됩니다

import { useState } from "react"; function FilterButton() { const [isOpen, setIsOpen] = useState(false); return ( <button type="button" onClick={() => setIsOpen((prev) => !prev)}> {isOpen ? "필터 닫기" : "필터 열기"} </button> );
}

이 정도 상태는 컴포넌트 바깥으로 빼지 않는 편이 더 읽기 쉽습니다. 버튼 하나의 열림 여부를 별도 store로 옮기면 상태 위치가 멀어지고, 코드를 읽는 사람은 버튼 하나를 이해하기 위해 store 파일까지 따라가야 합니다. 전역 상태 도구를 쓰는 기준은 “공유가 필요한가”이지, “상태가 존재하는가”가 아닙니다.

props 전달이 상태 관리가 되는 순간

상태를 부모로 올리는 방식은 React에서 자연스러운 해결책입니다. 부모가 상태를 들고 있고, 자식은 props로 값을 받습니다. 가까운 부모와 자식 사이에서는 데이터 흐름이 명확하게 보이므로 오히려 이 방식이 더 낫습니다.

문제는 컴포넌트가 깊어질 때 생깁니다. 어떤 컴포넌트는 값을 직접 쓰지 않는데도 자식에게 전달하기 위해 props를 받아야 합니다. 이 상태가 몇 단계만 이어져도 중간 컴포넌트는 UI 역할보다 데이터 운반 역할을 더 많이 하게 됩니다.

예를 들어 장바구니 수량을 헤더, 상품 카드, 장바구니 패널에서 같이 써야 한다고 가정해보겠습니다. 처음에는 ProductPage에서 상태를 만들고 필요한 곳에 내려보내면 됩니다. 하지만 중간에 레이아웃 컴포넌트, 섹션 컴포넌트, 리스트 컴포넌트가 추가되면 상태를 직접 쓰지 않는 컴포넌트까지 props를 계속 받아야 합니다.

function ProductPage() { const [cartCount, setCartCount] = useState(0); return ( <PageLayout cartCount={cartCount}> <ProductSection onAddCart={() => setCartCount((count) => count + 1)} /> <CartPanel cartCount={cartCount} /> </PageLayout> );
}

이 코드만 보면 큰 문제는 없어 보입니다. 그런데 PageLayout이 실제로는 cartCount를 사용하지 않고 내부의 Header에 넘기기만 한다면 역할이 섞입니다. 레이아웃을 담당하는 컴포넌트가 장바구니 상태 전달까지 함께 떠안게 됩니다.

이런 상황이 반복되면 컴포넌트를 나눴는데도 수정 범위는 넓어집니다. 상태 이름을 바꾸거나 장바구니 로직을 확장할 때 실제로 값을 쓰지 않는 중간 컴포넌트까지 같이 수정해야 합니다. Zustand를 검토할 만한 첫 번째 지점은 바로 이때입니다. props가 데이터 전달이 아니라 통로 유지 작업처럼 느껴지기 시작하면 상태 위치를 다시 봐야 합니다.

Context API만으로 애매해지는 경우

props 전달이 불편해지면 Context API를 떠올리게 됩니다. Context는 React에서 값을 여러 컴포넌트에 공유할 수 있는 기본 도구입니다. 테마, 언어 설정, 인증 정보처럼 앱의 넓은 영역에서 참조해야 하는 값에는 잘 맞습니다.

다만 Context를 상태 관리 도구처럼 계속 확장하면 구조가 금방 무거워질 수 있습니다. Context 파일을 만들고, Provider를 감싸고, custom hook을 만들고, 상태 종류가 늘어날수록 Provider도 늘어납니다. 작은 프로젝트에서는 버틸 수 있지만, 상태가 여러 도메인으로 나뉘면 어디까지 Context로 둘지 판단해야 합니다.

Context 자체가 문제라는 뜻은 아닙니다. 문제는 모든 공유 상태를 Context 하나로 밀어 넣거나, 반대로 상태마다 Provider를 계속 만드는 방식입니다. 특히 자주 바뀌는 UI 상태와 거의 바뀌지 않는 설정값을 같은 기준으로 다루면 렌더링 범위와 파일 구조를 같이 신경 써야 합니다.

상황먼저 검토할 방식이유
컴포넌트 내부에서만 쓰는 값상태 위치가 가장 가깝습니다.
부모와 가까운 자식이 함께 쓰는 값props데이터 흐름을 눈으로 따라가기 쉽습니다.
테마, 언어, 인증 정보처럼 넓게 공유되는 값Context APIReact 기본 구조 안에서 전역 공유가 가능합니다.
여러 화면에서 바뀌는 클라이언트 상태Zustandstore를 통해 필요한 컴포넌트가 직접 상태를 선택할 수 있습니다.

이 표는 Zustand를 쓰기 전의 거름망에 가깝습니다. 처음부터 “전역 상태니까 Zustand”로 가지 않고, 상태의 위치와 변경 범위를 먼저 보면 불필요한 store를 줄일 수 있습니다.

Zustand가 끼어드는 지점

Zustand는 store를 만들고, 컴포넌트가 그 store에서 필요한 값만 가져다 쓰는 방식으로 동작합니다. 기본적인 사용 흐름에서는 별도의 Provider로 앱 전체를 감싸지 않아도 됩니다. 이 차이 때문에 작은 전역 상태를 분리할 때 코드가 덜 늘어납니다.

Zustand store에는 상태값뿐 아니라 상태를 바꾸는 함수도 함께 둘 수 있습니다. 장바구니 수량, 사이드바 열림 여부, 선택된 필터처럼 서로 연결된 값과 변경 로직을 한 파일에서 관리할 수 있습니다. Redux Toolkit처럼 slice, reducer, dispatch 구조를 명확히 나누는 방식보다 시작 장벽이 낮게 느껴지는 이유도 여기에 있습니다.

다만 이 장점은 기준 없이 쓰면 단점이 되기도 합니다. store를 만들기 쉬우니 모든 값을 store에 넣게 되고, 시간이 지나면 하나의 store가 여러 화면의 상태를 모두 떠안는 구조가 될 수 있습니다. Zustand를 쓸 때도 “어떤 화면의 어떤 상태를 공유하려는가”를 먼저 정해야 합니다.

import { create } from "zustand"; type CartStore = { count: number; increase: () => void; reset: () => void;
}; export const useCartStore = create<CartStore>()((set) => ({ count: 0, increase: () => set((state) => ({ count: state.count + 1 })), reset: () => set({ count: 0 })}));

이 store는 장바구니 수량과 그 값을 바꾸는 함수를 같이 가지고 있습니다. 컴포넌트는 useCartStore를 불러와 필요한 값만 선택하면 됩니다. 여기서 중요한 부분은 “전역 변수처럼 아무 데서나 막 쓰기”가 아니라, 여러 컴포넌트가 공유해야 하는 클라이언트 상태의 접근 지점을 하나로 정리한다는 점입니다.

function HeaderCartCount() { const count = useCartStore((state) => state.count); return <span>장바구니 {count}개</span>;
} function AddCartButton() { const increase = useCartStore((state) => state.increase); return ( <button type="button" onClick={increase}> 장바구니 담기 </button> );
}

헤더는 수량만 구독하고, 버튼은 수량을 직접 읽지 않고 증가 함수만 가져옵니다. 이렇게 나누면 중간 컴포넌트가 cartCount, setCartCount를 계속 전달하지 않아도 됩니다. 상태를 쓰는 컴포넌트가 store에서 직접 필요한 조각을 선택하는 구조가 됩니다.

이 차이는 컴포넌트가 많아질수록 더 잘 보입니다. 상품 카드, 헤더, 모바일 하단 버튼, 장바구니 패널이 각각 장바구니 상태와 연결되어도, 중간 레이아웃 컴포넌트는 그 상태를 몰라도 됩니다. UI 구조와 상태 전달 경로가 덜 얽히는 것입니다.

서버 상태와 클라이언트 상태는 나눠서 봐야 합니다

Zustand를 배우기 시작하면 모든 상태를 store에 넣고 싶어질 수 있습니다. 하지만 서버에서 받아온 데이터까지 Zustand에 직접 넣기 시작하면 기준이 금방 흐려집니다. 상품 목록, 게시글 목록, 사용자 목록처럼 API로 받아오는 데이터는 단순히 저장만 하면 끝나는 값이 아닙니다.

서버 상태에는 로딩에러, 캐싱, 재요청, 무효화 같은 문제가 같이 따라옵니다. 이 영역은 TanStack Query가 맡는 쪽이 자연스럽습니다. 반대로 Zustand는 현재 열린 모달, 선택한 탭, 임시 필터, 장바구니 UI 상태처럼 브라우저 안에서 사용자가 조작하는 클라이언트 상태에 더 잘 맞습니다.

const productsQuery = useQuery({ queryKey: ["products", categoryId], queryFn: () => getProducts(categoryId)}); const selectedSort = useProductFilterStore((state) => state.selectedSort);
const setSelectedSort = useProductFilterStore((state) => state.setSelectedSort);

위 코드처럼 상품 목록은 TanStack Query가 가져오고, 사용자가 고른 정렬값은 Zustand가 들고 있을 수 있습니다. 서버에서 온 데이터와 사용자가 화면에서 조작하는 상태를 같은 store에 섞지 않으면, 각각의 책임이 더 선명해집니다.

이 기준을 잡아두면 Zustand를 과하게 쓰는 일을 줄일 수 있습니다. API 응답 데이터를 Zustand에 복사해두기 시작하면 캐시 만료, 재요청, 동기화 기준을 직접 챙겨야 합니다. 반대로 정렬 옵션, 필터 패널 열림 여부, 현재 선택된 탭처럼 클라이언트에서만 의미가 있는 값은 Zustand store에 두기 좋습니다.

Zustand를 선택할 때의 기준

useState, props, Context API, TanStack Query, Zustand의 선택 기준을 비교한 상태 관리 표

Zustand를 선택할지 판단할 때는 “전역 상태가 필요하다”보다 조금 더 좁게 보는 것이 좋습니다. 여러 컴포넌트가 같은 값을 읽고, 여러 위치에서 그 값을 바꾸며, props로 넘기기에는 경로가 길어진다면 Zustand를 검토할 만합니다.

반대로 값이 한 컴포넌트 안에서 끝난다면 useState가 낫습니다. 부모와 자식 한두 단계 사이에서만 필요한 값이라면 props가 더 명확합니다. 앱 전체 설정처럼 거의 변하지 않는 값은 Context API도 충분합니다. 서버에서 받아오는 데이터라면 TanStack Query를 먼저 놓고 봐야 합니다.

Zustand를 쓰기 좋은 상태는 보통 “사용자가 화면에서 조작하고, 여러 컴포넌트가 함께 반응해야 하는 값”입니다. 예를 들면 관리자 화면의 필터 조건, 사이드바 열림 여부, 선택된 탭, 다단계 폼의 임시 입력값, 장바구니 수량 같은 값입니다.

선택 기준을 코드 밖에서 먼저 정해두기

컴포넌트 내부에서만 끝나는가? → useState
가까운 부모와 자식만 공유하는가? → props
앱 전체 설정값인가? → Context API
API 응답 데이터인가? → TanStack Query
여러 컴포넌트가 바꾸는 클라이언트 상태인가? → Zustand

이 기준이 있으면 store가 불필요하게 커지는 것을 막을 수 있습니다. Zustand는 간단하게 시작할 수 있는 만큼, 기준 없이 쓰면 오히려 모든 상태가 한 store에 모일 수 있습니다. 처음에는 작은 store로 시작하고, 상태의 성격이 달라지면 store를 나누는 식으로 접근하는 쪽이 관리하기 쉽습니다.

다음 글에서 기본 store를 만들 때도 이 순서를 유지하면 됩니다. store 파일을 먼저 만들기보다, 어떤 상태가 여러 컴포넌트에 흩어져 있는지 먼저 찾습니다. 그다음 같은 도메인의 상태와 액션만 묶어야 나중에 store가 비대해지는 문제를 줄일 수 있습니다.

정리

Zustand를 사용하는 이유는 단순히 문법이 짧아서가 아닙니다. React에서 상태가 여러 컴포넌트로 퍼지고, props 전달이 길어지고, Context만으로는 구조가 애매해지는 순간이 생기기 때문입니다.

작은 상태는 useState에 두고가까운 컴포넌트 사이는 props로 처리하고, 전역 설정은 Context API로 관리할 수 있습니다. Zustand는 그보다 한 단계 더 나아가 여러 컴포넌트가 함께 읽고 바꾸는 클라이언트 상태를 store로 분리할 때 선택할 수 있습니다.

다음 단계에서 Zustand를 설치하고 기본 store를 만들 때도 같은 기준을 유지해야 합니다. store를 먼저 만들고 상태를 끼워 넣기보다, 어떤 상태가 컴포넌트 구조를 복잡하게 만드는지 먼저 찾는 것이 출발점입니다.

이 글이 마음에 드세요?

RSS 피드를 구독하세요!

“Zustand 사용 기준: React 상태 관리가 복잡해질 때 선택하기”에 대한 1개의 생각

댓글 남기기