alpaka206

Zustand useShallow 정리

date
Jan 19, 2026
slug
zustand-use-shallow
author
status
Public
tags
Study
harucut
summary
객체/배열 selector 리렌더링 잡는 가장 현실적인 방법
type
Post
thumbnail
category
💻 Frontend
updatedAt
Jan 25, 2026 04:35 PM
Zustand를 쓰다보면 “필요한 상태만 구독하면 리렌더링이 줄어든다”는 말을 듣곤합니다. 근데 실제 코드로 가면 이런 상황들이 자주 나옵니다.
  • 상태를 1개만 가져오면 깔끔한데 코드가 너무 길어짐
  • 여러 값을 한 번에 묶어 가져오고 싶지만 묶는 순간 리렌더링이 생각보다 자주 일어남
이때 사용하기 좋은 것이 selector가 반환하는 값이 “얕게(shallow) 같으면” 이전 결과를 재사용해서 불필요한 리렌더링을 막는 용도인 useShallow입니다. 그래서 이번 글에서는 useShallow 대해 정리해보고자 합니다.

1) 문제 상황: “selector가 객체/배열을 반환하면” 왜 리렌더링이 늘까?

Zustand에서 보통 이렇게 사용됩니다.
const count = useCounterStore((s) => s.count)
이건 Object.is 비교에서 값이 같으면 리렌더링이 안나지만 여러 값을 묶고 싶어서 아래와 같이 하게되면
const { count, inc } = useCounterStore((s) => ({ count: s.count, inc: s.inc, }))
이 selector는 매 렌더마다 새로운 객체를 만들게 됩니다.
{ count, inc }는 값이 같아도 참조가 다르므로, 기본 비교(Object.is)에선 변경됨으로 판단될 수 있어서 “count만 바뀌었는데도” 혹은 “아무것도 안 바뀌었는데도” 리렌더링이 생기곤 합니다.

2) useShallow가 하는 일

useShallow(selector)는 selector의 반환값이 이전 값과 shallow-equal이면, 이전 반환값을 돌려주도록 selector를 메모이징합니다.
  • 이전 { count: 1, inc: fn }
  • 새로 계산한 { count: 1, inc: fn }
이 둘이 얕게 같으면 “이전 객체를 그대로 재사용”해서 리렌더링 트리거를 줄여줍니다.

3) 사용법: store hook에 selector 대신 useShallow(selector)사용

import { useShallow } from "zustand/shallow" const { count, inc } = useCounterStore( useShallow((s) => ({ count: s.count, inc: s.inc, })) )

4) shallow 비교란?

shallow 비교란 최상위 프로퍼티만 비교하고 깊은 중첩 객체까지 들어가서 비교하지 않는 것 입니다.
const a = { x: 1, y: { z: 1 } } const b = { x: 1, y: { z: 1 } }
  • x는 둘 다 1 → 같음
  • y는 객체 → 참조가 다름 → shallow 비교에선 다름
즉, 중첩이 깊으면 useShallow로 완벽히 동일 판단을 기대하면 안됩니다.

5) 언제 useShallow를 써야 할까?

1) 여러 값을 객체로 묶어 가져올 때

const { user, logout } = useAuthStore( useShallow((s) => ({ user: s.user, logout: s.logout, })) )

2) selector가 배열을 반환할 때

배열도 객체이기 때문에 참조가 바뀌면 리렌더링이 발생합니다.
const [count, inc] = useCounterStore( useShallow((s) => [s.count, s.inc]) )

3) 계산된 값을 반환하는 selector가 있을 때

계산 결과가 shallow-equal이면 리렌더링을 막습니다.
const fullName = useUserStore((s) => `${s.first} ${s.last}`)
위의 예시는 string이기 때문에 문제가 없지만 계산 결과가 객체라면 이때는 useShallow가 유효해집니다.
const profile = useUserStore((s) => ({ fullName: `${s.first} ${s.last}`, isAdult: s.age >= 20, }))

6) 언제는 쓰지 말아야 할까?

1) 중첩 객체의 내부 변화까지 정확히 추적하고 싶을 때

useShallow는 깊게 비교하지 않고, 중첩 구조가 자주 바뀌는 데이터면 오히려 기대와 다르게 동작할 수 있습니다.

2) selector가 매번 새로운 함수를 만들 때

예를 들어 selector에서 함수를 새로 생성하면 shallow 비교가 깨집니다.
// 나쁜 예: selector에서 새 함수 생성 const x = useStore( useShallow((s) => ({ onClick: () => s.inc(), // 매번 새로운 함수 })) )
위의 예시는 매번 다른 함수 참조라서 shallow-equal이 성립하지 않습니다.

3) 하나만 구독하면 되는 경우

const count = useStore((s) => s.count) const inc = useStore((s) => s.inc)
useShallow를 필요없이 사용할 이유는 없습니다.

7) shallow vs useShallow 차이

  • shallow = 얕은 비교 함수 그 자체
  • useShallow = 그 얕은 비교를 이용해서 selector를 메모이징된 selector로 만들어주는 도구

8) 실전 패턴 3개

패턴 A) 액션들만 묶기

const { next, prev, play, pause } = usePlayerStore( useShallow((s) => ({ next: s.next, prev: s.prev, play: s.play, pause: s.pause, })) )

패턴 B) 상태와 액션 같이 묶기

const { isOpen, open, close } = useModalStore( useShallow((s) => ({ isOpen: s.isOpen, open: s.open, close: s.close, })) )

패턴 C) 배열로 가져오기

const [isOpen, open, close] = useModalStore( useShallow((s) => [s.isOpen, s.open, s.close]) )

9) 트러블 슈팅

❗ useShallow 썼는데도 리렌더링 됨

대부분 이유는 둘 중 하나입니다.
  1. selector가 반환하는 값 중에 매번 참조가 바뀌는 값이 섞여 있음
    1. 새 배열 생성
    2. 새 객체 생성
    3. 새 함수 생성
  1. 비교 대상이 중첩 객체라서 shallow-equal이 성립 안 함

❗ 무조건 useShallow를 쓰면 안되는가?

  • 한두 개만 가져오면 selector 분리하는 게 가장 단순하고 좋음
  • useShallow는 “여러 개 묶고 싶은데 참조가 문제일 때” 쓰는 기능

10) 정리

  • selector가 객체/배열을 반환하면 참조 때문에 불필요한 리렌더링이 생길 수 있습니다.
  • useShallow는 selector를 메모이징해서 shallow-equal이면 이전 반환값을 재사용합니다.
  • 여러 값을 한 번에 가져오고 싶을 때 가장 효과적입니다.
  • 대신 shallow 비교 특성상 중첩 객체나 매번 생성되는 값이 섞이면 효과가 줄어들 수 있습니다.