Blog

Next.js 15 Navigation별 캐싱 동작 완전 정리

Next.js 15의 캐싱 패러다임 변화와 Soft/Hard Navigation별 캐싱 동작을 완전 분석. Router Cache, Data Cache, Full Route Cache의 상호작용과 실전 마이그레이션 전략까지.

📋 목차

  1. 패러다임 전환: 기본 캐시에서 명시적 제어로
  2. Soft Navigation vs Hard Navigation 캐싱 차이점
  3. Layout과 Page 컴포넌트의 캐싱 동작 분석
  4. 동적 렌더링과 캐시 제어 메커니즘
  5. App Router 캐싱 전략 심화
  6. fetch 동작과 재검증 전략
  7. 실전 패턴과 마이그레이션 전략
  8. Next.js 16과 미래 전망
  9. 프로덕션 애플리케이션을 위한 핵심 정리

패러다임 전환: 기본 캐시에서 명시적 제어로

Next.js 15는 2024년 10월 21일 출시되어 애플리케이션의 캐싱 동작을 근본적으로 변경했습니다. 기존의 공격적인 기본 캐싱에서 명시적 옵트인 제어 방식으로 전환한 것입니다. 이는 예상치 못한 stale 데이터와 예측하기 어려운 캐시 동작에 대한 광범위한 개발자 피드백을 반영한 결정입니다.

가장 중요한 변경사항은 Router Cache에 영향을 미칩니다: 페이지 세그먼트의 기본값이 staleTime: 0으로 설정되어, 클라이언트가 navigation 시 항상 새로운 데이터를 가져옵니다. 또한 fetch 요청과 GET Route Handler도 더 이상 기본적으로 캐시되지 않습니다.

javascript
// Next.js 15의 기본 동작
const nextConfig = {
  experimental: {
    staleTimes: {
      dynamic: 0,  // 동적 라우트는 캐시하지 않음 (기본값)
      static: 0,   // 정적 라우트도 캐시하지 않음 (기본값)
    },
  },
};

// 이전 동작을 복원하려면 명시적으로 설정
const nextConfig = {
  experimental: {
    staleTimes: {
      dynamic: 30,  // 30초 캐싱
      static: 300,  // 5분 캐싱
    },
  },
};

Soft Navigation vs Hard Navigation 캐싱 차이점

Next.js는 두 가지 서로 다른 navigation 모드를 사용하며, 각각 완전히 다른 캐싱 동작을 보입니다. Soft navigation<Link> 컴포넌트나 router.push()를 통한 프로그래매틱 navigation에서 발생하며, React와 브라우저 상태를 보존하면서 Router Cache를 활용해 즉시 페이지 전환을 제공합니다.

반면 Hard navigation은 브라우저 새로고침, 직접 URL 입력, 외부 링크 클릭 시 발생하며, Router Cache를 완전히 우회하고 서버에서 새로운 컨텐츠를 가져옵니다.

javascript
// Navigation 타입을 프로그래매틱으로 감지하는 방법
import { headers } from 'next/headers';

async function getNavigationMode(): Promise<'soft' | 'hard'> {
  const headersList = await headers();
  const nextUrl = headersList.get('next-url');
  return nextUrl ? 'soft' : 'hard';
}

export default async function MyComponent() {
  const navMode = await getNavigationMode();
  console.log(`현재 Navigation 타입: ${navMode}`);
  
  return (
    <div>
      Navigation 모드: {navMode}
    </div>
  );
}
Navigation 타입LayoutPage설명
Soft Navigation
<Link>, router.push()
❌ 캐싱됨
(재실행 안됨)
✅ 재실행
(Next.js 15: 캐시 안됨)
클라이언트 사이드 라우팅
Hard Navigation
새로고침(F5), URL 직접 입력
✅ 재실행✅ 재실행전체 페이지 리로드

Layout과 Page 컴포넌트의 캐싱 동작 분석

Layout 컴포넌트의 지속적 캐싱

