- Published on
Next.js App Router 전역상태 리렌더 폭발 막는 법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 컴포넌트 기반의 Next.js App Router는 기본적으로 렌더 비용을 서버로 밀어내고, 클라이언트는 필요한 상호작용만 담당하게 설계되어 있습니다. 그런데 전역 상태를 무심코 layout.tsx 최상단에 올려두거나, 큰 객체를 한 번에 Context로 흘려보내면 상황이 급변합니다. 상태가 바뀔 때마다 Client Component 서브트리가 광범위하게 다시 렌더되며, 체감 성능이 급락하는 이른바 리렌더 폭발이 발생합니다.
이 글에서는 App Router에서 리렌더 폭발이 생기는 구조적 이유를 짚고, Provider 배치 전략, 상태 분리, selector 기반 구독, RSC 경계 활용 같은 실전 패턴으로 리렌더 범위를 통제하는 방법을 정리합니다.
왜 App Router에서 전역상태가 더 위험해졌나
App Router에서는 app 디렉터리의 컴포넌트가 기본적으로 Server Component입니다. Server Component는 클라이언트 번들에 포함되지 않고, 상태나 훅을 쓸 수 없습니다. 반면 전역 상태 라이브러리나 React Context는 대부분 Client Component에서 동작합니다.
문제는 전역 상태를 제공하려고 "use client" Provider를 상단 레이아웃에 두는 순간, 그 Provider 아래는 Client Component 경계가 생기고, 해당 서브트리의 많은 부분이 클라이언트 번들로 내려오게 됩니다. 그리고 Context value가 바뀌면 그 Provider 아래에서 Context를 읽는 컴포넌트는 모두 리렌더 대상이 됩니다.
특히 다음 패턴이 리렌더 폭발을 자주 만듭니다.
- Provider value로
{...}객체를 매 렌더마다 새로 생성 - 하나의 Context에 서로 무관한 상태를 한꺼번에 넣음
layout.tsx최상단에 전역 Provider를 두고 페이지 전체를 감쌈- 서버에서 가져온 큰 데이터 덩어리를 그대로 전역 상태에 저장하고 여기저기서 읽음
증상 체크: 리렌더 폭발을 의심해야 할 신호
- 입력창 타이핑, 토글 클릭 같은 작은 상호작용에도 화면이 버벅임
- React DevTools Profiler에서 상호작용 시 수십~수백 컴포넌트가 동시 커밋
- 특정 전역 상태 업데이트 한 번에 라우트 전체가 다시 그려지는 느낌
layout또는 공통 헤더, 사이드바까지 같이 리렌더됨
정확히는 “리렌더가 많다” 자체가 문제라기보다, 리렌더 범위가 불필요하게 넓고, 리렌더 비용이 큰 컴포넌트까지 끌려 들어가는 것이 문제입니다.
기본 원칙 1: Provider는 가능한 아래로 내린다
전역 상태를 반드시 app/layout.tsx에서 감싸야 하는 경우는 생각보다 많지 않습니다. 예를 들어 “장바구니 뱃지”가 헤더에 필요하다고 해서 사이트 전체를 전역 Provider로 감싸면, 장바구니 수량 업데이트가 페이지 본문까지 영향을 줄 수 있습니다.
나쁜 예: 루트 레이아웃에서 전역 Provider로 전체 감싸기
// app/layout.tsx
import Providers from "./providers";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko">
<body>
<Providers>
{children}
</Providers>
</body>
</html>
);
}
이 구조에서 Providers 내부 Context value가 바뀌면, children 아래의 많은 클라이언트 컴포넌트가 영향을 받습니다.
좋은 예: 상태가 필요한 영역만 감싸기
예를 들어 장바구니 상태가 헤더와 특정 페이지에만 필요하다면, 헤더 컴포넌트 내부 또는 특정 세그먼트 레이아웃에서만 Provider를 둡니다.
// app/(shop)/layout.tsx
import ShopProviders from "./shop-providers";
export default function ShopLayout({ children }: { children: React.ReactNode }) {
return <ShopProviders>{children}</ShopProviders>;
}
이렇게 세그먼트 단위로 Provider를 분리하면, 전역 상태 업데이트가 앱 전체로 전파되는 것을 원천적으로 줄일 수 있습니다.
기본 원칙 2: Context에는 “값”이 아니라 “구독 가능한 단위”를 흘린다
React Context의 핵심 함정은 “Context value가 바뀌면 구독자들이 리렌더된다”는 점입니다. 그리고 value가 객체라면, 참조가 바뀌는 순간 변경으로 간주됩니다.
흔한 실수: value 객체를 매번 새로 생성
"use client";
const AppContext = React.createContext(null);
export function AppProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = React.useState(null);
const [theme, setTheme] = React.useState("light");
// 매 렌더마다 새 객체 생성
const value = { user, setUser, theme, setTheme };
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}
theme만 바뀌어도 user를 쓰는 컴포넌트까지 같이 리렌더될 수 있습니다.
개선 1: Context를 목적별로 쪼갠다
"use client";
const UserContext = React.createContext(null);
const ThemeContext = React.createContext("light");
export function Providers({ children }: { children: React.ReactNode }) {
const [user, setUser] = React.useState(null);
const [theme, setTheme] = React.useState("light");
return (
<UserContext.Provider value={{ user, setUser }}>
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
</UserContext.Provider>
);
}
여전히 { user, setUser } 같은 객체는 매번 새로 만들어질 수 있으니, 다음 개선도 같이 적용하는 게 좋습니다.
개선 2: useMemo로 value 참조 안정화
"use client";
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = React.useState("light");
const value = React.useMemo(() => ({ theme, setTheme }), [theme]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
다만 이 방법은 “theme이 바뀌면 theme 구독자는 리렌더”라는 구조 자체는 그대로입니다. 즉, Context는 “상태 분리”가 가장 큰 레버입니다.
실전 해법 1: selector 기반 전역 상태를 쓴다 (Zustand 예시)
Context는 구독 단위를 세밀하게 쪼개기 어렵습니다. 반면 Zustand 같은 스토어는 selector로 필요한 조각만 구독할 수 있어 리렌더 폭발을 크게 줄일 수 있습니다.
Zustand 스토어 만들기
// app/store/cart.ts
import { create } from "zustand";
type CartState = {
count: number;
items: { id: string; qty: number }[];
inc: () => void;
setQty: (id: string, qty: number) => void;
};
export const useCartStore = create<CartState>((set) => ({
count: 0,
items: [],
inc: () => set((s) => ({ count: s.count + 1 })),
setQty: (id, qty) =>
set((s) => ({
items: s.items.map((it) => (it.id === id ? { ...it, qty } : it)),
})),
}));
컴포넌트에서 필요한 값만 selector로 구독
"use client";
import { useCartStore } from "../store/cart";
export function CartBadge() {
const count = useCartStore((s) => s.count);
return <span>Cart: {count}</span>;
}
export function CartItems() {
const items = useCartStore((s) => s.items);
return (
<ul>
{items.map((it) => (
<li key={it.id}>{it.id}: {it.qty}</li>
))}
</ul>
);
}
이 구조에서는 count만 바뀌면 CartBadge만 리렌더되고, items가 바뀌면 CartItems만 리렌더됩니다. Context에서 흔히 발생하는 “무관한 상태 변경에 동반 리렌더”를 줄이기 쉽습니다.
실전 해법 2: “서버 상태”를 전역 상태로 들고 있지 않는다
전역 상태에 서버에서 가져온 데이터(프로필, 설정, 리스트)를 넣고 앱 전역에서 참조하면, 다음 문제가 생깁니다.
- 갱신 타이밍이 애매해져 불필요한 동기화 로직 증가
- 상태 갱신이 한 번 일어날 때 참조하는 컴포넌트들이 대거 리렌더
- App Router의 RSC 캐시 및
fetch캐싱 이점을 제대로 못 씀
App Router에서는 서버 상태는 가능한 RSC에서 가져오고, 클라이언트에서는 상호작용에 필요한 “UI 상태”만 로컬 또는 얕은 전역으로 관리하는 편이 안정적입니다.
서버 캐시가 꼬이거나 무효화 전략이 필요하다면, RSC 캐시 무효화 패턴도 함께 보는 게 좋습니다.
실전 해법 3: URL 상태로 옮길 수 있는 것은 옮긴다
필터, 정렬, 페이지네이션 같은 상태를 전역 스토어에 넣으면, 작은 변경에도 많은 컴포넌트가 영향을 받습니다. 반면 URL 쿼리로 옮기면 상태의 범위가 라우트 단위로 자연스럽게 제한되고, 새로고침 및 공유도 쉬워집니다.
"use client";
import { useRouter, useSearchParams } from "next/navigation";
export function SortSelect() {
const router = useRouter();
const params = useSearchParams();
const sort = params.get("sort") ?? "recent";
return (
<select
value={sort}
onChange={(e) => {
const next = new URLSearchParams(params.toString());
next.set("sort", e.target.value);
router.replace(`?${next.toString()}`);
}}
>
<option value="recent">Recent</option>
<option value="price">Price</option>
</select>
);
}
여기서 주의할 점은 MDX에서 부등호가 노출되면 문제가 되므로, 코드 블록 밖에서는 ? 같은 문자를 그대로 써도 되지만, -> 같은 화살표 표기는 본문에 쓰지 말고 코드로만 표현하는 습관이 안전합니다.
실전 해법 4: Provider value에 “함수”를 넣을 때의 함정
Context에 액션 함수를 넣는 패턴은 흔합니다. 하지만 함수가 렌더마다 새로 생성되면 value 참조가 바뀌어 리렌더를 유발할 수 있습니다.
useCallback으로 액션을 안정화- 또는 아예 Context에는 dispatcher만 두고, 상태는 다른 Context로 분리
"use client";
const CounterStateContext = React.createContext(0);
const CounterActionsContext = React.createContext({ inc: () => {} });
export function CounterProvider({ children }: { children: React.ReactNode }) {
const [count, setCount] = React.useState(0);
const inc = React.useCallback(() => setCount((c) => c + 1), []);
const actions = React.useMemo(() => ({ inc }), [inc]);
return (
<CounterStateContext.Provider value={count}>
<CounterActionsContext.Provider value={actions}>
{children}
</CounterActionsContext.Provider>
</CounterStateContext.Provider>
);
}
이렇게 하면 count가 바뀔 때 상태 구독자만 리렌더되고, 액션만 쓰는 컴포넌트는 영향을 덜 받습니다.
실전 해법 5: 클라이언트 경계를 의도적으로 작게 만든다
App Router에서 성능을 지키는 핵심은 “클라이언트로 내려오는 영역을 최소화”하는 것입니다. 전역 상태가 필요하더라도, 그 상태가 필요한 작은 위젯만 Client Component로 만들고 나머지는 Server Component로 유지하는 편이 좋습니다.
예를 들어 페이지 전체가 아니라 버튼 하나만 상호작용이 필요하다면:
// app/products/page.tsx (Server Component)
import { AddToCartButton } from "./add-to-cart-button";
export default async function ProductsPage() {
const products = [{ id: "p1", name: "Keyboard" }];
return (
<div>
{products.map((p) => (
<div key={p.id}>
<div>{p.name}</div>
<AddToCartButton productId={p.id} />
</div>
))}
</div>
);
}
// app/products/add-to-cart-button.tsx (Client Component)
"use client";
import { useCartStore } from "../store/cart";
export function AddToCartButton({ productId }: { productId: string }) {
const inc = useCartStore((s) => s.inc);
return (
<button onClick={() => inc()}>
Add {productId}
</button>
);
}
이 구조에서는 제품 리스트 렌더는 서버에서 처리되고, 클라이언트는 버튼 상호작용만 담당합니다. 전역 상태를 쓰더라도 “클라이언트 경계가 작을수록” 리렌더 폭발이 일어날 표면적이 줄어듭니다.
디버깅 체크리스트: 어디서 리렌더가 새는지 찾기
- React DevTools Profiler로 상호작용 한 번에 커밋되는 컴포넌트 수 확인
- Provider 계층을 확인하고, 상태 변경이 필요 없는 영역까지 감싸고 있는지 점검
- Context value가 객체라면
useMemo적용 여부 확인 - 하나의 Context에 서로 다른 도메인 상태가 섞여 있는지 확인
- 서버에서 가져온 데이터를 굳이 클라이언트 전역 상태로 복제하고 있지 않은지 확인
- selector 기반 스토어로 바꾸거나, state slice로 쪼갤 여지가 있는지 검토
성능 문제는 원인과 증상이 멀리 떨어져 보이는 경우가 많습니다. 예를 들어 “장바구니 수량 업데이트”가 “상품 리스트 스크롤 끊김”으로 나타날 수 있습니다. 이런 식의 원인 추적은 분산 시스템에서 타임아웃 전파가 누락되어 장애가 증폭되는 패턴과도 닮았습니다.
권장 아키텍처 요약
- Provider는 루트가 아니라 “필요한 세그먼트”로 내린다
- Context는 목적별로 분리하고 value 참조를 안정화한다
- 전역 상태가 필요하면 selector 기반 스토어를 우선 검토한다
- 서버 상태는 RSC에서 가져오고, 클라이언트에는 UI 상태만 둔다
- URL로 표현 가능한 상태는 쿼리로 올려 라우트 경계로 제한한다
- 클라이언트 경계를 작게 유지해 번들 크기와 리렌더 표면적을 줄인다
마무리
Next.js App Router에서 전역 상태는 “편의성”과 “리렌더 범위 통제” 사이의 트레이드오프입니다. 무조건 전역 스토어를 피하라는 뜻이 아니라, Provider 위치와 구독 단위를 설계하지 않으면 App Router의 장점인 서버 중심 렌더링 구조가 쉽게 무너진다는 뜻입니다.
전역 상태가 필요한 이유를 먼저 분류해 보세요. 정말 앱 전역에서 필요한지, 특정 세그먼트만 필요한지, 서버 상태를 잘못 들고 있는 건 아닌지, URL로 옮길 수 있는지. 이 네 가지 질문만으로도 리렌더 폭발의 대부분은 예방할 수 있습니다.