프론트 웹 캐시 가이드 (Part 3) : Next.js 캐싱

개발.log·2025년 12월 10일
post-thumbnail

프론트 웹 캐시 가이드 (Part 3): Next.js 캐싱 - 4개의 캐싱 레이어

1편에서 HTTP 캐시의 기본을,
2편에서 TanStack Query를 통한 클라이언트 상태 캐싱을 다뤘다.
3부에서는 Next.js 서버가 관리하는 4개의 캐싱 레이어를 파헤쳐보자.
"왜 데이터가 안 바뀌지?"라는 질문의 80%는 이 4개 중 하나가 원인이다.


1. 왜 Next.js는 자체 캐시 레이어를 만들었나?

1편에서 배운 HTTP 캐시는 브라우저 ↔ 서버 간의 캐시다.
2편에서 배운 TanStack Query는 클라이언트에서 API 응답을 캐싱한다.
그런데 Next.js 서버 내부에서도 최적화가 필요하다.

HTTP 캐시만 있을 때:

브라우저 → Next.js 서버 → 외부 API
               │
               ├─ fetch('/api/products')  → DB 쿼리
               ├─ fetch('/api/products')  → DB 쿼리 (중복!)
               └─ fetch('/api/products')  → DB 쿼리 (중복!)

HTTP 캐시나 TanStack Query로는 해결할 수 없는 문제들이 있다.

문제HTTP 캐시의 한계TanStack Query의 한계
서버 렌더링 결과 재사용HTTP 캐시는 "요청-응답" 단위로 동작. 렌더링 중간 결과를 저장할 수 없음클라이언트에서만 동작
같은 렌더링 내 중복 요청Layout에서도 fetch, Page에서도 같은 fetch. HTTP 캐시는 요청이 나가야 동작Server Component에서 사용 불가
클라이언트 네비게이션 최적화SPA처럼 부드러운 페이지 전환. 매번 전체 HTML을 받으면 느림서버 데이터와 별개

그래서 Next.js는 HTTP 캐시 위에 4개의 추가 레이어를 만들었다.

🤔 비판적 시각: 복잡성의 대가

일부 개발자들은 Next.js가 CSR, SSR, SSG, ISR이라는 4가지 렌더링 모드(실행 컨텍스트)를 하나의 프레임워크에 모두 담으면서, 그 위에 스트리밍까지 얹었다고 비판한다. Next.js가 "UI 렌더링 어댑터"로 포지셔닝했다면 이런 설계가 이해되지만, "풀스택 프레임워크"로 제시되면서 소프트웨어 엔지니어링 원칙인 조합성(composability), 모듈성(modularity), 아키텍처 명확성 측면에서 혼란을 준다는 것이다.

이 가이드에서는 이런 복잡성을 인정하면서도, 실용적으로 각 캐시 레이어를 이해하고 활용하는 방법을 다룬다.


2. 먼저 알아야 할 것: RSC Payload

Next.js 캐싱을 이해하려면 RSC Payload를 먼저 알아야 한다. 4개 캐시 중 3개가 이걸 다루기 때문이다.

2-1. RSC Payload란?

RSC Payload = React Server Component의 렌더링 결과물

Server Component를 렌더링하면 HTML이 아니라 특별한 형식의 데이터가 만들어진다. 이게 RSC Payload다.

// 이 컴포넌트를 렌더링하면...
async function ProductPage() {
  const product = await fetchProduct(1)
  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.price}</p>
    </div>
  )
}

// 이런 RSC Payload가 생성된다:
0:["$","div",null,{"children":[
  ["$","h1",null,{"children":"맥북 프로"}],
  ["$","p",null,{"children":"2,500,000원"}]
]}]

2-2. RSC Payload vs HTML

HTMLRSC Payload
형태문자열 마크업JSON-like 직렬화 데이터
용도브라우저가 직접 렌더링React가 해석해서 DOM 생성
크기더 큼 (태그 반복)더 작음 (구조화됨)
활용첫 페이지 로드 (SEO)클라이언트 네비게이션

2-3. RSC Payload가 사용되는 시점

1. 첫 페이지 로드 (Full Page Load)
─────────────────────────────────────────────────────────
브라우저 → Next.js 서버

서버가 보내는 것:
├─ HTML (초기 화면용, SEO)
└─ RSC Payload (React hydration용, script에 포함)


2. 클라이언트 네비게이션 (Link 클릭)
─────────────────────────────────────────────────────────
브라우저 → Next.js 서버

서버가 보내는 것:
└─ RSC Payload만! (HTML 안 보냄)

React가 RSC Payload를 받아서 DOM을 업데이트한다.

2-4. 왜 RSC Payload를 쓸까?

클라이언트 네비게이션 시:

방법 1: 전체 HTML 받기
├─ 서버: 전체 HTML 렌더링 (느림)
├─ 네트워크: HTML 전송 (크기 큼)
├─ 브라우저: 전체 DOM 교체 (깜빡임)
└─ 결과: 느리고 UX 나쁨

방법 2: RSC Payload 받기 (Next.js 방식)
├─ 서버: RSC Payload 생성 (빠름)
├─ 네트워크: Payload 전송 (크기 작음)
├─ 브라우저: React가 필요한 부분만 DOM 업데이트
└─ 결과: 빠르고 부드러운 전환

3. Next.js의 4가지 캐싱 레이어: 전체 그림

