architecture2025-06-11·12 min·32/348

Next.js에서 ISR과 SSR 혼합 사용 전략

Next.js에서 Incremental Static Regeneration과 Server-Side Rendering을 혼합하여 최적의 성능을 달성하는 방법을 알아봅니다.

Next.js에서 ISR과 SSR 혼합 사용 전략

Introduction

Next.js는 ISR(Incremental Static Regeneration)과 SSR(Server-Side Rendering)을 모두 지원합니다. 어떤 페이지에는 ISR이 적합하고, 어떤 페이지에는 SSR이 필요합니다. 이 글에서는 각 기술의 특성을 이해하고 혼합 사용하는 전략을 다룹니다.

Environment

# 프로젝트 구조
my-app/
├── app/
│   ├── page.tsx                 # SSR: 실시간 업데이트 필요
│   ├── blog/
│   │   ├── [slug]/
│   │   │   └── page.tsx         # ISR: 자주 변경 안 됨
│   │   └── page.tsx             # ISR: 블로그 목록
│   ├── products/
│   │   └── [id]/
│   │       └── page.tsx         # ISR: 상품 정보
│   └── dashboard/
│       └── page.tsx             # SSR: 실시간 데이터
├── lib/
│   └── cache.ts
└── package.json

# 기술 스택
Next.js: 14.2.5
React: 18.3.1
Redis: 7.2

Problem

모든 페이지에 동일한 렌더링 전략을 적용하면 비효율적입니다:

// ❌ 모든 페이지를 SSR로 처리
export async function getServerSideProps() {
  const data = await fetch('https://api.example.com/data');
  return { props: { data } };
}

// 문제점:
// 1. 블로그 포스트: 자주 변경 안 되는데 매 요청마다 서버 호출
// 2. 대시보드: 실시간 데이터 필요한데 ISR 적용 불가

Analysis

ISR과 SSR의 특성 비교:

  • ISR: 빌드 타임 정적 생성 + 재검증 시간 후 재생성
  • SSR: 매 요청마다 서버에서 렌더링
  • 장점: 각 페이지에 최적화된 전략 적용 가능
  • 도전점: 캐싱 전략과 재검증 로직 설계

Solution

1. 페이지별 렌더링 전략 결정

// app/page.tsx - SSR (실시간 업데이트 필요)
export const dynamic = 'force-dynamic'; // SSR 강제

export default async function HomePage() {
  // 실시간 데이터
  const stats = await fetch('https://api.example.com/stats', {
    cache: 'no-store', // 캐시 사용 안 함
  });
  
  const data = await stats.json();
  
  return (
    

홈페이지

방문자 수: {data.visitors}
활성 사용자: {data.activeUsers}
); }
// app/blog/[slug]/page.tsx - ISR (자주 변경 안 됨)
import { revalidateTag } from 'next/cache';

export const revalidate = 3600; // 1시간 재검증

export default async function BlogPost({ params }: { params: { slug: string } }) {
  const post = await fetch(`https://api.example.com/posts/${params.slug}`, {
    next: { tags: ['blog'] }, // 태그 기반 재검증
  });
  
  const data = await post.json();
  
  return (
    

{data.title}

{data.content}
); }

2. 동적 라우트에서 ISR/SSR 선택

// app/products/[id]/page.tsx
interface ProductPageProps {
  params: { id: string };
  searchParams: { preview?: string };
}

export default async function ProductPage({ params, searchParams }: ProductPageProps) {
  // 프리뷰 모드면 SSR, 아니면 ISR
  const isPreview = searchParams.preview === 'true';
  
  if (isPreview) {
    // SSR: 실시간 데이터
    const product = await fetch(`https://api.example.com/products/${params.id}`, {
      cache: 'no-store',
    });
    const data = await product.json();
    
    return (
      

[프리뷰] {data.name}

{data.description}

); } // ISR: 캐시된 데이터 const product = await fetch(`https://api.example.com/products/${params.id}`, { next: { revalidate: 3600 }, // 1시간 재검증 }); const data = await product.json(); return (

{data.name}

{data.description}

); }

3. 캐시 태그를 이용한 수동 재검증

// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
import { NextRequest } from 'next/server';

export async function POST(request: NextRequest) {
  const { tag, secret } = await request.json();
  
  // 시크릿 검증
  if (secret !== process.env.REVALIDATION_SECRET) {
    return new Response('Unauthorized', { status: 401 });
  }
  
  // 특정 태그의 캐시 재검증
  revalidateTag(tag);
  
  return Response.json({ revalidated: true, tag });
}
// CMS 웹훅으로 재검증 트리거
// app/api/webhook/cms/route.ts
import { revalidateTag } from 'next/cache';
import { NextRequest } from 'next/server';

export async function POST(request: NextRequest) {
  const body = await request.json();
  
  // CMS 이벤트에 따른 캐시 재검증
  switch (body.event) {
    case 'post.updated':
      revalidateTag('blog');
      break;
    case 'product.updated':
      revalidateTag('products');
      break;
    case 'page.updated':
      revalidateTag('pages');
      break;
  }
  
  return Response.json({ success: true });
}

4. 커스텀 캐시 헤더

// lib/cache.ts
export const cacheConfig = {
  // 실시간 데이터 (SSR)
  dynamic: {
    cache: 'no-store',
  },
  
  // 자주 변경 안 되는 데이터 (ISR - 짧은 재검증)
  static: {
    next: { revalidate: 3600 }, // 1시간
  },
  
  // 거의 변경 안 되는 데이터 (ISR - 긴 재검증)
  semiStatic: {
    next: { revalidate: 86400 }, // 24시간
  },
  
  // 태그 기반 재검증
  tagged: (tag: string) => ({
    next: { tags: [tag] },
  }),
};
// 사용 예시
import { cacheConfig } from '@/lib/cache';

// SSR
const realtimeData = await fetch(url, cacheConfig.dynamic);

// ISR (1시간)
const staticData = await fetch(url, cacheConfig.static);

// ISR (24시간)
const semiStaticData = await fetch(url, cacheConfig.semiStatic);

// 태그 기반
const taggedData = await fetch(url, cacheConfig.tagged('blog'));

5. 미들웨어를 이용한 렌더링 전략 선택

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
  
  // 특정 경로에 헤더 추가하여 렌더링 전략 결정
  if (pathname.startsWith('/dashboard')) {
    const response = NextResponse.next();
    response.headers.set('x-render-mode', 'ssr');
    return response;
  }
  
  if (pathname.startsWith('/blog')) {
    const response = NextResponse.next();
    response.headers.set('x-render-mode', 'isr');
    return response;
  }
  
  return NextResponse.next();
}

export const config = {
  matcher: ['/dashboard/:path*', '/blog/:path*'],
};

Lessons Learned

  1. 페이지별 전략 선택: 실시간 데이터 필요 여부에 따라 ISR/SSR 결정
  2. 재검증 시간 설정: 데이터 변경 빈도에 따라 적절한 revalidate 값 설정
  3. 태그 기반 재검증: CMS 연동 시 태그 기반 재검증 활용
  4. 캐시 무효화: 수동 재검증 API로 실시간 캐시 무효화 가능

This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.