Published on

Next.js 렌더링 폭발 막기 - Zustand+useSyncExternalStore

Authors

Next.js 앱을 운영하다 보면 “상태 하나 바뀌었을 뿐인데 페이지가 전체적으로 다시 그려지는 느낌”을 받는 순간이 있습니다. 특히 App Router 환경에서 클라이언트 컴포넌트가 늘어나고, 전역 상태를 편하게 쓰기 시작하면 렌더링 폭발(rendering explosion) 이 쉽게 발생합니다. 증상은 대개 아래처럼 나타납니다.

  • 검색 입력 한 글자마다 리스트, 헤더, 사이드바, 모달까지 전부 리렌더
  • 스크롤/마우스 이동 같은 고빈도 이벤트가 전역 상태와 엮이며 프레임 드랍
  • React DevTools에서 특정 업데이트가 “수십~수백 컴포넌트”에 전파

이 글에서는 Zustand를 쓰는 프로젝트를 기준으로, 구독 단위를 컴포넌트별로 쪼개고, React 18의 useSyncExternalStore 관점에서 “왜 이 방식이 안정적인지”까지 연결해 설명합니다.

참고로, 렌더링 폭발은 “상태 관리 라이브러리를 뭘 쓰냐”보다 구독 모델을 어떻게 설계하느냐가 더 큽니다. Zustand는 그 지점을 비교적 단순한 API로 해결할 수 있어 Next.js와 궁합이 좋습니다.

렌더링 폭발의 정체: 구독 범위가 너무 넓다

렌더링 폭발의 핵심 원인은 보통 이겁니다.

  1. 전역 상태에서 큰 객체를 통째로 읽는다
  2. 또는 셀렉터가 매번 새 참조를 만들어낸다
  3. 그 결과 “실제로 필요 없는 컴포넌트”까지 변경으로 판단되어 리렌더된다

예를 들어 아래처럼 작성하면 위험합니다.

// ❌ 위험: store 전체를 가져오면 어떤 필드가 바뀌어도 리렌더될 수 있음
const store = useAppStore()

혹은 셀렉터가 새 객체를 매번 만들어도 위험합니다.

// ❌ 위험: 매 렌더마다 {a, b} 새 객체 생성 → 얕은 비교 없으면 리렌더 유발
const { a, b } = useAppStore((s) => ({ a: s.a, b: s.b }))

Zustand는 기본적으로 셀렉터 결과를 Object.is로 비교합니다. 즉, 셀렉터가 객체를 리턴하면 매번 새 참조가 되어 변경으로 간주될 수 있습니다. 이게 “폭발”의 흔한 출발점입니다.

React 18의 해법: useSyncExternalStore와 일관된 스냅샷

React 18에서 외부 스토어(Redux, Zustand 같은)에 대한 표준 통합 방식이 useSyncExternalStore입니다. 핵심은 다음 두 가지입니다.

  • React가 렌더링 중간에 외부 스토어가 바뀌어도 일관된 스냅샷(snapshot) 을 보장
  • 동시성 렌더링(Concurrent Rendering)에서 “찢어진 상태(tearing)”를 방지

Zustand는 내부적으로 React 바인딩에서 이 패턴을 사용합니다. 즉, Zustand를 올바르게 쓰는 것 자체가 useSyncExternalStore 기반의 안전한 구독 모델을 타는 것입니다.

하지만 “올바르게”의 기준은 결국 구독 단위를 얼마나 잘게 쪼개는가로 귀결됩니다.

Zustand로 구독 단위 쪼개기: 셀렉터를 작게, 값은 원자적으로

1) 스토어 전체 구독 금지

가장 먼저 할 일은 “스토어를 통째로 가져오는 코드”를 없애는 겁니다.

// ✅ 좋음: 필요한 필드만 구독
const query = useAppStore((s) => s.query)
const setQuery = useAppStore((s) => s.setQuery)

여기서 포인트는 querysetQuery를 따로 구독하는 것입니다. 함수는 보통 레퍼런스가 안정적이라 리렌더 원인이 되지 않지만, 습관적으로 분리해두면 셀렉터 설계가 깔끔해집니다.

2) 객체를 리턴해야 한다면 shallow를 명시

여러 값을 한 번에 가져오고 싶다면, Zustand의 shallow 비교를 쓰는 편이 안전합니다.

import { shallow } from 'zustand/shallow'

// ✅ 좋음: shallow 비교로 객체 반환의 리렌더 폭발을 방지
const { query, page } = useAppStore(
  (s) => ({ query: s.query, page: s.page }),
  shallow
)