RSC Payload를 이해했으니, 이제 4개의 캐싱 레이어를 살펴보자.

요청 흐름과 캐시 레이어 (위에서 아래로)
─────────────────────────────────────────────────────────

클라이언트 (브라우저)
────────────────────
┌──────────────────────────────────────────────────┐
│  1. Router Cache                                 │
│     • 이미 방문한 페이지? → 메모리에서 즉시 로드   │
│     • 없으면 서버로 요청                          │
└──────────────────────────────────────────────────┘
                         │ MISS
                         ▼
서버 (Next.js)
──────────────
┌──────────────────────────────────────────────────┐
│  2. Full Route Cache                             │
│     • 빌드된 정적 페이지? → 즉시 반환              │
│     • 동적 페이지면 렌더링 시작                    │
└──────────────────────────────────────────────────┘
                         │ MISS (동적 페이지)
                         ▼
┌──────────────────────────────────────────────────┐
│  3. Request Memoization                          │
│     • 이번 렌더링 중 같은 fetch 또 호출?          │
│     • 중복 제거 (단일 요청 내에서만)               │
└──────────────────────────────────────────────────┘
                         │
                         ▼
┌──────────────────────────────────────────────────┐
│  4. Data Cache                                   │
│     • 이 URL 데이터 캐시에 있나?                  │
│     • 있으면 반환, 없으면 외부 요청                │
└──────────────────────────────────────────────────┘
                         │ MISS
                         ▼
                    외부 API / DB

3-1. 각 캐시가 해결하는 문제

캐시해결하는 문제저장 위치수명
Router Cache페이지 이동마다 서버 요청브라우저 메모리0초~5분
Full Route Cache정적 페이지를 매번 렌더링Next.js 서버 디스크재배포까지
Request Memoization같은 데이터를 여러 컴포넌트가 fetchNext.js 서버 메모리렌더링 끝까지
Data Cache같은 API를 여러 사용자가 호출Next.js 서버 디스크무제한

3-2. Next.js 15 주요 변경사항

항목Next.js 14Next.js 15
Data Cache 기본값force-cache (캐시함)no-store (캐시 안 함)
Router Cache 유지 시간30초~5분0초 (비활성화)

이 변경의 이유는 뒤에서 자세히 다룬다.

🔥 Vercel의 고백: "Our Journey with Caching"

Next.js 팀은 2024년 10월 공식 블로그에서 캐싱 문제를 인정했다:

"개발자 경험이 우리가 제공한 캐싱 기본값과 제어 방식 때문에 어려움을 겪었다. fetch()의 기본값이 성능을 위해 캐싱하도록 변경되었지만, 빠른 프로토타이핑과 동적 앱에서 문제가 되었다."

이것이 Next.js 15에서 기본값을 뒤집은 이유다. 하지만 일부는 이런 급격한 방향 전환 자체가 프레임워크의 명확한 철학 부재를 보여준다고 비판한다.


4. Router Cache: 클라이언트 네비게이션 최적화

4-1. 개념

브라우저 메모리에 RSC Payload를 저장하여 클라이언트 네비게이션을 빠르게 한다.

브라우저 (Router Cache)
─────────────────────────────────────────────────────────

사용자: /products 페이지에서 /products/1 클릭

1. Router Cache 확인
   └─ /products/1 있음? → 없음

2. Next.js 서버에 RSC Payload 요청
   └─ GET /products/1?_rsc=xxxxx

3. 응답 받아서 Router Cache에 저장
   ┌─────────────────────────────────────────────────┐
   │  /products/1: RSC Payload 저장됨                │
   └─────────────────────────────────────────────────┘

4. 사용자: 뒤로가기 (다시 /products/1)
   └─ Router Cache에서 즉시 반환 (네트워크 요청 X)

4-2. 캐시 유효 시간

페이지 유형Next.js 14Next.js 15
정적 페이지5분0초
동적 페이지30초0초

4-3. Next.js 15에서 Router Cache를 비활성화한 이유

Next.js 14까지는 Router Cache가 30초(동적) ~ 5분(정적) 동안 유지됐는데, 이게 개발자들에게 많은 혼란을 줬다.

// 문제 상황
const handleDelete = async (id) => {
  await deleteProduct(id)
  router.push('/products')  // 목록으로 이동
}

// 사용자: "어? 삭제한 상품이 아직 보이네?"
// 원인: Router Cache가 이전 페이지를 30초간 보여줌

개발자들의 불만:

  • 예측 불가능: 언제 캐시되고 언제 안 되는지 헷갈림
  • 디버깅 어려움: "왜 데이터가 안 바뀌지?" → Router Cache 때문
  • 강제 무효화 필요: router.refresh()를 여기저기 뿌려야 했음
  • stale 데이터 문제: 실시간성이 중요한 앱에서 치명적

그래서 Next.js 15에서는 "캐시로 인한 버그보다 약간의 성능 손해가 낫다"는 판단으로 기본값을 0초로 변경했다.

4-4. 다시 활성화하려면

성능 최적화가 필요하면 명시적으로 켤 수 있다.

// next.config.js
module.exports = {
  experimental: {
    staleTimes: {
      dynamic: 30,  // 동적 페이지 30초 캐시
      static: 180,  // 정적 페이지 3분 캐시
    },
  },
}

4-5. 무효화 방법

'use client'

import { useRouter } from 'next/navigation'