Layout 컴포넌트는 사용자 세션 동안 지속적으로 캐시되며, soft navigation 중에는 절대 서버에서 다시 가져오지 않습니다. 이는 부분 렌더링을 가능하게 하는 의도적인 설계로, 공유 레이아웃이 마운트된 상태를 유지하면서 변경된 세그먼트만 다시 렌더링됩니다.

javascript
// /app/dashboard/layout.tsx
// Soft Navigation에서 매번 실행하려면 명시적 설정 필요
export const dynamic = 'force-dynamic'; // 강제 동적 렌더링

// 또는
import { connection } from 'next/server';

export default async function DashboardLayout({ children }) {
  // Next.js 15의 새로운 connection() API 사용
  await connection(); // 요청을 기다림
  
  console.log('Layout 실행 - Soft Navigation에서도 실행됨');
  
  const userData = await fetch('/api/user', {
    cache: 'no-store' // 캐시하지 않음
  });
  
  return (
    <div className="dashboard-layout">
      <nav>사용자: {userData.name}</nav>
      {children}
    </div>
  );
}

Page 컴포넌트의 새로운 동작

Next.js 15에서 Page 컴포넌트는 완전히 다른 동작을 보입니다. 새로운 기본값 staleTime: 0으로 인해 navigation 중에 페이지가 전혀 캐시되지 않습니다.

javascript
// /app/dashboard/page.tsx
export default async function DashboardPage({ 
  searchParams 
}: { 
  searchParams: Promise<{ tab?: string }> // Next.js 15: async
}) {
  // searchParams도 이제 async
  const { tab } = await searchParams;
  
  console.log('Page 실행 - 매번 실행됨 (Next.js 15)');
  
  const stats = await fetch('/api/stats', {
    // Next.js 15: 명시적으로 캐시해야 함
    next: { revalidate: 60 } // 60초 캐싱
  });
  
  return (
    <div>
      <h1>대시보드</h1>
      <p>: {tab}</p>
      <pre>{JSON.stringify(stats, null, 2)}</pre>
    </div>
  );
}

동적 렌더링과 캐시 제어 메커니즘

export const dynamic의 4가지 옵션

export const dynamicRoute Segment Config로, Next.js App Router에서 렌더링 동작을 제어하는 핵심 설정입니다. 특정 파일에서만 사용할 수 있습니다:

사용 가능한 파일 위치

  • page.tsx - 페이지 컴포넌트
  • layout.tsx - 레이아웃 컴포넌트
  • route.ts - API 라우트 핸들러
  • default.tsx - 기본 UI 컴포넌트
  • loading.tsx - 로딩 UI 컴포넌트
  • error.tsx - 에러 UI 컴포넌트
  • global-error.tsx - 글로벌 에러 UI 컴포넌트
  • not-found.tsx - 404 페이지 컴포넌트

4가지 옵션 상세 분석

javascript
// 1. 'auto' (기본값) - 가능한 한 캐시하되 동적 동작 방해하지 않음
// 📁 app/dashboard/page.tsx
export const dynamic = 'auto';

export default async function DashboardPage() {
  // 동적 함수 사용 시 자동으로 dynamic rendering으로 전환
  const data = await fetch('/api/data');
  return <div>{JSON.stringify(data)}</div>;
}

// 📁 app/api/users/route.ts
export const dynamic = 'auto';

export async function GET() {
  // API 라우트에서는 동적 함수 사용 시 자동으로 런타임에서 실행
  return Response.json({ users: [] });
}
javascript
// 2. 'force-dynamic' - 모든 요청에 대해 새로 렌더링 (가장 많이 사용)
// 📁 app/dashboard/layout.tsx
export const dynamic = 'force-dynamic';

export default async function DashboardLayout({ children }) {
  console.log('레이아웃이 매번 실행됨 - Soft Navigation에서도!');
  
  // Soft Navigation에서도 매번 서버에서 실행됨
  const userData = await fetch('/api/user', { cache: 'no-store' });
  
  return (
    <div>
      <header>사용자: {userData.name}</header>
      {children}
    </div>
  );
}