이 패턴 하나로도 “입력 한 글자에 리스트 전체가 다시 그려짐” 같은 문제가 크게 줄어드는 경우가 많습니다.

3) 상태를 큰 덩어리로 들고 있지 말고 정규화

예를 들어 검색 결과를 아래처럼 들고 있으면,

// ❌ 위험: results가 바뀌면 이를 읽는 모든 컴포넌트가 영향을 받기 쉬움
results: Array<{ id: string; title: string; ... }>

컴포넌트가 results 전체를 구독하는 순간, 작은 변경에도 대규모 리렌더가 발생합니다. 가능하면 아래처럼 분리합니다.

  • ids 배열
  • entities
  • selectedId
type Entity = { id: string; title: string; price: number }

type Store = {
  ids: string[]
  entities: Record<string, Entity>
  selectedId: string | null
  setSelectedId: (id: string | null) => void
}

이렇게 하면 selectedId만 바뀌는 경우 리스트 전체가 흔들리는 일을 줄일 수 있습니다.

Next.js(App Router)에서 자주 터지는 패턴과 처방

패턴 A: 루트 레이아웃에서 전역 상태를 읽는다

app/layout.tsx 혹은 상위 레이아웃에서 전역 상태를 읽으면, 해당 레이아웃 아래의 클라이언트 트리가 연쇄적으로 영향을 받을 수 있습니다. 특히 레이아웃이 클라이언트 컴포넌트일 때 체감이 큽니다.

  • 가능하면 레이아웃은 서버 컴포넌트로 유지
  • 전역 상태 구독은 “정말 필요한 leaf 컴포넌트”로 내리기

패턴 B: 라우팅 상태와 전역 상태를 섞는다

예를 들어 useSearchParams() 값을 전역 store에 동기화하고, 다시 그 store를 여러 컴포넌트가 구독하면 라우팅 변화가 곧 렌더링 폭발로 이어집니다.

  • URL 파라미터는 가능한 한 “읽는 컴포넌트에서만” 직접 사용
  • 꼭 전역화해야 한다면, 구독 범위를 최소화하고 selector를 원자적으로

패턴 C: 고빈도 이벤트를 전역 상태에 바로 반영

스크롤 위치, 마우스 좌표, 입력 타이핑을 전역 상태에 그대로 넣으면 업데이트 빈도 자체가 폭발합니다.

  • 로컬 state로 처리 후, 의미 있는 시점에만 전역 반영
  • requestAnimationFrame 또는 throttle/debounce

스크롤/애니메이션과 결합되면 체감 성능 문제가 더 커지는데, 이때는 렌더링 최적화와 별개로 브라우저 컴포지팅 레이어 관점도 같이 점검할 가치가 있습니다. 예를 들어 iOS Safari에서 스크롤 끊김을 다룬 글인 Safari iOS 스크롤 끊김 - compositing 레이어 최적화도 함께 참고하면 원인 분리가 쉬워집니다.

useSyncExternalStore로 직접 구독하기: “vanilla store + 커스텀 훅” 패턴

Zustand의 React 바인딩을 쓰지 않고, zustand/vanilla로 스토어를 만든 뒤 useSyncExternalStore로 직접 구독하면 “구독이 어디서 일어나는지”를 더 명확히 통제할 수 있습니다. 대규모 앱에서 모듈 경계를 명확히 하고 싶을 때 유용합니다.

1) vanilla store 만들기

// store/counterStore.ts
import { createStore } from 'zustand/vanilla'

type CounterState = {
  count: number
  inc: () => void
  dec: () => void
}

export const counterStore = createStore<CounterState>((set) => ({
  count: 0,
  inc: () => set((s) => ({ count: s.count + 1 })),
  dec: () => set((s) => ({ count: s.count - 1 })),
}))

2) useSyncExternalStore 기반 셀렉터 훅

중요 포인트는 getSnapshot에서 selector를 적용하고, 비교 안정성을 위해 selector가 원시값 또는 안정적인 참조를 반환하도록 설계하는 것입니다.

// store/useCounter.ts
import { useSyncExternalStore } from 'react'
import { counterStore } from './counterStore'

export function useCounterSelector<T>(selector: (s: ReturnType<typeof counterStore.getState>) => T) {
  return useSyncExternalStore(
    counterStore.subscribe,
    () => selector(counterStore.getState()),
    () => selector(counterStore.getState())
  )
}

이제 컴포넌트에서는 필요한 조각만 구독합니다.