function RefreshButton() {
  const router = useRouter()
  
  return (
    <button onClick={() => router.refresh()}>
      새로고침
    </button>
  )
}

5. Full Route Cache: 페이지 전체 저장

5-1. 개념

빌드 시점에 렌더링된 HTML과 RSC Payload를 Next.js 서버에 저장한다. 정적 페이지에만 적용된다.

빌드 시점 (npm run build):
─────────────────────────────────────────────────────────

/about 페이지 렌더링
     │
     ▼
┌─────────────────────────────────────────────────────┐
│  Full Route Cache (Next.js 서버 디스크)             │
│                                                     │
│  /about:                                            │
│    ├─ HTML: <!DOCTYPE html>...                      │
│    └─ RSC Payload: 0:["$","div",...]                │
│                                                     │
└─────────────────────────────────────────────────────┘

사용자 요청 시:
서버 → 캐시된 HTML/RSC Payload 즉시 반환 (렌더링 없음!)

5-2. Full Route Cache vs ISR

많이 헷갈리는 부분이다. 정리하면:

Full Route Cache = 저장소 (어디에 저장되는가)
ISR = 갱신 전략 (어떻게 갱신하는가)

관계: ISR = Full Route Cache + 시간 기반 재생성 전략
Full Route Cache만ISR
재생성 방식수동 (revalidatePath)시간 기반 (revalidate: 60)
예시회사 소개 페이지블로그 글 목록
// 1) Full Route Cache만 (ISR 아님)
// 빌드 시 생성, 수동으로만 갱신
export default async function Page() {
  const data = await fetch('https://api.example.com/products', {
    cache: 'force-cache'  // 또는 태그 기반
  })
  return <div>{data}</div>
}
// 갱신: revalidatePath('/products') 호출 시에만


// 2) ISR (Full Route Cache + 시간 기반 재생성)
export default async function Page() {
  const data = await fetch('https://api.example.com/products', {
    next: { revalidate: 60 }  // 60초마다 백그라운드 재생성
  })
  return <div>{data}</div>
}

5-3. 정적 vs 동적 페이지

구분정적 페이지동적 페이지
Full Route Cache적용미적용
렌더링 시점빌드 시요청 시
예시블로그 글, 회사 소개검색 결과, 대시보드

동적 렌더링이 되는 조건:

// 1. cookies() 또는 headers() 사용
import { cookies } from 'next/headers'

export default function Page() {
  const token = cookies().get('token')  // 동적!
  return <div>...</div>
}

// 2. searchParams 사용
export default function Page({ 
  searchParams 
}: { 
  searchParams: { q: string } 
}) {
  // searchParams가 있으면 동적!
  return <div>검색어: {searchParams.q}</div>
}

// 3. fetch에 no-store
async function Page() {
  const data = await fetch(url, { cache: 'no-store' })  // 동적!
  return <div>...</div>
}

// 4. dynamic 설정
export const dynamic = 'force-dynamic'  // 강제 동적

5-4. 빌드 출력 확인

$ npm run build

Route (app)                    Size     First Load JS
┌ ○ /                          5.2 kB   89.2 kB
├ ○ /about                     1.2 kB   85.2 kB
├ ● /blog/[slug]               2.1 kB   86.1 kB
├ λ /dashboard                 3.5 kB   87.5 kB
└ λ /search                    2.8 kB   86.8 kB

○  (Static)   정적 페이지 - Full Route Cache 적용
●  (SSG)      generateStaticParams로 빌드 시 생성
λ  (Dynamic)  동적 페이지 - 요청마다 렌더링

6. Request Memoization: 중복 요청 제거

6-1. 문제 상황

문제 상황:
┌──────────────────────────────────────────────────────────┐
│  Layout                                                  │
│  └─ await fetchUser(1)  ─────────┐                       │
│                                  │                       │
│  Page                            │  같은 요청이          │
│  └─ await fetchUser(1)  ─────────┤  3번 발생?            │
│                                  │                       │
│  Component                       │                       │
│  └─ await fetchUser(1)  ─────────┘                       │
└──────────────────────────────────────────────────────────┘

6-2. Request Memoization이 해결

Request Memoization 적용 후:
─────────────────────────────────────────────────────────

첫 번째 호출: fetchUser(1)
→ 실제 네트워크 요청 발생
→ 결과를 Next.js 서버 메모리에 저장

두 번째 호출: fetchUser(1)
→ 메모리에서 결과 반환 (네트워크 요청 X)

세 번째 호출: fetchUser(1)
→ 메모리에서 결과 반환 (네트워크 요청 X)

결과: 네트워크 요청 1번만 발생!

6-3. 동작 조건

Request Memoization이 적용되는 조건:

  • GET 메서드만 해당 (POST, PUT 등은 X)
  • 같은 URL + 같은 옵션이어야 함
  • 같은 렌더링 사이클 내에서만 유효
// 메모이제이션 O - 같은 URL과 옵션
await fetch('/api/user/1')
await fetch('/api/user/1')  // 캐시된 결과 반환

// 메모이제이션 X - URL이 다름
await fetch('/api/user/1')
await fetch('/api/user/2')  // 새 요청

// 메모이제이션 X - 옵션이 다름
await fetch('/api/user/1', { cache: 'no-store' })
await fetch('/api/user/1', { cache: 'force-cache' })  // 새 요청

6-4. 수명

