⚡ 06. 선택적 구독과 렌더링 최적화: "왜 키보드만 쳤는데 버튼이 버벅거리죠?"

📋 개요

Zustand의 셀렉터(Selector)와 깊은 비교(Shallow)를 활용한 극강의 렌더링 최적화 기법

🎯 이 섹션을 읽고 나면:

  • Zustand useStore 의 기본 구독 방식과 리액트 리렌더링 감지 원리(참조 동일성)를 이해할 수 있다.
  • useShallow 와 셀렉터(Selector)를 활용해 불필요한 렌더 트리 탐색을 원천봉쇄할 수 있다.
  • 컴포넌트 생명주기 바깥(React Outside) 에서 스토어의 값을 바로 읽어오는 최적화 패턴을 도입할 수 있다.

📋 목차


📌 이 문서를 읽기 전에

⏱️ 예상 읽기 시간: 15분 / 핵심 파트: 10분

🗺️ 이 문서의 흐름
리렌더링 폭탄 증상 확인 → Selector를 통한 정밀 타격(구독) → 객체/배열 깊은 비교(useShallow) → React 밖에서의 스토어 접근

🗺️ 이 문서의 배경 세계관: '영수네 커뮤니티'

  • 🐣 영철 ( 신입 ): "리드 님! 커뮤니티 글쓰기 폼에서 제목 입력창에 타이핑을 막 치고 있는데, 이상하게 화면 하단에 있는 '임시저장' 버튼이나 '뒤로가기' 버튼 컴포넌트들이 아주 미세하게 버벅이는 것 같아요. Zustand 썼으니까 Context API 때처럼 다 리렌더링되진 않을 텐데요?"
  • 🎨 영숙 ( UX ): "영철 씨, 제 폰(구형 아이폰)으로 방금 테스트해봤는데 키보드 타이핑할 때마다 프레임 드랍이 너무 심해요. 배터리 금방 닳겠어요."
  • 🦁 영호 ( 리드 ): "영철 님, Zustand 자체는 빠르지만, 스토어에서 상태를 꺼내 쓸 때 구독(Subscribe)의 그물망을 너무 넓게 쳤기 때문입니다. 뷔페에서 먹을 반찬 하나만 콕 집어와야지, 접시 통째로 들고 오면 접시 안의 다른 반찬이 바뀔 때마다 렌더링 알람이 울리거든요. 최적화 메스를 들어봅시다."

🤔 왜 알아야 하는가

Zustand는 태생적으로 React의 Context API가 가진 "값 하나 바뀌면 하위 트리 싹 다 재렌더링" 문제를 해결하기 위해 설계되었습니다. 하지만 '어떻게' 스토어에서 값을 꺼내오는지(구독 방식) 에 따라 그 성능적 이점을 100% 누릴 수도, 아니면 최악의 병목 병기를 만들 수도 있습니다.

실무에서 앱이 커지고 한 페이지 내에 컴포넌트가 수십~수백 개씩 쌓이게 되면, "내가 보던 상태가 아닌데 렌더링이 도는 현상"을 막기 위한 셀렉터(Selector) 설계가 아키텍처의 품질을 가릅니다.

특히 자바스크립트는 객체나 배열을 새로 만들 때마다 새로운 메모리 주소를 할당합니다(참조 변경). 리액트와 Zustand는 이 주소값을 얕은 비교(===)하여 변경을 감지하므로, 똑같은 값이라도 주소가 달라지면 렌더링 사이클이 돌아버리는 함정이 곳곳에 도사리고 있습니다.


🏗️ 1. 구독망 좁히기: Selector의 정밀 타격

영철이가 짜놓은 대참사 코드를 보겠습니다.

❌ 통째로 가져오기 (전체 구독 = 렌더링 폭탄)

// 1. 스토어 상태: 글쓰기 폼
const useFormStore = create((set) => ({
  title: '',
  content: '',
  isSubmitting: false,
}));
 
// 2. 🐣 영철이의 버튼 컴포넌트
function SubmitArea() {
  // 🚨 재앙의 시작: 스토어 객체 껍데기 전체를 구독함
  const store = useFormStore();
  
  // store.title 창에 "안녕" 이라고 타이핑할 때마다
  // 이 SubmitArea 컴포넌트도 무조건 같이 재렌더링됨!!
  return <button disabled={store.isSubmitting}>제출</button>;
}

useFormStore() 처럼 인자 없이 호출하면, Zustand는 스토어 객체 전체를 구독(Subscribe)합니다. 즉 title이 바뀌든 content가 바뀌든, 스토어 내부의 값이 단 하나라도 바뀌면 이 컴포넌트가 재호출됩니다.

✅ 셀렉터로 정밀 타격하기

