프로필 로고
2026-04-06

Next.js fetch와 캐싱

Next.js 서버 환경에서 fetch가 Data Cache에 응답을 저장하는 방식과, 시간 기반·온디맨드 재검증(revalidatePath, revalidateTag)의 동작 원리 및 라우트 세그먼트 캐시 설정을 정리한다.

  • Next.js
  • 캐싱
  • fetch
  • revalidate

무엇인가?

  1. 브라우저 표준 fetch API는 원래 클라이언트 환경을 위한 것이었다.

  2. Next.js는 이를 서버 환경에서 확장하여, 자동 캐싱과 재검증 기능을 내장시켰다.

  3. 응답은 서버 측 Data Cache에 저장되며, 이 캐시는 요청 간, 배포 간에도 영구적으로 유지된다.

  4. Data Cache는 단순한 메모리 캐시가 아니라, 파일 시스템 수준에서 지속되는 영구 저장소다.

  5. 여기서 중요한 점은 “fetch 확장 기능과 Data Cache 관련 내용은 오직 서버 사이드에 해당된다는 것”이다.

클라이언트 사이드에서의 fetch

  1. 클라이언트 컴포넌트에서 fetch를 사용하면 Next.js의 확장 캐싱이 적용되지 않는다.

  2. 브라우저 표준 fetch API가 그대로 동작하므로, Data Cache에 저장되지 않고 매 호출마다 네트워크 요청이 발생한다.

  3. cache: 'force-cache', next: { revalidate }, next: { tags } 등의 옵션을 붙여도 무시된다.

    // ❌ 클라이언트 컴포넌트 — Next.js 캐싱 옵션이 전혀 동작하지 않음
        'use client'
     
        useEffect(() => {
          fetch('https://api.example.com/posts', {
            next: { revalidate: 60 }, // 무시됨
            cache: 'force-cache',     // 무시됨
          })
        }, [])
  4. 클라이언트에서 데이터를 캐싱하고 싶다면 tanstack-query, swr 같은 클라이언트 사이드 캐싱 라이브러리를 사용한다.

    // ✅ 클라이언트 컴포넌트에서 캐싱이 필요한 경우
        'use client'
        import { useQuery } from '@tanstack/react-query'
     
        export default function Posts() {
          const { data } = useQuery({
            queryKey: ['posts'],
            queryFn: () => fetch('https://api.example.com/posts').then(r => r.json()),
            staleTime: 60 * 1000, // 60초 동안 캐시 유효
          })
        }

fetch 캐싱 방법

options.cache
  1. 여기서부터는 서버 컴포넌트, 서버 환경을 가정한다.

  2. fetch 요청에 cache 옵션을 붙여 캐싱 동작을 제어한다.

  3. 기본값은 'force-cache', 캐시가 존재하면 그것을 반환하고 없으면 새로 요청 후 저장한다.

    // force-cache: 캐시 우선 전략 (기본값)
    const data = await fetch('https://api.example.com/posts', {
      cache: 'force-cache',
      // 캐시 있음 → 캐시 반환
      // 캐시 없음 → fetch 후 Data Cache에 저장
    });
  4. 'no-store'를 사용하면 캐시를 전혀 사용하지 않고 매 요청마다 새로 fetch한다.

    // no-store: 캐시 완전 비활성화
    const data = await fetch('https://api.example.com/posts', {
      cache: 'no-store',
      // 항상 새로 fetch, 결과를 캐시에 저장하지 않음
    });
  5. 'force-cache'는 변경이 드문 정적 데이터에 적합하고, 'no-store'는 실시간 데이터(주식, 재고 등)에 사용한다.

  6. next: { revalidate: 0 }'no-store'와 동일하게 동작하므로 혼용하지 않도록 주의한다.

재검증1) 시간 기반

