본문으로 건너뛰기
Kreath Archive
TechProjectsBooksAbout
TechProjectsBooksAbout

내비게이션

  • Tech
  • Projects
  • Books
  • About
  • Tags

카테고리

  • AI / ML
  • 웹 개발
  • 프로그래밍
  • 개발 도구

연결

  • GitHub
  • Email
  • RSS
© 2026 Kreath Archive. All rights reserved.Built with Next.js + MDX
홈TechProjectsBooksAbout
//
  1. 홈
  2. 테크
  3. 5장: 데이터 페칭과 캐싱 전략의 대전환
2026년 1월 28일·웹 개발·

5장: 데이터 페칭과 캐싱 전략의 대전환

Next.js 14에서 16까지 캐싱 전략이 어떻게 변화했는지 살펴봅니다. 네 가지 캐싱 레이어, fetch() 기본값 변경, 재검증 전략을 다룹니다.

20분823자9개 섹션
nextjsreacttypescriptfrontend
공유
nextjs-app-router5 / 13
12345678910111213
이전4장: 인터셉팅 라우트와 모달 패턴다음6장: Cache Components와 'use cache' 디렉티브

4장까지 라우팅 시스템을 다루었습니다. 이번 장에서는 Next.js App Router의 핵심인 데이터 페칭과 캐싱 전략을 살펴봅니다. 특히 Next.js 14에서 15, 그리고 16으로 넘어오면서 캐싱 기본값이 어떻게 변화했는지에 집중합니다.

캐싱이 중요한 이유

웹 애플리케이션의 성능은 데이터를 얼마나 효율적으로 가져오고 재사용하느냐에 크게 좌우됩니다. Next.js는 프레임워크 수준에서 다층 캐싱 시스템을 제공하여, 개발자가 세밀하게 캐싱 전략을 제어할 수 있도록 합니다.

하지만 Next.js 14에서 15로 넘어오면서 캐싱 기본값이 근본적으로 변경되었습니다. 이 변화를 이해하지 못하면 예상치 못한 동작에 당황하거나, 성능 최적화 기회를 놓치게 됩니다.

Next.js의 네 가지 캐싱 레이어

Next.js App Router는 네 가지 독립적인 캐싱 레이어를 가지고 있습니다.

1. Request Memoization (요청 메모이제이션)

같은 렌더링 과정에서 동일한 fetch() 호출이 여러 번 발생하면, 실제로는 한 번만 실행하고 결과를 재사용합니다. 이것은 React의 기능이며, 하나의 서버 렌더링 패스 동안에만 유지됩니다.

여러 컴포넌트에서 같은 데이터 요청
typescript
// Layout에서 호출
async function Layout({ children }: { children: React.ReactNode }) {
  const user = await fetch('/api/user'); // 실제 fetch 실행
  return <div>{children}</div>;
}
 
// Page에서도 동일한 호출
async function Page() {
  const user = await fetch('/api/user'); // 메모이제이션 - 캐시에서 반환
  return <div>{user.name}</div>;
}
Info

Request Memoization은 GET 메서드의 fetch() 요청에만 적용됩니다. POST, DELETE 등의 요청은 메모이제이션되지 않습니다. 또한 React의 cache() 함수를 사용하면 fetch()가 아닌 함수도 메모이제이션할 수 있습니다.

2. Data Cache (데이터 캐시)

fetch() 응답을 서버 측에서 지속적으로 캐싱합니다. Request Memoization이 하나의 렌더링 패스에서만 유지되는 반면, Data Cache는 여러 요청과 배포에 걸쳐 유지됩니다.

Data Cache 동작
typescript
// 캐시됨 (Next.js 14 기본값)
const data = await fetch('https://api.example.com/data', {
  cache: 'force-cache',
});
 
// 캐시 안 됨 (Next.js 15 기본값)
const data = await fetch('https://api.example.com/data', {
  cache: 'no-store',
});
 
// 시간 기반 재검증
const data = await fetch('https://api.example.com/data', {
  next: { revalidate: 3600 }, // 1시간
});

