dev.syw

요청 메모이제이션·데이터·풀라우트·라우터 캐시를 정밀 제어한다.

Next.js 4계층 캐싱 아키텍처 심층 제어

입문편에서 fetchcache 옵션과 revalidate로 기본 캐싱을 맛봤다면, 이번 레슨은 그 아래에서 무슨 일이 벌어지는지를 파고듭니다. Next.js는 단순한 HTTP 캐시가 아니라 4개의 서로 다른 캐시 계층을 조율하는 복잡한 시스템을 갖추고 있습니다. 이 계층들이 어떻게 상호작용하고, 무효화 신호가 어떻게 전파되며, 운영 환경에서 어떤 함정이 도사리는지를 이해해야만 예측 가능한 애플리케이션을 만들 수 있습니다.

학습 목표

  • Request Memoization, Data Cache, Full Route Cache, Router Cache 4계층의 경계와 수명 주기를 구분할 수 있다.
  • revalidateTag / revalidatePath의 무효화 전파 범위와 타이밍 차이를 설명할 수 있다.
  • unstable_cache, fetch 옵션, cache() 함수로 데이터 캐시를 세밀하게 제어할 수 있다.
  • x-nextjs-cache 헤더와 서버 로그를 활용해 HIT/MISS 상태를 추적할 수 있다.
  • 분산 환경과 stale-while-revalidate에서 발생하는 캐시 일관성 함정을 피할 수 있다.

1. 4계층 캐시 구조와 상호작용

Next.js의 캐싱은 요청이 들어온 순간부터 브라우저에 응답이 도달하기까지 4개의 관문을 통과합니다.

계층위치범위기본 수명
Request Memoization서버 메모리단일 렌더링 트리, 단일 요청요청 종료 시 파기
Data Cache서버 파일시스템/외부 스토어서버 프로세스 간 공유 가능영구 (명시적 무효화 전까지)
Full Route CacheCDN / 서버 파일시스템정적으로 렌더링된 라우트 전체빌드 or revalidate 까지
Router Cache브라우저 메모리현재 탭·세션탐색 사이 (Next.js 14 기준 동적 30초·정적 5분, 15부터 동적 기본 0초·staleTimes로 조정)

계층 간 데이터 흐름

요청이 들어오면 Next.js는 아래 순서로 각 캐시를 확인합니다.

브라우저 요청
  └─> [Router Cache] HIT → 즉시 반환
       MISS ↓
  └─> [Full Route Cache] HIT → HTML 반환, Router Cache 채움
       MISS ↓
  └─> React 렌더링 시작
        └─> fetch() 호출
              └─> [Request Memoization] HIT → 동일 렌더 트리 내 중복 제거
                   MISS ↓
              └─> [Data Cache] HIT → 캐시된 응답 반환
                   MISS ↓
              └─> 실제 네트워크 요청 → Data Cache 채움

Request Memoization은 특히 이해하기 쉽게 놓칩니다. 서버 컴포넌트 트리에서 여러 컴포넌트가 동일한 URL로 fetch를 호출하더라도 실제 네트워크 요청은 단 한 번만 발생합니다. 이는 React의 cache() 래퍼로 구현되어 있으며, 렌더링이 끝나면 즉시 파기됩니다.

// app/components/UserCard.tsx
// 여러 컴포넌트에서 같은 URL fetch → 네트워크 요청은 1번만 발생
async function UserCard({ userId }: { userId: string }) {
  // 동일 렌더 트리 내 다른 컴포넌트가 이미 호출했다면 메모이즈된 값 반환
  // 서버 측 fetch는 절대 URL을 요구하므로 베이스 URL을 환경변수로 구성
  const user = await fetch(`${process.env.API_BASE_URL}/api/users/${userId}`).then(r => r.json())
  return <div>{user.name}</div>
}

💡 TIP Request Memoization은 fetch뿐 아니라 React의 cache() 함수로 감싼 임의의 비동기 함수에도 동일하게 적용됩니다.


2. 태그 기반 무효화: revalidateTag와 revalidatePath

무효화는 캐싱에서 가장 까다로운 부분입니다. revalidateTagrevalidatePath는 비슷해 보이지만 전파 범위가 다릅니다.