options.next.revalidate
  1. next.revalidate 옵션은 일정 시간(초) 이후 캐시를 자동으로 재검증하도록 예약한다.

  2. 이 방식은 stale-while-revalidate 전략을 따른다.

  3. 즉, 캐시가 만료되어도 즉시 새 데이터를 기다리지 않고, 우선 오래된 캐시를 반환한 뒤 백그라운드에서 갱신한다.

    // 60초마다 재검증
    const data = await fetch('https://api.example.com/posts', {
      next: { revalidate: 60 },
      // 0초 → no-store와 동일 (캐시 없음)
      // 60 → 60초 동안 캐시 유효
      // false → 무기한 캐싱 (force-cache와 동일)
    });

시간 기반 재검증은 정적 라우트와 동적 라우트에서 동작 방식이 다르다.

  1. 정적 라우트에서는 빌드 시 fetch를 한 번 실행하고 그 결과를 캐시에 저장한다.

  2. revalidate 시간이 지난 뒤 다음 요청이 들어오면, ISR(Incremental Static Regeneration) 방식으로 페이지 전체를 백그라운드에서 재생성한다.

  3. 동적 라우트에서는 페이지 자체가 매 요청마다 렌더링되지만, fetch 응답은 여전히 Data Cache에서 꺼내 쓴다.

  4. 즉, 동적 라우트라도 revalidate 시간 안에는 새로 fetch하지 않고 캐시된 응답을 재사용한다.

  5. 핵심 차이는 정적 라우트는 페이지 HTML 자체가 캐시되고, 동적 라우트는 페이지 HTML은 매번 생성되되 fetch 응답 데이터만 캐시된다는 점이다.

재검증2) 온디맨드

  1. 시간 기반 방식은 일정 주기로 갱신되므로, 데이터가 변경되어도 주기가 끝날 때까지 오래된 데이터가 제공될 수 있다.

  2. 온디맨드 재검증은 이 문제를 해결하기 위해, 특정 이벤트 발생 시 즉시 캐시를 무효화한다.

  3. Next.js는 이를 위해 revalidatePathrevalidateTag 두 가지 함수를 제공한다.

  4. 경로 기반 재검증은 특정 URL 경로의 캐시 전체를 무효화한다.

    // app/actions.ts (Server Action)
    'use server'
    import { revalidatePath } from 'next/cache'
     
    export async function createPost() {
      // DB에 새 글 저장 로직...
     
      revalidatePath('/blog')
      // → /blog 경로와 연관된 모든 캐시 즉시 무효화
      // → 다음 요청 시 새로 fetch하여 캐시 재저장
    }
  5. 이때 중요한 점은, revalidatePath는 서버 측의 Data Cache만 지우는 것이 아니라, 브라우저에 저장되어 있는 Router Cache도 함께 무효화한다.

  6. 태그 기반 재검증은 특정 태그가 붙은 fetch 캐시만 선택적으로 무효화한다.

    // 1단계: fetch 시 태그 지정 (캐시 그룹화)
    const posts = await fetch('https://api.example.com/posts', {
      next: { tags: ['posts'] },
      // 이 응답은 'posts' 태그로 Data Cache에 저장됨
    })
     
    const user = await fetch('https://api.example.com/user/1', {
      next: { tags: ['posts', 'user-1'] },
      // 두 태그를 동시에 붙일 수 있음
    })
     
    // 2단계: 필요한 시점에 태그로 무효화
    import { revalidateTag } from 'next/cache'
     
    export async function updatePost() {
      // 업데이트 로직...
     
      revalidateTag('posts')
      // → 'posts' 태그가 달린 모든 fetch 캐시 즉시 무효화
      // → posts, user-1 응답 모두 무효화됨
    }
  7. revalidatePath는 특정 경로와 연결된 모든 캐시(fetch, 라우트 캐시 등)를 무효화하고, revalidateTag는 태그가 일치하는 fetch 캐시만 선택적으로 무효화한다는 점에서 차이가 있다.

  8. 페이지 단위로 갱신하고 싶을 때는 revalidatePath, 데이터 단위로 정밀하게 제어하고 싶을 때는 revalidateTag를 선택한다.