// 📁 app/api/current-time/route.ts  
export const dynamic = 'force-dynamic';

export async function GET() {
  // 매 요청마다 새로운 시간 반환 (캐시되지 않음)
  return Response.json({ 
    time: new Date().toISOString(),
    random: Math.random() 
  });
}
javascript
// 3. 'error' - 정적 생성을 강제하고 동적 API 사용 시 에러 발생
// 📁 app/about/page.tsx
export const dynamic = 'error';

export default async function AboutPage() {
  // ✅ 정적 데이터는 OK
  const staticData = await fetch('https://api.example.com/static', {
    cache: 'force-cache'
  });
  
  // ❌ 이런 코드가 있으면 빌드 시 에러 발생
  // const cookies = await cookies(); // Error!
  // const headers = await headers(); // Error!
  
  return (
    <div>
      <h1>회사 소개</h1>
      <p>정적으로 생성된 페이지입니다</p>
    </div>
  );
}
javascript
// 4. 'force-static' - 동적 API를 빈 값으로 처리하여 정적 생성
// 📁 app/products/page.tsx
export const dynamic = 'force-static';

export default async function ProductsPage() {
  // 동적 API들이 빈 값을 반환함
  const cookieStore = await cookies(); // 빈 객체 반환
  const headersList = await headers(); // 빈 Headers 객체 반환
  
  console.log('쿠키:', cookieStore.getAll()); // 빈 배열
  console.log('헤더:', headersList.get('user-agent')); // null
  
  // 정적으로 생성되지만 동적 API 사용으로 인한 에러는 발생하지 않음
  return (
    <div>
      <h1>상품 목록 (정적 생성)</h1>
      <p>빌드 시점에 생성된 페이지</p>
    </div>
  );
}

특별한 사용 사례들

javascript
// 📁 app/error.tsx - 에러 페이지에서 동적 정보 표시
export const dynamic = 'force-dynamic';