revalidateTag — 데이터 중심 무효화

revalidateTag는 특정 태그가 붙은 모든 Data Cache 항목을 무효화합니다. 같은 태그를 공유하는 여러 라우트의 캐시가 동시에 만료됩니다.

// app/lib/data.ts
export async function getPost(id: string) {
  const res = await fetch(`https://api.example.com/posts/${id}`, {
    next: { tags: ['posts', `post-${id}`] }, // ✅ 태그 부여
  })
  return res.json()
}

export async function getPosts() {
  const res = await fetch('https://api.example.com/posts', {
    next: { tags: ['posts'] }, // ✅ 컬렉션 태그
  })
  return res.json()
}
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
import { NextRequest } from 'next/server'

export async function POST(req: NextRequest) {
  const { tag, secret } = await req.json()

  if (secret !== process.env.REVALIDATION_SECRET) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 })
  }

  revalidateTag(tag)
  // 'posts' 태그를 무효화하면 getPost, getPosts 캐시 모두 만료됨
  return Response.json({ revalidated: true, tag })
}

revalidatePath — 라우트 중심 무효화

revalidatePath는 특정 경로의 Full Route Cache와 해당 경로에서 사용된 Data Cache를 함께 무효화합니다.

// app/actions/posts.ts
'use server'

import { revalidatePath, revalidateTag } from 'next/cache'

export async function updatePost(id: string, data: FormData) {
  await fetch(`https://api.example.com/posts/${id}`, {
    method: 'PATCH',
    body: data,
  })

  // 방법 1: 특정 포스트 경로 무효화 (Full Route Cache + Data Cache)
  revalidatePath(`/blog/${id}`)

  // 방법 2: 태그 기반으로 데이터만 무효화 (더 정교한 제어)
  revalidateTag(`post-${id}`)
  revalidateTag('posts') // 목록도 같이 무효화
}
구분revalidateTagrevalidatePath
무효화 대상해당 태그의 Data Cache해당 경로의 Full Route Cache + Data Cache
적합한 상황데이터 레이어 중심, 여러 페이지 공유 데이터특정 URL의 전체 캐시를 확실히 날릴 때
전파 속도즉시 (다음 요청에 MISS 처리)즉시 (다음 요청에 재렌더링)

⚠️ 주의 revalidatePath('/', 'layout')을 호출하면 루트 레이아웃 아래 모든 경로의 Full Route Cache가 무효화됩니다. 편리하지만 트래픽이 많은 서비스에서는 캐시 stampede(동시 재생성)가 발생할 수 있으니 신중하게 사용하세요.


3. On-demand ISR과 시간 기반 revalidate 조합 전략

시간 기반 revalidate와 On-demand ISR은 배타적이 아닙니다. 두 전략을 조합하면 신선도와 서버 부하를 동시에 제어할 수 있습니다.

// app/blog/[slug]/page.tsx
import { getPost } from '@/lib/data'

export const revalidate = 3600 // ✅ 1시간마다 자동 재검증 (폴백 전략)

export default async function BlogPost({ params }: { params: { slug: string } }) {
  // 이 fetch는 Data Cache에 저장되며 태그도 함께 부여
  const post = await fetch(`https://cms.example.com/posts/${params.slug}`, {
    next: {
      tags: [`post-${params.slug}`, 'posts'],
      revalidate: 3600, // 개별 fetch 수준에서도 제어 가능
    },
  }).then(r => r.json())

  return <article>{post.content}</article>
}
// app/api/webhook/cms/route.ts
// CMS 웹훅: 콘텐츠 수정 즉시 On-demand 무효화
import { revalidateTag } from 'next/cache'

export async function POST(req: Request) {
  const payload = await req.json()
  const { slug, event } = payload

  if (event === 'entry.update' || event === 'entry.publish') {
    revalidateTag(`post-${slug}`) // ✅ 즉시 무효화
    // 다음 요청에서 재렌더링 후 새 캐시 생성
    // 그 사이 1시간 기반 revalidate가 만료되지 않아도 최신 데이터 보장
  }

  return Response.json({ ok: true })
}