// 🦁 영호: "접시 째로 들고오지 말고, 필요한 반찬(상태)만 쏙 뽑아오세요."
function SubmitAreaPro() {
  // 💡 정밀 타격! 스토어 내에서 isSubmitting 필드 1개만 구독함.
  const isSubmitting = useFormStore((state) => state.isSubmitting);
  
  return <button disabled={isSubmitting}>제출</button>;
}

오직 isSubmitting 값이 변할 때만 컴포넌트가 다시 그려집니다. 영철이가 제목(title) 텍스트 박스에 수백 자를 타이핑해도 이 버튼 영역은 미동조차 하지 않는 평화를 얻습니다.


💎 2. 얕은 비교: useShallow의 마법

"그럼 값 여러 개가 필요할 땐 어쩌죠?"

영철이는 반찬을 두 개 이상 담기 위해, 셀렉터 안에서 객체를 새로 만들어 리턴했습니다.

// ❌ 영철: "음.. 배열이나 객체로 두 개 다 반환하면 깔끔하겠군!"
function FormHeader() {
  // 🚨 또 폭발! 셀렉터가 매번 '새로운 배열' 주소값을 리턴함
  const [title, isSubmitting] = useFormStore((state) => [state.title, state.isSubmitting]);
}

Zustand 내부는 리턴된 값을 이전 값과 === (엄격한 동일성) 비교합니다. [title, isSubmitting]은 매 렌더링 평가마다 새로운 배열 인스턴스(새로운 메모리 주소)를 생성해버리므로, Zustand는 "값이 바뀌었네? 렌더링해!" 라고 오해하게 됩니다.

useShallow 를 통한 얕은 비교 병압

이 함정을 빠져나가기 위해 Zustand가 공식 지원하는 useShallow 훅 미들웨어를 장착합니다.

import { useShallow } from 'zustand/react/shallow';
 
function FormHeaderPro() {
  // 🦁 영호: "Zustand야, 객체/배열 주소가 아니라 그 안쪽의 1뎁스 내용물을 보고 비교해줘."
  const { title, isSubmitting } = useFormStore(
    useShallow((state) => ({ 
      title: state.title, 
      isSubmitting: state.isSubmitting 
    }))
  );
}

이제 안쪽 내용물인 title 스트링 혹은 isSubmitting 불리언 값이 바뀔 때만 리렌더링이 발생합니다.


🧩 3. 렌더링 바깥의 해방 구역: getState()

사실 대부분의 실수는 "함수"를 렌더링에 묶으려다 발생합니다. 상태 값을 꺼내는 건 화면을 그리기(Render) 위함이지만, 상태를 바꾸는 함수(Action) 들은 렌더링 정보가 필요 없습니다. 단지 버튼 클릭 체인 끝에서 실행되기만 하면 되죠.

// ❌ 굳이 스토어에서 함수를 뽑아서 (리렌더링 구독망에 묶어서) 씀
function ResetButton() {
  const resetForm = useFormStore(state => state.resetForm);
  return <button onClick={resetForm}>초기화</button>;
}

Zustand의 진가는 컴포넌트 바깥(Outer Space)에서 스토어를 마음대로 주무를 수 있다는 점입니다. 훅(useFormStore())을 사용하지 않고, 스토어 객체 자체의 .getState().setState() 를 호출하세요.

// ✅ React 컴포넌트 생명주기와 완전 분리 (Zero 렌더링 오버헤드)
// 컴포넌트 파일 밖이든 안이든 상관없이 호출 가능
const handleReset = () => {
  useFormStore.getState().resetForm();
};
 
function ResetButtonPro() {
  // 🦁 영호: "이 컴포넌트는 스토어를 '구독'조차 하지 않습니다. 그저 찌를 뿐."
  return <button onClick={handleReset}>초기화</button>;
}

이 패턴은 특히 전역 에러 핸들러, Axios 인터셉터 내부, 웹소켓 유틸리티 함수 등 React의 트리 바깥에 존재하는 순수 JS 영역에서 모달을 띄우거나 전역 로딩 상태를 끌 때 압도적인 위력을 발휘합니다.


📝 마무리 퀴즈

Q1. Zustand에서 배열이나 객체 등 2개 이상의 파생 상태를 인라인 셀렉터로 동시에 반환([state.a, state.b])할 때, 불필요한 리렌더링 폭탄을 막아주는 공식 유틸리티(React Hook 미들웨어)는 무엇인가요?

  • A) React.memo
  • B) useDeepCompare
  • C) useShallow
  • D) getState

정답: C

