- Published on
Next.js App Router 렌더링 폭주 - useSyncExternalStore로 진화시키기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 컴포넌트와 클라이언트 컴포넌트가 공존하는 Next.js App Router 환경에서는, 전역 상태를 “아무 데서나” 읽는 순간 렌더링이 폭주(render storm)하는 일이 생각보다 쉽게 발생합니다. 특히 Zustand 같은 외부 스토어를 사용할 때, 구독 범위를 넓게 잡거나 selector를 부정확하게 작성하면 입력 한 번에 수십~수백 개 컴포넌트가 연쇄적으로 리렌더링됩니다.
이 글은 App Router에서 흔히 겪는 렌더링 폭주의 형태를 정리하고, React가 권장하는 외부 스토어 연결 방식인 useSyncExternalStore를 활용해 구독 경계를 명확히 만드는 방법을 다룹니다. 제목의 Z...는 보통 Zustand를 떠올리게 되지만, 본문에서 설명하는 패턴은 어떤 외부 스토어든 적용 가능합니다.
App Router에서 렌더링 폭주가 더 치명적인 이유
App Router는 기본이 서버 컴포넌트(Server Component)이고, 상태/이벤트가 필요한 부분만 클라이언트 컴포넌트(Client Component)로 분리합니다. 이 구조 자체는 성능에 유리하지만, 다음 조건이 겹치면 “클라이언트 섬”이 커지면서 폭주가 시작됩니다.
- 전역 스토어를 상위 레이아웃/Provider에서 크게 감싸고
- 하위에서 “전체 상태” 또는 “매번 새 객체”를 selector로 반환하고
- 그 상태가 자주 변하고(예: 검색어, 드래그, 스크롤, 타이머)
- React 18 동시성(Concurrent Rendering)에서 구독 스냅샷이 불안정하면
결과적으로 작은 변경이 큰 리렌더링 파도를 만들고, TTI 악화, 입력 지연, 배터리 소모, 모바일 발열까지 이어집니다.
증상 체크리스트: 이러면 거의 렌더링 폭주입니다
다음 중 2개 이상이면 구독 설계를 의심하세요.
- 타이핑할 때 입력이 “한 박자 늦게” 따라옴
- DevTools Profiler에서 커밋이 연달아 찍히고, 관련 없는 컴포넌트까지 리렌더링됨
- selector가 객체/배열을 매번 새로 만들어 반환함
useEffect에서 전역 상태를 읽고 다시 전역 상태를 쓰는 루프가 있음- hydration 이후 특정 페이지에서만 CPU가 계속 높음
렌더링 폭주는 인프라의 무한 재시작 루프와도 닮았습니다. 원인이 하나가 아니라 “구독-변경-렌더-파생 변경”의 순환 고리가 생기는 게 핵심입니다. 이런 루프를 끊는 사고방식은 systemd 서비스 자동 재시작 무한루프 진단 가이드에서 다룬 접근과도 유사합니다.
왜 useSyncExternalStore인가
React는 외부 스토어(React 바깥에 있는 상태)를 안전하게 구독하기 위한 표준 훅으로 useSyncExternalStore를 제공합니다.
핵심 장점은 다음입니다.
- 동시성 렌더링에서 “스냅샷 일관성”을 보장하는 패턴을 제공
- 구독 함수(
subscribe)와 스냅샷 함수(getSnapshot)를 분리해, 언제 리렌더링해야 하는지 React가 정확히 판단 - 서버 렌더링 시
getServerSnapshot을 통해 hydration 미스매치 위험을 줄임
Zustand는 내부적으로 useSyncExternalStore 기반의 바인딩을 이미 사용하지만, 문제는 “어떻게 쓰느냐”입니다. 특히 selector가 불안정하면 React가 매 렌더마다 “값이 바뀌었다”고 판단해 폭주가 발생할 수 있습니다.
흔한 실수 1: 전체 상태를 구독하기
아래처럼 스토어 전체를 읽으면, 어떤 필드가 바뀌든 해당 컴포넌트는 무조건 리렌더링됩니다.
'use client'
import { useStore } from './store'
export function Header() {
// 안티 패턴: store 전체 구독
const state = useStore()
return (
<header>
<span>{state.user?.name}</span>
<span>{state.cartCount}</span>
</header>
)
}
해결은 “필요한 조각만” 구독하는 것입니다.
'use client'
import { useStore } from './store'
export function Header() {
const userName = useStore((s) => s.user?.name)
const cartCount = useStore((s) => s.cartCount)
return (
<header>
<span>{userName}</span>
<span>{cartCount}</span>
</header>
)
}
하지만 이것만으로 충분하지 않은 경우가 많습니다. 다음 실수가 더 위험합니다.
흔한 실수 2: selector가 매번 새 객체를 반환하기
아래처럼 selector에서 객체를 만들어 반환하면, 값이 같아도 참조가 매번 달라집니다.
'use client'
import { useStore } from './store'
export function Summary() {
// 위험: 매번 새 객체
const summary = useStore((s) => ({
total: s.total,
discount: s.discount,
}))
return (
<div>
{summary.total} / {summary.discount}
</div>
)
}
이 경우 React 입장에서는 summary가 항상 바뀐 것처럼 보일 수 있고, 결과적으로 리렌더링이 불필요하게 발생합니다.
해결책은 다음 중 하나입니다.
- 스토어 훅이 equality 함수를 지원하면 shallow 비교 적용
- selector를 가능한 한 원시값 단위로 쪼개기
- 또는
useSyncExternalStore로 “선택된 스냅샷”을 안정적으로 만들기
useSyncExternalStore로 커스텀 스토어를 안전하게 연결하기
Zustand를 쓰지 않거나, 더 강한 통제가 필요하면 외부 스토어를 직접 만들고 useSyncExternalStore로 연결하는 것이 가장 명확합니다.
1) 최소 스토어 구현
// store.ts
export type State = {
query: string
resultsCount: number
}
type Listener = () => void
let state: State = {
query: '',
resultsCount: 0,
}
const listeners = new Set<Listener>()
export const store = {
getSnapshot(): State {
return state
},
// SSR에서 초기값이 필요하면 별도 제공
getServerSnapshot(): State {
return { query: '', resultsCount: 0 }
},
subscribe(listener: Listener) {
listeners.add(listener)
return () => listeners.delete(listener)
},
setState(partial: Partial<State>) {
const next = { ...state, ...partial }
if (Object.is(next, state)) return
state = next
for (const l of listeners) l()
},
}
2) selector 기반 훅 만들기
중요 포인트는 “선택된 값이 같으면 같은 참조를 유지”하거나, 최소한 equality 체크를 통해 불필요한 리렌더링을 막는 것입니다.
'use client'
import { useRef } from 'react'
import { useSyncExternalStore } from 'react'
import { store, type State } from './store'
function defaultEqual(a: unknown, b: unknown) {
return Object.is(a, b)
}
export function useExternalStoreSelector<T>(
selector: (s: State) => T,
isEqual: (a: T, b: T) => boolean = defaultEqual
) {
const lastSelectionRef = useRef<T | null>(null)
return useSyncExternalStore(
store.subscribe,
() => {
const nextSelection = selector(store.getSnapshot())
const prevSelection = lastSelectionRef.current
if (prevSelection !== null && isEqual(prevSelection, nextSelection)) {
return prevSelection
}
lastSelectionRef.current = nextSelection
return nextSelection
},
() => selector(store.getServerSnapshot())
)
}
이 패턴의 요점은 getSnapshot이 “선택된 값의 안정적인 스냅샷”을 반환하도록 만드는 것입니다. equality가 true라면 이전 참조를 재사용해 React가 변경으로 인식하지 않게 합니다.
3) 사용 예시
'use client'
import { useExternalStoreSelector } from './useExternalStoreSelector'
import { store } from './store'
export function SearchBox() {
const query = useExternalStoreSelector((s) => s.query)
return (
<input
value={query}
onChange={(e) => store.setState({ query: e.target.value })}
placeholder="Type to search"
/>
)
}
export function ResultsBadge() {
const count = useExternalStoreSelector((s) => s.resultsCount)
return <span>Results: {count}</span>
}
이렇게 하면 query가 바뀔 때 ResultsBadge는 리렌더링되지 않고, resultsCount가 바뀔 때 SearchBox는 리렌더링되지 않습니다. 구독 경계가 명확해져 폭주 가능성이 크게 줄어듭니다.
Zustand를 쓴다면: “selector 안정성”과 “구독 면적”부터 줄이기
Zustand 자체는 잘 만들어진 편이지만, App Router에서는 다음 원칙을 더 엄격히 적용하는 것이 좋습니다.
1) 클라이언트 컴포넌트 섬을 최소화
레이아웃 전체를 'use client'로 만들고 전역 Provider로 감싸는 순간, 서버 컴포넌트 이점을 대부분 잃습니다. 가능한 한:
- 페이지의 상단/레이아웃은 서버 컴포넌트 유지
- 상호작용이 필요한 작은 부분만 클라이언트 컴포넌트로 분리
- 전역 상태를 꼭 써야 하는 UI만 구독
2) selector에서 객체를 만들면 shallow 비교를 강제
Zustand는 shallow 같은 비교 도구를 제공합니다. 객체를 반환해야 한다면 반드시 얕은 비교를 적용해 “값이 같으면 리렌더링하지 않게” 만드세요.
'use client'
import { shallow } from 'zustand/shallow'
import { useStore } from './store'
export function Summary() {
const { total, discount } = useStore(
(s) => ({ total: s.total, discount: s.discount }),
shallow
)
return (
<div>
{total} / {discount}
</div>
)
}
3) action과 state를 분리 구독
액션까지 함께 객체로 묶어 반환하면 참조 변화 가능성이 커집니다. 액션은 보통 안정적이므로 별도 구독 또는 스토어에서 직접 호출하는 식으로 단순화합니다.
App Router에서 특히 자주 터지는 “렌더링 폭주 트리거”
1) URL 동기화와 전역 상태를 양방향으로 연결
searchParams 변경 => store setState => router push => searchParams 변경… 같은 순환이 생기면 렌더링이 폭주합니다. 단방향 규칙을 정하세요.
- URL이 소스 오브 트루스면: store는 파생값 캐시로만
- store가 소스 오브 트루스면: URL 업데이트는 디바운스/명시적 커밋 시점에만
2) 타이핑/스크롤 이벤트에서 전역 상태를 초당 수십 번 갱신
입력값을 전역에 저장하는 순간, 구독자가 많으면 곧바로 폭주합니다. 로컬 state로 입력을 처리하고, 커밋 시점에만 전역 상태로 올리는 방식이 안전합니다.
'use client'
import { useState, useTransition } from 'react'
import { store } from './store'
export function SearchBoxCommitOnPause() {
const [local, setLocal] = useState('')
const [, startTransition] = useTransition()
return (
<input
value={local}
onChange={(e) => {
const v = e.target.value
setLocal(v)
startTransition(() => {
store.setState({ query: v })
})
}}
/>
)
}
startTransition은 모든 문제를 해결하진 않지만, UI 우선순위를 조정해 “입력 지연”을 줄이는 데 도움됩니다.
디버깅 방법: 어디서 폭주가 시작되는지 찾기
- React DevTools Profiler로 “가장 먼저 느려지는 커밋”을 찾습니다.
- 해당 커밋에서 리렌더링된 컴포넌트 목록을 보고, 공통 조상/공통 훅을 추적합니다.
- 전역 스토어 구독이 있다면 selector를 확인합니다.
- 전체 상태 구독인지
- 객체/배열을 새로 만드는지
- equality 비교가 있는지
- 구독을 쪼개고, selector를 원시값 중심으로 재작성합니다.
이 과정은 데이터 파이프라인에서 “의도치 않은 중복 평가”를 잡는 것과 비슷합니다. 한 번만 실행돼야 할 계산이 여러 번 실행되면 비용이 폭증하듯, 렌더도 “필요한 곳만” 실행되도록 설계해야 합니다. 비슷한 사고의 예로 Kotlin Sequence에서 map이 두 번 실행될 때 글을 함께 보면 도움이 됩니다.
정리: 폭주를 막는 체크리스트
- 클라이언트 컴포넌트 범위를 최소화하고, 전역 상태 구독을 UI의 작은 섬으로 제한
- 스토어 전체 구독 금지, selector는 원시값 단위로 쪼개기
- selector가 객체를 만들면 shallow 비교 또는 참조 안정화 적용
- URL
↔store 양방향 동기화 루프 차단 - 고빈도 이벤트는 로컬 state 처리 후 커밋(디바운스/트랜지션)
- 필요하면
useSyncExternalStore로 구독 스냅샷을 직접 통제
App Router에서의 성능 문제는 “코드 몇 줄 최적화”보다 “구독 경계 설계”가 더 큰 차이를 만듭니다. 렌더링 폭주를 한 번이라도 겪었다면, 다음 리팩터링 때는 스토어 연결을 useSyncExternalStore 관점에서 다시 그려보는 것이 가장 빠른 해결책이 될 가능성이 큽니다.
추가로, 타입 레벨에서 selector 안정성을 더 강하게 보장하고 싶다면 TS 최신 기능을 활용해 API를 정리하는 것도 좋습니다. 관련해서는 TS 5.5+ const 타입 파라미터로 추론 고치기도 함께 참고할 만합니다.