SSR(Server-Side Rendering)과 캐싱. 얼핏 보면 상반된 개념 같습니다. 하나는 매번 새로운 데이터를 위해, 다른 하나는 성능을 위해 존재하니까요. 하지만 트래픽이 급증하는 현실에서 이 둘 사이의 균형점을 찾는 것이야말로 현대 웹 개발의 핵심 과제입니다.
SSR의 딜레마: 신선함 vs 성능
Next.js의 SSR은 매 요청마다 서버에서 페이지를 렌더링합니다. 사용자는 항상 최신 데이터를 받을 수 있지만, 그 대가는 만만치 않습니다. 데이터베이스 쿼리, API 호출, 서버 로직 실행이 모든 요청에서 반복되면서 응답 지연과 인프라 비용 증가로 이어지죠.
특히 App Router가 도입된 Next.js 15+에서는 export const dynamic = 'force-dynamic'
같은 지시문으로 캐싱 동작을 미세 조정할 수 있지만, 전체 페이지 캐싱에는 더 정밀한 제어가 필요합니다.
전체 페이지 캐싱이 빛나는 순간들
모든 SSR 페이지를 캐시해야 하는 건 아닙니다. 하지만 다음과 같은 상황에서는 게임 체인저가 될 수 있어요:
- 콘텐츠 변경 빈도가 낮은 동적 페이지: 제품 목록이나 블로그 상세 페이지처럼 몇 분 또는 몇 시간마다 업데이트되는 콘텐츠라면, 60초간 캐시하고 여러 사용자에게 재사용하는 것이 훨씬 효율적입니다.
- 익명 사용자 중심의 대량 트래픽: 가격 페이지나 지역별 랜딩 페이지 같은 공개 페이지는 개인화가 불필요한 경우가 많죠. 로그인하지 않은 사용자가 대부분이라면 하나의 버전을 안전하게 캐시할 수 있습니다.
- 복잡한 백엔드 로직을 가진 페이지: 여러 API나 복잡한 데이터베이스 쿼리를 사용하는 페이지에서 전체 페이지 캐싱은 TTFB(첫 번째 바이트 도달 시간)를 극적으로 개선할 수 있습니다.
실전 전략 1: Cache-Control 헤더의 마법
Vercel 같은 CDN 지원 환경에서는 HTTP 헤더만으로도 강력한 캐싱을 구현할 수 있습니다.
// app/product/[slug]/page.tsx
import { headers } from 'next/headers';
import { fetchProductBySlug } from '@/lib/data';
export const dynamic = 'force-dynamic';
export default async function ProductPage({ params }) {
const { slug } = params;
const product = await fetchProductBySlug(slug);
const responseHeaders = headers();
responseHeaders.set(
'Cache-Control',
'public, s-maxage=60, stale-while-revalidate=120'
);
return (
<main>
<h1>{product.title}</h1>
<p>{product.description}</p>
<p>Price: ${product.price}</p>
</main>
);
}
이 설정의 핵심은 stale-while-revalidate=120
입니다. 캐시가 만료되어도 사용자는 기다리지 않고 즉시 이전 버전을 받으며, 백그라운드에서 새 버전이 조용히 준비됩니다.
실전 전략 2: Edge Middleware로 개인화 정복하기
개인화된 콘텐츠는 캐싱의 천적이지만, Edge Middleware를 활용하면 이 문제를 우아하게 해결할 수 있습니다.
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export const config = {
matcher: ['/product/:path*'],
};
export function middleware(request: NextRequest) {
const geo = request.geo?.country?.toLowerCase() || 'us';
const url = request.nextUrl.clone();
url.pathname = /${geo}${url.pathname}
;
return NextResponse.rewrite(url, {
headers: {
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=120',
},
});
}
이제 독일 사용자는 /de/product/shoes
를, 미국 사용자는 /us/product/shoes
를 보게 되며, 각 버전이 독립적으로 캐시됩니다. 개인화를 포기하지 않으면서도 캐싱의 이점을 누릴 수 있는 거죠.
ISR과 Edge의 완벽한 조합
ISR(Incremental Static Regeneration)을 Edge 캐싱과 결합하면 정적 생성만큼 빠르면서도 SSR처럼 신선한 콘텐츠를 제공할 수 있습니다.
// app/blog/[slug]/page.tsx
export const revalidate = 60;
export default async function BlogPost({ params }) {
const post = await getPost(params.slug);
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
export const revalidate = 60
한 줄로 페이지를 60초간 캐시하되, 백그라운드에서 조용히 재생성하는 시스템이 완성됩니다.
디버깅과 모니터링: 보이지 않는 것은 개선할 수 없다
Vercel에서는 x-vercel-cache
헤더로 실시간 캐싱 상태를 확인할 수 있습니다:
HIT
: 캐시에서 제공 (이상적인 상태)MISS
: 서버로 요청 전달STALE
: 만료된 캐시 제공, 백그라운드 재생성 중
curl -I https://your-domain.com/page
이런 식으로 헤더를 확인하여 캐싱이 의도대로 작동하는지 실시간으로 모니터링할 수 있습니다.
피해야 할 함정들
- 쿠키의 함정: 인증 관련 쿠키가 있으면 CDN 캐싱이 완전히 우회될 수 있습니다. 공개 페이지에서는 불필요한 쿠키를 제거하세요.
- 과도한 변형 생성: 지역 + A/B 테스트 + 디바이스 + 로그인 상태로 너무 많은 조합을 만들면 캐시가 비효율적이 됩니다.
- 재검증 빈도의 균형:
revalidate
값을 추측하지 말고, 콘텐츠 변경 빈도와 업데이트 중요도, 백엔드 부하를 종합적으로 고려하세요.
성능과 신선도의 조화
SSR 페이지 캐싱은 모순이 아닙니다. 스마트한 Cache-Control 헤더, Edge Middleware를 통한 개인화 인식 라우팅, Redis를 활용한 수동 제어, ISR과 CDN의 결합을 통해 신선도나 사용자 경험을 희생하지 않으면서도 성능 병목을 강점으로 바꿀 수 있습니다.
전체 페이지 캐싱의 핵심은 단순한 비용 절감이 아닙니다. 사용자에게는 초고속 페이지 로딩을, 서버에게는 부하 분산을 제공하는 것이죠. 여러분의 Next.js 프로젝트에서 어떤 페이지가 캐싱의 혜택을 받을 수 있을지 한번 점검해보세요.
참고 자료: Melvin Prince, “Full-Page Caching in Next.js: How to Cache SSR Pages Without Losing Freshness”