3. Full Route Cache (전체 라우트 캐시)

빌드 시점에 정적으로 렌더링된 라우트의 HTML과 RSC Payload를 캐시합니다. 동적 라우트(쿠키, 헤더 접근 등)에는 적용되지 않습니다.

4. Router Cache (라우터 캐시)

클라이언트 측에서 방문한 라우트의 RSC Payload를 메모리에 캐시합니다. 뒤로가기/앞으로가기 탐색이 즉시 이루어지며, prefetch된 라우트도 빠르게 탐색할 수 있습니다.

Next.js 14 vs 15: 캐싱 기본값의 대전환

Next.js 15에서 가장 큰 변화는 캐싱의 기본값이 바뀐 것입니다.

영역Next.js 14Next.js 15
fetch() 기본값force-cache (캐시됨)no-store (캐시 안 됨)
GET Route Handlers캐시됨캐시 안 됨
Client Router Cache (Pages)staleTime 30초staleTime 0
Client Router Cache (Layouts)staleTime 5분staleTime 5분 (유지)

fetch() 기본값 변경

Next.js 14에서는 fetch()가 기본적으로 캐시되었습니다. 이 설계 의도는 "성능 최적화를 기본으로 제공"하는 것이었지만, 실제로는 많은 개발자들이 데이터가 갱신되지 않는 문제로 혼란을 겪었습니다.

Next.js 14: 의도치 않은 캐싱
typescript
// Next.js 14에서 이 코드는 첫 번째 요청의 결과를 계속 캐시
async function Dashboard() {
  const stats = await fetch('https://api.example.com/stats');
  // 사용자가 새로고침해도 같은 데이터가 표시됨
  return <StatsDisplay data={await stats.json()} />;
}

Next.js 15에서는 fetch()의 기본값이 no-store로 변경되어, 명시적으로 캐싱을 선택해야 합니다.

Next.js 15: 명시적 캐싱
typescript
// 캐싱이 필요하면 명시적으로 선언
async function Dashboard() {
  const stats = await fetch('https://api.example.com/stats', {
    cache: 'force-cache',        // 명시적 캐싱
    next: { revalidate: 60 },    // 또는 시간 기반 재검증
  });
  return <StatsDisplay data={await stats.json()} />;
}
Tip

Next.js 15의 철학은 "캐싱은 옵트인(opt-in)"입니다. 성능 최적화가 필요한 곳에 개발자가 직접 캐싱을 적용하는 것이 기본이며, 이는 예측 가능한 동작을 보장합니다.

GET Route Handlers 변경

Next.js 14에서는 GET Route Handlers가 기본적으로 캐시되었습니다.

app/api/data/route.ts
typescript
// Next.js 14: 이 응답이 자동으로 캐시됨
export async function GET() {
  const data = await db.query('SELECT * FROM items');
  return Response.json(data);
}
 
// Next.js 15: 캐시되지 않음. 캐싱하려면 명시적 설정 필요
export const dynamic = 'force-static';
export async function GET() {
  const data = await db.query('SELECT * FROM items');
  return Response.json(data);
}

Client Router Cache staleTime 변경

Next.js 14에서는 페이지의 Router Cache staleTime이 30초였습니다. 즉, 한 번 방문한 페이지를 30초 이내에 다시 방문하면 캐시된 버전이 표시되었습니다. Next.js 15에서는 이 값이 0으로 변경되어, 페이지 탐색 시 항상 최신 데이터를 가져옵니다.

next.config.ts에서 staleTime 커스터마이징
typescript
import type { NextConfig } from 'next';
 
const nextConfig: NextConfig = {
  experimental: {
    staleTimes: {
      dynamic: 30,  // 동적 페이지의 staleTime (기본 0)
      static: 180,  // 정적(prefetch) 페이지의 staleTime (기본 300)
    },
  },
};
 
export default nextConfig;
Warning

