- Published on
Next.js App Router 리렌더 폭발 추적·해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 컴포넌트와 클라이언트 컴포넌트가 섞인 Next.js App Router 환경에서 특정 페이지가 스크롤만 해도, 입력만 해도, 혹은 라우팅 후에 CPU가 치솟으며 리렌더가 폭발하는 경우가 있습니다. 문제는 대개 하나의 원인이 아니라 경계 설계, 상태 전파, 데이터 패칭, 렌더 트리 구조가 맞물리며 증폭된다는 점입니다.
이 글은 “왜 이렇게 많이 렌더되는지”를 추적 가능한 형태로 계측하고, App Router 특유의 함정들을 재현 가능한 체크리스트로 해결하는 방법을 다룹니다.
리렌더 폭발의 전형적 증상
다음 중 하나라도 해당하면 “렌더 트리 증폭”을 의심할 수 있습니다.
- 입력 한 글자마다 화면 전체가 깜빡이거나, 네트워크 요청이 반복된다
useEffect가 예상보다 자주 실행되고, 그 안의setState가 연쇄적으로 이어진다useSearchParams나usePathname를 쓰는 순간 상위 트리가 같이 다시 렌더된다- 라우팅 후에 동일 데이터 요청이 중복되고, Suspense fallback 이 반복된다
- 개발 모드에서 유난히 심하고, 프로덕션에서는 줄어들지만 여전히 버벅인다
핵심은 “리렌더 자체”보다 리렌더가 비싼 작업을 동반하는지입니다. 예를 들어 리렌더마다 대형 리스트 필터링, 차트 재계산, 폼 검증, 또는 클라이언트 fetch 가 재실행되면 체감 성능이 급격히 나빠집니다.
먼저 확인할 것: 개발 모드 착시 제거
React 18 개발 모드에서 StrictMode 는 일부 렌더와 이펙트를 의도적으로 2회 호출해 부작용을 드러냅니다. 그래서 “개발 환경에서만 폭발”처럼 보일 수 있습니다.
- 프로덕션 빌드로 재현:
next build후next start - React DevTools Profiler로 “커밋 수”와 “렌더 소요시간”을 분리해서 본다
다만 StrictMode가 원인이라기보다, 원래도 불안정한 이펙트/상태 설계를 증폭해 보여주는 경우가 대부분입니다.
추적 1단계: 어떤 컴포넌트가 몇 번 렌더되는지 계측
가장 먼저 “누가” 렌더되는지 숫자로 찍어야 합니다. 콘솔에 감으로 찍으면 금방 길을 잃습니다.
렌더 카운터 훅
// app/_debug/useRenderCount.ts
'use client'
import { useEffect, useRef } from 'react'
export function useRenderCount(name: string) {
const countRef = useRef(0)
countRef.current += 1
useEffect(() => {
// 커밋 시점에 찍으면 노이즈가 줄어듭니다
// eslint-disable-next-line no-console
console.log(`[render] ${name}:`, countRef.current)
})
}
'use client'
import { useRenderCount } from '../_debug/useRenderCount'
export function SearchBox() {
useRenderCount('SearchBox')
return <input />
}
이렇게 “상위 레이아웃”, “페이지”, “리스트”, “아이템” 단위로 박아두면, 폭발의 진앙이 상태를 가진 컴포넌트인지, 라우터 훅을 쓰는 컴포넌트인지, Context 프로바이더인지가 빠르게 드러납니다.
React DevTools Profiler에서 봐야 하는 것
- Commit이 자주 발생하는가
- 한 Commit에서 어떤 컴포넌트가 “렌더 비용”을 가장 많이 쓰는가
Why did this render류의 힌트가 반복되는가
렌더 횟수와 비용이 같이 크면 구조 문제일 확률이 높고, 렌더 횟수는 많은데 비용이 작으면 메모이제이션으로 완화 가능성이 큽니다.
추적 2단계: App Router에서 특히 자주 터지는 원인 지도
App Router는 “서버 컴포넌트 기본”이라는 전제가 있어서, Pages Router 시절의 감각으로 코드를 옮기면 리렌더 폭발이 쉽게 발생합니다.
원인 A: 클라이언트 컴포넌트가 레이아웃 루트까지 올라감
layout.tsx 나 상위 레이아웃에서 useState 를 쓰려고 use client 를 붙이는 순간, 그 아래 트리가 대규모로 클라이언트화되고 렌더 경계가 무너집니다.
나쁜 예는 다음 패턴입니다.
app/layout.tsx에use client를 붙임- 전역 Provider를 한 파일에 몰아넣고, 그 Provider가 자주 바뀌는 값을 Context로 내려보냄
해결은 “클라이언트 Provider를 필요한 곳까지 내리고”, “Provider 값을 안정화”하는 것입니다.
// app/providers.tsx
'use client'
import { useMemo, useState } from 'react'
export function Providers({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<'light' | 'dark'>('light')
// value 객체를 매 렌더마다 새로 만들면 하위가 전부 리렌더됩니다
const value = useMemo(() => ({ theme, setTheme }), [theme])
return (
// 예시: ThemeContext.Provider value={value}
<>{children}</>
)
}
그리고 app/layout.tsx 는 가능한 서버 컴포넌트로 유지하고, 필요한 부분만 감싸는 식으로 경계를 좁힙니다.
원인 B: useSearchParams / usePathname 가 상위 트리 리렌더를 유발
라우팅 상태를 읽는 훅은 URL 변경마다 렌더를 트리거합니다. 문제는 이를 상위 컴포넌트에서 사용하면, URL의 작은 변화가 페이지 전체 렌더로 번질 수 있다는 점입니다.
해결 원칙은 단순합니다.
- 라우팅 훅은 가장 아래, 실제로 필요한 작은 클라이언트 컴포넌트에서만 사용
- URL 기반 상태는 가능한 서버에서 처리하거나, 클라이언트에서는 최소 범위로 캡슐화
// 나쁜 예: Page에서 searchParams를 읽고 큰 트리를 렌더
'use client'
import { useSearchParams } from 'next/navigation'
export default function Page() {
const sp = useSearchParams()
const q = sp.get('q') ?? ''
return <BigList query={q} />
}
// 개선: URL 읽기는 작은 컴포넌트로 격리
'use client'
import { useSearchParams } from 'next/navigation'
export function QueryBadge() {
const sp = useSearchParams()
const q = sp.get('q') ?? ''
return <span>{q}</span>
}
BigList 는 가능한 서버 컴포넌트로 두고, q 를 서버에서 받아 필터링된 결과를 렌더하는 식으로 분리하면 효과가 큽니다.
원인 C: Context value가 매번 바뀌어 하위가 전부 리렌더
Context는 편하지만, “value 객체가 매 렌더마다 새로 생성”되면 소비자가 모두 리렌더됩니다.
value={{ a, b }}형태는 위험 신호- Provider 안에서
setInterval이나Date.now()같은 변동 값을 섞는 경우도 위험
해결은 useMemo 로 value를 안정화하거나, Context를 쪼개는 것입니다.
const value = useMemo(() => ({ user, logout }), [user, logout])
또는 “자주 바뀌는 값”과 “거의 안 바뀌는 값”을 서로 다른 Context로 분리하면 폭발을 크게 줄일 수 있습니다.
원인 D: 파생 상태를 state로 저장해 연쇄 업데이트 발생
다음 패턴이 있으면 리렌더 폭발로 이어지기 쉽습니다.
- props로부터 계산 가능한 값을
useState로 또 저장 useEffect로 props 변화를 감지해setState하는 구조
이 경우 렌더 흐름이 렌더 → 이펙트 → setState → 렌더 로 한 번 더 늘어나고, 의존성 배열이 불안정하면 루프가 됩니다.
가능하면 파생 값은 useMemo 로 계산하고 state를 줄입니다.
'use client'
import { useMemo } from 'react'
export function FilteredList({ items, q }: { items: string[]; q: string }) {
const filtered = useMemo(() => {
const query = q.trim().toLowerCase()
if (!query) return items
return items.filter(v => v.toLowerCase().includes(query))
}, [items, q])
return (
<ul>
{filtered.map(v => (
<li key={v}>{v}</li>
))}
</ul>
)
}
원인 E: 키가 불안정해 리스트가 매번 재마운트
key 가 인덱스이거나, 매 렌더마다 바뀌는 값을 쓰면 아이템이 전부 언마운트 후 마운트됩니다. 이때 아이템 내부 이펙트가 다시 실행되며 “렌더 폭발처럼” 보입니다.
key={index}사용을 최소화- 서버에서 내려오는 고유 ID를 키로 사용
원인 F: 클라이언트 fetch가 렌더와 결합되어 중복 호출
클라이언트 컴포넌트에서 다음처럼 작성하면, 상태 변화마다 fetch가 재실행될 수 있습니다.
'use client'
import { useEffect, useState } from 'react'
export function BadFetcher({ q }: { q: string }) {
const [data, setData] = useState<any>(null)
useEffect(() => {
fetch(`/api/search?q=${encodeURIComponent(q)}`)
.then(r => r.json())
.then(setData)
}, [q])
return <pre>{JSON.stringify(data)}</pre>
}
이 자체는 정상인데, 만약 q 가 상위 렌더 폭발에 의해 불안정하게 바뀌거나, 컴포넌트가 재마운트되면 호출이 폭증합니다.
App Router에서는 가능한 한 데이터 패칭을 서버로 옮기고, 클라이언트는 상호작용만 담당하게 만드는 것이 안정적입니다.
해결 전략 1: 서버 컴포넌트로 되돌리고, 클라이언트 섬을 작게 만든다
가장 강력한 처방은 클라이언트 컴포넌트를 줄이는 것입니다.
- 목록 데이터, 상세 데이터는 서버 컴포넌트에서 가져오기
- 클라이언트는
onClick, 입력 상태 등 “진짜 UI 상호작용”만 담당
// app/items/page.tsx (Server Component)
import { Suspense } from 'react'
async function getItems() {
const res = await fetch('https://example.com/api/items', {
cache: 'no-store'
})
return res.json() as Promise<{ id: string; name: string }[]>
}
export default async function Page() {
const items = await getItems()
return (
<div>
<h1>Items</h1>
<Suspense fallback={<div>Loading...</div>}>
<ItemsList items={items} />
</Suspense>
</div>
)
}
function ItemsList({ items }: { items: { id: string; name: string }[] }) {
return (
<ul>
{items.map(it => (
<li key={it.id}>{it.name}</li>
))}
</ul>
)
}
이 구조에서는 클라이언트 렌더 폭발이 일어나도 “데이터 패칭 중복”과 “대형 계산”이 함께 폭발할 가능성이 크게 줄어듭니다.
해결 전략 2: 상태의 범위를 줄이고 전파를 끊는다
리렌더 폭발의 본질은 “작은 상태 변화가 너무 넓은 트리에 전파되는 것”입니다.
실무에서 효과가 큰 순서대로 정리하면 다음과 같습니다.
- 상위 레이아웃에서
use client제거 가능성 검토 - Provider를 페이지 단위 혹은 섹션 단위로 내리기
- Context를 쪼개고 value를
useMemo로 안정화 - 라우팅 훅 사용 위치를 아래로 내리기
- 파생 상태 제거,
useMemo로 대체
이 과정은 “원인을 하나씩 제거”하는 방식이라 재발 방지에 유리합니다.
해결 전략 3: 메모이제이션은 마지막에, 정확히
React.memo, useMemo, useCallback 은 만능이 아닙니다. 하지만 “원인이 구조가 아니라 비용”일 때는 아주 유효합니다.
- 리스트 아이템 컴포넌트가 무겁고, props가 안정적일 때
React.memo - 핸들러가 하위로 많이 전달될 때
useCallback - 대형 계산 결과 캐시가 필요할 때
useMemo
주의할 점은 “의존성이 불안정하면 메모이제이션이 무력화”된다는 것입니다. 예를 들어 props로 객체 리터럴을 매번 생성해 넘기면 memo는 효과가 없습니다.
// 나쁜 예: itemOptions가 매 렌더마다 새 객체
<ItemRow options={{ dense: true }} />
// 개선: 상수로 빼거나 useMemo
const options = useMemo(() => ({ dense: true }), [])
<ItemRow options={options} />
해결 전략 4: 서버 캐시와 재검증을 의도적으로 설계
App Router에서는 서버 fetch 캐시 정책이 렌더 경험에 영향을 줍니다.
cache: 'no-store'는 매 요청 새로 가져오므로 느릴 수 있음next: { revalidate: N }를 쓰면 서버에서 캐시를 재사용해 안정적
렌더 폭발과 직접 연결되는 경우는 “데이터가 매번 바뀌어야 한다고 착각해 no-store 를 남발”하면서, 클라이언트 상호작용까지 얹혀 전체가 무거워지는 패턴입니다.
데이터가 실시간이 아니라면 재검증을 적극 고려하세요.
await fetch('https://example.com/api/items', {
next: { revalidate: 30 }
})
실전 디버깅 체크리스트
문제를 재현한 상태에서 아래 순서대로 확인하면 보통 빠르게 수렴합니다.
- 프로덕션 모드에서도 폭발하는가
- 렌더 카운터로 “진앙 컴포넌트”를 찾았는가
- 진앙이 Provider 또는 라우팅 훅 사용 컴포넌트인가
- 상위 레이아웃에
use client가 붙어 트리가 클라이언트화됐는가 - Context value가 매 렌더마다 새 객체인가
- 파생 상태를
useEffect로 동기화하고 있지 않은가 - 리스트 key가 안정적인가
- 렌더마다 fetch나 비싼 계산이 다시 실행되는가
이 체크리스트는 “렌더 횟수”와 “렌더 비용”을 분리해 접근하는 데 목적이 있습니다.
부록: 관찰 가능한 시스템으로 만들기
리렌더 폭발은 프론트엔드에서 끝나지 않고, API 요청 폭증이나 백엔드 타임아웃으로 이어질 수 있습니다. 특히 검색 자동완성, 스트리밍, 재시도 로직이 붙으면 더 빨리 장애로 확산됩니다.
- 스트리밍이나 재시도 설계가 얽혀 있다면 중복 토큰/중복 요청 패턴도 함께 점검하세요. 관련해서는 OpenAI SSE 스트리밍 끊김·중복 토큰 재시도 패턴 글이 비슷한 “중복 발생 메커니즘”을 다룹니다.
- 클라이언트에서 요청이 폭증하면 429나 백오프 설계까지 연결됩니다. OpenAI 429 Rate Limit 재시도·백오프 설계 도 함께 참고할 만합니다.
마무리: 폭발을 “렌더 문제”가 아니라 “경계 문제”로 본다
App Router에서 리렌더 폭발을 가장 빨리 끝내는 관점은 “메모이제이션을 더 하자”가 아니라, 서버와 클라이언트의 경계를 다시 긋고 상태 전파 범위를 줄이자입니다.
- 상위 레이아웃을 서버 컴포넌트로 유지
- 라우팅 훅과 Provider를 아래로 내리기
- Context value 안정화 및 분리
- 파생 상태 제거, 리스트 key 안정화
- 데이터 패칭은 서버로 이동하고 캐시 정책을 설계
이 원칙으로 트리를 정리한 뒤에, 남은 비용에만 memo 와 useMemo 를 적용하면 “렌더 횟수”와 “렌더 비용”이 함께 안정화되는 경우가 많습니다.