Published on

Next.js 14 RSC 캐시 꼬임·중복 fetch 7가지

Authors

Next.js 14(App Router)의 RSC(React Server Components) 환경은 기본적으로 fetch 를 똑똑하게 캐시하고, 같은 요청을 dedupe(동일 요청 병합)해 주는 것처럼 보입니다. 그런데 실제 운영에서는 “왜 같은 API가 두 번(혹은 그 이상) 호출되지?” 또는 “분명 revalidate 걸었는데 데이터가 안 바뀐다/엉뚱하게 섞인다” 같은 문제가 자주 발생합니다.

이 글은 RSC 캐시가 꼬이거나 fetch중복되는 대표적인 7가지 패턴을, 재현 가능한 형태로 정리한 체크리스트입니다. 핵심은 다음 3가지를 분리해서 이해하는 것입니다.

  • RSC 렌더링 모델: 서버에서 컴포넌트 트리를 스트리밍하고, 라우트 전환 시 일부 트리만 다시 계산됩니다.
  • Next.js fetch 캐시: fetch(url, { next: { revalidate, tags } })cache 옵션에 의해 캐시 정책이 결정됩니다.
  • 요청 병합(dedupe): “동일한 요청”으로 판단되는 경우에만 병합됩니다(조금만 달라도 다른 요청).

아래 7가지를 순서대로 점검하면 대부분의 “캐시 꼬임/중복 fetch” 이슈를 구조적으로 해결할 수 있습니다.


1) 쿼리스트링/헤더/쿠키 차이로 캐시 키가 달라지는 경우

가장 흔한 중복 원인입니다. 개발자는 같은 API를 호출한다고 생각하지만, 실제로는 URL이나 헤더가 미세하게 달라 캐시 키가 달라집니다.

  • URL 쿼리 파라미터 순서가 다름: ?a=1&b=2 vs ?b=2&a=1
  • headers 에 매번 다른 값이 들어감: x-request-id, Authorization, Cookie
  • cache: 'no-store' 를 섞어 씀

재현 코드

// app/products/page.tsx (RSC)
async function getProducts(sort: string) {
  const url = `https://api.example.com/products?sort=${sort}`
  return fetch(url, {
    // 헤더가 요청마다 달라지면 dedupe가 깨질 수 있음
    headers: {
      'x-request-id': crypto.randomUUID(),
    },
  }).then(r => r.json())
}

export default async function Page() {
  const a = await getProducts('new')
  const b = await getProducts('new')
  return <pre>{JSON.stringify({ aLen: a.length, bLen: b.length }, null, 2)}</pre>
}

해결책

  • 캐시 가능한 요청에는 변동 헤더를 넣지 말고, 필요하면 서버에서 로깅은 다른 레이어에서 처리합니다.
  • 쿼리스트링은 정규화합니다.
function stableQuery(params: Record<string, string>) {
  const usp = new URLSearchParams()
  Object.keys(params).sort().forEach(k => usp.set(k, params[k]))
  return usp.toString()
}

async function getProducts(sort: string) {
  const qs = stableQuery({ sort })
  const url = `https://api.example.com/products?${qs}`
  return fetch(url, {
    next: { revalidate: 60 },
  }).then(r => r.json())
}

2) cookies()/headers()/searchParams 사용으로 라우트가 동적화되는 경우

RSC에서 cookies()headers() 를 읽으면 해당 경로가 동적(dynamic) 으로 처리되며, 캐시 전략이 기대와 다르게 바뀔 수 있습니다. 특히 인증 상태에 따라 사용자별 응답이 달라지는 페이지에서 자주 꼬입니다.

전형적인 패턴

import { cookies } from 'next/headers'

export default async function Page() {
  const token = cookies().get('token')?.value

  const me = await fetch('https://api.example.com/me', {
    headers: { Authorization: `Bearer ${token}` },
  }).then(r => r.json())

  return <pre>{JSON.stringify(me, null, 2)}</pre>
}

이 코드는 사용자별 데이터를 가져오므로 캐시를 잘못 걸면 데이터가 섞이거나, 반대로 캐시가 전혀 안 먹어서 매번 호출될 수 있습니다.

해결책

  • 사용자별 응답이면 명시적으로 cache: 'no-store' 를 사용합니다.
  • 반대로 “공용 데이터”는 사용자 컨텍스트를 읽지 않는 컴포넌트로 분리합니다.
// 사용자별
async function getMe(token?: string) {
  return fetch('https://api.example.com/me', {
    cache: 'no-store',
    headers: token ? { Authorization: `Bearer ${token}` } : {},
  }).then(r => r.json())
}

