- Published on
Next.js App Router RSC 캐시·prefetch로 리렌더 제거
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 컴포넌트(React Server Components, RSC) 기반의 Next.js App Router는 “클라이언트 리렌더를 줄이기 위한 구조”를 이미 갖고 있습니다. 그런데 실제 프로젝트에서는 페이지 이동마다 로딩이 튀고, 같은 데이터를 반복 조회하고, 클라이언트 컴포넌트가 불필요하게 다시 그려지는 일이 흔합니다.
핵심은 두 가지입니다.
- RSC에서의 데이터 캐시를 의도대로 설계해 “같은 입력이면 같은 결과”를 재사용하게 만들기
- 라우트 prefetch로 “이동하기 전에” RSC 페이로드를 미리 받아 전환 시점을 매끄럽게 만들기
이 글은 App Router에서 캐시 계층을 어떻게 이해하고, 어떤 코드 패턴으로 리렌더를 제거(또는 체감상 0에 가깝게)하는지 실전 관점으로 정리합니다. 리렌더 자체의 원인 분석이 필요하면 React 리렌더 폭증 원인 - useMemo 의존성 함정도 함께 읽어보면 연결이 잘 됩니다.
1) App Router에서 “리렌더”가 생기는 지점부터 분해
App Router는 기본적으로 다음 흐름입니다.
- 서버에서 RSC 트리를 렌더링하고, 결과를 RSC 페이로드로 전송
- 클라이언트는 서버에서 온 RSC 결과를 “붙이고(hydrate)” 필요한 Client Component만 인터랙션 가능하게 만듦
- 라우트 이동 시에도 “다시 전체 CSR 렌더”가 아니라, 서버에서 새 RSC를 받아 부분적으로 갱신
그런데 체감상 리렌더가 많아지는 대표 원인은 아래입니다.
- 데이터 fetch가 매 전환마다 새로 발생(캐시 미스)해서 Suspense 로딩이 계속 노출
- 레이아웃 경계가 잘못 잡혀서, 실제로는 유지되어야 할 UI가 route change마다 재생성
- Client Component에 props로 매번 새로운 객체/함수가 내려가서 하위 트리가 다시 렌더
- prefetch가 꺼져 있거나 동작 조건을 못 맞춰서, 이동 시점에 네트워크 왕복이 발생
이 글의 초점은 1과 4를 “RSC 캐시·prefetch”로 해결하는 것입니다. 2와 3은 구조/React 최적화 영역이니 필요하면 별도로 점검하세요.
2) RSC 캐시 레이어: fetch 캐시, 라우트 캐시, 데이터 캐시
Next.js App Router 캐시는 크게 세 겹으로 이해하면 실무에서 덜 헷갈립니다.
fetch캐시: 서버에서 실행되는fetch호출의 결과를 Next가 캐싱- 라우트 캐시(Full Route Cache): 정적 렌더링 가능한 경로라면 HTML 및 RSC 결과를 캐싱
- 클라이언트 라우터 캐시: 클라이언트가 라우트 이동 시 RSC 페이로드를 재사용
중요한 포인트는 “서버 컴포넌트에서의 데이터 패칭은 fetch 옵션과 segment 설정에 따라 캐시 성격이 바뀐다”는 점입니다.
2-1) 기본: fetch는 캐시될 수도, 매번 새로 칠 수도 있다
다음은 대표적인 패턴입니다.
// app/posts/page.tsx
export default async function Page() {
const res = await fetch('https://example.com/api/posts', {
// next: { revalidate: 60 } 처럼 설정하면 ISR 성격
next: { revalidate: 60 },
})
const posts = await res.json()
return (
<main>
<h1>Posts</h1>
<pre>{JSON.stringify(posts, null, 2)}</pre>
</main>
)
}
next: { revalidate: 60 }을 주면 “60초 동안은 같은 응답을 재사용”하는 방향으로 동작합니다.- 반대로 완전 실시간이 필요하면
cache: 'no-store'를 명시해 캐시를 끊어야 합니다.
await fetch('https://example.com/api/posts', {
cache: 'no-store',
})
문제는 “실시간이 아닌데도” 무심코 no-store를 써서, 라우트 이동마다 동일 데이터가 재요청되고 Suspense 로딩이 매번 보이는 경우가 많다는 것입니다.
2-2) segment 레벨 설정: dynamic, revalidate
페이지/레이아웃 단위로 동작을 고정할 수도 있습니다.
// app/posts/page.tsx
export const revalidate = 60
// 또는 export const dynamic = 'force-dynamic'
export default async function Page() {
const res = await fetch('https://example.com/api/posts')
const posts = await res.json()
return <pre>{JSON.stringify(posts, null, 2)}</pre>
}
revalidate를 두면 해당 segment에서의 캐시 정책을 일관되게 가져갈 수 있습니다.dynamic = 'force-dynamic'은 “항상 동적 렌더”를 강제하는 옵션이라, 리렌더/로딩 플리커를 줄이려는 목적이라면 남용을 피하는 게 좋습니다.
3) RSC에서 “같은 요청인데도” 캐시가 안 먹는 흔한 함정
캐시가 안 먹는 상황은 대부분 “키가 달라져서”입니다. 특히 아래 케이스가 자주 보입니다.
3-1) URL에 매번 바뀌는 쿼리를 넣음
await fetch(`https://example.com/api/posts?ts=${Date.now()}`)
이건 매번 다른 URL이라 캐시 적중이 사실상 불가능합니다. 진짜로 필요한 게 아니라면 제거하세요.
3-2) 쿠키/헤더에 따라 응답이 달라지는데 캐시를 기대함
예를 들어 사용자별 데이터인데도 공용 캐시처럼 기대하면 안 됩니다. 이때는 사용자별 캐시 전략(짧은 revalidate, 또는 아예 no-store)을 명확히 하거나, 사용자별 데이터는 클라이언트에서 가져오도록 분리하는 편이 낫습니다.
3-3) 서버 컴포넌트에서 매번 새로운 “입력”을 생성
예: 랜덤 값, 현재 시간, request id 등을 데이터 패칭 키에 섞음. 결과적으로 매번 다른 결과가 되어 캐시가 깨집니다.
4) unstable_cache로 “함수 단위” 캐시 만들기
fetch 캐시만으로 부족할 때가 있습니다.
- DB 쿼리(Prisma, Drizzle, Kysely 등)처럼
fetch가 아닌 호출 - 여러 API 호출을 묶어 만든 “가공된 결과”를 캐시하고 싶을 때
이때 Next의 unstable_cache를 고려할 수 있습니다.
// lib/posts.ts
import { unstable_cache } from 'next/cache'
async function queryPostsFromDB() {
// DB 조회라고 가정
return [{ id: 1, title: 'hello' }]
}
export const getCachedPosts = unstable_cache(
async () => queryPostsFromDB(),
['posts:list'],
{ revalidate: 60 }
)
// app/posts/page.tsx
import { getCachedPosts } from '@/lib/posts'
export default async function Page() {
const posts = await getCachedPosts()
return (
<main>
<h1>Posts</h1>
<ul>
{posts.map((p) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
</main>
)
}
이 패턴의 장점은 “캐시 키를 내가 통제”할 수 있다는 점입니다. 특히 리스트/상세/필터 조합에서 캐시 키를 설계하면, 라우트 이동 시 같은 데이터 재사용이 쉬워져 로딩과 리렌더 체감이 크게 줄어듭니다.
5) 캐시 무효화: revalidateTag와 태그 기반 전략
리렌더를 줄이려고 캐시를 늘리면, 다음 고민은 “언제 갱신하지”입니다. 글 작성/수정 같은 mutation 이후에 즉시 반영해야 한다면 태그 기반 무효화가 실무에서 편합니다.
5-1) fetch에 태그 달기
await fetch('https://example.com/api/posts', {
next: { tags: ['posts'] },
})
5-2) 서버 액션 또는 API에서 태그 무효화
// app/actions.ts
'use server'
import { revalidateTag } from 'next/cache'
export async function createPost(formData: FormData) {
const title = String(formData.get('title') || '')
await fetch('https://example.com/api/posts', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ title }),
})
revalidateTag('posts')
}
이렇게 하면
- 읽기 화면은 캐시로 빠르게 제공
- 쓰기 이후에는 관련 태그만 골라 즉시 갱신
이라는 균형이 가능합니다.
6) prefetch로 “이동 전에” RSC를 받아 리렌더 체감 제거
캐시가 있어도, 이동 시점에 네트워크 왕복이 발생하면 로딩이 보일 수 있습니다. 여기서 prefetch가 중요해집니다.
6-1) Link는 기본적으로 prefetch를 한다(조건부)
import Link from 'next/link'
export function Nav() {
return (
<nav>
<Link href="/posts">Posts</Link>
<Link href="/settings">Settings</Link>
</nav>
)
}
App Router에서 Link는 뷰포트에 들어오면 prefetch를 시도합니다. 다만 다음 경우 prefetch 체감이 떨어질 수 있습니다.
- 링크가 화면에 늦게 나타남(모달/드롭다운 안)
- 사용자가 클릭하기 전에 링크가 뷰포트에 들어오지 않음
- 네트워크가 느리고 prefetch 타이밍이 늦음
이럴 때는 prefetch를 “더 확실하게” 트리거할 수 있습니다.
6-2) router.prefetch를 명시 호출
'use client'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
export function PrefetchPostsOnIdle() {
const router = useRouter()
useEffect(() => {
const id = window.setTimeout(() => {
router.prefetch('/posts')
}, 500)
return () => window.clearTimeout(id)
}, [router])
return null
}
- 예: 홈에서 사용자가 자주 가는 경로를 “살짝 늦게” prefetch
- 결과: 클릭 시점에는 이미 RSC 페이로드가 준비되어 전환이 즉시 일어남
주의할 점은 남발하면 트래픽이 늘고, 모바일에서 역효과가 날 수 있다는 것입니다. “전환 확률이 높은 상위 1~3개 경로” 정도로 제한하는 게 안전합니다.
7) 레이아웃 유지로 리렌더 비용을 구조적으로 차단
prefetch와 캐시가 잘 되어도, 레이아웃 경계가 잘못되면 전환 때 UI가 통째로 바뀌는 느낌이 납니다. App Router의 장점은 layout.tsx가 유지된다는 점이므로, “변하지 않는 UI”는 레이아웃으로 끌어올리는 게 정석입니다.
// app/(dashboard)/layout.tsx
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div style={{ display: 'grid', gridTemplateColumns: '240px 1fr' }}>
<aside>
// 사이드바는 유지
</aside>
<section>{children}</section>
</div>
)
}
이 구조에서 /posts에서 /settings로 이동하면, 사이드바는 유지되고 children만 교체됩니다. 즉 “리렌더 제거”의 큰 축은 캐시뿐 아니라 라우트 트리 설계입니다.
8) 스트리밍과 Suspense: 로딩을 “없애기”보다 “숨기기”
현실적으로 모든 데이터를 캐시할 수는 없습니다. 그럴 때는 스트리밍과 Suspense로 “사용자 체감”을 줄입니다.
// app/posts/page.tsx
import { Suspense } from 'react'
async function PostsList() {
const res = await fetch('https://example.com/api/posts', {
next: { revalidate: 60 },
})
const posts = await res.json()
return (
<ul>
{posts.map((p: any) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
)
}
export default function Page() {
return (
<main>
<h1>Posts</h1>
<Suspense fallback={<div>Loading list...</div>}>
{/** 서버 컴포넌트 스트리밍 */}
<PostsList />
</Suspense>
</main>
)
}
- 캐시가 적중하면 fallback이 거의 보이지 않음
- 캐시 미스여도 페이지 전체가 막히지 않고, 부분만 늦게 채워짐
9) 실전 체크리스트: “리렌더 제거”를 위한 우선순위
- 같은 데이터인데
cache: 'no-store'로 박아둔 곳부터 제거하고revalidate로 전환 - 사용자별 데이터와 공용 데이터를 분리(공용은 캐시, 사용자별은 짧게 또는 분리 렌더)
- DB/가공 결과는
unstable_cache로 함수 단위 캐시 + 키 설계 - mutation 이후 즉시 반영이 필요하면 태그 기반 무효화(
tags,revalidateTag) - 전환 확률 높은 경로만
Link/router.prefetch로 선제 로딩 - 유지되어야 할 UI를
layout.tsx로 올려 route change 시 재생성 방지
캐시는 성능을 올리지만, 잘못 설계하면 “오래된 데이터”라는 또 다른 장애를 만듭니다. 이 균형 감각은 인프라/DB에서도 동일한데, 예를 들어 락 경합이나 갱신 타이밍을 다루는 감각은 PostgreSQL 데드락(40P01) 원인·해결 9단계 같은 글의 접근과도 닮아 있습니다.
10) 마무리: 캐시·prefetch는 “리렌더 최적화”의 본체다
App Router에서 리렌더를 줄이는 가장 큰 레버는 클라이언트의 memo 트릭이 아니라, 서버가 만든 RSC 결과를 얼마나 재사용하고(캐시), 얼마나 미리 가져오느냐(prefetch)입니다.
- 캐시를 설계하면 같은 라우트/같은 입력에서 서버 작업과 네트워크 비용이 줄고
- prefetch를 설계하면 사용자가 클릭하는 순간 이미 결과가 준비되어 전환이 즉시 일어나며
- 레이아웃 경계를 잘 잡으면 유지되어야 할 UI는 아예 다시 만들지 않습니다
결과적으로 “리렌더를 제거했다”는 체감은, 대부분 이 세 가지가 함께 맞물릴 때 만들어집니다.