Published on

Next.js App Router RSC 캐시 무효화와 중복 fetch 제거

Authors

서버 컴포넌트 중심의 Next.js App Router는 기본값이 강력한 대신, 캐시와 데이터 갱신 시점이 직관적이지 않아 운영에서 자주 삐끗합니다. 특히 RSC에서 fetch 를 여러 곳에서 호출했는데도 "한 번만 나갈 줄 알았는데" 중복 호출이 발생하거나, 반대로 "갱신했는데도 화면이 안 바뀌는" 상황이 반복됩니다.

이 글은 App Router에서 자주 헷갈리는 두 가지를 한 번에 정리합니다.

  • RSC 캐시가 어떤 레이어에서 생기고, 어떤 트리거로 무효화되는지
  • 중복 fetch 를 제거하는 대표 패턴(요청 디듀프, 데이터 레이어 분리, 태그 기반 재검증)

운영 관점에서 보면, 잘못된 캐시 전략은 DB 부하를 폭발시키거나(중복 호출), 반대로 stale 데이터로 장애를 만듭니다. DB가 병목이 되면 락 대기나 튜닝까지 이어질 수 있으니, 캐시를 먼저 안정화하는 게 비용 대비 효과가 큽니다. 관련해서는 PostgreSQL VACUUM 안 먹힐 때 - bloat·autovacuum 튜닝 같은 글이 도움이 됩니다.

App Router에서 캐시는 "한 겹"이 아니다

App Router에서 흔히 말하는 "캐시"는 최소 3가지 레이어로 나뉩니다.

  1. 데이터 캐시(Data Cache): 서버에서 fetch 결과를 캐싱
  2. RSC 페이로드 캐시(React Server Components payload): 서버 컴포넌트 렌더 결과 조각
  3. 라우트 캐시(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) headerscookies 가 달라서 캐시 키가 달라짐

인증 토큰이 헤더에 들어가거나, 사용자별 쿠키를 읽는 순간 사실상 "사용자별 데이터"가 되어 디듀프 및 캐시 효율이 급격히 떨어집니다.

  • 공용 데이터(카탈로그, 공지 등)와 사용자 데이터(내 주문, 내 프로필)를 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:1product:01 혼용)

중복 fetch 가 있을 때

  • URL, 메서드, 헤더, next 옵션이 완전히 동일한지 확인
  • 한 트리에서 여러 컴포넌트가 같은 데이터를 각각 가져오는 구조인지 확인
  • 공용 데이터에 쿠키, 헤더 의존이 섞이지 않았는지 확인
  • 가능한 경우 cache() 로 명시적 디듀프를 걸거나, 상위에서 한 번만 호출하도록 리팩터링

DB가 느려져서 원인을 추적하다 보면, 결국 "불필요한 요청"이 락 경합을 악화시키는 형태로 나타나는 경우가 많습니다. 락 대기까지 번졌다면 PostgreSQL 락 대기 폭증? deadlock 진단·해결 관점으로 함께 점검하는 게 좋습니다.

추천 조합(실전 기본값)

운영에서 무난한 조합을 하나 추천하면 아래입니다.

  • 공용 목록, 공용 상세
    • fetchnext: { tags: [...], revalidate: 60~300 }
    • 쓰기 작업 후 revalidateTag 로 필요한 것만 무효화
    • UX 즉시 갱신이 필요하면 클라이언트에서 router.refresh()
  • 사용자별 데이터
    • 가능하면 별도 라우트 또는 별도 API로 분리
    • cache: 'no-store' 로 단순하게 시작하고, 병목이 확인되면 세분화

마무리

App Router의 RSC 캐시 전략은 "정적 최적화"와 "서버 컴포넌트"가 결합되면서, 단순한 HTTP 캐시보다 훨씬 강력해졌습니다. 대신 개발자가 다음을 명확히 설계해야 합니다.

  • 무엇을 공용 캐시로 둘지, 무엇을 사용자별로 둘지
  • 시간 기반(revalidate)과 이벤트 기반(revalidateTag, revalidatePath) 중 무엇을 쓸지
  • 중복 fetch 를 구조적으로 막을지(상위 집계), 메모이제이션으로 막을지(cache())

이 세 가지가 잡히면, "갱신이 안 된다"와 "중복 호출로 비용이 튄다"는 문제가 동시에 줄어듭니다.