3) revalidatecache 옵션을 섞어서 의미가 뒤집히는 경우

Next.js에서 fetch 캐시 정책은 조합에 따라 직관과 다르게 동작합니다. 특히 아래 조합이 자주 문제를 만듭니다.

  • cache: 'no-store' 를 주면 next: { revalidate } 는 사실상 의미가 없어집니다(항상 매 요청).
  • cache: 'force-cache' 는 “가능하면 캐시 고정” 성격이라, 데이터 갱신이 늦게 보일 수 있습니다.

문제 코드(의도: 30초 캐시)

await fetch('https://api.example.com/news', {
  cache: 'no-store',
  next: { revalidate: 30 },
})

해결책

  • “시간 기반 캐시”면 cache 는 기본값을 두고 next.revalidate 만 사용합니다.
await fetch('https://api.example.com/news', {
  next: { revalidate: 30 },
})
  • “절대 캐시 금지”면 cache: 'no-store' 만 사용합니다.

4) 동일 데이터를 여러 컴포넌트가 각각 가져와서 중복 호출되는 경우(구조적 중복)

RSC는 컴포넌트 단위로 서버에서 실행됩니다. 같은 페이지 트리 안에서 서로 다른 컴포넌트가 같은 API를 호출하면, dedupe가 되더라도 조건이 맞지 않으면 중복 호출이 발생합니다.

특히 다음 상황에서 중복이 쉽게 발생합니다.

  • 서로 다른 컴포넌트가 URL을 조금 다르게 구성
  • 한쪽은 no-store, 다른 쪽은 revalidate
  • 한쪽은 headers 를 추가

해결책 1: 데이터 패칭을 상위로 끌어올리고 props로 전달

// app/dashboard/page.tsx
import Summary from './Summary'
import Chart from './Chart'

async function getStats() {
  return fetch('https://api.example.com/stats', {
    next: { revalidate: 60 },
  }).then(r => r.json())
}

export default async function Page() {
  const stats = await getStats()
  return (
    <>
      <Summary stats={stats} />
      <Chart stats={stats} />
    </>
  )
}

해결책 2: cache() 로 서버 함수 메모이제이션(요청 단위)

React의 cache 유틸(서버에서 동일 인자로 호출 시 메모이제이션)을 사용하면 구조적 중복을 줄이기 쉽습니다.

import { cache } from 'react'

export const getStats = cache(async () => {
  const res = await fetch('https://api.example.com/stats', {
    next: { revalidate: 60 },
  })
  return res.json()
})

주의: 이 메모이제이션은 “서버 런타임/요청 경계”와 결합되어 동작하므로, 영구 캐시로 오해하면 안 됩니다.


5) Suspense 경계/병렬 렌더링으로 동일 fetch가 겹쳐 보이는 경우

RSC 스트리밍과 Suspense 는 체감상 “동시에 여러 조각이 렌더링”됩니다. 이때 같은 데이터를 서로 다른 경계에서 요청하면, 타이밍에 따라 dedupe가 기대만큼 먹지 않거나 “로그상 중복”처럼 보일 수 있습니다.

점검 포인트

  • 동일 API를 호출하는 컴포넌트가 서로 다른 Suspense 경계에 있나?
  • Promise.all 로 묶어 상위에서 한 번에 가져오면 해결되나?
export default async function Page() {
  const [a, b] = await Promise.all([
    fetch('https://api.example.com/a', { next: { revalidate: 60 } }).then(r => r.json()),
    fetch('https://api.example.com/b', { next: { revalidate: 60 } }).then(r => r.json()),
  ])

  return <pre>{JSON.stringify({ a, b }, null, 2)}</pre>
}

6) revalidateTag/tags 를 안 맞춰서 “갱신했는데 그대로”가 되는 경우

시간 기반(revalidate)만으로는 운영에서 한계가 있습니다. 예를 들어 CMS에서 글을 수정했는데 60초를 기다릴 수 없으면, 태그 기반 무효화가 필요합니다.

문제는 fetch 에 붙인 tags 와 무효화 코드의 태그 문자열이 조금이라도 다르면, “무효화했는데 안 바뀜”이 됩니다.

올바른 태그 기반 캐시

// 데이터 패칭
export async function getPost(id: string) {
  return fetch(`https://api.example.com/posts/${id}`,
    { next: { tags: [`post:${id}`], revalidate: 3600 } }
  ).then(r => r.json())
}
// app/actions.ts
'use server'