이 패턴의 핵심은 시간 기반 revalidate를 안전망으로, On-demand ISR을 주 무효화 수단으로 사용하는 것입니다. CMS 웹훅이 실패하더라도 최대 1시간 안에 자동 갱신됩니다.


4. unstable_cache · fetch 옵션 · cache() 함수로 데이터 계층 세밀 제어

fetch를 직접 쓸 수 없는 ORM 쿼리나 SDK 호출도 Data Cache에 통합할 수 있습니다.

unstable_cache — fetch 외부 데이터 소스 캐싱

// app/lib/db.ts
import { unstable_cache } from 'next/cache'
import { db } from './database' // Prisma, Drizzle 등

export const getCachedPosts = unstable_cache(
  async (authorId: string) => {
    // ORM 쿼리도 Data Cache에 저장됨
    return db.post.findMany({ where: { authorId } })
  },
  ['posts-by-author'], // 캐시 키 접두사
  {
    tags: ['posts'],   // revalidateTag로 무효화 가능
    revalidate: 600,   // 10분
  }
)
// app/author/[id]/page.tsx
import { getCachedPosts } from '@/lib/db'

export default async function AuthorPage({ params }: { params: { id: string } }) {
  // 동일한 authorId로 여러 번 호출해도 캐시 HIT
  const posts = await getCachedPosts(params.id)
  return <PostList posts={posts} />
}

cache() 함수 — Request Memoization 확장

React의 cache() 함수는 동일 렌더링 트리 내 중복 호출을 제거합니다. unstable_cache와 조합하면 두 계층을 동시에 활용할 수 있습니다.

// app/lib/data.ts
import { cache } from 'react'
import { unstable_cache } from 'next/cache'

// unstable_cache: Data Cache (요청 간 지속)
// cache(): Request Memoization (단일 렌더링 내 중복 제거)
export const getUser = cache(
  unstable_cache(
    async (id: string) => {
      return db.user.findUnique({ where: { id } })
    },
    ['user'],
    { tags: ['users'], revalidate: 300 }
  )
)

fetch 옵션 조합 치트시트

// ✅ 항상 최신 데이터 (캐시 완전 비활성)
fetch(url, { cache: 'no-store' })

// ✅ 무효화 전까지 Data Cache에 무기한 보관 (미스 시 런타임에도 가져옴)
fetch(url, { cache: 'force-cache' })

// ✅ 시간 기반 재검증
fetch(url, { next: { revalidate: 60 } })

// ✅ 태그 기반 무효화 (On-demand ISR)
fetch(url, { next: { tags: ['posts'] } })

// ✅ 태그 + 시간 기반 동시 적용
fetch(url, { next: { tags: ['posts'], revalidate: 3600 } })

// ❌ cache: 'no-store'와 revalidate 동시 사용 — 의미 충돌
fetch(url, { cache: 'no-store', next: { revalidate: 60 } })

⚠️ 주의 export const dynamic = 'force-dynamic'을 라우트에 설정하면 해당 라우트 내 모든 fetch 호출이 cache: 'no-store'로 강제됩니다. 개별 fetch 옵션이 무시되므로, 일부 데이터만 동적으로 만들고 싶다면 라우트 레벨 옵션 대신 fetch 수준에서 제어하세요.


5. 캐시 디버깅: 로그와 헤더로 HIT/MISS 추적

캐시 동작을 눈으로 확인하지 않으면 문제가 생겼을 때 원인을 찾기 어렵습니다.

x-nextjs-cache 응답 헤더

프로덕션(또는 next start)에서 라우트 응답 헤더를 확인하면 Full Route Cache 상태를 알 수 있습니다.

curl -I https://your-app.com/blog/hello-world
# HTTP/2 200
# x-nextjs-cache: HIT      ← Full Route Cache에서 서빙됨
# x-nextjs-cache: MISS     ← 캐시 없음, 새로 렌더링
# x-nextjs-cache: STALE    ← 만료됐으나 백그라운드 재생성 중 (stale-while-revalidate)
# x-nextjs-cache: SKIP     ← 동적 렌더링, 캐시 대상 아님

서버 로그로 Data Cache 추적

