- Published on
Next.js App Router 렌더 폭주, useSyncExternalStore로 잡기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 컴포넌트와 클라이언트 컴포넌트가 섞이는 Next.js App Router 환경에서 “렌더 폭주”는 생각보다 흔합니다. 특히 전역 상태를 컨텍스트로 크게 감싸거나, window 이벤트·스토리지·웹소켓 같은 외부 소스를 리액트 상태로 무심코 끌어오면, 작은 변경이 트리 전체의 리렌더로 번져 성능이 급격히 떨어집니다.
이 글은 App Router에서 자주 발생하는 렌더 폭주의 패턴을 짚고, 리액트가 제공하는 표준 훅인 useSyncExternalStore로 “구독 기반 상태”를 안전하고 예측 가능하게 연결하는 방법을 다룹니다. Zustand·Redux 같은 라이브러리를 쓰더라도 내부적으로 같은 개념을 사용하므로, 원리를 이해하면 디버깅이 훨씬 쉬워집니다.
참고로 런타임/번들러 혼용 문제로 디버깅이 더 어려워질 수 있으니, 환경이 Node.js 22라면 Node.js 22에서 require·ESM 혼용 에러 해결법도 같이 확인해두면 좋습니다.
App Router에서 렌더 폭주가 잘 생기는 이유
1) 큰 Context Provider 하나로 모든 상태를 밀어넣는 패턴
Context는 “값이 바뀌면 해당 Provider 아래의 모든 소비자”가 리렌더될 수 있습니다. 컨텍스트 값을 객체로 묶어 전달하고, 그 객체가 상태 변경마다 새로 생성되면 체감상 전체 앱이 흔들립니다.
예를 들어 아래처럼 value에 user, theme, socketState, routeMeta 등을 한 번에 넣으면, 어느 하나만 바뀌어도 소비자들이 광범위하게 다시 그려집니다.
'use client'
import React, { createContext, useMemo, useState } from 'react'
type AppState = {
user: { id: string; name: string } | null
theme: 'light' | 'dark'
setTheme: (t: 'light' | 'dark') => void
}
export const AppContext = createContext<AppState | null>(null)
export function AppProvider({ children }: { children: React.ReactNode }) {
const [user] = useState<AppState['user']>(null)
const [theme, setTheme] = useState<AppState['theme']>('light')
// user나 theme가 바뀌면 value 객체가 새로 만들어짐
const value = useMemo(() => ({ user, theme, setTheme }), [user, theme])
return <AppContext.Provider value={value}>{children}</AppContext.Provider>
}
useMemo를 썼는데도 “theme 변경 시 user를 쓰는 컴포넌트까지” 흔들리는 느낌이 든다면, 구조적으로 컨텍스트가 너무 넓은 범위를 책임지고 있는 겁니다.
2) 외부 이벤트를 useEffect로 받아 useState에 던지는 패턴
resize, storage, visibilitychange, message, online/offline 같은 이벤트를 받아서 setState를 호출하면, 이벤트 빈도에 따라 렌더가 계속 발생합니다. 특히 resize는 드래그하는 동안 초당 수십 번 발생할 수 있습니다.
3) App Router에서의 경계 착시
App Router는 서버 컴포넌트가 기본이고, 클라이언트 컴포넌트는 필요한 곳에만 두는 것이 이상적입니다. 그런데 전역 상태를 최상단 레이아웃에서 클라이언트 Provider로 감싸면, 서버 컴포넌트 장점(스트리밍·캐시·서버 계산)을 포기하고 클라이언트 트리로 확장되는 경우가 많습니다.
useSyncExternalStore가 해결하는 핵심
useSyncExternalStore는 “리액트 외부에 존재하는 상태 저장소”를 리액트와 동기화하기 위한 공식 API입니다.
핵심은 두 가지입니다.
- 구독(subscribe)과 스냅샷(getSnapshot)을 분리해, 리액트가 필요한 시점에만 값을 읽고 변경을 감지하게 합니다.
- 동시성 렌더링(Concurrent Rendering)에서도 일관된 스냅샷을 보장하도록 설계돼, 단순
useEffect기반 구독보다 안전합니다.
즉, 외부 상태를 “리액트 상태로 복제”해서 전체를 흔드는 대신, 컴포넌트가 필요한 값만 구독하고 그 값이 바뀔 때만 리렌더되도록 경계를 세울 수 있습니다.
실전 1: 초간단 외부 스토어 만들기
아래는 프레임워크 없이도 동작하는 “외부 스토어” 예시입니다. 핵심은 subscribe가 “변경 시 호출할 리스너 등록”을 제공하고, getSnapshot이 “현재 값”을 돌려주는 구조입니다.
type Listener = () => void
type StoreState = {
theme: 'light' | 'dark'
sidebarOpen: boolean
}
function createStore(initial: StoreState) {
let state = initial
const listeners = new Set<Listener>()
return {
getSnapshot() {
return state
},
setState(partial: Partial<StoreState>) {
const next = { ...state, ...partial }
// 값이 같으면 불필요한 notify 방지
if (next.theme === state.theme && next.sidebarOpen === state.sidebarOpen) return
state = next
listeners.forEach((l) => l())
},
subscribe(listener: Listener) {
listeners.add(listener)
return () => listeners.delete(listener)
},
}
}
export const uiStore = createStore({ theme: 'light', sidebarOpen: false })
이제 컴포넌트에서 useSyncExternalStore로 구독합니다.
'use client'
import { useSyncExternalStore } from 'react'
import { uiStore } from './ui-store'
export function ThemeToggle() {
const theme = useSyncExternalStore(
uiStore.subscribe,
() => uiStore.getSnapshot().theme
)
return (
<button
onClick={() => uiStore.setState({ theme: theme === 'light' ? 'dark' : 'light' })}
>
current: {theme}
</button>
)
}
export function SidebarButton() {
const open = useSyncExternalStore(
uiStore.subscribe,
() => uiStore.getSnapshot().sidebarOpen
)
return (
<button onClick={() => uiStore.setState({ sidebarOpen: !open })}>
sidebar: {open ? 'open' : 'closed'}
</button>
)
}
여기서 중요한 점은 ThemeToggle은 theme 스냅샷만 읽고, SidebarButton은 sidebarOpen만 읽는다는 것입니다. 같은 스토어를 공유해도, 각 컴포넌트는 “자기가 읽는 스냅샷이 바뀔 때”만 리렌더됩니다.
실전 2: selector 최적화(리렌더 전파 차단)
위 예시는 () => uiStore.getSnapshot().theme처럼 필드를 직접 집어 읽었습니다. 하지만 보통은 selector를 일반화하고 싶습니다.
문제는 selector가 객체를 새로 만들어 반환하면, 값이 같아도 매번 “다른 참조”로 인식되어 리렌더가 발생할 수 있다는 점입니다.
안전한 패턴: 원시값 또는 안정적인 참조를 반환
'use client'
import { useSyncExternalStore } from 'react'
import { uiStore } from './ui-store'
export function useUITheme() {
return useSyncExternalStore(uiStore.subscribe, () => uiStore.getSnapshot().theme)
}
객체를 반환해야 한다면: 메모이제이션 또는 얕은 비교 전략
리액트 기본 useSyncExternalStore는 “동일성 비교”에 가깝게 동작하므로, 매번 새 객체를 만들면 불리합니다. 이때는
- 스토어 내부에서 “필드별 캐시”를 두거나
- selector가 반환하는 객체를 캐시하거나
- 아예 구독 단위를 더 쪼개는 것
이 더 단순하고 효과적입니다.
실전 3: window 이벤트를 외부 스토어로 분리하기(리사이즈 폭주 방지)
resize를 useEffect로 받아 setState하면, 리사이즈 중 렌더가 폭주합니다. 이를 외부 스토어로 만들고, 필요한 컴포넌트만 구독하도록 바꾸면 전파 범위를 줄일 수 있습니다.
type Listener = () => void
function createViewportStore() {
let width = typeof window === 'undefined' ? 0 : window.innerWidth
const listeners = new Set<Listener>()
function notify() {
listeners.forEach((l) => l())
}
function onResize() {
const next = window.innerWidth
if (next === width) return
width = next
notify()
}
return {
getSnapshot: () => width,
subscribe: (listener: Listener) => {
listeners.add(listener)
if (listeners.size === 1) window.addEventListener('resize', onResize)
return () => {
listeners.delete(listener)
if (listeners.size === 0) window.removeEventListener('resize', onResize)
}
},
}
}
export const viewportWidthStore = createViewportStore()
'use client'
import { useSyncExternalStore } from 'react'
import { viewportWidthStore } from './viewport-store'
export function OnlyThisRerendersOnResize() {
const width = useSyncExternalStore(
viewportWidthStore.subscribe,
viewportWidthStore.getSnapshot
)
return <div>width: {width}</div>
}
이렇게 하면 “리사이즈를 관찰해야 하는 컴포넌트만” 렌더됩니다. 기존에 상위 레이아웃에서 useState(width)를 들고 내려보내던 구조라면, 체감 성능 차이가 크게 납니다.
추가로 리사이즈 자체를 줄이고 싶다면 requestAnimationFrame 기반 스로틀을 onResize에 적용하는 것도 좋습니다.
App Router에서 배치 위치: 어디에 스토어를 두는가
1) 서버 컴포넌트에 스토어를 두지 않기
외부 스토어는 보통 브라우저 이벤트나 사용자 상호작용을 다루므로 클라이언트 전용입니다. 파일 상단에 use client가 필요한 컴포넌트와 달리, 스토어 모듈은 window 접근을 지연하거나 typeof window === 'undefined' 가드를 확실히 두어야 합니다.
2) “최상단 Provider로 감싸기”를 습관적으로 하지 않기
컨텍스트 Provider는 편하지만 범위를 넓히기 쉽습니다. App Router에서는 특히 app/layout.tsx에서 전역 클라이언트 Provider를 두는 순간, 클라이언트 트리가 커지면서 렌더 비용이 증가합니다.
- 정말 전역이어야 하는 값만 Provider로
- 나머지는
useSyncExternalStore기반 “필요한 곳만 구독”
이 조합이 실무에서 균형이 좋습니다.
디버깅 체크리스트: 렌더 폭주를 재현·측정·차단
1) React DevTools Profiler로 “누가 왜 렌더되는지”부터 확인
폭주 상황에서 감으로 최적화하면, 효과가 없거나 더 나빠질 수 있습니다.
- 특정 상태 변경 시 커밋이 몇 번 발생하는지
- 어떤 컴포넌트가 가장 자주 렌더되는지
- props 변경이 원인인지, context 변경이 원인인지
를 먼저 분리하세요.
2) 컨텍스트 값이 객체라면 참조 안정성부터 점검
value={{ ... }} 형태로 매 렌더마다 새 객체를 만드는지 확인합니다. 필요하다면 컨텍스트를 여러 개로 쪼개거나, 아예 외부 스토어 구독으로 바꾸는 편이 낫습니다.
3) 외부 이벤트는 “한 곳에서만” 구독하고, 필요 컴포넌트만 연결
resize를 여러 컴포넌트에서 각각 addEventListener로 구독하면 이벤트 처리도 중복되고, 상태 갱신도 난립합니다. 외부 스토어로 단일화하면 구독 수명 관리가 깔끔해집니다.
4) “네트워크/이미지 최적화”도 렌더 폭주와 함께 보자
렌더가 잦으면 이미지 컴포넌트나 레이아웃 시프트가 더 두드러질 수 있습니다. 이미지 관련 이슈가 동반된다면 Next.js 이미지 최적화 - next/image 400·캐시 해결도 함께 점검하는 것이 좋습니다.
흔한 함정과 운영 팁
함정 1) 스냅샷 함수에서 무거운 계산을 수행
getSnapshot은 렌더 중에도 호출될 수 있습니다. 여기서 큰 정렬, 깊은 복사, 비싼 파싱을 하면 역효과입니다. 스냅샷은 가볍게, 계산은 캐시하거나 이벤트 시점에 미리 해두세요.
함정 2) 스토어가 너무 커져서 결국 “전역 단일체”가 됨
외부 스토어는 컨텍스트보다 전파를 줄일 수 있지만, 스토어가 거대해지면 관리가 어려워집니다.
- UI 상태(테마, 사이드바)
- 세션 상태(로그인, 토큰)
- 실시간 상태(웹소켓, 폴링)
를 스토어 단위로 분리하면 변경 원인 추적이 쉬워집니다.
함정 3) 서버에서 초기값을 주입해야 하는데 클라이언트만 보고 설계
예를 들어 사용자 설정(테마)을 서버에서 읽어 초기 HTML에 반영하고 싶다면, 서버 컴포넌트에서 쿠키를 읽어 초기 클래스를 잡고, 이후 클라이언트 스토어가 그 값을 이어받도록 설계해야 깜빡임이 줄어듭니다.
결론: App Router 성능의 핵심은 “구독 경계”
Next.js App Router에서 렌더 폭주는 대개 “상태 변경이 너무 넓은 범위로 전파”되는 구조에서 발생합니다. useSyncExternalStore는 외부 상태를 리액트에 안전하게 연결하면서도, 필요한 컴포넌트만 리렌더되도록 구독 경계를 만들 수 있는 표준 도구입니다.
정리하면 다음 순서가 가장 실무적입니다.
- Profiler로 폭주 지점을 특정
- 컨텍스트 전역 단일 Provider 패턴을 의심
- 외부 이벤트·전역 상태를
useSyncExternalStore구독으로 분리 - 스냅샷을 가볍게 유지하고, 구독 단위를 쪼개기
이 과정을 거치면 “App Router인데도 SPA처럼 전체가 흔들리는 느낌”을 상당 부분 제거할 수 있고, 기능 추가 시에도 성능 회귀를 방지하기 쉬워집니다.