Next.js App Router에서 SSR·SSG·ISR 선택 가이드 | 렌더링 전략과 캐싱
이 글의 핵심
App Router에서 SSR·SSG·ISR을 fetch 캐시·revalidate·동적 세그먼트와 함께 비교하고, 콘텐츠 유형별 선택 기준을 제시합니다.
들어가며
Next.js App Router(13+)에서는 페이지가 “서버에서 어떻게 만들어지고, 얼마나 캐시되느냐”가 성능·SEO·운영 비용을 동시에 결정합니다. Next.js App Router SSR SSG 차이를 단순히 이름만 아는 수준에서 끝내면, fetch 한 줄 때문에 의도와 다른 캐시 동작을 겪기 쉽습니다.
이 글은 서버 컴포넌트 기본 모델 위에서 정적 생성·서버 렌더링·증분 재검증(ISR)이 어떻게 매핑되는지 정리하고, revalidate와 Route Segment Config로 일관되게 제어하는 방법을 다룹니다. 2026년 기준 App Router + fetch 캐시 시맨틱을 전제로 합니다.
운영 환경에서는 CDN·데이터 캐시·서버 컴포넌트 캐시가 겹쳐 있어, “문서대로인데 왜 옛데이터가 보이지?” 같은 이슈가 나올 수 있습니다. 아래에서는 개념 → 코드 → 고급 설정 → 선택 기준 → 사례 → 트러블슈팅 순으로 정리합니다.
왜 App Router에서 캐시 이야기가 중요한가요?
동일한 페이지라도 fetch 한 줄·세그먼트 설정에 따라 HTML이 캐시된 것인지·매 요청마다 새로 그려지는지가 갈립니다. 이는 TTFB·원본 부하·데이터 신선도에 직결되므로, 팀에서 “이 데이터는 몇 초까지 낡아도 되는가?”를 먼저 합의하는 것이 비용 대비 효과가 큽니다.
프로덕션에서 주의할 점
- 사용자별 데이터(
me, 장바구니)를force-cache로 가져오면 캐시 오염·유출 사고로 이어질 수 있습니다. 경로 분리와no-store를 습관화합니다. revalidate만 믿고 중요한 이벤트 후 즉시 반영이 필요하면revalidateTag·revalidatePath트리거를 배포·CMS 훅과 연결합니다.- CDN 엣지 캐시는 Next 데이터 캐시와 별개 레이어입니다. “앱에서는 갱신했는데 엣지가 옛날” 같은 이슈를 헤더·퍼지 정책으로 잡습니다.
비유로 이해하기 (Kubernetes 글과 연결)
Pod가 한 척의 배라면, Deployment는 같은 규격으로 여러 척을 운용하는 함대에 가깝습니다. Next에서는 라우트 세그먼트·fetch 정책이 “이 배(페이지 조각)를 언제 다시 출항(재검증)시킬지”를 정하는 운용 규칙에 해당합니다—직접 K8s를 쓰지 않아도 같은 ‘일관된 운용’이 필요합니다.
이 글을 읽으면
- SSR·SSG·ISR이 App Router에서 무엇을 의미하는지 구분할 수 있습니다
fetch옵션과revalidate로 데이터 단위 캐싱을 설계할 수 있습니다- 동적 라우트·개인화·관리자 화면에서 전략을 나누는 기준을 얻습니다
목차
개념 설명
용어 정리 (Pages Router와의 감각 차이)
| 구분 | 직관적 의미 | App Router에서의 실체 |
|---|---|---|
| SSG | 빌드(또는 재생성) 시점 HTML | 기본적으로 서버 컴포넌트 트리를 정적으로 캐시할 수 있는 경로. fetch(..., { cache: 'force-cache' }) 등 |
| SSR | 요청마다 서버에서 HTML | 동적 렌더링 — cache: 'no-store' 또는 dynamic = 'force-dynamic'에 가깝게 동작 |
| ISR | 주기·온디맨드로 정적 결과 갱신 | fetch의 revalidate 초 또는 revalidatePath / revalidateTag |
App Router는 “페이지 단위 옵션 한 방”보다 데이터 페칭 단위(fetch)와 세그먼트 설정의 조합으로 캐시 경계가 생깁니다.
React Server Components(RSC)와의 관계
서버 컴포넌트는 기본적으로 서버에서만 실행되며, 클라이언트 번들로 보내는 건 직렬화된 결과입니다. “SSR이냐 SSG냐”는 그 결과를 언제·어떻게 캐시하느냐의 문제로 귀결됩니다.
실전 구현
2-1. SSG에 가깝게: 빌드 시 고정 데이터
// app/posts/page.tsx — 빌드 타임에 캐시 (기본 force-cache에 가깝게 사용)
export default async function PostsPage() {
const res = await fetch('https://api.example.com/posts', {
cache: 'force-cache',
});
const posts = await res.json();
return (
<ul>
{posts.map((p: { id: string; title: string }) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
);
}
위 예에서 cache: 'force-cache'는 가능한 한 캐시된 결과를 사용하려는 뜻이며, Route Segment Config나 다른 fetch와 함께 세그먼트 전체 동작을 만듭니다.
2-2. SSR: 요청마다 최신 데이터
// app/dashboard/page.tsx
export default async function DashboardPage() {
const res = await fetch('https://api.example.com/me', {
cache: 'no-store',
});
const user = await res.json();
return <div>{user.name}</div>;
}
2-3. ISR: 시간 기반 재검증
// app/blog/[slug]/page.tsx
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const res = await fetch(`https://api.example.com/posts/${slug}`, {
next: { revalidate: 3600 }, // 1시간마다 백그라운드 재검증
});
if (!res.ok) notFound();
const post = await res.json();
return <article>{post.body}</article>;
}
next: { revalidate: 3600 }는 최대 3600초 동안은 캐시를 유지하다가, 이후 백그라운드 재검증으로 갱신하는 ISR 스타일의 기대에 맞춥니다(플랫폼·캐시 레이어와 함께 해석).
2-4. 태그 기반 무효화(운영에서 특히 유용)
// fetch 시
await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] },
});
// Server Action 또는 Route Handler에서
import { revalidateTag } from 'next/cache';
export async function POST() {
revalidateTag('posts');
return Response.json({ ok: true });
}
요약: 한 경로 안에서도 컴포넌트별 fetch 설정이 다르면 “혼합 전략”이 됩니다. 팀 규칙으로 데이터 소스별 캐시 정책 표를 두면 실수가 줄어듭니다.
3. 고급: Route Segment Config와 동적·정적 경계
세그먼트 단위 동적 강제
// app/admin/layout.tsx
export const dynamic = 'force-dynamic';
export const fetchCache = 'force-no-store';
dynamic:'auto' | 'force-dynamic' | 'error' | 'force-static'— 라우트 전체의 기본 동작 경향revalidate: 세그먼트 단위 기본 ISR 주기(라우트에 적용되는 전역 revalidate 개념과 함께 이해)
generateStaticParams로 SSG 범위 명시
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const slugs = await getAllSlugs(); // 빌드 시 가능한 목록
return slugs.map((slug) => ({ slug }));
}
CMS가 수만 개 슬러그를 내보내면 전부 빌드에 넣지 말고, 상위 N개만 정적화하고 나머지는 온디맨드 ISR로 두는 식의 설계가 흔합니다.
성능과 비교
| 상황 | 권장 방향 | 이유 |
|---|---|---|
| 마케팅 랜딩, 문서, 법적 고지 | SSG + 긴 revalidate 또는 순수 정적 | CDN 캐시 적중률 최대, TTFB 안정 |
| 제품 대시보드, 장바구니 | SSR(no-store) 또는 클라이언트 분리 | 사용자별 데이터, 캐시 오염 방지 |
| 블로그·카탈로그 | ISR + revalidateTag | 트래픽 대비 최신성 균형 |
| 실시간성 높은 재고·가격 | SSR + 짧은 TTL 또는 Edge + 외부 캐시 | Next 캐시만으로는 한계가 있을 수 있음 |
핵심: App Router에서는 “이 페이지는 SSG”보다 **“이 fetch는 몇 초까지 낡아도 되는가?”**를 먼저 합의하는 편이 설계가 명확해집니다.
5. 실무 사례
- 전자상거래 상품 목록: 목록은
revalidate: 300+ 태그products, 가격 변경 시revalidateTag('products'). - 로그인 후 헤더:
cache: 'no-store'로 사용자 정보, 공용 네비는 정적 조각으로 분리해 개인화 범위를 최소화. - 문서 사이트: 거의 SSG, 검색만 클라이언트 또는 별도 API — 렌더링 전략과 검색 인덱스 전략을 분리.
트러블슈팅
“빌드할 땐 최신인데 운영에서만 옛데이터다”
fetch캐시와 **revalidate**가 다른 레이어인지 확인하세요. CDN·호스팅(Vercel 등)의 데이터 캐시 설정도 함께 봅니다.
“revalidatePath 했는데 반영이 안 된다”
- 같은 세그먼트 트리와 캐시 키를 쓰는지, 태그 기반이 더 안정적인지 점검합니다.
“개인 정보가 다른 사용자에게 보일 뻔했다”
- 절대 사용자별 API를
force-cache로 가져오지 마세요. 세션·쿠키가 섞이는fetch는no-store또는 권한 경계가 분리된 Route로 이동합니다.
마무리
Next.js App Router SSR SSG 차이는 이름보다 fetch와 세그먼트 설정이 만드는 캐시 스토리로 이해하는 것이 빠릅니다. 팀에서는 데이터 계층별 캐시 표준(TTL, 태그, 무효화 트리거)을 문서화해 두면, 성능과 신선도 사이에서 반복되는 논쟁이 줄어듭니다. 비동기·서버 부하 측면은 Node.js 성능 글과 함께 보시면 연결하기 좋습니다.