next.config.js에서 캐시 로깅을 활성화하면 개발 중 Data Cache HIT/MISS를 콘솔에서 볼 수 있습니다.

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  logging: {
    fetches: {
      fullUrl: true, // ✅ 전체 URL 표시
    },
  },
}

module.exports = nextConfig
JavaScript

개발 서버를 실행하면 터미널에서 다음과 같은 출력이 나타납니다.

GET /blog/hello-world 200 in 45ms
 │ GET https://api.example.com/posts/hello-world 200 in 12ms (cache: HIT)
 │ GET https://api.example.com/author/123 200 in 0ms  (cache: HIT, deduplicated)

커스텀 캐시 핸들러로 분산 환경 디버깅

멀티 인스턴스 환경에서는 각 인스턴스가 독립적인 Data Cache를 가지므로 Redis 같은 공유 스토어가 필요합니다.

// cache-handler.js (프로젝트 루트)
const { CacheHandler } = require('@neshca/cache-handler')
const createRedisHandler = require('@neshca/cache-handler/redis-strings').default
const { createClient } = require('redis')

CacheHandler.onCreation(async () => {
  const client = createClient({ url: process.env.REDIS_URL })
  await client.connect()

  return {
    handlers: [
      createRedisHandler({ client }),
    ],
  }
})

module.exports = CacheHandler
JavaScript
// next.config.js
module.exports = {
  cacheHandler: require.resolve('./cache-handler.js'),
  cacheMaxMemorySize: 0, // 인메모리 캐시 비활성, Redis 단독 사용
}
JavaScript

6. 캐시 일관성 함정과 회피 전략

Stale-While-Revalidate 함정

x-nextjs-cache: STALE 상태는 만료된 캐시를 즉시 반환하고 백그라운드에서 재생성을 시작한다는 의미입니다. 이 시간 동안 두 번째 요청자도 여전히 구버전을 받습니다.

// 금융·재고 데이터처럼 반드시 최신이어야 하는 경우
// ❌ revalidate는 stale-while-revalidate 구간이 존재함
fetch(url, { next: { revalidate: 60 } })

// ✅ 항상 최신 데이터가 필요하다면 cache: 'no-store'
fetch(url, { cache: 'no-store' })

분산 환경 캐시 공유 문제

기본 Next.js Data Cache는 파일시스템에 저장되기 때문에 로드 밸런서 뒤 여러 인스턴스를 운영하면 인스턴스마다 캐시 상태가 달라집니다.

인스턴스 A: POST /api/posts → revalidateTag('posts') → A의 캐시 무효화
인스턴스 B: 여전히 구버전 캐시를 서빙 ← 문제 발생!

이를 해결하려면 위 섹션에서 소개한 Redis 공유 캐시 핸들러를 적용하거나, Vercel처럼 플랫폼 레벨에서 캐시 무효화를 처리하는 환경을 사용해야 합니다.

Router Cache 강제 무효화

브라우저 측 Router Cache는 클라이언트에서만 제어할 수 있습니다. 서버에서 데이터를 변경해도 이미 방문한 경로의 Router Cache가 남아 있으면 사용자는 구버전을 볼 수 있습니다.

// app/components/RefreshButton.tsx
'use client'

import { useRouter } from 'next/navigation'

export function RefreshButton() {
  const router = useRouter()

  return (
    <button
      onClick={() => {
        // ✅ Router Cache를 무효화하고 현재 경로를 재요청
        router.refresh()
      }}
    >
      새로고침
    </button>
  )
}

💡 TIP 서버 액션이 완료된 후 revalidatePath / revalidateTag를 호출하면 Next.js가 자동으로 클라이언트에 갱신 신호를 보내 Router Cache도 함께 무효화됩니다. 별도의 router.refresh() 호출 없이도 UI가 자동으로 업데이트됩니다.

빌드 시 정적 생성과 Runtime revalidate 충돌

generateStaticParams로 빌드 시 생성된 페이지는 Full Route Cache에 초기값이 들어가 있습니다. On-demand ISR로 무효화하면 다음 요청에서 재렌더링 후 새 캐시가 생성됩니다. 문제는 빌드 환경과 런타임 환경의 환경 변수나 외부 서비스가 다를 때 발생합니다.