revalidatePath 는 Data Cache, Router Cache 두 가지를 무효화할까?

  1. Data Cache는 서버에 존재하며, fetch 응답 데이터를 저장한다.

  2. Router Cache는 브라우저에 존재하며, 이미 방문한 페이지의 RSC 페이로드를 저장한다.

  3. 사용자가 /blog를 한 번 방문하면, 브라우저는 그 페이지의 렌더링 결과를 Router Cache에 담아둔다.

  4. 이후 같은 경로로 이동할 때 Next.js는 서버에 요청하지 않고 Router Cache에서 꺼내 즉시 보여준다.

  5. 즉, 서버의 Data Cache가 아무리 최신 데이터로 갱신되어 있어도, 브라우저가 Router Cache를 먼저 꺼내 쓰면 사용자는 새 데이터를 볼 수 없다.

  6. 만약 Data Cache만 무효화했을 때, 서버의 fetch 응답은 최신 데이터로 갱신된다.

  7. 그러나 브라우저의 Router Cache는 여전히 이전 페이지 결과를 들고 있다.

  8. 사용자가 /blog로 이동하면 브라우저는 서버에 요청하지 않고 Router Cache를 그대로 보여준다.

  9. Router Cache 유지 시간을 섲렁할 수 있고, 그 시간이 지나야 비로소 새 데이터가 보인다.

    서버 Data Cache → ✅ 최신 데이터
    브라우저 Router Cache → ❌ 이전 페이지 결과 그대로
     
    사용자가 /blog 이동 → Router Cache 히트 → 이전 목록 표시
  10. 반대로 Router Cache만 무효화했을 때, 브라우저는 캐시가 없으므로 서버에 새로 요청을 보낸다.

  11. 그러나 서버의 Data Cache가 살아있으므로, 서버는 fetch를 새로 실행하지 않고 캐시된 응답을 반환한다.

  12. 결과적으로 브라우저는 분명 새로 요청했지만, 받은 데이터는 여전히 갱신 전 데이터다.

revalidateTag 는 왜 Data Cache만 무효화시킬까?

  1. Data Cache는 fetch 요청 URL과 옵션을 기준으로 응답 데이터를 저장하며, 태그는 이 항목에 붙이는 레이블이다.

  2. Router Cache는 URL 경로(route path)를 기준으로 페이지 전체의 RSC 페이로드를 저장한다.

  3. 핵심은 태그와 경로가 1:1로 대응하지 않는다는 점이다.

  4. 예를 들어 'posts' 태그 하나가 /blog, /blog/[slug], /admin/posts 등 여러 경로에 걸쳐 사용될 수 있다.

  5. 반대로 하나의 경로에는 'posts', 'user', 'category' 등 여러 태그가 붙은 fetch가 혼재할 수 있다.

    // /blog/page.tsx 안에 여러 태그의 fetch가 공존
    const posts = await fetch('/api/posts', { next: { tags: ['posts'] } })
    const user  = await fetch('/api/user',  { next: { tags: ['user'] } })
    const cats  = await fetch('/api/cats',  { next: { tags: ['category'] } })
  6. 따라서 revalidateTag('posts')를 호출해도, 이 태그가 어느 경로의 Router Cache와 연결되어 있는지 Next.js가 특정할 수 없다.

  7. Router Cache는 경로 단위로만 무효화할 수 있고, 태그는 경로 정보를 담고 있지 않으므로 Router Cache를 건드릴 수단이 없다.

  8. 반면 revalidatePath('/blog')는 경로를 명시하므로, 그 경로의 Router Cache 항목을 정확히 찾아 무효화할 수 있다.

  9. 결국 revalidateTag의 설계 의도 자체가 "어느 경로를 다시 그릴지"가 아니라 "어떤 데이터를 무효화할지"이다.

  10. 데이터가 무효화되면, Router Cache가 만료된 이후 브라우저가 서버에 재요청할 때 서버는 새로 fetch하여 최신 데이터를 반환한다.

  11. 만약 데이터 갱신과 동시에 Router Cache까지 즉시 제거하고 싶다면, revalidateTagrevalidatePath를 함께 호출하거나 revalidatePath만 사용하면 된다.

    export async function updatePost() {
          revalidateTag('posts')      // Data Cache만 무효화
          revalidatePath('/blog')     // Router Cache까지 함께 무효화하고 싶을 때 추가
        }