export default async function ErrorPage({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  // 에러 발생 시간을 동적으로 표시
  const errorTime = new Date().toISOString();
  
  return (
    <div>
      <h2>오류가 발생했습니다!</h2>
      <p>발생 시간: {errorTime}</p>
      <p>오류 내용: {error.message}</p>
      <button onClick={reset}>다시 시도</button>
    </div>
  );
}

// 📁 app/loading.tsx - 로딩 페이지는 보통 정적
export const dynamic = 'force-static';

export default function Loading() {
  // 정적으로 생성되어 빠른 로딩 화면 제공
  return (
    <div className="loading-spinner">
      <div className="spinner"></div>
      <p>로딩 중...</p>
    </div>
  );
}

상속 규칙과 우선순위

javascript
// 📁 app/dashboard/layout.tsx
export const dynamic = 'force-static'; // 레이아웃은 정적

// 📁 app/dashboard/analytics/page.tsx  
export const dynamic = 'force-dynamic'; // 페이지는 동적

// 결과: 이 페이지만 동적으로 렌더링되고,
//       레이아웃은 여전히 정적으로 유지됨
//       (더 구체적인 설정이 우선순위를 가짐)

export default async function AnalyticsPage() {
  // 이 페이지는 매번 동적으로 렌더링
  const realTimeData = await fetch('/api/analytics', {
    cache: 'no-store'
  });
  
  return <div>실시간 분석 데이터</div>;
}

주의사항

javascript
// ⚠️ 컴포넌트 내부에서는 사용 불가
function MyComponent() {
  // ❌ 이렇게 사용하면 안됨
  // export const dynamic = 'force-dynamic';
  
  return <div>컴포넌트</div>;
}

// ⚠️ 조건부로 설정 불가
// ❌ 이렇게 사용하면 안됨
// export const dynamic = process.env.NODE_ENV === 'development' ? 'force-dynamic' : 'auto';

// ✅ 올바른 사용법 - 파일 최상위에서 상수로만 선언
export const dynamic = 'force-dynamic';

connection()과 unstable_noStore()의 진화

Next.js 15는 unstable_noStore()를 대체하는 새로운 connection() API를 도입했습니다.

javascript
// 🚫 이전 방식 (deprecated)
import { unstable_noStore as noStore } from 'next/cache';

export default async function OldComponent() {
  noStore(); // 더 이상 권장하지 않음
  const data = await db.query(...);
  return <div>{data}</div>;
}

// ✅ Next.js 15 권장 방식
import { connection } from 'next/server';

export default async function NewComponent() {
  await connection(); // 요청을 기다림을 명시적으로 표현
  const data = await db.query(...);
  return <div>{data}</div>;
}

비동기 요청 API들

Next.js 15의 모든 요청 의존적 API는 이제 비동기입니다. 이는 정적 최적화를 개선하기 위해 이러한 작업의 비동기적 특성을 명시적으로 만든 것입니다.

javascript
// Next.js 15: 모든 것이 async
import { cookies, headers } from 'next/headers';

export default async function MyPage({ 
  params,
  searchParams 
}: { 
  params: Promise<{ slug: string }>;
  searchParams: Promise<{ q?: string }>;
}) {
  // 모든 API에 await 필요
  const cookieStore = await cookies();
  const headersList = await headers();
  const { slug } = await params;
  const { q } = await searchParams;
  
  const theme = cookieStore.get('theme');
  const userAgent = headersList.get('user-agent');
  
  return (
    <div data-theme={theme?.value}>
      <h1>페이지: {slug}</h1>
      <p>검색어: {q}</p>
      <small>브라우저: {userAgent}</small>
    </div>
  );
}

App Router 캐싱 전략 심화

4가지 캐싱 메커니즘의 상호작용

Next.js 15는 성능을 최적화하기 위해 함께 작동하는 4개의 서로 다른 캐싱 레이어를 사용합니다:

  1. Request Memoization: 단일 렌더 패스 중 React 레벨에서 작동
  2. Data Cache: fetch 결과를 요청과 배포 간에 지속 (명시적 옵트인 필요)
  3. Full Route Cache: 정적으로 렌더된 라우트의 RSC Payload와 HTML 저장
  4. Router Cache: 브라우저에서 라우트 세그먼트를 캐시하여 클라이언트 사이드 성능 유지
javascript
// 캐시 레이어들의 상호작용 예시
export default async function CacheExample() {
  // 1. Request Memoization: 같은 렌더 중 중복 제거
  const data1 = await fetch('/api/data');
  const data2 = await fetch('/api/data'); // 자동으로 메모화됨
  
  // 2. Data Cache: 명시적 옵트인
  const cachedData = await fetch('/api/cached-data', {
    cache: 'force-cache', // Next.js 15에서 명시적 캐시
    next: { revalidate: 3600 } // 1시간 후 재검증
  });
  
  // 3. Full Route Cache: dynamic 설정에 따라 결정
  // 4. Router Cache: 클라이언트에서 자동으로 처리
  
  return <div>{/* 컴포넌트 내용 */}</div>;
}

실험적 "use cache" 지시문과 dynamicIO

javascript
// next.config.js에서 활성화
const nextConfig = {
  experimental: {
    dynamicIO: true,
    cacheComponents: true,
  },
};

// 함수 레벨에서 세밀한 캐시 제어
export async function getProductData(id: string) {
  'use cache'; // 이 함수의 결과를 캐시
  const response = await fetch(`/api/products/${id}`);
  return response.json();
}

// 캐시 생명주기 설정
import { cacheLife } from 'next/cache';

export async function getBlogPosts() {
  'use cache';
  cacheLife('hours'); // 또는 cacheLife({ stale: 3600, revalidate: 900, expire: 86400 })
  
  const posts = await fetch('/api/posts');
  return posts.json();
}

// 컴포넌트에서 사용
export default async function ProductPage({ id }: { id: string }) {
  const product = await getProductData(id); // 캐시된 함수 호출
  const posts = await getBlogPosts(); // 시간별 캐시된 함수 호출
  
  return (
    <div>
      <h1>{product.name}</h1>
      <aside>
        <h2>관련 포스트</h2>
        {posts.map(post => (
          <article key={post.id}>{post.title}</article>
        ))}
      </aside>
    </div>
  );
}

fetch 동작과 재검증 전략

시간 기반 및 온디맨드 재검증 패턴

javascript
// 개별 fetch 재검증
export default async function DataComponent() {
  // 1시간마다 재검증
  const hourlyData = await fetch('https://api.example.com/hourly', {
    next: { revalidate: 3600 }
  });
  
  // 태그 기반 재검증을 위한 설정
  const posts = await fetch('https://api.example.com/posts', {
    next: { 
      tags: ['posts', 'featured'],
      revalidate: 1800 // 30분
    }
  });
  
  return (
    <div>
      <section>
        <h2>시간별 데이터</h2>
        <pre>{JSON.stringify(hourlyData, null, 2)}</pre>
      </section>
      
      <section>
        <h2>게시물</h2>
        {posts.map(post => (
          <article key={post.id}>{post.title}</article>
        ))}
      </section>
    </div>
  );
}

// 라우트 세그먼트 레벨 재검증
export const revalidate = 3600; // 1시간

Server Actions에서 캐시 무효화

javascript
'use server'
import { revalidateTag, revalidatePath } from 'next/cache';

export async function updatePost(formData: FormData) {
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;
  
  // 데이터베이스 업데이트
  await updatePostInDatabase({ title, content });
  
  // 특정 태그 무효화
  revalidateTag('posts'); // 'posts' 태그가 있는 모든 데이터 무효화
  revalidateTag('featured'); // 'featured' 태그 무효화
  
  // 특정 경로 무효화
  revalidatePath('/blog', 'layout'); // 레이아웃까지 포함해서 무효화
  revalidatePath('/blog/[slug]', 'page'); // 특정 페이지만 무효화
  
  return { success: true };
}

// 컴포넌트에서 사용
export default function PostEditor() {
  return (
    <form action={updatePost}>
      <input name="title" placeholder="제목" />
      <textarea name="content" placeholder="내용" />
      <button type="submit">게시물 업데이트</button>
    </form>
  );
}

unstable_cache로 고급 캐싱

javascript
import { unstable_cache } from 'next/cache';

// 데이터베이스 쿼리나 복잡한 계산에 대한 프로그래매틱 캐싱
const getCachedUser = unstable_cache(
  async (id: string) => {
    console.log(`사용자 ${id} 데이터 조회 중...`);
    const user = await getUserFromDatabase(id);
    return user;
  },
  ['user'], // 캐시 키 부분들
  {
    tags: ['users'], // 재검증을 위한 태그
    revalidate: 3600, // 1시간 후 재검증
  }
);

export default async function UserProfile({ userId }: { userId: string }) {
  const user = await getCachedUser(userId);
  
  return (
    <div className="user-profile">
      <h1>{user.name}</h1>
      <p>{user.email}</p>
      <img src={user.avatar} alt={`${user.name}의 아바타`} />
    </div>
  );
}

실전 패턴과 마이그레이션 전략

일반적인 실수와 해결책

javascript
// ❌ 잘못된 방법 - cookies()가 캐싱을 깨뜨림
async function getUser() {
  'use cache';
  const session = await cookies().get('session'); // 캐싱이 작동하지 않음
  return fetchUser(session);
}

// ✅ 올바른 방법 - 동적 로직과 캐시된 로직 분리
async function getUser(sessionToken: string) {
  'use cache';
  return fetchUser(sessionToken);
}

// 컴포넌트에서 사용
export default async function Profile() {
  const cookieStore = await cookies();
  const session = cookieStore.get('session');
  
  if (!session) {
    return <div>로그인이 필요합니다</div>;
  }
  
  const user = await getUser(session.value);
  
  return (
    <div className="profile">
      <h1>{user.name}</h1>
    </div>
  );
}

Suspense와 동적 컨텐츠

javascript
import { Suspense } from 'react';

// 동적 컨텐츠를 위한 비동기 컴포넌트
async function DynamicUserContent() {
  await connection(); // 요청 대기
  
  const userData = await fetch('/api/user-specific-data', {
    cache: 'no-store' // 캐시하지 않음
  });
  
  return (
    <div className="user-content">
      <h2>개인화된 컨텐츠</h2>
      <pre>{JSON.stringify(userData, null, 2)}</pre>
    </div>
  );
}

// 캐시된 공통 컨텐츠
async function CachedCommonContent() {
  'use cache';
  
  const commonData = await fetch('/api/common-data');
  
  return (
    <div className="common-content">
      <h2>공통 컨텐츠</h2>
      <p>{commonData.message}</p>
    </div>
  );
}

export default function HybridPage() {
  return (
    <div className="page-container">
      {/* 캐시된 공통 컨텐츠 */}
      <CachedCommonContent />
      
      {/* 동적 컨텐츠를 Suspense로 감싸기 */}
      <Suspense fallback={<div>사용자 데이터 로딩 중...</div>}>
        <DynamicUserContent />
      </Suspense>
    </div>
  );
}

마이그레이션 전략

javascript
// 1단계: 임시로 이전 동작 복원
const nextConfig = {
  experimental: {
    staleTimes: {
      dynamic: 30,
      static: 300,
    },
  },
  // 특정 세그먼트에 대해 fetch 캐싱 복원
  fetchCache: 'default-cache',
};

// 2단계: 점진적으로 새로운 캐싱 도입
export default async function MigrationExample() {
  // 기존 코드는 그대로 두고
  const legacyData = await fetch('/api/legacy');
  
  // 새로운 캐싱 패턴 점진적 도입
  const newData = await fetch('/api/new-endpoint', {
    next: { revalidate: 60, tags: ['new-data'] }
  });
  
  return (
    <div>
      <section>
        <h2>기존 데이터</h2>
        <pre>{JSON.stringify(legacyData, null, 2)}</pre>
      </section>
      
      <section>
        <h2>새로운 캐싱 패턴</h2>
        <pre>{JSON.stringify(newData, null, 2)}</pre>
      </section>
    </div>
  );
}

Next.js 16과 미래 전망

Next.js 팀은 실험적 캐싱 기능들을 통합된 cacheComponents 플래그 아래로 통합하고 있으며, 이는 Next.js 16에서 예상됩니다. 이는 Dynamic IO, 'use cache' 지시문, Partial Prerendering을 응집력 있는 캐싱 시스템으로 결합할 것입니다.

javascript
// Next.js 16 예상 설정
const nextConfig = {
  experimental: {
    cacheComponents: true, // 모든 캐싱 기능 통합
    turbo: true, // Turbopack 통합 강화
  },
};

프로덕션 애플리케이션을 위한 핵심 정리

Next.js 15의 캐싱 변경사항은 자동 최적화보다 개발자 경험과 데이터 신선도를 우선시합니다. 캐시하지 않는 것이 기본값인 새로운 동작은 가장 흔한 버그의 원인인 예상치 못한 stale 데이터를 제거하면서, 명시적 성능 최적화를 위한 강력한 도구들을 제공합니다.

권장하는 접근 방법

  1. 캐시하지 않는 기본값으로 시작
  2. 성능 영향 측정
  3. 명확한 이점이 있는 곳에 선택적 캐싱 적용
  4. 'use cache' 지시문과 dynamicIO 모드를 새 프로젝트에서 실험

가장 중요한 것은 새로운 캐싱 모델의 명시적 특성을 받아들이는 것입니다. 초기 설정이 더 많이 필요하지만, 향상된 제어와 예측가능성은 더 나은 사용자 경험과 적은 프로덕션 이슈로 이어집니다.


📚 참고 자료 및 출처

공식 Next.js 문서

캐싱 관련 API 문서

심화 가이드

Next.js 14 비교 문서

GitHub 이슈 및 토론

커뮤니티 리소스