{data.title}
{data.content}
Next.js에서 Incremental Static Regeneration과 Server-Side Rendering을 혼합하여 최적의 성능을 달성하는 방법을 알아봅니다.
Next.js는 ISR(Incremental Static Regeneration)과 SSR(Server-Side Rendering)을 모두 지원합니다. 어떤 페이지에는 ISR이 적합하고, 어떤 페이지에는 SSR이 필요합니다. 이 글에서는 각 기술의 특성을 이해하고 혼합 사용하는 전략을 다룹니다.
# 프로젝트 구조
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모든 페이지에 동일한 렌더링 전략을 적용하면 비효율적입니다:
// ❌ 모든 페이지를 SSR로 처리
export async function getServerSideProps() {
const data = await fetch('https://api.example.com/data');
return { props: { data } };
}
// 문제점:
// 1. 블로그 포스트: 자주 변경 안 되는데 매 요청마다 서버 호출
// 2. 대시보드: 실시간 데이터 필요한데 ISR 적용 불가ISR과 SSR의 특성 비교:
// 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}
);
}// 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}
);
}// 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 });
}// 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'));// 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*'],
};This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.