이전 글에서 Zustand selector는 언제 쓰는 것이 좋은지 정리했다.
그다음 단계에서 자주 같이 등장하는 키워드가 바로 shallow다.
selector를 찾아보다 보면 아래 같은 코드도 자주 보게 된다.
import { shallow } from "zustand/shallow";
const { bears, honey } = useBearStore(
(state) => ({
bears: state.bears,
honey: state.honey,
}),
shallow
);
처음 보면 "selector를 쓸 때는 shallow도 같이 써야 하나?"라는 생각이 들 수 있다.
하지만 실제로는 그렇지 않다.
shallow는 항상 필요한 옵션이 아니라, 여러 값을 묶어서 선택할 때 생기는 비교 문제를 완화하기 위한 도구에 가깝다.
이번 글에서는 shallow가 정확히 무엇인지, 언제 필요하고 언제는 굳이 쓰지 않아도 되는지를 정리해보려고 한다.
shallow 비교가 필요한 이유
Zustand에서 selector를 사용할 때 가장 단순한 형태는 아래처럼 원시값 하나를 꺼내는 경우다.
const bears = useBearStore((state) => state.bears);
이 경우는 비교가 단순하다.
기존 값과 다음 값이 같은지만 보면 된다.
문제는 selector가 객체나 배열처럼 참조형 데이터를 새로 만들어서 반환할 때 생긴다.
예를 들어 아래 코드를 보자.
const selected = useBearStore((state) => ({
bears: state.bears,
honey: state.honey,
}));
겉으로 보기에는 bears와 honey만 가져오는 깔끔한 코드다.
하지만 이 selector는 실행될 때마다 새 객체를 만들 수 있다.
즉, 값 자체는 같아도 참조가 새로 만들어지면 "다른 값"으로 판단될 여지가 생긴다.
이럴 때 불필요한 재렌더링이 발생할 수 있다.
shallow는 바로 이 지점에서 의미가 있다.
객체 전체를 깊게 비교하는 것은 아니지만, 1단계 수준에서 각 프로퍼티가 같은지 비교해서
"겉으로 같은 값이면 다시 렌더링하지 않도록" 도와주는 역할을 한다.
언제 shallow가 필요한가
내가 실제로 shallow를 고려하는 경우는 보통 아래 3가지다.
1. selector가 객체를 반환할 때
가장 대표적인 경우다.
const { bears, honey } = useBearStore(
(state) => ({
bears: state.bears,
honey: state.honey,
}),
shallow
);
이 코드는 bears와 honey를 한 번에 가져오고 싶을 때 자주 쓴다.
이때 shallow가 없으면 selector가 반환하는 새 객체 때문에 비교가 기대와 다르게 동작할 수 있다.
즉, "여러 값을 객체로 묶어서 selector에서 반환한다"면 shallow를 먼저 떠올려볼 만하다.
2. selector가 배열을 반환할 때
객체뿐 아니라 배열도 마찬가지다.
const [bears, honey] = useBearStore(
(state) => [state.bears, state.honey],
shallow
);
배열 역시 새로 생성되면 참조가 달라질 수 있다.
그래서 배열로 여러 값을 함께 선택하는 경우에도 shallow가 같이 언급된다.
3. 여러 값을 함께 가져와야 하는데, 분리해서 쓰기엔 오히려 코드가 어색할 때
가끔은 아래처럼 각각 따로 구독하는 편이 더 단순하다.
const bears = useBearStore((state) => state.bears);
const honey = useBearStore((state) => state.honey);
이 경우라면 shallow 자체가 필요 없다.
하지만 값들이 늘 함께 쓰이고, 하나의 의미 있는 묶음처럼 읽히는 경우도 있다.
예를 들어 width, height를 같이 쓰거나, isLoading, error를 한 세트처럼 다루는 경우다.
그럴 때 객체나 배열로 묶어 가져오는 선택을 할 수 있고, 이때 shallow가 자연스럽게 따라온다.
언제는 shallow가 필요 없을까
반대로 shallow를 굳이 쓰지 않아도 되는 경우도 분명하다.
1. 원시값 하나만 선택할 때
아래 같은 경우라면 보통 shallow는 필요 없다.
const bears = useBearStore((state) => state.bears);
const isLoading = useUserStore((state) => state.isLoading);
숫자, 문자열, 불리언처럼 비교가 단순한 값 하나를 꺼내는 데는 shallow가 개입할 여지가 거의 없다.
2. 차라리 selector를 분리하는 편이 더 명확할 때
실무에서는 "한 번에 예쁘게 가져오기"보다 "어떤 상태를 구독하는지 드러나는 코드"가 더 중요할 때가 많다.
예를 들어 아래 두 코드 중 무엇이 더 명확한지는 상황에 따라 다르다.
const { bears, honey } = useBearStore(
(state) => ({
bears: state.bears,
honey: state.honey,
}),
shallow
);
const bears = useBearStore((state) => state.bears);
const honey = useBearStore((state) => state.honey);
값이 많지 않다면 두 번째가 더 단순할 수 있다.
이 경우에는 shallow를 도입하기보다 selector를 나누는 편이 읽기 쉽다.
3. 중첩 객체까지 안전하게 비교해줄 것이라고 기대할 때
shallow는 말 그대로 얕은 비교다.
즉, 1단계 수준의 프로퍼티 비교에는 도움이 되지만, 깊은 구조까지 모두 비교해주지는 않는다.
예를 들어 이런 경우를 생각해보자.
const profile = useUserStore(
(state) => ({
user: state.user,
}),
shallow
);
겉보기에는 user 하나만 꺼내온 것처럼 보이지만, user 내부 구조가 복잡하고 참조가 자주 바뀐다면
기대한 만큼 안정적으로 동작하지 않을 수 있다.
즉, shallow를 "깊은 비교를 대신해주는 만능 도구"로 생각하면 안 된다.
shallow를 쓸지 말지 판단하는 기준
내 기준에서는 아래 순서로 생각하면 충분하다.
1. 먼저 정말 여러 값을 한 번에 가져와야 하는지 본다
대부분의 경우는 selector를 나누는 편이 더 단순하다.
const bears = useBearStore((state) => state.bears);
const honey = useBearStore((state) => state.honey);
이렇게 해결된다면 굳이 shallow까지 갈 필요가 없다.
2. 여러 값을 묶는 것이 더 자연스럽다면 shallow를 고려한다
예를 들어 함께 움직이는 값이고, 하나의 의미 있는 묶음이라면 아래처럼 가져올 수 있다.
import { shallow } from "zustand/shallow";
const { bears, honey } = useBearStore(
(state) => ({
bears: state.bears,
honey: state.honey,
}),
shallow
);
이 경우 shallow는 "새 객체가 만들어지는 문제를 완화하는 도구"로 이해하면 된다.
3. 그래도 코드가 복잡해진다면 추상화를 다시 줄인다
성능 최적화 코드는 종종 가독성을 해친다.
만약 shallow를 붙인 코드가 팀원에게 바로 읽히지 않고, 왜 필요한지 설명이 길어진다면
그 시점에서 한 번 더 단순한 구조로 돌아가 보는 편이 좋다.
최적화는 중요하지만, 읽을 수 없는 최적화는 오래 버티기 어렵다.
예제로 보는 추천 패턴
단일 값이면 단순하게
const count = useCounterStore((state) => state.count);
이 경우는 shallow가 필요 없다.
값이 두세 개고 각각 의미가 분명하면 분리해서
const count = useCounterStore((state) => state.count);
const isLoading = useCounterStore((state) => state.isLoading);
이 방식은 추적이 쉽고, 디버깅도 편하다.
꼭 함께 다뤄야 하는 값이라면 shallow와 함께
import { shallow } from "zustand/shallow";
const { count, isLoading } = useCounterStore(
(state) => ({
count: state.count,
isLoading: state.isLoading,
}),
shallow
);
이 패턴은 "묶어서 읽는 것이 분명히 더 자연스러운가?"를 기준으로 선택하면 좋다.
마무리
Zustand에서 shallow는 selector를 쓸 때 무조건 붙여야 하는 옵션이 아니다.
대신 아래처럼 이해하면 훨씬 실용적이다.
- 원시값 하나를 선택할 때는 보통 필요 없다
- 여러 값을 객체나 배열로 묶어 반환할 때 고려할 수 있다
- 깊은 비교가 아니라 얕은 비교라는 점을 잊으면 안 된다
- 복잡해진다면 차라리 selector를 나누는 편이 더 나을 수 있다
결국 shallow의 핵심은 "여러 값을 함께 선택할 때 생기는 참조 비교 문제를 줄이는 것"이다.
이걸 성능 최적화 도구로만 보기보다, selector의 반환 형태를 다룰 때 필요한 보조 장치로 이해하면 훨씬 덜 헷갈린다.
다음에는 Zustand에서 store를 너무 크게 만들었을 때 어떤 문제가 생기는지, 그리고 slice처럼 나누어 생각하면 어떤 점이 좋아지는지도 이어서 정리해볼 수 있을 것 같다.
'Develope > React' 카테고리의 다른 글
| Zustand에서 전역 상태와 서버 상태를 어떻게 구분할까? (0) | 2026.04.13 |
|---|---|
| Zustand store를 너무 크게 만들면 어떤 문제가 생길까? slice 패턴으로 나누는 기준 (0) | 2026.04.13 |
| Zustand Selector는 언제 써야 할까? 성능과 가독성 사이에서 판단하는 기준 (1) | 2026.04.09 |
| CRA에서 Vite + SSR로 마이그레이션하며 겪은 문제와 해결 과정 (0) | 2026.04.05 |
| Recoil - Cannot update a component (`Batcher`) while rendering a different component 에러에 대하여 (2) | 2020.07.02 |