- Published on
Next.js App Router RSC 캐시 무효화와 중복 fetch 제거
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 컴포넌트 중심의 Next.js App Router는 기본값이 강력한 대신, 캐시와 데이터 갱신 시점이 직관적이지 않아 운영에서 자주 삐끗합니다. 특히 RSC에서 fetch 를 여러 곳에서 호출했는데도 "한 번만 나갈 줄 알았는데" 중복 호출이 발생하거나, 반대로 "갱신했는데도 화면이 안 바뀌는" 상황이 반복됩니다.
이 글은 App Router에서 자주 헷갈리는 두 가지를 한 번에 정리합니다.
- RSC 캐시가 어떤 레이어에서 생기고, 어떤 트리거로 무효화되는지
- 중복
fetch를 제거하는 대표 패턴(요청 디듀프, 데이터 레이어 분리, 태그 기반 재검증)
운영 관점에서 보면, 잘못된 캐시 전략은 DB 부하를 폭발시키거나(중복 호출), 반대로 stale 데이터로 장애를 만듭니다. DB가 병목이 되면 락 대기나 튜닝까지 이어질 수 있으니, 캐시를 먼저 안정화하는 게 비용 대비 효과가 큽니다. 관련해서는 PostgreSQL VACUUM 안 먹힐 때 - bloat·autovacuum 튜닝 같은 글이 도움이 됩니다.
App Router에서 캐시는 "한 겹"이 아니다
App Router에서 흔히 말하는 "캐시"는 최소 3가지 레이어로 나뉩니다.
- 데이터 캐시(Data Cache): 서버에서
fetch결과를 캐싱 - RSC 페이로드 캐시(React Server Components payload): 서버 컴포넌트 렌더 결과 조각
- 라우트 캐시(Full Route Cache): 정적 렌더링 결과(HTML, RSC)를 라우트 단위로 캐싱
여기서 개발자가 가장 자주 부딪히는 건 1번과 2번입니다.
fetch를 기본 설정으로 쓰면 Next.js가 요청을 캐싱할 수 있습니다.- 서버 컴포넌트는 렌더 결과 자체가 캐싱될 수 있어, 데이터는 바뀌었는데 화면은 안 바뀌는 것처럼 보일 수 있습니다.
그리고 이 레이어들은 서로 영향을 줍니다. 예를 들어 데이터 캐시를 무효화해도, 이미 만들어진 RSC 페이로드가 살아있으면 기대한 갱신이 즉시 안 보일 수 있습니다. 반대로 RSC를 새로 만들게 해도, 내부에서 호출하는 fetch 가 캐시되어 있으면 데이터는 그대로일 수 있습니다.
RSC에서 fetch 캐시 기본 동작 정리
Next.js App Router에서 서버 컴포넌트의 fetch 는 Next.js가 래핑한 fetch 로 동작하며, 옵션에 따라 캐시 전략이 달라집니다.
1) 완전 동적: 매 요청마다 새로 가져오기
// app/products/page.tsx
export default async function Page() {
const res = await fetch('https://api.example.com/products', {
cache: 'no-store',
})
const products = await res.json()
return <pre>{JSON.stringify(products, null, 2)}</pre>
}
- 장점: 항상 최신
- 단점: 트래픽 증가 시 API, DB 부하 증가
운영에서 "관리자 화면"이나 "실시간성"이 강한 페이지는 no-store 가 안전합니다.
2) 시간 기반 재검증: revalidate 초 단위
// app/products/page.tsx
export default async function Page() {
const res = await fetch('https://api.example.com/products', {
next: { revalidate: 60 },
})
const products = await res.json()
return <pre>{JSON.stringify(products, null, 2)}</pre>
}
- 60초 동안은 캐시를 쓰고, 만료되면 다음 요청에서 재검증
- 트래픽이 많은 목록 페이지에 현실적인 기본값
3) 태그 기반 재검증: 특정 데이터만 선택적으로 무효화
가장 실전적인 패턴은 태그입니다.
// app/lib/api.ts
export async function getProducts() {
const res = await fetch('https://api.example.com/products', {
next: { tags: ['products'], revalidate: 3600 },
})
return res.json()
}
이렇게 해두면, 나중에 "products만" 즉시 무효화할 수 있습니다.
캐시 무효화의 핵심 도구: revalidateTag, revalidatePath, router.refresh
캐시 무효화는 크게 두 갈래입니다.
- 서버에서 캐시를 깨는 방법:
revalidateTag,revalidatePath - 클라이언트에서 RSC를 다시 받아오게 하는 방법:
router.refresh
1) 서버 액션에서 태그 기반 무효화
상품을 생성하는 서버 액션이 있고, 생성 후 목록이 즉시 갱신되어야 한다고 합시다.
// app/products/actions.ts
'use server'
import { revalidateTag } from 'next/cache'
export async function createProduct(formData: FormData) {
const name = String(formData.get('name') ?? '')
await fetch('https://api.example.com/products', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name }),
})
revalidateTag('products')
}
revalidateTag('products')는next: { tags: ['products'] }로 묶어둔fetch캐시를 무효화합니다.- 목록 페이지가
getProducts()를 쓰고 있다면, 다음 렌더에서 새 데이터를 가져옵니다.
2) revalidatePath 는 "페이지 단위"로 깨고 싶을 때
태그 설계가 아직 없거나, 특정 경로의 캐시를 통째로 무효화하고 싶다면 경로 기반이 빠릅니다.
import { revalidatePath } from 'next/cache'
revalidatePath('/products')
다만 경로 기반은 범위가 커지기 쉬워서, 데이터가 복잡해질수록 태그 기반으로 넘어가는 게 운영에 유리합니다.
3) 클라이언트에서 즉시 갱신: router.refresh()
서버 액션을 호출한 뒤, 현재 화면에서 즉시 RSC를 다시 받아와 UI를 최신화하려면 router.refresh() 를 씁니다.
// app/products/NewProductForm.tsx
'use client'
import { useRouter } from 'next/navigation'
import { createProduct } from './actions'
export function NewProductForm() {
const router = useRouter()
return (
<form
action={async (formData) => {
await createProduct(formData)
router.refresh()
}}
>
<input name="name" placeholder="name" />
<button type="submit">Create</button>
</form>
)
}
정리하면,
revalidateTag또는revalidatePath: 서버 캐시를 무효화router.refresh: 현재 클라이언트가 보고 있는 RSC 트리를 다시 가져오게 함
둘 중 하나만으로는 "즉시 갱신"이 안 될 수 있고, 보통은 같이 씁니다.
중복 fetch 가 생기는 대표 원인 4가지
"Next.js는 같은 fetch 를 자동으로 디듀프해준다"는 이야기를 들었는데, 실제로는 중복 호출이 발생하는 경우가 많습니다. 대표 원인은 아래 4가지입니다.
1) 요청 URL은 같지만 옵션이 달라서 다른 요청으로 취급
예를 들어 한쪽은 cache: 'no-store', 다른 쪽은 revalidate 를 쓰면 디듀프가 깨집니다.
await fetch(url, { cache: 'no-store' })
await fetch(url, { next: { revalidate: 60 } })
같은 데이터라면 옵션을 통일해야 합니다.
2) headers 나 cookies 가 달라서 캐시 키가 달라짐
인증 토큰이 헤더에 들어가거나, 사용자별 쿠키를 읽는 순간 사실상 "사용자별 데이터"가 되어 디듀프 및 캐시 효율이 급격히 떨어집니다.
- 공용 데이터(카탈로그, 공지 등)와 사용자 데이터(내 주문, 내 프로필)를 API 레이어부터 분리하세요.
- 공용 데이터는 가능한 한 쿠키, 세션 의존을 제거하세요.
3) 서버 컴포넌트 트리에서 같은 데이터를 여러 컴포넌트가 각각 가져옴
가장 흔한 실수는 "각 컴포넌트가 자기 데이터만 가져오게" 만들다가, 결과적으로 같은 API를 여러 번 호출하는 구조가 되는 겁니다.
이 경우는 데이터 접근을 한 곳으로 모으는 게 정답입니다.
4) 개발 모드에서의 착시(Strict Mode, HMR)
개발 모드에서는 리렌더링과 재요청이 더 자주 발생할 수 있습니다. 중복 호출을 논하기 전에, 반드시 프로덕션 빌드에서 재현되는지 확인하세요.
중복 fetch 제거 패턴 3가지
패턴 A: 데이터 함수(Repository)로 모으고, 한 번만 호출
페이지(또는 레이아웃)에서 필요한 데이터를 모아서 가져오고, 하위 컴포넌트에는 결과만 내려주는 방식입니다.
// app/products/page.tsx
import { getProducts } from '../lib/api'
export default async function Page() {
const products = await getProducts()
return (
<div>
<ProductsHeader count={products.length} />
<ProductsList products={products} />
</div>
)
}
function ProductsHeader({ count }: { count: number }) {
return <h2>Total: {count}</h2>
}
function ProductsList({ products }: { products: Array<{ id: string; name: string }> }) {
return (
<ul>
{products.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
)
}
- 장점: 호출 지점이 명확해지고, 중복이 구조적으로 사라짐
- 단점: 컴포넌트 재사용성이 약간 떨어질 수 있음
패턴 B: cache() 로 요청 디듀프를 "명시적"으로 고정
React가 제공하는 cache() 를 사용하면, 같은 인자로 호출되는 비동기 함수를 요청 단위로 메모이즈할 수 있습니다.
// app/lib/products.ts
import { cache } from 'react'
export const getProductsCached = cache(async () => {
const res = await fetch('https://api.example.com/products', {
next: { tags: ['products'], revalidate: 300 },
})
return res.json()
})
그리고 여러 서버 컴포넌트에서 이 함수를 호출해도, 같은 요청 사이클 내에서 중복 호출이 줄어듭니다.
// app/products/page.tsx
import { getProductsCached } from '../lib/products'
export default async function Page() {
const products = await getProductsCached()
return <pre>{JSON.stringify(products, null, 2)}</pre>
}
주의할 점:
cache()는 "요청 단위 메모이제이션"에 가깝고, Next의 Data Cache와는 목적이 다릅니다.- 사용자별 데이터(쿠키 기반)까지 무심코
cache()로 감싸면, 의도치 않은 공유가 생길 수 있으니 입력 파라미터 설계를 엄격히 하세요.
패턴 C: 태그 기반 캐시 설계로 "갱신 비용"을 낮추기
중복 호출을 완전히 없애기 어렵다면, 최소한 "비싼 호출"이 자주 일어나지 않게 만들어야 합니다.
- 목록:
next: { tags: ['products'], revalidate: 300 } - 상세:
next: { tags: ['product:' + id], revalidate: 300 }
// app/lib/api.ts
export async function getProduct(id: string) {
const res = await fetch(`https://api.example.com/products/${id}`, {
next: { tags: [`product:${id}`], revalidate: 300 },
})
return res.json()
}
업데이트 시에는 필요한 것만 깨면 됩니다.
// app/products/actions.ts
'use server'
import { revalidateTag } from 'next/cache'
export async function updateProduct(id: string, name: string) {
await fetch(`https://api.example.com/products/${id}`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name }),
})
revalidateTag(`product:${id}`)
revalidateTag('products')
}
이렇게 하면,
- 상세 페이지는 해당 상품만
- 목록 페이지는 목록 태그만
정확히 갱신되어 캐시 효율과 최신성의 균형이 좋아집니다.
운영에서 자주 터지는 함정: "동적 함수"가 전체를 동적으로 만든다
서버 컴포넌트에서 cookies() 나 headers() 같은 요청 의존 값을 읽으면, 해당 경로가 동적으로 처리될 수 있고 캐시 전략이 달라집니다. 공용 목록 페이지에서 무심코 cookies() 를 읽으면:
- 라우트 캐시가 깨지고
- Data Cache 효율도 떨어지고
- 결과적으로 중복 호출이 늘어날 수 있습니다
해결책은 보통 둘 중 하나입니다.
- 공용 데이터 페이지에서는 요청 의존 API를 읽지 않는다
- 사용자별 데이터는 별도 세그먼트로 분리하고, 그 구간만 동적으로 만든다
이건 Kubernetes에서도 "한 컨테이너의 문제"가 전체 Pod 재시작으로 번지는 것과 유사한 구조적 문제입니다. 장애를 빨리 좁히는 관점은 K8s CrashLoopBackOff 원인 10분내 찾는 법 같은 글의 접근과도 닮아 있습니다.
디버깅 체크리스트: 캐시가 안 깨질 때, 중복 호출이 있을 때
캐시가 안 깨지는 것처럼 보일 때
revalidateTag를 호출했는데, 해당fetch가 정말next.tags를 달고 있는지 확인- 서버 액션 이후 화면이 즉시 바뀌어야 한다면
router.refresh()를 호출했는지 확인 - 개발 모드 착시인지 프로덕션 빌드에서 확인
- 태그 문자열이 일관적인지 확인(예:
product:1과product:01혼용)
중복 fetch 가 있을 때
- URL, 메서드, 헤더,
next옵션이 완전히 동일한지 확인 - 한 트리에서 여러 컴포넌트가 같은 데이터를 각각 가져오는 구조인지 확인
- 공용 데이터에 쿠키, 헤더 의존이 섞이지 않았는지 확인
- 가능한 경우
cache()로 명시적 디듀프를 걸거나, 상위에서 한 번만 호출하도록 리팩터링
DB가 느려져서 원인을 추적하다 보면, 결국 "불필요한 요청"이 락 경합을 악화시키는 형태로 나타나는 경우가 많습니다. 락 대기까지 번졌다면 PostgreSQL 락 대기 폭증? deadlock 진단·해결 관점으로 함께 점검하는 게 좋습니다.
추천 조합(실전 기본값)
운영에서 무난한 조합을 하나 추천하면 아래입니다.
- 공용 목록, 공용 상세
fetch에next: { tags: [...], revalidate: 60~300 }- 쓰기 작업 후
revalidateTag로 필요한 것만 무효화 - UX 즉시 갱신이 필요하면 클라이언트에서
router.refresh()
- 사용자별 데이터
- 가능하면 별도 라우트 또는 별도 API로 분리
cache: 'no-store'로 단순하게 시작하고, 병목이 확인되면 세분화
마무리
App Router의 RSC 캐시 전략은 "정적 최적화"와 "서버 컴포넌트"가 결합되면서, 단순한 HTTP 캐시보다 훨씬 강력해졌습니다. 대신 개발자가 다음을 명확히 설계해야 합니다.
- 무엇을 공용 캐시로 둘지, 무엇을 사용자별로 둘지
- 시간 기반(
revalidate)과 이벤트 기반(revalidateTag,revalidatePath) 중 무엇을 쓸지 - 중복
fetch를 구조적으로 막을지(상위 집계), 메모이제이션으로 막을지(cache())
이 세 가지가 잡히면, "갱신이 안 된다"와 "중복 호출로 비용이 튄다"는 문제가 동시에 줄어듭니다.