라우트 세그먼트 설정 옵션

  1. fetch 단위가 아니라, 라우트 파일 전체에 캐시 동작을 일괄 적용하고 싶을 때 사용한다.

  2. layout.tsx 또는 page.tsx 상단에 특정 변수를 export하는 것만으로 설정된다.

    // app/blog/page.tsx 상단에 선언
     
    // 렌더링 방식 제어
    export const dynamic = 'auto'
    // 'auto'        → fetch 옵션에 따라 자동 결정 (기본값)
    // 'force-static' → cookies, headers 등 동적 함수도 무시하고 강제 정적 렌더링
    // 'force-dynamic'→ 항상 동적 렌더링, 모든 fetch를 no-store처럼 처리
    // 'error'       → 동적 렌더링 시도 시 에러 발생 (정적 보장)
     
    // 라우트 전체 revalidate 설정
    export const revalidate = 60
    // 이 파일 안의 모든 fetch에 revalidate: 60 일괄 적용
    // false → 무기한 캐싱 / 0 → no-store와 동일
     
    // 전체 fetch의 cache 전략 고정
    export const fetchCache = 'auto'
    // 'auto'         → 각 fetch의 cache 옵션 존중 (기본값)
    // 'force-cache'  → 모든 fetch를 force-cache로 강제
    // 'force-no-store'→ 모든 fetch를 no-store로 강제
  3. 첫 번째는 페이지 전체를 완전 정적으로 고정하는 경우다.

    // 마케팅 랜딩 페이지, 약관 페이지 등
    export const dynamic = 'force-static'
    // → cookies(), headers() 같은 동적 함수도 무시하고 무조건 정적 빌드
    // → 배포 시 HTML이 완성되므로 가장 빠름
  4. 두 번째는 페이지 전체에 공통 갱신 주기를 주는 경우다.

    // 블로그 목록, 상품 목록 등 — 자주 바뀌지 않지만 최신이어야 하는 경우
    export const revalidate = 3600 // 1시간
    // → 이 파일 안에 있는 모든 fetch에 revalidate: 3600 일괄 적용
    // → 개별 fetch에 더 짧은 revalidate가 있으면 그것이 우선
  5. 세 번째는 페이지를 항상 동적으로 강제하는 경우다.

    // 대시보드, 로그인 사용자 전용 페이지 등
    export const dynamic = 'force-dynamic'
    // → 모든 fetch가 no-store처럼 동작
    // → revalidate 선언이 있어도 무시됨 — 같이 쓰지 않는다
  6. 라우트 전체의 재검증 시간은 해당 라우트 내에 있는 모든 revalidate 설정값 중 가장 낮은 값으로 결정된다.

  7. 만약 개별 fetch에 더 짧은 revalidate가 설정되어 있으면, 라우트 레벨 설정보다 개별 설정이 우선 적용된다.

  8. 반대로 개별 fetch에 더 긴 revalidate가 있어도, 라우트 설정이 더 짧다면 라우트 설정이 기준이 된다.

  9. fetchCache는 외부 라이브러리나 서드파티 fetch가 섞여 있어 개별 옵션을 일일이 붙이기 어려울 때 전체를 강제하는 용도로 쓴다.

  10. 대부분의 페이지는 dynamic 하나 또는 revalidate 하나만 선언하는 것으로 충분하다.