요청 시작 (렌더링 시작)
    │
    ├─ fetch 호출 → 메모이제이션에 저장
    ├─ fetch 호출 → 메모이제이션에서 반환
    ├─ fetch 호출 → 메모이제이션에서 반환
    │
응답 완료 (렌더링 종료) → 메모이제이션 자동 삭제

다음 요청 → 새로운 메모이제이션 시작

6-5. fetch 외의 함수에 적용하기

React의 cache() 함수를 사용하면 fetch 외의 함수도 메모이제이션할 수 있다.

import { cache } from 'react'

// DB 조회 함수를 메모이제이션
export const getUser = cache(async (id: string) => {
  const user = await db.user.findUnique({ where: { id } })
  return user
})

// 여러 컴포넌트에서 호출해도 DB 쿼리는 1번만 실행

6-6. 주의: Route Handler에서는 안 됨

// Route Handler에서는 메모이제이션이 적용되지 않는다!
// app/api/data/route.ts
export async function GET() {
  const a = await fetch('https://api.example.com/data')  // 요청 1
  const b = await fetch('https://api.example.com/data')  // 요청 2 (중복!)
  return Response.json({ a, b })
}

// Server Component에서는 적용됨
// app/page.tsx
export default async function Page() {
  const a = await fetch('https://api.example.com/data')  // 요청 1
  const b = await fetch('https://api.example.com/data')  // 메모이제이션됨
  return <div>...</div>
}

7. Data Cache: 영구 데이터 저장

7-1. 개념

fetch() 결과를 Next.js 서버에 영구적으로 저장한다. 여러 요청, 여러 사용자가 공유한다.

"서버"가 어디인지 명확히 하자:

배포 환경Data Cache 저장 위치
VercelVercel Data Cache 서비스 (전역 분산 KV 스토리지)
자체 호스팅 (AWS 등).next/cache/fetch-cache/ (로컬 파일시스템)
Docker컨테이너 내 파일시스템 (재시작 시 사라질 수 있음)
// 명시적으로 캐시 (Next.js 14 기본)
const data = await fetch('https://api.example.com/products', {
  cache: 'force-cache'
})

// 캐시 안 함 (Next.js 15 기본)
const data = await fetch('https://api.example.com/products', {
  cache: 'no-store'
})

// 60초마다 재검증
const data = await fetch('https://api.example.com/products', {
  next: { revalidate: 60 }
})

// 태그 기반 (수동 무효화용)
const data = await fetch('https://api.example.com/products', {
  next: { tags: ['products'] }
})

7-2. 동작 방식

사용자 A 요청:
fetch('/api/products')
      │
      ▼
Data Cache 확인 → 없음 → API 호출 → 결과 저장 → 응답
                              │
                              ▼
                    ┌─────────────────┐
                    │   Data Cache    │
                    │  (Next.js 서버) │
                    │  products: [...] │
                    └─────────────────┘
                              │
사용자 B 요청:                │
fetch('/api/products')        │
      │                       │
      ▼                       ▼
Data Cache 확인 → 있음! → 바로 반환 (API 호출 X)

7-3. 캐시 옵션 비교

옵션동작Next.js 14Next.js 15
cache: 'force-cache'영구 캐시기본값명시 필요
cache: 'no-store'캐시 안 함명시 필요기본값
next: { revalidate: 60 }60초마다 재검증--
next: { tags: ['x'] }태그로 수동 무효화--

7-4. 악명 높은 문제: "데이터 수정했는데 안 바뀌어요!"

// 상황: 상품 가격을 수정했는데 화면에 안 바뀜!

// 1. 사용자가 상품 페이지 방문
// 2. 가격이 10,000원으로 Data Cache에 저장됨
// 3. 관리자가 가격을 8,000원으로 수정
// 4. 사용자가 새로고침해도 여전히 10,000원 (Data Cache)
// 5. 심지어 다른 사용자도 10,000원을 봄!

// 해결: revalidateTag 호출 필요
import { revalidateTag } from 'next/cache'

async function updatePrice(productId, newPrice) {
  await db.updatePrice(productId, newPrice)
  revalidateTag('products')  // 이걸 잊으면 대참사
}