레이아웃의 staleTime은 Next.js 15에서도 5분으로 유지됩니다. 레이아웃은 탐색 시 리렌더링되지 않는 것이 App Router의 핵심 설계이기 때문입니다.

재검증 전략 (Revalidation)

캐시된 데이터를 최신 상태로 유지하는 세 가지 재검증 전략이 있습니다.

시간 기반 재검증 (Time-based)

일정 시간이 지나면 캐시를 자동으로 무효화합니다.

시간 기반 재검증
typescript
// fetch 단위
const data = await fetch('https://api.example.com/posts', {
  next: { revalidate: 3600 }, // 1시간마다 재검증
});
 
// 페이지/레이아웃 단위
export const revalidate = 3600;
 
async function Page() {
  const data = await fetch('https://api.example.com/posts');
  return <PostList posts={await data.json()} />;
}

시간 기반 재검증은 stale-while-revalidate(SWR) 패턴을 따릅니다. 재검증 시간이 지난 후 첫 번째 요청은 여전히 캐시된 (stale) 데이터를 반환하면서 백그라운드에서 데이터를 다시 가져옵니다. 데이터가 갱신되면 이후 요청부터 새 데이터가 반환됩니다.

태그 기반 재검증 (Tag-based)

데이터에 태그를 부여하고, 태그 단위로 캐시를 무효화합니다.

태그 기반 재검증
typescript
// 데이터 fetch 시 태그 부여
const posts = await fetch('https://api.example.com/posts', {
  next: { tags: ['posts'] },
});
 
const post = await fetch(`https://api.example.com/posts/${id}`, {
  next: { tags: ['posts', `post-${id}`] },
});
Server Action에서 태그 무효화
typescript
'use server';
 
import { revalidateTag } from 'next/cache';
 
export async function createPost(formData: FormData) {
  await db.posts.create({
    title: formData.get('title') as string,
    content: formData.get('content') as string,
  });
 
  revalidateTag('posts'); // 'posts' 태그가 붙은 모든 캐시 무효화
}

경로 기반 재검증 (Path-based)

특정 경로의 캐시를 무효화합니다.

경로 기반 재검증
typescript
'use server';
 
import { revalidatePath } from 'next/cache';
 
export async function updateProfile(formData: FormData) {
  await db.users.update({
    name: formData.get('name') as string,
  });
 
  revalidatePath('/profile');          // 특정 경로
  revalidatePath('/blog', 'layout');   // 레이아웃 단위로 하위 전체
  revalidatePath('/blog', 'page');     // 페이지만
}
Tip

revalidatePath('/')를 호출하면 애플리케이션의 모든 라우트 캐시가 무효화됩니다. 강력하지만 그만큼 광범위하므로, 가능하면 태그 기반 재검증을 사용하는 것이 더 정밀합니다.

세 전략 비교

전략적합한 상황장점단점
시간 기반주기적으로 변하는 데이터설정이 간단불필요한 갱신 발생 가능
태그 기반데이터 변경 시점을 아는 경우가장 정밀한 제어태그 설계가 필요
경로 기반특정 페이지 갱신직관적범위가 넓을 수 있음

React cache()로 요청 중복 제거

fetch() 외의 데이터 소스(ORM, 데이터베이스 직접 호출 등)에는 Request Memoization이 자동으로 적용되지 않습니다. 이 경우 React의 cache() 함수를 사용하여 같은 렌더링 패스에서의 중복 호출을 방지할 수 있습니다.

lib/data.ts
typescript
import { cache } from 'react';
import { db } from '@/lib/db';
 
// cache()로 감싸면 같은 렌더링 패스에서 중복 호출 방지
export const getUser = cache(async (id: string) => {
  return await db.users.findUnique({ where: { id } });
});
 
export const getPosts = cache(async () => {
  return await db.posts.findMany({ orderBy: { createdAt: 'desc' } });
});
여러 컴포넌트에서 사용
typescript
// Layout
async function Layout({ children }: { children: React.ReactNode }) {
  const user = await getUser('user-1'); // DB 쿼리 실행
  return <Nav user={user}>{children}</Nav>;
}
 
