Zustand 리렌더링 원리와 selector 최적화 방법

2026.05.12·수정 2026.07.20·약 16분

Zustand 리렌더링: selector가 줄이는 범위를 먼저 구분합니다

Zustand 리렌더링을 최적화할 때 핵심은 “store가 바뀌었는가”가 아니라 “이 컴포넌트가 구독한 selector 결과가 바뀌었는가”입니다. 다만 selector는 Zustand store 업데이트로 생기는 렌더를 좁힐 뿐, 부모 렌더·props·context·컴포넌트의 지역 state까지 차단하지는 않습니다. 이 글은 React의 렌더와 커밋, Zustand 구독과 비교, useShallow의 적용 조건을 분리해 과도한 렌더와 정상 렌더를 구분합니다.

검증 기준: 2026-07-19 기준 React 공식 문서와 Zustand v5.0.14 공식 문서·릴리스를 확인했습니다. 먼저 재현하고 측정한 뒤 selector를 최소화하며, 마지막에 같은 동작으로 전후 결과를 검증합니다.

React 렌더와 Zustand 구독 모델

렌더와 DOM 커밋은 같은 사건이 아닙니다

React에서 렌더는 컴포넌트 함수를 다시 호출해 다음 UI를 계산하는 단계이고, 커밋은 필요한 변경을 DOM에 반영하는 단계입니다. 컴포넌트 함수가 실행됐더라도 계산 결과가 같으면 실제 DOM은 바뀌지 않을 수 있습니다. 따라서 콘솔 호출 횟수, React Profiler의 렌더, 브라우저 DOM 변경을 한 숫자로 해석하면 원인을 잘못 짚기 쉽습니다. 이 구분은 React Render and Commit의 모델을 따릅니다.

Zustand Hook은 selector 결과를 구독합니다

create로 만든 React Hook에 selector를 전달하면 컴포넌트는 store 전체가 아니라 그 selector의 결과를 사용합니다. 일반적인 단일 값 selector는 이전 결과와 다음 결과를 Object.is로 비교합니다. 관련 없는 store 필드가 바뀌어도 선택한 값이 같다면 그 store 알림 때문에 해당 컴포넌트를 다시 그릴 필요가 없습니다.

import { create } from 'zustand';

type AppStore = {
  count: number;
  theme: 'light' | 'dark';
  increase: () => void;
  toggleTheme: () => void;
};

export const useAppStore = create<AppStore>()((set) => ({
  count: 0,
  theme: 'light',
  increase: () => set((state) => ({ count: state.count + 1 })),
  toggleTheme: () =>
    set((state) => ({
      theme: state.theme === 'light' ? 'dark' : 'light',
    })),
}));
렌더 원인 selector가 줄일 수 있는가 확인 지점
관련 없는 Zustand 필드 변경 예, 선택 결과가 같다면 가능 selector 결과의 이전·다음 값
선택한 Zustand 값 변경 아니요, 정상 렌더 UI가 새 값을 반영하는지
부모 컴포넌트 렌더 아니요 props와 React.memo 경계
지역 state 또는 context 변경 아니요 해당 Hook과 Provider
Zustand selector 구독 구조와 컴포넌트 렌더링 흐름
store 변경, selector 재평가, 결과 비교, React 렌더 요청은 서로 다른 단계입니다.

selector를 어디까지 좁힐까

store 전체 구독은 문법 오류가 아니라 넓은 선택입니다

useAppStore()처럼 selector 없이 호출하는 것은 허용됩니다. 다만 반환된 store의 어떤 필드를 JSX에서 쓰는지와 관계없이 store 변경 범위에 넓게 반응할 수 있습니다. 작은 store를 대부분 사용하는 화면이라면 읽기 쉬운 선택일 수 있지만, 여러 도메인이 섞인 store에서 필드 하나만 쓰는 컴포넌트라면 의존성이 코드에 드러나는 단일 값 selector가 더 안전합니다.

// 유효하지만 구독 범위가 넓습니다.
function BroadCounter() {
  const store = useAppStore();
  return <button onClick={store.increase}>{store.count}</button>;
}

// 값과 action의 의존성을 각각 드러냅니다.
function FocusedCounter() {
  const count = useAppStore((state) => state.count);
  const increase = useAppStore((state) => state.increase);
  return <button onClick={increase}>{count}</button>;
}

selector는 실제 렌더 의존성에 맞춥니다

무조건 selector 개수를 늘리는 것이 목표는 아닙니다. 컴포넌트가 하나의 숫자만 그리면 그 숫자를 선택하고, 값과 action을 함께 쓴다면 각각 선택하거나 얕은 비교가 가능한 묶음으로 선택합니다. action 함수는 보통 store 생성 시 한 번 만들어져 참조가 안정적이지만, action 자체를 매 업데이트마다 새 함수로 교체하는 설계라면 그 가정도 깨집니다. 최적화 기준은 “짧은 코드”가 아니라 selector 결과의 안정성과 컴포넌트 책임입니다.