'use client'

import { useCounterSelector } from '@/store/useCounter'
import { counterStore } from '@/store/counterStore'

export function Counter() {
  const count = useCounterSelector((s) => s.count)

  return (
    <div>
      <p>count: {count}</p>
      <button onClick={() => counterStore.getState().dec()}>-</button>
      <button onClick={() => counterStore.getState().inc()}>+</button>
    </div>
  )
}

이 패턴의 장점은 다음과 같습니다.

  • 구독 지점이 useSyncExternalStore로 고정되어 디버깅이 쉬움
  • React 바인딩에 의존하지 않아도 동시성 안전성 확보
  • 스토어 모듈을 UI 레이어와 느슨하게 결합 가능

단, selector가 객체를 반환하면 앞서 말한 참조 문제로 리렌더가 늘 수 있으니, 가능하면 원시값/단일 필드를 반환하거나, 별도의 메모이제이션/얕은 비교 전략을 도입해야 합니다.

“렌더링 폭발”을 줄이는 Zustand 스토어 설계 체크리스트

1) 액션과 상태를 분리하고, 액션은 안정적으로 유지

type Store = {
  query: string
  setQuery: (q: string) => void
}

액션을 매번 새로 만들지 않도록 create 초기화 시점에 고정하고, 컴포넌트에서는 상태와 액션을 분리 구독합니다.

2) 셀렉터는 “최소 단위”로

  • 컴포넌트 하나당 1~3개 셀렉터로 쪼개기
  • 큰 객체를 통째로 반환하지 않기

3) 파생 상태는 store에 저장하기 전에 다시 생각

예를 들어 filteredItems를 store에 저장하면 queryitems 변경 시마다 파생 값이 갱신되고, 이를 구독하는 컴포넌트가 연쇄 리렌더됩니다.

  • 파생 값은 컴포넌트에서 useMemo로 계산
  • 또는 파생 값을 구독하는 컴포넌트를 별도로 분리

4) 서버/클라이언트 경계를 명확히

App Router에서는 서버 컴포넌트가 기본입니다.

  • 데이터 패칭과 정적 렌더링은 서버에서
  • 상호작용 상태만 클라이언트 store에서

이 경계를 지키면 “클라이언트 트리 전체가 상태 때문에 흔들리는 현상”을 구조적으로 줄일 수 있습니다.

성능 측정: 무엇이 줄었는지 확인하는 방법

최적화는 “느낌”이 아니라 “측정”으로 마무리해야 합니다.

  • React DevTools Profiler에서 commit 횟수와 렌더링된 컴포넌트 수 비교
  • 특정 액션(예: setQuery) 실행 시 리렌더 범위가 줄었는지 확인
  • 개발 중에는 why-did-you-render 같은 도구로 불필요 렌더를 탐지

렌더링 폭발은 대개 한두 군데의 “큰 구독”에서 시작합니다. 프로파일링으로 가장 큰 덩어리를 먼저 찾고, selector를 쪼개는 것만으로도 효과가 큽니다.

타입 안정성: 셀렉터/스토어 계약을 단단하게 만들기

Zustand 스토어가 커지면 selector가 난립하고, 리팩토링 중 필드명이 바뀌거나 파생 로직이 섞이며 성능/정확성이 같이 무너질 수 있습니다. 이때는 타입 레벨에서 “스토어 shape”를 강하게 고정해두는 것이 도움이 됩니다.

TypeScript 5.x의 satisfies를 활용하면 “추론을 유지하면서도 계약을 검증”할 수 있는데, 관련 내용은 TS 5.x satisfies로 타입 검증·추론 둘 다 잡기에서 더 깊게 다룹니다.

정리: Zustand와 useSyncExternalStore로 폭발을 ‘구조적으로’ 막기

  • 렌더링 폭발은 상태 라이브러리 문제가 아니라 구독 범위 설계 문제인 경우가 많습니다.
  • Zustand는 React 18의 useSyncExternalStore 패턴과 잘 맞고, selector 기반으로 구독을 쪼개기 쉽습니다.
  • 핵심 처방은 단순합니다.
    • store 전체 구독을 없애고
    • selector를 원자적으로 만들고
    • 객체 반환 시 shallow를 명시하고
    • 고빈도 이벤트는 전역 상태에 바로 넣지 않는다

이 네 가지를 체크리스트처럼 적용하면, Next.js에서도 “입력 한 글자에 앱이 흔들리는” 종류의 렌더링 폭발을 상당 부분 예방할 수 있습니다.