// app/blog/[slug]/page.tsx

// ✅ fallback 동작: 빌드 시 생성되지 않은 경로는 런타임에 생성
export async function generateStaticParams() {
  const posts = await fetch('https://cms.example.com/posts?limit=100').then(r => r.json())
  return posts.map((p: { slug: string }) => ({ slug: p.slug }))
}

// 빌드 시 없던 slug → 런타임에 동적 렌더링 후 캐시 저장
// revalidateTag로 무효화 → 다음 요청에서 재렌더링

요약

  • Next.js는 Request Memoization → Data Cache → Full Route Cache → Router Cache 4계층을 순서대로 확인하며, 각 계층은 수명과 범위가 다릅니다.
  • revalidateTag는 데이터 중심, revalidatePath는 라우트 중심으로 무효화하며, 서버 액션 내에서 호출하면 Router Cache까지 자동으로 갱신됩니다.
  • 시간 기반 revalidate를 안전망으로, On-demand ISR(웹훅 트리거)을 주 무효화 수단으로 조합하면 신선도와 성능을 동시에 얻을 수 있습니다.
  • ORM·SDK 등 fetch 외 데이터 소스는 unstable_cachecache() 조합으로 Data Cache와 Request Memoization 양쪽에 통합하세요.
  • x-nextjs-cache 헤더와 logging.fetches 설정으로 캐시 동작을 눈으로 확인하고, 멀티 인스턴스 환경에서는 Redis 공유 캐시 핸들러로 일관성을 확보하세요.
  • stale-while-revalidate 구간의 존재를 인식하고, 실시간성이 중요한 데이터는 cache: 'no-store'로 명시적으로 제어하세요.

연습문제

  1. 다음 코드에서 getPosts()getPostById(id)는 각각 어떤 캐시 계층에 저장되며, revalidateTag('posts')를 호출했을 때 두 함수의 캐시가 모두 무효화되는지 설명하세요.
export async function getPosts() {
  return fetch('https://api.example.com/posts', {
    next: { tags: ['posts'] },
  }).then(r => r.json())
}

export async function getPostById(id: string) {
  return fetch(`https://api.example.com/posts/${id}`, {
    next: { tags: ['posts', `post-${id}`] },
  }).then(r => r.json())
}

힌트 next.tags 배열에 동일한 태그가 있으면 revalidateTag 한 번으로 모두 무효화됩니다.

  1. 블로그 CMS에서 글을 수정하면 즉시 반영되어야 하지만, CMS 웹훅이 가끔 실패할 수 있습니다. 안전망까지 갖춘 웹훅 API 라우트를 작성하세요.

힌트 On-demand ISR과 시간 기반 revalidate를 동시에 적용합니다. 웹훅에서는 revalidateTag를, fetch에서는 next.revalidate를 사용하세요.

  1. 다음 서버 컴포넌트 트리에서 getUser('123')은 몇 번의 실제 네트워크 요청을 발생시킬까요? 그 이유를 Request Memoization 관점에서 설명하세요.
// UserProfile.tsx
async function UserProfile() {
  const user = await getUser('123')
  return <div><Avatar userId="123" /><Bio userId="123" /></div>
}

// Avatar.tsx
async function Avatar({ userId }: { userId: string }) {
  const user = await getUser(userId) // getUser('123') 재호출
  return <img src={user.avatar} />
}

// Bio.tsx
async function Bio({ userId }: { userId: string }) {
  const user = await getUser(userId) // getUser('123') 재호출
  return <p>{user.bio}</p>
}

힌트 getUsercache() 또는 동일 URL의 fetch로 구현되어 있다고 가정하세요.

  1. 멀티 인스턴스 배포 환경에서 서버 액션으로 revalidateTag('products')를 호출했는데 일부 사용자에게는 여전히 구버전 데이터가 보입니다. 원인과 해결책을 설명하세요.

힌트 기본 Data Cache 저장소의 위치를 생각해보세요.


💡 연습문제 풀이

불러오는 중…

함께 보면 좋은 자료

댓글 0

Next.js 심화” 강좌에 대한 댓글입니다.

댓글을 작성하려면 로그인이 필요합니다.