새 객체 결과와 useShallow

새 객체·배열은 내용이 같아도 새 참조입니다

selector가 매 평가마다 객체나 배열을 새로 만들면 Object.is 기준에서는 이전 결과와 다릅니다. Zustand v5에서는 selector 출력이 안정적이어야 하며, 불안정한 새 참조를 반환하는 패턴은 불필요한 렌더뿐 아니라 구성에 따라 최대 업데이트 깊이 오류로 이어질 수 있습니다. 값 하나씩 분리하는 것이 가장 명확하고, 여러 최상위 값을 묶어야 할 때 공식 useShallow를 적용합니다.

import { useShallow } from 'zustand/react/shallow';

function CounterToolbar() {
  const { count, increase } = useAppStore(
    useShallow((state) => ({
      count: state.count,
      increase: state.increase,
    })),
  );

  return <button onClick={increase}>현재 값: {count}</button>;
}

shallow는 최상위 한 단계만 비교합니다

useShallow는 selector 함수를 감싸 이전 결과와 다음 결과를 얕게 비교해 메모이즈된 결과를 돌려줍니다. 최상위 primitive 값이나 안정된 참조를 묶을 때 알맞습니다. 중첩 객체 내부까지 재귀적으로 비교하는 deep equality가 아니며, 큰 객체를 통째로 선택한 뒤 모든 문제를 해결하는 장치도 아닙니다. 공식 shallow APIuseShallow Hook에서 이 경계를 확인할 수 있습니다.

selector 결과 우선 선택 이유
숫자·문자열·boolean 하나 단일 selector 기본 Object.is로 충분
여러 최상위 값의 새 객체 분리 selector 또는 useShallow 새 컨테이너 참조를 안정화
자주 바뀌는 큰 중첩 객체 필요한 leaf로 축소 얕은 비교는 내부 변경을 대신 설계하지 않음
비싼 파생 계산 측정 후 메모이제이션 검토 구독 비교와 계산 비용은 별도 문제

참조 불변성과 Map·Set

같은 참조를 변이하면 selector가 변경을 놓칠 수 있습니다

selector 최적화는 state를 불변 방식으로 갱신한다는 전제 위에서 동작합니다. 객체·배열·Map·Set을 제자리에서 수정하고 같은 참조를 반환하면 선택 결과가 같다고 판단될 수 있습니다. 이때 문제는 “리렌더가 너무 많다”가 아니라 “변경됐는데 렌더가 안 된다”로 바뀝니다. 비교 함수를 추가하기 전에 새 참조가 만들어지는지부터 확인해야 합니다.

type SelectionStore = {
  selected: Set<string>;
  toggle: (id: string) => void;
};

export const useSelectionStore = create<SelectionStore>()((set) => ({
  selected: new Set<string>(),
  toggle: (id) =>
    set((state) => {
      const selected = new Set(state.selected);
      selected.has(id) ? selected.delete(id) : selected.add(id);
      return { selected };
    }),
}));

Map·Set도 새 인스턴스를 반환합니다

Zustand 공식 Maps and Sets 사용 가이드도 갱신 시 새 인스턴스를 만드는 패턴을 제시합니다. 중첩 객체라면 바뀌는 경로의 각 객체를 복사합니다. selector를 leaf 값까지 좁혀도 상위 state를 직접 변이한다면 구독 알림과 비교를 신뢰할 수 없습니다.

부모 렌더와 React.memo

selector는 부모가 자식을 호출하는 경로를 막지 않습니다

부모가 자신의 state 때문에 다시 렌더되면 자식도 기본적으로 다시 렌더됩니다. 자식의 Zustand selector 결과가 그대로여도 이 부모 경로는 별개입니다. 먼저 Profiler에서 렌더 원인이 store인지 부모인지 구분하고, 자식이 같은 props로 자주 다시 계산되며 그 비용이 실제로 크다면 memo 경계를 검토합니다.

React.memo는 보조 최적화이지 보장 계약이 아닙니다

memo는 부모가 같은 props를 전달했을 때 자식 렌더를 건너뛸 수 있게 하지만, React 공식 문서상 성능 최적화이며 렌더 방지 보장이 아닙니다. 자식 자신의 state나 소비하는 context가 바뀌면 다시 렌더됩니다. inline 객체·함수 props가 매번 새 참조라면 memo 효과도 줄어듭니다. 세부 기준은 React memo API를 따릅니다.

리렌더링 최적화 절차

1단계: 같은 동작으로 기준선을 측정합니다

  1. React DevTools Profiler에서 문제가 되는 사용자 동작 하나를 기록합니다.
  2. 개발 Strict Mode의 추가 호출과 실제 production 동작을 구분합니다.
  3. store의 관련 필드 변경, 관련 없는 필드 변경, 부모 state 변경을 각각 따로 실행합니다.
  4. 렌더 횟수뿐 아니라 commit 시간과 사용자 체감 지연을 함께 기록합니다.

