Develope/React

Zustand에서 shallow 비교는 언제 필요할까? selector와 함께 이해하기

oper0116 2026. 4. 11. 17:22
반응형

이전 글에서 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,
}));

겉으로 보기에는 bearshoney만 가져오는 깔끔한 코드다.
하지만 이 selector는 실행될 때마다 새 객체를 만들 수 있다.

즉, 값 자체는 같아도 참조가 새로 만들어지면 "다른 값"으로 판단될 여지가 생긴다.
이럴 때 불필요한 재렌더링이 발생할 수 있다.

shallow는 바로 이 지점에서 의미가 있다.

객체 전체를 깊게 비교하는 것은 아니지만, 1단계 수준에서 각 프로퍼티가 같은지 비교해서
"겉으로 같은 값이면 다시 렌더링하지 않도록" 도와주는 역할을 한다.

언제 shallow가 필요한가

내가 실제로 shallow를 고려하는 경우는 보통 아래 3가지다.

1. selector가 객체를 반환할 때

가장 대표적인 경우다.

const { bears, honey } = useBearStore(
  (state) => ({
    bears: state.bears,
    honey: state.honey,
  }),
  shallow
);

이 코드는 bearshoney를 한 번에 가져오고 싶을 때 자주 쓴다.
이때 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처럼 나누어 생각하면 어떤 점이 좋아지는지도 이어서 정리해볼 수 있을 것 같다.

반응형