💡 상세 해설:

  • 원리 설명: Zustand는 셀렉터가 반환하는 값의 주소 참조(===)로 변경을 감지합니다. 객체나 배열 리터럴을 매 렌더링마다 새로 반환하면 주소값이 계속 달라집니다. 이때 useShallow로 감싸주면 주소가 아닌 1뎁스 내부의 프로퍼티 값을 비교하여 불필요한 렌더 트리 탐색을 방지합니다.
  • 오답 피드백: "영철 님, React.memo는 Props 비교용이지 스토어 구독 최적화 방패가 아닙니다. 상태를 여러 개 뽑을 땐 습관적으로 useShallow를 바르세요!"
  • 📌 핵심 기억법: 🦁 영호: "배열이나 객체 형태의 바구니로 여러 개를 담아올 때는 useShallow라는 비닐을 꼭 씌우세요."

Q2. React 컴포넌트 생명주기와 완전 독립된 외부 스크립트(예: Axios 에러 인터셉터)에서 Zustand 스토어의 상태를 안전하게 읽고 쓰려면 어떤 메서드를 사용해야 하나요?

  • A) useStore.subscribe()
  • B) useStore.getState() / useStore.setState()
  • C) useStore()
  • D) 외부 스크립트에서는 접근이 불가능하다.

정답: B

💡 상세 해설:

  • 원리 설명: Zustand 스토어(객체 자신)는 훅 바깥에서도 접근 가능한 순수 JS 클로저입니다. 렌더링 사이클에 개입하지 않는 getState()setState()를 사용하면 리액트 외곽 우주에서도 스토어 조작이 가능합니다.
  • 오답 피드백: "영철 님, 훅(useStore())은 리액트 컴포넌트나 커스텀 훅 안에서만 호출할 수 있습니다. 바깥에서는 날것의 API(.getState())를 불러야죠."
  • 📌 핵심 기억법: 🦁 영호: "컴포넌트 바깥의 해방 구역에서는 getState()가 완장입니다."

Q3. 🏋️‍♂️ 영철의 테스트 타임 (Q&A)

영철이가 커뮤니티 채팅 목록을 만들 때 상태 값 3개를 동시에 읽어오고 싶어 합니다. 아래 방식 중에서 프레임 드랍(불필요한 리렌더링)을 최소화하는 가장 안전하고 공식적인 코드는 무엇일까요?

A) const store = useChatStore(); const { users, messages, isTyping } = store;
B) const { users, messages, isTyping } = useChatStore(state => ({ users: state.users, messages: state.messages, isTyping: state.isTyping }));
C) const { users, messages, isTyping } = useChatStore(useShallow(state => ({ users: state.users, messages: state.messages, isTyping: state.isTyping })));

정답: C

💡 상세 해설:

  • 원리 설명: A는 스토어 전체(접시 통째로)를 구독하여 채팅과 무관한 '다크모드' 상태표시줄만 바뀌어도 리렌더링 폭탄이 터집니다. B는 필요한 것만 가져오는 것처럼 보이지만, 매번 {} 객체를 새로 생성하므로 무한 리렌더링 루프나 오발탄 렌더링이 발생합니다. C는 useShallow가 얕은 비교를 걸어주므로, 내부 값(users, messages, typing)의 상태가 실질적으로 바뀌었을 때만 컴포넌트를 깨웁니다.
  • 오답 피드백: "영철 님! 비싼 고기를 부위별로 잘 썰었으면(Selector), 랩을 씌워서 신선도를 비교해야죠(useShallow). 썰기만 하고 버려두면 B번처럼 다 상합니다!"
  • 📌 핵심 기억법: 🦁 영호: "값 1개만 뽑을 거면 쌩 Selector! 값 2개 이상을 배열/객체로 뽑을 거면 무조건 useShallow 장착!"

🐣 영철이의 퇴근 일기

오늘은 렌더링 프로파일러를 켜보고 너무 창피해서 쥐구멍에라도 숨고 싶었다. 키보드로 글 한 글자 칠 때마다 하단에 달린 추천 버튼이랑 모달 껍데기, 심지어 헤더까지 일제히 껌뻑껌뻑 불이 켜지며 재평가되는 모습을 보니 "이게 내 코드라고?" 싶었다. 하지만 영호 리드 님이 알려준 useShallow 하나 감싸는 순간, 브라우저가 평온을 되찾고 버벅임이 싹 사라졌다! 이래서 원리를 알아야 프레임을 지키는구나. 퇴근길 버스 안에서 Zustand 공식 문서를 정독하며 최적화 뽕에 좀 더 취해봐야겠다 ✨.

💡 "상태를 구독하는 건 보험에 가입하는 거다. 쓸데없는 특약(전체상태 구독)을 빼고, 내게 딱 필요한 특약(Selector)만 찝어서 얕게(Shallow) 서명하자."