이 글에서 정리하는 내용
Zustand에서 selector를 사용하는 이유를 컴포넌트 구독 범위 관점으로 정리합니다. 전체 store를 가져오는 방식이 왜 편해 보이는지, 상태가 늘어났을 때 어떤 문제가 생기는지, 여러 값을 가져올 때 를 언제 검토해야 하는지까지 관리자 필터 UI 흐름으로 정리합니다.
- 전체 store를 가져오면 왜 편해 보이는가
- selector는 필요한 값만 구독하는 기준이다
- 여러 값을 가져올 때 생기는 참조 문제
- 컴포넌트별 selector 적용 기준
- 코드를 바꿀 때 확인할 부분
- 정리
전체 store를 가져오면 왜 편해 보이는가

Zustand를 처음 쓸 때는 store에서 필요한 값을 한 번에 꺼내는 방식이 가장 편해 보입니다. 상태와 action이 모두 한 객체 안에 있으니 구조 분해로 꺼내면 코드도 짧습니다. 작은 카운터 예제나 설정값 한두 개를 다루는 화면에서는 이 방식만으로도 크게 불편하지 않습니다.
문제는 store가 실제 화면의 여러 기능을 담기 시작할 때 드러납니다. 관리자 화면의 목록 필터를 예로 들면 검색어, 카테고리, 정렬 기준, 보기 방식, 초기화 action이 같은 store에 들어갈 수 있습니다. 검색창 컴포넌트는 검색어와 검색어 변경 함수만 필요하지만, 전체 store를 가져오면 컴포넌트가 자신과 관계없는 값까지 같이 들고 있는 모양이 됩니다. 화면이 바로 깨지는 오류가 아니라서 처음에는 티가 덜 나고, 필터 조건이 늘어난 뒤에야 수정하기 불편한 구조로 보이는 경우가 많습니다.
처음 작성하기 쉬운 형태
import { create } from "zustand"; type FilterState = { keyword: string; category: string; sort: "latest" | "popular"; viewMode: "grid" | "list"; setKeyword: (keyword: string) => void; setCategory: (category: string) => void; setSort: (sort: "latest" | "popular") => void; resetFilters: () => void;
}; export const useFilterStore = create<FilterState>((set) => ({ keyword: "", category: "all", sort: "latest", viewMode: "grid", setKeyword: (keyword) => set({ keyword }), setCategory: (category) => set({ category }), setSort: (sort) => set({ sort }), resetFilters: () => set({ keyword: "", category: "all", sort: "latest", viewMode: "grid"})})); 이 store 자체는 문제가 없습니다. 상태와 action이 한곳에 모여 있어 필터 화면을 만들기 시작하기에는 충분합니다. 다만 이 store를 컴포넌트에서 어떻게 꺼내 쓰는지가 다음 문제를 만듭니다.
function SearchInput() { const { keyword, category, sort, setKeyword } = useFilterStore(); return ( <input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="검색어를 입력하세요" /> );
} 이 코드에서 이 실제로 쓰는 값은 와 입니다. 그런데 코드만 보면도 이 컴포넌트의 관심사처럼 섞여 있습니다. 지금은 사용하지 않는 변수를 지우면 되지만, 전체 store를 계속 가져오는 습관이 남아 있으면 컴포넌트가 커질수록 어느 상태 변경이 어느 화면에 영향을 주는지 추적하기 어려워집니다.
Zustand는 hook을 통해 store를 사용할 수 있지만, hook을 호출하는 방식에 따라 컴포넌트가 구독하는 범위가 달라집니다. 처럼 selector 없이 호출하면 store 전체를 가져오는 모양이 됩니다. 이 상태에서는 나중에 나 가 바뀌었을 때 검색창이 왜 다시 렌더링되는지 확인해야 하는 상황이 생길 수 있습니다. selector는 이런 상황을 줄이기 위해 컴포넌트가 바라보는 store 조각을 코드에 직접 남기는 장치입니다.
selector는 필요한 값만 구독하는 기준이다
selector는 store 전체 중에서 컴포넌트가 사용할 부분만 고르는 함수입니다. 문법만 보면 처럼 단순하지만, 의미는 조금 더 분명합니다. 이 컴포넌트는 store 중에서 를 바라본다는 선언입니다.
검색창 컴포넌트는 검색어를 보여주고, 사용자가 입력한 값을 store에 반영하는 역할만 가집니다. 그렇다면 selector도 그 범위 안에서 끝나는 것이 자연스럽습니다.
필요한 상태와 action만 가져오기
function SearchInput() { const keyword = useFilterStore((state) => state.keyword); const setKeyword = useFilterStore((state) => state.setKeyword); return ( <input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="검색어를 입력하세요" /> );
} 이렇게 바꾸면 줄 수는 늘어납니다. 대신 컴포넌트가 무엇을 필요로 하는지 바로 보입니다. 은 나 를 모릅니다. 필터 화면이 커져도 검색창은 검색어 입력이라는 책임에 머뭅니다. 나중에 검색창만 별도 컴포넌트로 분리하거나 모바일 검색 UI로 재사용할 때도 불필요한 상태 의존성이 따라오지 않습니다.
action도 같은 기준으로 가져오면 됩니다. action은 상태값처럼 화면에 직접 표시되지는 않지만, 컴포넌트가 사용하는 store의 일부입니다. 특정 버튼이 만 실행한다면, 해당 버튼은 reset action만 selector로 가져와도 충분합니다.
function ResetFilterButton() { const resetFilters = useFilterStore((state) => state.resetFilters); return <button onClick={resetFilters}>필터 초기화</button>;
} 이 코드는 상태값을 하나도 읽지 않습니다. 버튼의 역할이 초기화 실행이라면 이 정도가 오히려 더 명확합니다. 나중에 필터 store에 날짜 범위나 가격 범위가 추가되어도 이 새 상태를 직접 알 필요는 없습니다.
여러 값을 가져올 때 생기는 참조 문제
selector를 쓰다 보면 값을 하나씩 가져오는 코드가 길어 보일 수 있습니다. 그래서 관련된 값을 객체로 묶어 한 번에 가져오고 싶어집니다. 검색어와 카테고리를 함께 쓰는 필터 요약 컴포넌트라면 아래처럼 작성할 수 있습니다.
function FilterSummary() { const filter = useFilterStore((state) => ({ keyword: state.keyword, category: state.category})); return ( <p> 검색어: {filter.keyword || "없음"} / 카테고리: {filter.category} </p> );
} 이 코드는 읽기에는 깔끔합니다. 하지만 selector가 객체를 반환한다는 점을 봐야 합니다. JavaScript에서 객체는 내용이 같아도 새로 만들어지면 다른 참조입니다. Zustand는 selector의 반환값을 기준으로 변경 여부를 판단하기 때문에, 객체나 배열을 매번 새로 반환하는 코드는 불필요한 렌더링을 만들 수 있습니다.
이때 를 검토합니다. 는 selector 결과를 얕게 비교해서, 객체 안의 실제 값이 바뀌지 않았다면 이전 결과를 재사용하는 데 사용합니다. 모든 selector에 붙이는 장식은 아닙니다. 단일 문자열, 숫자, boolean을 가져올 때보다 여러 값을 객체나 배열로 묶어서 반환할 때 먼저 확인할 도구입니다.
객체로 묶어 가져올 때 useShallow 사용하기
import { useShallow } from "zustand/react/shallow"; function FilterSummary() { const { keyword, category } = useFilterStore( useShallow((state) => ({ keyword: state.keyword, category: state.category})) ); return ( <p> 검색어: {keyword || "없음"} / 카테고리: {category} </p> );
} 여기서 얕은 비교는 객체 안의 1단계 값만 비교한다는 뜻입니다. 와 가 문자열처럼 단순한 값이면 판단하기 쉽습니다. 반대로 중첩 객체나 배열 안의 값을 직접 바꾸는 구조라면 만으로 문제가 해결된다고 보면 안 됩니다. Zustand에서 selector를 깔끔하게 쓰려면 store 업데이트도 불변성을 지키는 방식으로 작성해야 합니다. selector는 읽는 범위를 좁히고, update 로직은 상태 변경 기준을 흔들리지 않게 만드는 역할로 나눠 보는 것이 좋습니다.
primitive 값 하나만 가져오는 selector에는 보통 가 필요하지 않습니다. 처럼 문자열 하나를 가져오는 경우라면 반환값 자체가 비교하기 단순합니다. 는 여러 값을 묶는 선택 결과에서 먼저 떠올리는 편이 낫습니다.
컴포넌트별 selector 적용 기준
selector를 잘게 나누는 목적은 코드를 무조건 길게 만드는 데 있지 않습니다. 기준은 컴포넌트의 역할입니다. 컴포넌트가 화면에 보여주는 값, 사용자의 입력을 받아 바꾸는 값, 버튼 클릭으로 실행하는 action을 기준으로 selector를 정하면 됩니다.
필터 화면을 컴포넌트별로 나누면 selector 기준이 더 선명해집니다.
| 컴포넌트 | 필요한 store 값 | selector 기준 |
|---|---|---|
| SearchInput | keyword, setKeyword | 검색어 입력에 필요한 값만 가져옵니다. |
| CategoryTabs | category, setCategory | 카테고리 선택 상태와 변경 action만 가져옵니다. |
| SortSelect | sort, setSort | 정렬 기준과 변경 action만 가져옵니다. |
| ResetFilterButton | resetFilters | 초기화 action만 가져옵니다. |
이런 식으로 나누면 store 하나를 여러 컴포넌트가 공유하더라도 각 컴포넌트의 관심사는 좁게 유지됩니다. 전역 상태를 쓴다고 해서 모든 컴포넌트가 전역 상태 전체를 알아야 하는 것은 아닙니다. 오히려 Zustand를 편하게 쓰려면 store는 공유하되, 컴포넌트가 읽는 범위는 좁히는 쪽이 관리하기 쉽습니다.
반복되는 selector를 커스텀 훅으로 감싸기
export function useFilterKeyword() { return useFilterStore((state) => state.keyword);
} export function useSetFilterKeyword() { return useFilterStore((state) => state.setKeyword);
} export function useFilterReset() { return useFilterStore((state) => state.resetFilters);
} selector가 여러 파일에서 반복된다면 커스텀 훅으로 감싸는 방식도 가능합니다. 다만 처음부터 모든 상태에 대해 훅을 만들어두면 파일만 늘어날 수 있습니다. 여러 컴포넌트에서 반복해서 사용하거나, selector 내부 기준을 나중에 바꿀 가능성이 있는 값부터 분리하는 정도가 현실적입니다. store 파일 하나에 모든 selector 훅을 몰아넣는 방식보다, 필터 도메인이나 화면 단위로 묶어두면 찾기도 쉽습니다.
예를 들어 를 단순 문자열로 쓰다가 나중에 앞뒤 공백을 제거한 값으로 보여줘야 한다면, 여러 컴포넌트에 흩어진 selector를 각각 수정하는 것보다 안쪽만 바꾸는 편이 낫습니다. 이런 경우에는 커스텀 훅이 단순 축약 이상의 의미를 가집니다.
코드를 바꿀 때 확인할 부분

