Zustand를 사용하다 보면 자연스럽게 selector를 접하게 된다.
공식 예제나 여러 글에서 아래와 같은 코드를 자주 볼 수 있다.
const bears = useBearStore((state) => state.bears);
처음 보면 단순히 "원하는 값만 꺼내오는 문법" 정도로 느껴질 수 있다.
하지만 실제로는 렌더링 범위를 줄이고, 컴포넌트의 관심사를 분리하고, 상태 구독 범위를 명확하게 만든다는 점에서 꽤 중요한 역할을 한다.
다만 여기서 한 가지 고민이 생긴다.
selector는 항상 써야 할까?
아니면 필요할 때만 선택적으로 써야 할까?
이번 글에서는 Zustand selector를 "무조건 써야 하는 패턴"으로 보기보다, 언제 도입하면 좋고 언제는 굳이 복잡도를 늘릴 필요가 없는지에 집중해서 정리해보려고 한다.
Selector가 하는 일
selector는 store 전체를 구독하는 대신, 내가 필요한 값만 골라서 구독하게 해준다.
예를 들어 다음과 같은 store가 있다고 가정해보자.
import { create } from "zustand";
interface BearState {
bears: number;
honey: number;
increaseBears: () => void;
increaseHoney: () => void;
}
export const useBearStore = create<BearState>((set) => ({
bears: 0,
honey: 0,
increaseBears: () => set((state) => ({ bears: state.bears + 1 })),
increaseHoney: () => set((state) => ({ honey: state.honey + 1 })),
}));
그리고 컴포넌트에서 아래처럼 사용한다고 해보자.
const state = useBearStore();
이 방식은 store의 모든 상태를 한 번에 가져온다.
문제는 내가 실제로 bears만 필요하더라도 honey가 바뀌는 순간에도 영향을 받을 수 있다는 점이다.
반대로 아래처럼 selector를 사용하면,
const bears = useBearStore((state) => state.bears);
이 컴포넌트는 bears 값이 바뀔 때만 다시 렌더링된다.
즉, selector의 핵심은 다음 한 줄로 정리할 수 있다.
필요한 값만 구독해서 불필요한 렌더링 범위를 줄이는 것
언제 selector를 쓰는 것이 좋을까
내가 실제로 selector를 적극적으로 쓰는 경우는 보통 아래 4가지다.
1. store에 상태가 여러 개 있고, 컴포넌트가 그중 일부만 필요할 때
가장 흔하고, 가장 설득력 있는 경우다.
예를 들어 사용자 store에 아래 값들이 함께 들어 있다고 해보자.
interface UserState {
profile: { name: string; age: number };
isLoading: boolean;
error: string | null;
setProfile: (profile: { name: string; age: number }) => void;
setLoading: (value: boolean) => void;
}
그런데 어떤 컴포넌트는 isLoading만 필요하고, 다른 컴포넌트는 profile.name만 필요할 수 있다.
이때 store 전체를 통째로 가져오면 구독 범위가 너무 넓어진다.
이럴 때는 이렇게 쪼개는 편이 훨씬 낫다.
const isLoading = useUserStore((state) => state.isLoading);
const userName = useUserStore((state) => state.profile.name);
컴포넌트가 어떤 상태에 의존하는지가 코드에서 바로 보이고, 렌더링 범위도 자연스럽게 좁아진다.
2. 렌더링이 잦은 화면에서 작은 상태 변화가 자주 일어날 때
대시보드, 입력 폼, 실시간 목록, 필터 UI처럼 화면 일부가 자주 바뀌는 곳에서는 selector의 가치가 커진다.
이런 화면에서는 상태 하나가 바뀔 때마다 관련 없는 컴포넌트까지 다시 그려지면 체감 성능이 쉽게 나빠진다.
예를 들어 검색창 입력값, 정렬 옵션, 목록 데이터, 로딩 상태가 하나의 store에 모두 들어 있는 경우를 생각해보자.
검색어가 바뀔 때마다 목록 전체를 구독하는 컴포넌트들이 함께 흔들리면 디버깅도 어렵고 성능도 불안해진다.
이럴 때 selector를 사용하면 "어떤 컴포넌트가 무엇 때문에 다시 렌더링되는지"를 비교적 명확하게 통제할 수 있다.
3. 컴포넌트의 관심사를 분리하고 싶을 때
selector는 성능 최적화 도구이기도 하지만, 동시에 설계 도구이기도 하다.
예를 들어 아래 코드는 컴포넌트가 store 구조를 너무 많이 알고 있는 상태다.
const state = useUserStore();
return (
<>
<div>{state.profile.name}</div>
{state.isLoading ? <Spinner /> : null}
</>
);
이 방식은 당장은 편해 보여도 컴포넌트가 store 전체와 강하게 결합되기 쉽다.
나중에 상태 구조가 바뀌면 영향을 받는 범위도 넓어진다.
반면 selector를 사용하면 필요한 관심사만 선언적으로 꺼낼 수 있다.
const userName = useUserStore((state) => state.profile.name);
const isLoading = useUserStore((state) => state.isLoading);
이렇게 해두면 이 컴포넌트는 "이 두 값에만 관심이 있다"는 사실이 더 선명해진다.
4. 커스텀 훅으로 재사용 가능한 구독 패턴을 만들고 싶을 때
selector는 커스텀 훅과 함께 쓸 때 더 깔끔해진다.
export const useUserName = () => useUserStore((state) => state.profile.name);
export const useIsUserLoading = () => useUserStore((state) => state.isLoading);
이런 식으로 자주 쓰는 selector를 감싸두면 컴포넌트에서는 store 구조를 몰라도 된다.
const userName = useUserName();
const isLoading = useIsUserLoading();
규모가 조금만 커져도 이런 방식이 유지보수에 꽤 도움이 된다.
언제는 굳이 selector를 쓰지 않아도 될까
반대로 selector를 무조건 도입할 필요가 없는 경우도 있다.
1. store 구조가 아주 단순하고, 한 컴포넌트에서 대부분의 값을 다 쓰는 경우
예를 들어 작은 데모 화면이나 토이 프로젝트에서 store가 사실상 하나의 화면 전용이라면 selector를 세밀하게 나누는 이점이 크지 않을 수 있다.
코드는 결국 읽기 쉬워야 한다.
최적화 이득이 거의 없는데 selector만 과하게 늘어나면 오히려 흐름이 더 복잡해질 수 있다.
2. 아직 성능 문제가 관찰되지 않았을 때
selector는 분명 도움이 되는 패턴이지만, 모든 코드를 "미리 최적화"하는 방향으로 적용할 필요는 없다.
지금 당장 상태도 단순하고 렌더링 비용도 크지 않다면, 먼저 명확하게 작성한 뒤 필요할 때 분리해도 늦지 않다.
특히 초반부터 모든 값을 selector, shallow 비교, 커스텀 훅으로 쪼개기 시작하면 코드가 빠르게 추상화된다.
그런데 그 추상화가 실제 문제를 해결하지 못하면 유지보수 난이도만 올라간다.
3. selector가 오히려 의미 없는 중간 레이어가 될 때
예를 들어 아래 코드는 형식적으로 selector를 쓰고 있지만 얻는 이점이 거의 없다.
const state = useUserStore((state) => state);
이건 사실상 store 전체를 다시 꺼내오는 것과 크게 다르지 않다.
selector를 쓴다고 해서 항상 좋은 구조가 되는 것은 아니다.
핵심은 "정말 필요한 조각만 가져오고 있는가"다.
여러 값을 함께 가져올 때는 주의가 필요하다
selector를 쓰다 보면 아래처럼 여러 값을 객체로 묶어서 반환하고 싶을 때가 있다.
const { bears, honey } = useBearStore((state) => ({
bears: state.bears,
honey: state.honey,
}));
이 패턴은 읽기에는 편하다.
하지만 매 렌더링마다 새 객체가 만들어질 수 있기 때문에 비교 방식에 따라 기대한 만큼 최적화되지 않을 수 있다.
그래서 여러 값을 함께 선택할 때는 아래 같은 판단이 필요하다.
- 정말 함께 움직여야 하는 값인가
- 따로 구독하는 편이 더 단순하지는 않은가
- 객체/배열 반환으로 인해 비교 비용이나 재렌더링 이슈가 생기지 않는가
예를 들어 단순하다면 차라리 나눠 쓰는 쪽이 더 읽기 좋을 때도 많다.
const bears = useBearStore((state) => state.bears);
const honey = useBearStore((state) => state.honey);
실무에서는 "한 번에 예쁘게 가져오는 코드"보다 "왜 이 컴포넌트가 다시 렌더링되는지 추적하기 쉬운 코드"가 더 유리한 경우가 많다.
결국 selector는 성능과 가독성의 균형 문제다
정리하면 selector는 무조건 써야 하는 문법도 아니고, 안 쓰면 안 되는 고급 기능도 아니다.
대신 아래 질문에 "예"가 많아질수록 selector를 도입할 이유가 커진다.
- store가 점점 커지고 있는가
- 컴포넌트가 store 전체에 과하게 의존하고 있는가
- 자주 바뀌는 상태 때문에 불필요한 렌더링이 생기고 있는가
- 컴포넌트의 관심사를 더 명확하게 나누고 싶은가
반대로 아래 상황이라면 조금 더 단순하게 시작해도 괜찮다.
- 화면 규모가 작고 store 구조가 단순한가
- 한 컴포넌트가 어차피 대부분의 상태를 다 쓰는가
- 아직 성능 이슈보다 가독성과 개발 속도가 더 중요한가
즉, selector의 목적은 "무조건 최적화"가 아니라 "필요한 상태만 구독한다는 의도를 코드에 드러내는 것"에 가깝다.
마무리
내 기준에서 Zustand selector는 성능 최적화를 위해서만 쓰는 기능은 아니다.
오히려 컴포넌트가 어떤 상태에 의존하는지 드러내고, store와 UI의 결합을 조금 더 느슨하게 만드는 데 더 큰 가치가 있다고 느낀다.
그래서 보통은 이렇게 판단하면 충분하다.
- store가 커지고 있다면 selector를 적극적으로 검토한다
- 자주 바뀌는 상태가 있다면 selector를 우선 고려한다
- 아직 단순한 화면이라면 과하게 추상화하지 않는다
결국 중요한 것은 selector를 "썼는지"가 아니라, 지금 코드가 필요한 상태만 잘 구독하고 있는지다.
다음 글에서는 Zustand에서 selector를 사용할 때 shallow 비교가 왜 같이 언급되는지, 그리고 어떤 경우에 실제로 필요한지까지 이어서 정리해보려고 한다.
'Develope > React' 카테고리의 다른 글
| CRA에서 Vite + SSR로 마이그레이션하며 겪은 문제와 해결 과정 (0) | 2026.04.05 |
|---|---|
| Recoil - Cannot update a component (`Batcher`) while rendering a different component 에러에 대하여 (2) | 2020.07.02 |
| [React]반복문으로 컴포넌트 렌더링시 고유값을 부여하는 이유 (0) | 2020.06.23 |
| CRA(Create React App) 서비스워커(ServiceWorker) 커스텀 (0) | 2020.06.13 |