import { revalidateTag } from 'next/cache'

export async function updatePost(id: string, payload: unknown) {
  await fetch(`https://api.example.com/posts/${id}`, {
    method: 'PATCH',
    body: JSON.stringify(payload),
    headers: { 'content-type': 'application/json' },
  })

  revalidateTag(`post:${id}`)
}

체크리스트

  • 태그 문자열 컨벤션을 코드베이스 전체에서 통일했나?
  • 목록과 상세가 같은 태그를 공유해야 하는데 분리되어 있지 않나?
    • 예: posts:listpost:${id} 를 함께 무효화해야 할 수 있음

API 레이트 리밋이나 백오프 설계가 필요한 경우도 많습니다. 외부 API 호출이 많아져 429 가 나면 캐시 이슈처럼 보이기도 하니, 호출 제어는 별도로 정리해 두는 것이 좋습니다: OpenAI 429 Rate Limit 해결 - 백오프·큐·배치


7) 개발 모드에서만 중복 fetch가 심하게 보이는 경우(Dev 특성)

개발 모드에서는 다음 이유로 운영보다 중복 호출이 더 자주 관측됩니다.

  • Fast Refresh/HMR로 서버 컴포넌트가 자주 재실행
  • 디버깅을 위한 추가 렌더/재요청
  • 로컬에서 프록시/미들웨어가 헤더를 덧붙여 캐시 키가 달라짐

해결책

  • “운영에서의 실제 동작”은 next buildnext start 로 검증합니다.
  • 로깅 시 요청 식별자를 붙이되, 캐시 키에 영향을 주는 헤더/쿼리로 섞지 않습니다.
  • 미들웨어에서 request.headers 를 조작했다면, 그로 인해 fetch 캐시가 깨지지 않는지 확인합니다.

운영 장애로 번지면 원인이 캐시가 아니라 “동시성 폭증으로 인한 DB 락/데드락”인 경우도 있습니다. RSC에서 서버 호출이 늘면 백엔드도 같이 흔들리기 때문에, 데이터 계층도 함께 점검하는 게 안전합니다: MySQL InnoDB 데드락 폭증, Redis 분산락 튜닝


실전 디버깅: 중복 fetch를 눈으로 확인하는 로깅 패턴

RSC에서 “정말 같은 요청이 두 번 나갔는지”를 확인하려면, 서버에서 URL/옵션을 표준화해서 로그를 남기는 게 좋습니다.

function logFetch(input: string, init?: RequestInit) {
  const method = init?.method ?? 'GET'
  const cache = (init as any)?.cache
  const nextOpt = (init as any)?.next

  console.log('[fetch]', JSON.stringify({ method, input, cache, next: nextOpt }))
  return fetch(input, init)
}

export async function getSomething() {
  const res = await logFetch('https://api.example.com/something', {
    next: { revalidate: 60, tags: ['something'] },
  })
  return res.json()
}

이 로그를 기준으로 아래를 비교합니다.

  • URL이 완전히 동일한가(쿼리 포함)
  • cache/next.revalidate/next.tags 가 동일한가
  • headers 가 달라지는가(특히 쿠키/인증)

정리: 7가지 점검 순서(우선순위)

  1. URL/쿼리/헤더가 미세하게 달라 캐시 키가 달라지는지
  2. cookies()/headers() 사용으로 동적화가 걸렸는지
  3. cachenext.revalidate 조합이 의도와 일치하는지
  4. 같은 데이터를 여러 컴포넌트가 구조적으로 중복 패칭하는지
  5. Suspense/병렬 렌더 경계로 요청이 겹치는지
  6. tagsrevalidateTag 문자열이 정확히 매칭되는지
  7. 개발 모드 특성으로만 중복이 과장되어 보이는지

RSC 캐시는 “한 번 이해하면 편한” 대신, 애매한 상태로 쓰면 작은 차이(헤더 하나, 옵션 하나)로 결과가 크게 달라집니다. 위 7가지를 기준으로 요청을 표준화하고, 사용자별/공용 데이터를 분리하고, 태그 기반 무효화를 도입하면 캐시 꼬임과 중복 fetch를 대부분 제거할 수 있습니다.

추가로 런타임/모듈 시스템 이슈로 서버 코드가 의도치 않게 중복 실행되는 케이스(특히 ESM 전환 과정에서)도 있으니, Node 런타임을 올리면서 문제가 생겼다면 이 글도 함께 참고할 만합니다: Node 22에서 require가 안 될 때 ESM 전환법