기존에 처럼 전체 store를 가져오던 코드를 selector 방식으로 바꿀 때는 한 번에 모든 코드를 뜯어고치기보다 컴포넌트 단위로 확인하는 것이 안정적입니다. 먼저 해당 컴포넌트가 실제로 화면에 표시하는 값과 이벤트에서 사용하는 action을 나눠봅니다.
그다음 사용하지 않는 상태가 같이 들어와 있는지 확인합니다. 검색창 컴포넌트 안에 가 들어와 있다면, 정말 검색창에서 정렬 기준이 필요한지 다시 보는 식입니다. 필요 없다면 selector 범위에서 제거합니다.
마지막으로 selector가 객체나 배열을 반환하는지 확인합니다. 여러 값을 묶어 반환해야 하는 상황이라면 값들이 단순한지, 불필요한 새 참조가 만들어질 수 있는지가 필요한지 순서대로 보면 됩니다. React DevTools로 렌더링 흔적을 확인할 때도 이 기준이 있으면 원인을 좁히기 쉽습니다. 이 점검은 성능 최적화 이전에 컴포넌트가 store를 읽는 방식을 정리하는 과정에 가깝습니다.
const keyword = useFilterStore((state) => state.keyword);
const setKeyword = useFilterStore((state) => state.setKeyword); const { category, sort } = useFilterStore( useShallow((state) => ({ category: state.category, sort: state.sort}))
); 위 코드처럼 단일 primitive 값은 그대로 가져오고, 여러 값을 묶어야 할 때만 를 붙이면 기준이 단순해집니다. 모든 곳에 같은 패턴을 강제로 적용하기보다 반환값의 형태를 보고 판단하는 것이 중요합니다.
정리
Zustand selector는 store에서 값을 꺼내는 짧은 문법이 아니라, 컴포넌트가 어떤 상태를 구독할지 정하는 기준입니다. 전체 store를 가져오는 코드는 처음에는 빠르게 작성할 수 있지만, 상태가 늘어나면 컴포넌트의 관심사가 쉽게 넓어집니다.
값 하나만 필요하면 해당 값만 selector로 가져오고, action 하나만 필요하면 action만 가져오면 됩니다. 여러 값을 한 번에 가져와야 한다면 객체나 배열을 반환하게 되므로 참조 비교 문제를 같이 봐야 합니다. 이때 는 모든 selector에 붙이는 규칙이 아니라, 선택 결과가 새 참조를 만들 수 있을 때 검토하는 도구입니다.
다음에 Zustand store를 컴포넌트에 연결할 때는 “이 컴포넌트가 store 전체를 알아야 하는가”를 먼저 확인하면 됩니다. 대부분의 경우 필요한 값은 생각보다 적습니다. selector는 그 범위를 코드에 남기는 역할을 하고이 기준이 잡혀 있으면 store가 커져도 컴포넌트 구조가 덜 흔들립니다.