2단계: 가장 좁은 원인부터 수정합니다

  1. 전체 store 구독이면서 실제로 한두 필드만 쓰면 단일 selector로 좁힙니다.
  2. selector가 새 객체·배열을 만들면 분리 selector를 우선 검토하고, 최상위 묶음이 필요할 때 useShallow를 씁니다.
  3. 변경을 놓친다면 state 변이와 같은 참조 반환 여부를 먼저 고칩니다.
  4. 부모 경로가 원인이고 계산 비용이 확인됐을 때만 props 안정화와 memo를 검토합니다.
Zustand 리렌더링 최적화 체크리스트 흐름
측정, 원인 분리, selector 축소, 참조 안정화, 같은 시나리오 재검증 순서로 진행합니다.

예외와 흔한 오해

useShallow를 모든 selector에 붙이지 않습니다

primitive 하나를 선택하는 selector에는 기본 비교면 충분합니다. useShallow를 기계적으로 추가하면 의도가 흐려지고 비교 비용만 생길 수 있습니다. 반대로 중첩 객체의 일부가 바뀌는데 최상위 참조가 그대로라면 shallow 비교가 변이를 복구해 주지도 않습니다.

개발 Strict Mode 호출을 곧바로 production 버그로 보지 않습니다

개발 Strict Mode는 순수하지 않은 렌더를 찾기 위해 컴포넌트나 계산을 추가 호출할 수 있습니다. 콘솔 숫자만 보고 최적화하지 말고 Profiler와 production 빌드에서도 동일한 사용자 동작을 확인합니다. 다만 렌더 함수와 selector는 추가 호출돼도 안전한 순수 계산이어야 합니다.

store 분할은 마지막 구조 판단입니다

selector를 적절히 사용하면 하나의 store에서도 구독 범위를 좁힐 수 있습니다. 렌더 횟수만 보고 store를 여러 개로 쪼개면 action의 원자성이나 도메인 경계가 오히려 흐려질 수 있습니다. 소유권과 변경 주기가 실제로 다르고 테스트·유지보수 이점이 있을 때 분할합니다.

재현 검증: 최적화가 실제로 작동하는지 확인합니다

관련 필드와 무관한 필드를 독립적으로 바꿉니다

FocusedCounter가 countincrease만 선택하는 상태에서 아래 시나리오를 각각 기록합니다. 검증 중에는 부모를 고정하거나 부모 변경을 별도 시나리오로 분리해야 selector 효과를 잘못 판정하지 않습니다.

검증 동작 기대 결과 실패 시 우선 확인
increase() 실행 카운터가 한 번 새 값으로 반영 action과 count selector
toggleTheme() 실행 count 전용 컴포넌트는 store 원인 렌더 없음 전체 구독·새 객체 selector
부모 지역 state 변경 memo가 없다면 자식 렌더 가능 정상 부모 경로를 store 문제로 오인했는지
Set 항목 토글 새 Set 참조와 선택 결과 반영 제자리 변이·같은 참조 반환
같은 시나리오 재측정 동작 동일, commit 비용은 유지 또는 감소 과도한 memo·비싼 비교

통과 조건은 렌더 0회가 아니라 정확성과 비용입니다

선택 값이 바뀌면 다시 렌더되는 것이 정상입니다. 최종 통과 조건은 관련 없는 store 변경에는 구독 컴포넌트가 반응하지 않고, 관련 값 변경은 빠짐없이 UI에 반영되며, 부모·context 경로는 별도로 설명되고, 전후 사용자 동작이 같다는 것입니다. 렌더를 무조건 0으로 만드는 목표는 stale UI와 복잡한 비교 함수를 만들 수 있습니다.

공식 출처와 내부 학습 경로

공식 문서·릴리스

내부 학습 경로

결론: selector는 구독 경계를 표현하는 도구입니다

Zustand 리렌더링 최적화의 우선순위는 명확합니다. 먼저 React 렌더와 DOM 커밋을 구분하고, store 변경·부모 렌더·context·지역 state 중 실제 원인을 측정합니다. store 원인이라면 컴포넌트가 사용하는 값만 selector로 선택하고, 여러 최상위 값을 새 객체로 묶어야 할 때만 useShallow를 적용합니다. state는 새 참조로 갱신하며, 부모 경로의 비용이 확인된 뒤에만 memo를 검토합니다.

마지막으로 관련 필드, 무관한 필드, 부모 state, Map·Set 갱신을 같은 시나리오로 다시 실행합니다. 관련 값은 빠짐없이 반영되고 관련 없는 store 변경만 건너뛰며 사용자 동작과 commit 비용이 유지되거나 개선됐다면 최적화가 통과한 것입니다. 이 검증이 없다면 selector 수가 늘어도 성능 개선이라고 결론 내릴 수 없습니다.

이 글이 마음에 드세요?

RSS 피드를 구독하세요!

“Zustand 리렌더링 원리와 selector 최적화 방법”에 대한 1개의 생각

댓글 남기기