💬 실제 개발자들의 목소리 (GitHub Discussion #54075)

"우리 회사는 B2B SaaS로 비즈니스 크리티컬한 데이터를 다룹니다. 데이터 신선함이 핵심인데, NextJS는 모든 앱에 하나의 캐시 정책이 맞다고 가정합니다. 페이스북을 만드는 게 아니라면, stale 뉴스피드는 괜찮겠지만 우리 앱에서는 치명적입니다."

"사용자가 잘못된 데이터를 보는 것이 로딩 스피너를 피하는 것보다 훨씬 해롭습니다. 그런데 프레임워크가 이 선택을 우리 대신 해버립니다."

7-5. revalidate 동작 (Stale-While-Revalidate)

타임라인 (revalidate: 60 설정 시):

0초        30초       60초       70초       80초
│          │          │          │          │
▼          ▼          ▼          ▼          ▼
요청1      요청2      요청3      요청4      요청5
캐시저장   캐시사용   캐시사용   캐시반환   새캐시사용
                      (만료됨)   +백그라운드
                                 갱신 시작

60초가 지나도 바로 새 데이터를 가져오는 게 아니라, 일단 캐시된 데이터를 반환하고 백그라운드에서 새 데이터를 가져온다.

7-6. 무효화 방법

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

// 경로 기반
revalidatePath('/products')
revalidatePath('/products', 'layout')  // 레이아웃 포함
revalidatePath('/', 'layout')          // 전체 사이트

// 태그 기반 (권장)
revalidateTag('products')
revalidateTag(`product-${id}`)

8. 4가지 캐시 비교: 한눈에 보기

8-1. 비교표

Router CacheFull Route CacheRequest MemoizationData Cache
위치브라우저 메모리Next.js 서버 디스크Next.js 서버 메모리Next.js 서버 디스크
범위현재 탭모든 사용자단일 렌더링모든 사용자
수명0초~5분재배포까지렌더링 끝까지무제한
대상페이지 이동전체 페이지fetch 호출fetch 결과
무효화router.refresh()revalidatePath자동revalidateTag/Path

8-2. 요청 흐름

사용자 요청: GET /products
─────────────────────────────────────────────────────────

      ┌─ Router Cache 확인
      │   └─ 있으면 → 바로 반환 (끝)
      │   └─ 없으면 → 서버로 요청
      │
      ▼
  ┌─ Full Route Cache 확인
  │   └─ 있으면 (정적) → 바로 반환
  │   └─ 없으면 (동적) → 렌더링 시작
  │
  ▼
  ┌─ 렌더링 중 fetch 호출
  │
  │   ┌─ Request Memoization 확인
  │   │   └─ 같은 요청 있으면 → 재사용
  │   │   └─ 없으면 → Data Cache 확인
  │   │
  │   ▼
  │   ┌─ Data Cache 확인
  │   │   └─ 있고 신선하면 → 반환
  │   │   └─ 없거나 만료면 → 실제 API 호출 → 결과 캐시
  │
  ▼
렌더링 완료 → 응답 → Router Cache에 저장

8-3. 실행 컨텍스트별 캐싱 동작

코드가 어디서 실행되는지에 따라 캐싱 동작이 달라진다.

실행 컨텍스트Request MemoizationData Cache
Server Component적용됨적용됨
Client Component- (브라우저 실행)-
Server Action안 됨적용됨
Route Handler안 됨적용됨
Middleware안 됨제한적

⚠️ 이게 바로 문제다: "같은 코드, 다른 동작"

비판자들이 지적하는 핵심이 바로 이것이다. 동일한 fetch() 코드가 실행 컨텍스트에 따라 완전히 다르게 동작한다. Server Component에서는 메모이제이션이 되고, Route Handler에서는 안 된다. 이런 "마법 같은" 동작이 디버깅을 어렵게 만든다.

프레임워크가 제어권을 가져가는 건 괜찮지만, 명확하고 신뢰할 수 있는 애플리케이션 라이프사이클을 제공하지 않으면 개발자가 시스템을 예측하기 어렵다.

같은 코드, 다른 캐싱 동작 예시:

// 1. Server Component에서 fetch
async function ServerFetch() {
  const data = await fetch('https://api.com/data')  // Memoization + Data Cache
  return <div>{data}</div>
}

// 2. Route Handler에서 fetch
export async function GET() {
  const data = await fetch('https://api.com/data')  // Data Cache만
  return NextResponse.json(data)
}

// 3. Server Action에서 fetch
'use server'
export async function getData() {
  const data = await fetch('https://api.com/data')  // Data Cache만
  return data
}

8-4. 렌더링 모드 × 캐시 레이어 조합

Next.js의 4가지 렌더링 모드와 4가지 캐시 레이어가 어떻게 조합되는지 살펴보자.

렌더링 모드 요약

모드렌더링 시점특징
CSR브라우저서버는 빈 HTML만, JS가 렌더링
SSR요청마다 서버매 요청 시 서버에서 렌더링
SSG빌드 시 서버빌드할 때 미리 HTML 생성
ISR빌드 + 주기적 재생성SSG + 시간 기반 갱신

조합표

Router CacheFull Route CacheRequest MemoizationData Cache
CSR
SSR⚠️
SSG
ISR

CSR + 각 캐시

CSR은 서버 렌더링이 없어서 서버 캐시가 전부 무관하다.

// CSR 예시 - 'use client' 컴포넌트에서 useEffect로 fetch
'use client'
export default function Page() {
  const [data, setData] = useState(null)
  
  useEffect(() => {
    fetch('/api/data').then(res => res.json()).then(setData)
  }, [])
  
  return <div>{data}</div>
}
// → 브라우저에서 fetch, Next.js 캐시 전혀 안 탐
// → 캐싱하려면 TanStack Query 사용

SSR + 각 캐시

SSR은 매 요청마다 서버에서 렌더링한다.

// SSR 예시 - 동적 렌더링
export default async function Page({ searchParams }) {
  // searchParams 사용 → 자동으로 SSR
  const data = await fetch(`/api/search?q=${searchParams.q}`, {
    cache: 'no-store'
  })
  return <div>{data}</div>
}
캐시적용이유
Router Cache⚠️ 제한적Next.js 15 기본값 0초
Full Route Cache동적 렌더링이라 캐시 안 됨
Request Memoization같은 렌더링 내 중복 fetch 제거
Data Cachefetch 옵션에 따라 캐시 가능

SSG + 각 캐시

SSG는 빌드 시점에 미리 렌더링한다. 모든 캐시가 적용된다.

// SSG 예시 - 정적 페이지
export default async function Page() {
  const data = await fetch('https://api.example.com/products', {
    cache: 'force-cache'
  })
  return <div>{data}</div>
}

흐름:

빌드 시:
────────
렌더링 → Request Memoization → Data Cache 저장
    ↓
Full Route Cache에 HTML + RSC Payload 저장


요청 시:
────────
Router Cache 확인 → 없으면 서버로
    ↓
Full Route Cache에서 즉시 반환 (렌더링 없음!)
    ↓
Router Cache에 저장

ISR + 각 캐시

ISR은 SSG + 시간 기반 재생성이다.

// ISR 예시 - 60초마다 재생성
export default async function Page() {
  const data = await fetch('https://api.example.com/products', {
    next: { revalidate: 60 }
  })
  return <div>{data}</div>
}

흐름:

0초: 첫 요청 → Full Route Cache에서 반환
60초: 요청 → 캐시 반환 + 백그라운드 재생성 시작
70초: 요청 → 새로 갱신된 캐시에서 반환

실무에서 자주 쓰는 조합

상황렌더링 모드주요 캐시
마케팅 페이지, 블로그SSGFull Route + Data
상품 목록 (가끔 변경)ISRFull Route + Data (revalidate)
검색 결과, 대시보드SSRRequest Memoization + Data (선택적)
실시간 데이터SSR + no-storeRequest Memoization만
인터랙티브 앱CSR없음 (TanStack Query로 대체)

핵심 정리

캐시 많이 탐 ←─────────────────────→ 캐시 안 탐

   SSG        ISR        SSR        CSR
    │          │          │          │
 4개 다 적용  4개 다 적용  2~3개     0개
    │          │          │          │
 가장 빠름   꽤 빠름    요청마다   브라우저 의존

9. 태그 기반 캐시 무효화

9-1. 태그 설정

태그는 캐시된 데이터에 "라벨"을 붙이는 것이다.

// 전체 상품 목록
const products = await fetch('https://api.example.com/products', {
  next: { tags: ['products'] }
})

// 개별 상품 (여러 태그 가능)
const product = await fetch(`https://api.example.com/products/${id}`, {
  next: { tags: ['products', `product-${id}`] }
})

// 카테고리별 상품
const categoryProducts = await fetch(
  `https://api.example.com/products?category=${categoryId}`,
  { next: { tags: ['products', `category-${categoryId}`] } }
)

9-2. 무효화

'use server'
import { revalidateTag } from 'next/cache'

// 상품 수정 → 해당 상품만 무효화
export async function updateProduct(id, data) {
  await db.products.update(id, data)
  revalidateTag(`product-${id}`)
}

// 상품 추가 → 전체 목록 무효화
export async function createProduct(data) {
  await db.products.create(data)
  revalidateTag('products')
}

9-3. revalidatePath vs revalidateTag

revalidatePathrevalidateTag
용도특정 페이지만 갱신여러 페이지에 걸친 데이터 갱신
범위경로 기반태그 기반
유연성낮음높음
추천간단한 케이스복잡한 케이스
// 상품 상세 페이지가 100개 있을 때

// revalidatePath (비효율적)
revalidatePath('/products/1')
revalidatePath('/products/2')
// ... 100개를 일일이?

// revalidateTag (효율적)
revalidateTag('products')  // 한 번에 해결!

10. 실전 문제 해결

"수정했는데 목록에 안 보여요"

원인: Data Cache가 이전 데이터를 가지고 있음

'use server'
import { revalidateTag } from 'next/cache'

export async function updateProduct(id, data) {
  await db.products.update(id, data)
  revalidateTag('products')
  revalidateTag(`product-${id}`)
}

"새로고침하면 보이는데 뒤로가기하면 안 보여요"

원인: Router Cache가 이전 버전을 가지고 있음

'use client'
import { useRouter } from 'next/navigation'

function ProductList() {
  const router = useRouter()
  
  const handleUpdate = async () => {
    await updateProduct(id, data)
    router.refresh()  // 이게 핵심!
  }
}

"다른 사용자가 추가한 데이터가 안 보여요"

원인: Data Cache + Router Cache

// 1. 추가할 때 캐시 무효화
'use server'
export async function createProduct(data) {
  await db.products.create(data)
  revalidateTag('products')
}

// 2. 목록에서 주기적 재검증
const products = await fetch('/api/products', {
  next: { revalidate: 60, tags: ['products'] }
})

// 3. 또는 실시간 필요 시 캐시 안 함
const products = await fetch('/api/products', {
  cache: 'no-store'
})

"배포했는데 이전 페이지가 보여요"

원인: Full Route Cache + 브라우저 HTTP 캐시

// next.config.js - HTML은 캐시 안 함
module.exports = {
  async headers() {
    return [
      {
        source: '/:path*',
        headers: [
          { key: 'Cache-Control', value: 'no-cache' }
        ]
      }
    ]
  }
}

"로그인했는데 쿠키가 안 저장돼요"

이건 캐시 문제가 아니라 HTTP 헤더 전달 문제다. 하지만 Next.js Route Handler를 쓸 때 자주 겪는 문제라서 여기서 다룬다.

상황 설명

우리 회사 구조:

프론트엔드 (Next.js) ──── 백엔드 (Spring/Express 등)
     │                         │
  3000번 포트              8080번 포트

CORS 문제나 API 키 보안 때문에 Next.js를 중간 프록시로 쓰는 경우가 많다.

브라우저 → Next.js Route Handler → 백엔드
          (프록시 역할)

문제 발생

// app/api/login/route.ts

export async function POST(request: Request) {
  // 1. 백엔드에 로그인 요청
  const res = await fetch('http://backend:8080/login', {
    method: 'POST',
    body: await request.text(),
  })
  
  // 2. 백엔드가 보내준 것:
  //    - Body: { success: true, user: {...} }
  //    - Header: Set-Cookie: token=abc123; HttpOnly
  
  // 3. 우리가 반환하는 것:
  return Response.json(await res.json())
  //    - Body만 반환됨!
  //    - Set-Cookie 헤더는 사라짐!
}

왜 이런 일이?

택배 비유:

판매자(백엔드) → 택배기사(Next.js) → 구매자(브라우저)

판매자가 보낸 것:
├─ 상품 (Body) ✅
└─ 편지 (Set-Cookie 헤더) ❌ ← 택배기사가 안 전해줌!

Set-Cookie는 브라우저가 직접 받은 HTTP 응답에 있어야 쿠키로 저장된다. Next.js Route Handler가 중간에서 받아서 새 응답을 만들면, 원래 헤더는 사라진다.

해결: 명시적으로 헤더 전달

// app/api/login/route.ts
import { NextResponse } from 'next/server'

export async function POST(request: Request) {
  const res = await fetch('http://backend:8080/login', {
    method: 'POST',
    body: await request.text(),
  })
  
  // 백엔드 응답에서 Set-Cookie 꺼내기
  const setCookie = res.headers.get('set-cookie')
  
  // 새 응답 만들기
  const response = NextResponse.json(await res.json())
  
  // Set-Cookie 붙이기
  if (setCookie) {
    response.headers.set('Set-Cookie', setCookie)
  }
  
  return response
}

요청 흐름 비교

수정 전:
─────────
브라우저 → Next.js → 백엔드
   │          │         │
   │          │←─ Set-Cookie ─│
   │          │
   │←─ Body만 ─│  ← Set-Cookie 사라짐!
   │
쿠키 저장 안 됨 😱


수정 후:
─────────
브라우저 → Next.js → 백엔드
   │          │         │
   │          │←─ Set-Cookie ─│
   │          │
   │←─ Body + Set-Cookie ─│  ← 명시적으로 복사!
   │
쿠키 정상 저장 ✅

자동 전달되는 것 vs 안 되는 것

자동 전달됨자동 전달 안 됨
응답 BodySet-Cookie
Content-Type (명시 시)Cache-Control
커스텀 헤더

대안: 백엔드 직접 호출

Next.js 프록시를 안 거치면 이 문제가 없다.

// 클라이언트에서 백엔드 직접 호출
const res = await fetch('https://backend.com/login', {
  method: 'POST',
  credentials: 'include',  // 쿠키 자동 처리
  body: JSON.stringify(data),
})
// Set-Cookie가 자동으로 브라우저에 저장됨!

단, 이 경우 백엔드에서 CORS 설정이 필요하다.


11. 권장 패턴

11-1. fetch 옵션 선택

// 실시간 데이터 (주식, 채팅)
fetch(url, { cache: 'no-store' })

// 자주 바뀌는 데이터 (뉴스, 댓글)
fetch(url, { next: { revalidate: 60 } })

// 가끔 바뀌는 데이터 (상품 목록)
fetch(url, { next: { tags: ['products'] } })

// 거의 안 바뀌는 데이터 (설정)
fetch(url, { cache: 'force-cache' })

11-2. 데이터 변경 후 캐시 갱신 (TanStack Query 포함)

// Server Action - Data Cache 무효화
'use server'
export async function deleteProduct(id: string) {
  await fetch(`${API_URL}/products/${id}`, { method: 'DELETE' })
  revalidateTag('products')  // 1. Data Cache
}

// Client Component - TanStack Query + Router Cache 무효화
'use client'
import { useQueryClient } from '@tanstack/react-query'

const handleDelete = async (id) => {
  await deleteProduct(id)           // 1. Data Cache (서버)
  queryClient.invalidateQueries({   // 2. TanStack Query 캐시 (클라이언트)
    queryKey: ['products']
  })
  router.refresh()                  // 3. Router Cache (클라이언트)
}

12. TanStack Query와 Next.js 캐시 비교

2편에서 배운 TanStack Query와 Next.js Data Cache는 둘 다 API 응답을 캐싱하지만, 실행 위치가 다르다.

TanStack Query (클라이언트):
├─ 위치: 브라우저 메모리
├─ 범위: 현재 사용자의 현재 탭
├─ 특징: 탭 닫으면 사라짐
└─ 용도: Client Component에서 데이터 fetching

Next.js Data Cache (서버):
├─ 위치: Next.js 서버 (또는 Vercel)
├─ 범위: 모든 사용자가 공유
├─ 특징: 서버 재시작해도 유지
└─ 용도: Server Component에서 데이터 fetching

12-1. 언제 뭘 쓰나?

상황추천이유
Server Component에서 fetchNext.js Data Cache서버에서 캐싱, SEO 유리
Client Component에서 fetchTanStack Query클라이언트 상태 관리
실시간 데이터 (polling)TanStack QueryrefetchInterval 지원
정적 데이터 (SSG/ISR)Next.js Full Route Cache빌드 시 생성
사용자별 다른 데이터TanStack Query + private개인화 데이터

13. 빠른 참조

캐시 레이어별 확인

증상의심 캐시해결
같은 fetch 중복Request Memoization자동 처리됨
오래된 데이터Data CacherevalidateTag
정적 페이지 갱신Full Route CacherevalidatePath
뒤로가기 시 오래됨Router Cacherouter.refresh()
쿠키 저장 안 됨프록시 헤더 전달Set-Cookie 명시적 복사

fetch 옵션

fetch(url, { cache: 'no-store' })        // 캐시 안 함
fetch(url, { cache: 'force-cache' })     // 영구 캐시
fetch(url, { next: { revalidate: 60 }})  // 시간 기반
fetch(url, { next: { tags: ['x'] }})     // 태그 기반

무효화 함수

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

revalidatePath('/products')   // 경로 기반
revalidateTag('products')     // 태그 기반
router.refresh()              // Router Cache

14. 미래 방향: use cache와 dynamicIO

Next.js 팀은 캐싱 문제를 인식하고 새로운 접근법을 실험 중이다.

14-1. 현재 문제점 요약

현재 (Next.js 14~15):
─────────────────────────────────────────────────────────
- fetch()의 암묵적 캐싱 동작
- export const dynamic, revalidate 등 세그먼트 설정이 탈출구로 남발
- unstable_cache()가 비직관적
- 개발자가 "어디서 캐시되는지" 추적하기 어려움

14-2. 새로운 방향: 명시적 캐싱

// 실험적 기능 (dynamicIO 플래그 필요)

// 동적: Suspense로 감싸면 동적
export default async function Page() {
  return (
    <Suspense fallback="...">
      <DynamicComponent />  {/* 매 요청마다 실행 */}
    </Suspense>
  )
}

// 정적: "use cache"로 명시적 캐싱
"use cache"
export default async function Page() {
  const data = await fetch(...)  // 캐시됨
  return <div>{data}</div>
}

// 함수 레벨 캐싱
async function getProducts() {
  "use cache"
  return await db.products.findMany()
}

14-3. 핵심 변화

현재미래 (실험적)
기본 캐시 → opt-out기본 동적 → opt-in 캐시
암묵적 동작명시적 선언 (use cache)
세그먼트 설정 탈출구Suspense + use cache 조합

아직 실험 단계

dynamicIOuse cache는 Next.js canary에서만 사용 가능하며, 프로덕션에는 권장되지 않는다. 하지만 이 방향성은 Next.js 팀이 커뮤니티 피드백을 반영하고 있음을 보여준다.

비판자들이 원했던 "명시적이고 예측 가능한 동작"으로 한 발짝 다가가는 것이다.


다음 편 예고

Part 4: CDN 캐싱 완벽 가이드에서는:

  • CloudFront와 S3의 역할
  • 전세계 사용자에게 빠르게 배포하기
  • 캐시 무효화 전략
  • 프론트엔드 배포 스크립트

마무리: Next.js 캐싱, 어떻게 바라볼 것인가

Next.js의 캐싱 시스템은 강력하지만 복잡하다. 이 복잡성에 대해 두 가지 시각이 있다:

좋은 프레임워크의 3가지 특성 (Harshal Patil)

  1. 계층화된 아키텍처: 양파처럼 레이어를 벗길수록 복잡하지만 더 세밀한 제어 가능
  2. 명확한 라이프사이클: 코드가 언제, 어디서, 어떻게 실행되는지 예측 가능
  3. 실수 방지: 일반적인 작업을 지루할 정도로 안전하게 만듦

Next.js는 이 기준에서 어떨까?

긍정적 시각:

  • 성능 최적화를 프레임워크 레벨에서 해결
  • 개발자가 캐싱을 직접 구현하지 않아도 됨
  • Next.js 15에서 기본값이 개선됨

비판적 시각:

  • 4가지 렌더링 모드 + 4가지 캐시 레이어 = 복잡성 폭발
  • 같은 코드가 실행 컨텍스트에 따라 다르게 동작
  • "Leaky Abstraction": 추상화가 복잡성을 숨기기보다 오히려 더 많은 복잡성을 만듦
  • 프레임워크보다는 "렌더링 도구"에 가깝다는 비판

실용적 접근:

1. 기본값을 믿지 말 것
   → 캐싱 옵션을 항상 명시적으로 설정

2. 실행 컨텍스트를 파악할 것
   → 이 코드가 어디서 실행되는지 항상 인식

3. 단순함을 추구할 것
   → 필요 없는 캐싱은 끄기 (no-store)
   → 필요한 곳만 선택적으로 캐싱

4. 디버깅 도구 활용
   → 개발자 도구에서 캐시 상태 확인
   → 로깅으로 캐시 HIT/MISS 추적

5. 프록시 동작 이해하기
   → Next.js가 중간에서 어떤 역할을 하는지 파악
   → 헤더 전달이 필요하면 명시적으로 처리

Next.js를 사용한다면, 이 캐싱 시스템을 이해하고 제어해야 한다. 이해 없이 사용하면 "왜 데이터가 안 바뀌지?", "왜 쿠키가 안 저장되지?"라는 질문에 시달리게 될 것이다.


참고 자료

profile
Think Big Aim High Act Now

0개의 댓글