// Page
async function Page() {
  const user = await getUser('user-1'); // cache 히트 - DB 쿼리 생략
  return <Profile user={user} />;
}
Warning

cache()는 하나의 서버 렌더링 패스에서만 유효합니다. 여러 요청에 걸친 캐싱이 필요하면 Next.js 15의 unstable_cache 또는 Next.js 16의 "use cache" 디렉티브를 사용해야 합니다.

unstable_cache: fetch가 아닌 데이터 캐싱

Next.js 15에서 도입된 unstable_cache는 ORM 쿼리, 직접 데이터베이스 호출 등 fetch()가 아닌 비동기 함수의 결과를 Data Cache에 저장합니다.

unstable_cache 사용
typescript
import { unstable_cache } from 'next/cache';
import { db } from '@/lib/db';
 
const getCachedPosts = unstable_cache(
  async () => {
    return await db.posts.findMany({
      orderBy: { createdAt: 'desc' },
      take: 10,
    });
  },
  ['posts-list'],                  // 캐시 키
  {
    revalidate: 3600,              // 1시간마다 재검증
    tags: ['posts'],               // 태그 기반 무효화 가능
  }
);
 
async function PostList() {
  const posts = await getCachedPosts();
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}
Info

unstable_cache라는 이름의 "unstable" 접두사는 API가 변경될 수 있음을 의미합니다. 실제로 Next.js 16에서는 "use cache" 디렉티브가 이를 대체하는 더 우아한 방법으로 도입되었습니다. 다음 장에서 자세히 다룹니다.

실전: 캐싱 전략 설계

전자상거래 상품 페이지를 예로 들어, 각 데이터에 적합한 캐싱 전략을 설계해 보겠습니다.

app/products/[id]/page.tsx
typescript
import { cache } from 'react';
import { revalidateTag } from 'next/cache';
 
// 상품 기본 정보: 자주 변하지 않으므로 시간 기반 캐싱
async function getProduct(id: string) {
  const res = await fetch(`https://api.example.com/products/${id}`, {
    next: {
      revalidate: 3600,
      tags: [`product-${id}`],
    },
  });
  return res.json();
}
 
// 재고 정보: 실시간성 중요, 캐싱 안 함
async function getStock(id: string) {
  const res = await fetch(`https://api.example.com/products/${id}/stock`, {
    cache: 'no-store',
  });
  return res.json();
}
 
// 리뷰: 태그 기반 재검증 (리뷰 작성 시 무효화)
async function getReviews(productId: string) {
  const res = await fetch(
    `https://api.example.com/products/${productId}/reviews`,
    {
      next: {
        tags: [`reviews-${productId}`],
        revalidate: 300, // 5분 간격으로도 재검증
      },
    }
  );
  return res.json();
}
 
// 사용자별 추천: 요청마다 다르므로 캐싱 안 함
async function getRecommendations(userId: string) {
  const res = await fetch(
    `https://api.example.com/recommendations/${userId}`,
    { cache: 'no-store' }
  );
  return res.json();
}

이처럼 데이터의 특성에 맞게 캐싱 전략을 혼합하여 사용하는 것이 실전에서의 핵심입니다.

Next.js 14에서 15로 마이그레이션

기존 Next.js 14 프로젝트를 15로 업그레이드할 때 캐싱 관련 주의사항을 정리합니다.

1. fetch() 기본값 변경 대응

기존에 캐싱에 의존하던 fetch() 호출에 명시적으로 cache: 'force-cache'를 추가합니다.

2. Route Segment Config 활용

페이지 단위로 캐싱 동작을 제어할 수 있습니다.

정적 생성 유지가 필요한 페이지
typescript
// 이 페이지의 모든 fetch를 강제로 캐시
export const fetchCache = 'default-cache';
 
// 또는 정적 생성 강제
export const dynamic = 'force-static';

3. 점진적 마이그레이션

한 번에 모든 것을 바꾸기보다, 페이지별로 캐싱 전략을 점검하고 필요한 곳에만 명시적 캐싱을 추가하는 것을 권장합니다.

next.config.ts - 14 동작 유지 (임시)
typescript
import type { NextConfig } from 'next';
 
const nextConfig: NextConfig = {
  experimental: {
    staleTimes: {
      dynamic: 30,  // 14의 동작 유지
      static: 300,
    },
  },
};
 
export default nextConfig;

핵심 요약

  • Next.js App Router는 Request Memoization, Data Cache, Full Route Cache, Router Cache의 네 가지 캐싱 레이어를 가지고 있습니다.
  • Next.js 15에서 fetch() 기본값이 force-cache에서 no-store로, GET Route Handlers가 캐시에서 비캐시로, Router Cache staleTime이 30초에서 0으로 변경되었습니다.
  • 재검증 전략은 시간 기반(revalidate), 태그 기반(revalidateTag), 경로 기반(revalidatePath)의 세 가지가 있으며, 데이터 특성에 맞게 조합하여 사용합니다.
  • React의 cache() 함수로 fetch가 아닌 데이터 소스의 요청 중복을 제거하고, unstable_cache로 Data Cache에 저장할 수 있습니다.
  • Next.js 15의 캐싱 철학은 "옵트인"입니다. 기본적으로 캐시하지 않고, 필요한 곳에 명시적으로 캐싱을 선언합니다.

다음 장에서는 Next.js 16에서 도입된 Cache Components와 "use cache" 디렉티브를 다룹니다. unstable_cache를 대체하는 더 선언적이고 강력한 캐싱 방법을 살펴봅니다.

이 글이 도움이 되셨나요?

관련 주제 더 보기

#nextjs#react#typescript#frontend

관련 글

웹 개발

6장: Cache Components와 'use cache' 디렉티브

Next.js 16의 Cache Components 시스템을 다룹니다. 'use cache' 디렉티브, cacheLife 프로필, cacheTag 무효화, 세 가지 캐시 변형을 살펴봅니다.

2026년 1월 30일·17분
웹 개발

4장: 인터셉팅 라우트와 모달 패턴

Next.js의 인터셉팅 라우트로 모달, 사진 갤러리, 미리보기 패턴을 구현합니다. 병렬 라우트와의 조합, 딥 링킹, 공유 가능한 URL을 다룹니다.

2026년 1월 26일·12분
웹 개발

7장: 스트리밍 SSR과 로딩 UI 전략

Next.js의 스트리밍 SSR 동작 원리를 살펴봅니다. loading.tsx, Suspense 경계, 스켈레톤 설계, 프로그레시브 렌더링 전략과 성능 지표 영향을 다룹니다.

2026년 2월 1일·17분
이전 글4장: 인터셉팅 라우트와 모달 패턴
다음 글6장: Cache Components와 'use cache' 디렉티브

댓글

목차

약 20분 남음
  • 캐싱이 중요한 이유
  • Next.js의 네 가지 캐싱 레이어
    • 1. Request Memoization (요청 메모이제이션)
    • 2. Data Cache (데이터 캐시)
    • 3. Full Route Cache (전체 라우트 캐시)
    • 4. Router Cache (라우터 캐시)
  • Next.js 14 vs 15: 캐싱 기본값의 대전환
    • fetch() 기본값 변경
    • GET Route Handlers 변경
    • Client Router Cache staleTime 변경
  • 재검증 전략 (Revalidation)
    • 시간 기반 재검증 (Time-based)
    • 태그 기반 재검증 (Tag-based)
    • 경로 기반 재검증 (Path-based)
    • 세 전략 비교
  • React cache()로 요청 중복 제거
  • unstable_cache: fetch가 아닌 데이터 캐싱
  • 실전: 캐싱 전략 설계
  • Next.js 14에서 15로 마이그레이션
    • 1. fetch() 기본값 변경 대응
    • 2. Route Segment Config 활용
    • 3. 점진적 마이그레이션
  • 핵심 요약