Next.js 15 완벽 가이드 — SSR·SSG·ISR 차이점과 실전 프로젝트 적용법 총정리

이 글을 끝까지 읽으면, SSR·SSG·ISR의 개념 차이부터 Next.js 15의 신기능 App Router 활용법, 실전 프로젝트에 바로 적용할 수 있는 렌더링 전략 선택 기준까지 한 번에 정리됩니다.

안녕하세요, ICT리더 리치입니다. 솔직히 말하면, 저도 처음 Next.js를 접했을 때 SSR이랑 SSG가 뭐가 다른 건지 한참 헷갈렸습니다. 공식 문서를 읽어도 "서버에서 렌더링한다"는 말만 반복될 뿐, 실제로 언제 어떤 걸 써야 하는지 감이 안 잡혔거든요. 그러다 클라이언트 프로젝트에서 ISR을 잘못 설정해서 데이터가 30분 동안 갱신이 안 됐던 경험을 하고 나서야 제대로 공부하게 됐습니다.

Next.js 15는 2024년 말 정식 출시 이후 App Router가 기본 구조로 자리잡으면서, 기존 Pages Router 방식과는 꽤 다른 개발 패턴을 요구합니다. 오늘 이 글에서는 렌더링 방식의 개념 비교부터 실제 코드 패턴, 그리고 프로젝트 규모에 따른 전략 선택 기준까지 실무 관점에서 낱낱이 파헤쳐 드리겠습니다.

Next.js 15 SSR SSG ISR 렌더링 전략을 분석하는 여성 개발자 대표 썸네일
Next.js 15 SSR SSG ISR 완벽 가이드 여성 대표 썸네일 이미지

1. Next.js 15란? — App Router 중심의 변화 총정리

혹시 Next.js 13에서 14, 그리고 15로 넘어오면서 "이제 Pages Router는 쓰면 안 되나?"라는 걱정을 해보신 적 있으신가요? 저도 그랬습니다. Next.js 15는 2024년 10월 공식 출시되었으며, React 19 RC를 지원하고 Turbopack이 기본 번들러로 자리잡은 것이 가장 큰 특징입니다. Vercel 내부 벤치마크 기준으로 Turbopack은 webpack 대비 로컬 서버 시작 속도가 최대 76.7% 빠르고, 코드 변경 반영(HMR)이 96.3% 빠르다고 발표했습니다.

App Router는 Next.js 13부터 도입됐지만, 15에서 비로소 안정화 단계에 접어들었습니다. 기존 Pages Router의 getServerSideProps, getStaticProps 방식 대신, 이제는 Server Component와 Client Component를 명시적으로 구분하는 패턴을 사용합니다. 파일 기반 라우팅 구조도 app/ 디렉토리 중심으로 재편되었죠. 특히 15에서는 fetch 캐싱 동작이 기본값으로 변경되어, 이전 버전에서 자동 캐싱되던 것이 이제는 기본적으로 캐시하지 않는 방향으로 바뀌었습니다. 이 부분이 마이그레이션 시 가장 많이 헷갈리는 지점이기도 합니다.

Next.js 15의 핵심은 "더 명시적인 렌더링 제어"입니다. 자동 마법 같던 동작들이 이제는 개발자가 직접 선언하는 방식으로 바뀌었습니다.

💡 실전 팁: 기존 Next.js 13/14 프로젝트를 15로 마이그레이션할 때는 fetch() 캐싱 옵션을 전수 검토하세요. cache: 'force-cache'가 이전에는 기본값이었지만, 15에서는 명시하지 않으면 캐시하지 않습니다.

다음 섹션에서는 이 모든 변화의 핵심인 SSR·SSG·ISR 렌더링 방식을 직관적으로 비교해 드리겠습니다. ▶


2. SSR vs SSG vs ISR — 핵심 비교와 실수하기 쉬운 차이점

"세 가지 다 서버에서 뭔가 한다는 건 알겠는데, 실제로 어떻게 다른 거예요?" — 이 질문을 받을 때마다 저는 배달 음식 비유를 씁니다. SSR은 주문할 때마다 요리하는 것, SSG는 미리 대량으로 만들어 두는 것, ISR은 유통기한이 있는 미리 만든 음식을 일정 시간마다 새로 만들어 교체하는 방식입니다. 어떤 서비스에 어떤 방식이 맞는지, 아래 표를 보면 한눈에 정리됩니다.

구분 SSR (서버사이드 렌더링) SSG (정적 사이트 생성) ISR (점진적 정적 재생성)
렌더링 시점 요청 시마다 서버에서 생성 빌드 타임에 HTML 생성 빌드 후 주기적으로 재생성
데이터 신선도 항상 최신 데이터 빌드 시점 데이터 고정 revalidate 시간 이후 갱신
응답 속도 상대적으로 느림 가장 빠름 (CDN 캐싱) 빠름 (캐시 히트 시)
서버 부하 요청마다 연산 발생 빌드 시에만 연산 주기적으로만 연산
적합한 페이지 로그인·개인화·실시간 데이터 마케팅·문서·랜딩 페이지 블로그·상품 목록·뉴스
Next.js 15 구현 cache: 'no-store' cache: 'force-cache' next: { revalidate: N }

하나 더 짚고 싶은 것은, 이 세 가지가 상호 배타적이지 않다는 점입니다. 실무에서는 한 프로젝트 안에서 페이지별로 다른 렌더링 전략을 혼합 사용하는 것이 일반적입니다. 메인 페이지는 SSG, 상품 상세는 ISR, 마이페이지는 SSR로 가져가는 식이죠.

3번 섹션에서는 SSR을 잘못 남발했을 때 생기는 실제 성능 문제와, 올바른 사용 판단 기준을 실무 경험 기반으로 풀어드립니다. ▶


3. SSR 완전 이해 — 언제 써야 하고 언제 쓰면 안 되나?

SSR을 쓰면 항상 좋은 것 아닌가요? 사실 그렇게 생각하는 분들이 꽤 많습니다. 하지만 저는 실제로 이커머스 프로젝트에서 상품 목록 페이지 전체를 SSR로 구현했다가 트래픽이 몰리는 특가 이벤트 시간에 서버가 버티지 못하는 경험을 했습니다. 월간 방문자 50만 기준으로 SSG 대비 서버 비용이 3배 이상 증가했습니다. SSR은 강력하지만 비용이 따릅니다.

Next.js 15 App Router에서 SSR은 Server Component에서 fetch(url, { cache: 'no-store' })를 사용하거나, 세그먼트 단위로 export const dynamic = 'force-dynamic'을 선언하는 방식으로 구현합니다. 요청마다 새로운 데이터가 필요한 경우에만 진짜 SSR이 필요한 것인지 반드시 자문해 보세요.

  • SSR 써야 할 때 ①: 로그인한 사용자 정보가 페이지 내용에 직접 영향을 주는 경우 (마이페이지, 맞춤 대시보드)
  • SSR 써야 할 때 ②: 실시간 재고, 주식 가격처럼 캐싱이 절대로 허용되지 않는 데이터를 보여줄 때
  • SSR 쓰면 안 될 때 ①: 모든 사용자에게 동일한 콘텐츠를 보여주는 공개 페이지 (ISR 또는 SSG로 충분)
  • SSR 쓰면 안 될 때 ②: 트래픽 피크가 예상되는 이벤트 페이지, 랜딩 페이지, 마케팅 캠페인 페이지
  • Partial Prerendering(PPR): Next.js 15에서 실험적으로 도입된 기능으로, 한 페이지 안에서 정적 부분과 동적 부분을 분리해 렌더링하는 하이브리드 방식

⚠️ 주의: Next.js 15에서 cookies(), headers()를 사용하면 해당 라우트 전체가 자동으로 동적 렌더링으로 전환됩니다. 의도치 않게 SSR이 되는 경우가 많으니 확인이 필요합니다.

▶ 실전 코드 ① — App Router SSR 기본 구현 (cache: 'no-store')

로그인한 사용자의 주문 내역처럼 요청마다 반드시 최신 데이터가 필요한 페이지입니다. cache: 'no-store'를 명시하면 해당 fetch는 캐싱 없이 매번 서버에서 실행됩니다.

// app/orders/page.tsx
// 요청마다 서버에서 최신 데이터를 가져오는 SSR 페이지 import { cookies } from 'next/headers' // 이 선언 하나로 전체 라우트가 동적 렌더링으로 전환됩니다 export const dynamic = 'force-dynamic' async function getOrders(userId: string) { const res = await fetch( `https://api.example.com/orders?userId=${userId}`, { // ✅ 핵심: 캐싱 완전 비활성화 → 요청마다 새 데이터 cache: 'no-store', headers: { 'Content-Type': 'application/json', }, } ) if (!res.ok) throw new Error('주문 데이터를 가져오는 데 실패했습니다') return res.json() } export default async function OrdersPage() { // cookies() 호출 자체가 동적 렌더링을 트리거합니다 const cookieStore = cookies() const userId = cookieStore.get('userId')?.value if (!userId) { return <p>로그인이 필요합니다.</p> } const orders = await getOrders(userId) return ( <main> <h1>내 주문 내역</h1> <ul> {orders.map((order: any) => ( <li key={order.id}> 주문번호: {order.id} | 금액: {order.amount.toLocaleString()}원 </li> ))} </ul> </main> ) }

▶ 실전 코드 ② — Suspense로 SSR 스트리밍 처리 (UX 향상)

SSR의 단점 중 하나는 데이터가 모두 준비될 때까지 빈 화면을 보여준다는 점입니다. Suspense로 컴포넌트를 감싸면 데이터 로딩 중에도 레이아웃을 먼저 보여주고, 데이터가 준비되는 순서대로 스트리밍 방식으로 전송합니다.

// app/dashboard/page.tsx — Suspense 스트리밍 SSR 패턴
import { Suspense } from 'react' import UserProfile from '@/components/UserProfile' import RecentOrders from '@/components/RecentOrders' import StockWidget from '@/components/StockWidget' export default function DashboardPage() { return ( <main style={{ padding: '2rem' }}> <h1>대시보드</h1> {/* 사용자 프로필: 빠르게 로드 */} <Suspense fallback={<p>프로필 불러오는 중...</p>}> <UserProfile /> </Suspense> {/* 주문 내역: 독립적으로 스트리밍 */} <Suspense fallback={<p>주문 내역 불러오는 중...</p>}> <RecentOrders /> </Suspense> {/* 실시간 데이터: 마지막에 도착해도 레이아웃은 유지 */} <Suspense fallback={<div>시세 로딩 중...</div>}> <StockWidget /> </Suspense> </main> ) } // components/RecentOrders.tsx — 개별 SSR 컴포넌트 async function RecentOrders() { const res = await fetch('https://api.example.com/recent-orders', { cache: 'no-store', // SSR: 항상 최신 }) const orders = await res.json() return ( <ul> {orders.slice(0, 5).map((o: any) => ( <li key={o.id}>{o.productName} — {o.status}</li> ))} </ul> ) } export default RecentOrders

💡 실전 팁: Suspense 경계를 세밀하게 나눌수록 사용자는 더 빠른 체감 속도를 경험합니다. 데이터 로딩이 느린 컴포넌트를 별도 Suspense로 격리하면, 나머지 콘텐츠가 먼저 표시됩니다.

⚠️ 주의: Next.js 15에서 cookies(), headers()를 사용하면 해당 라우트 전체가 자동으로 동적 렌더링으로 전환됩니다. 의도치 않게 SSR이 되는 경우가 많으니 확인이 필요합니다.


4. SSG·ISR 실전 패턴 — 정적 생성과 점진적 재생성 활용법

의외로 많은 개발자들이 간과하는 사실 하나 — Next.js 앱의 80% 이상 페이지는 SSR이 필요하지 않습니다. Vercel의 발표 자료를 보면, 최적화된 Next.js 프로젝트에서 SSG·ISR로 처리 가능한 페이지 비율이 전체의 70~85%에 달한다고 합니다. 그만큼 SSG와 ISR을 잘 활용하는 것이 성능과 비용 모두를 잡는 핵심입니다.

Next.js 15 App Router에서 SSG는 기본 Server Component에서 fetch(url, { cache: 'force-cache' })를 사용하거나, 아예 외부 데이터 없이 정적 HTML만 반환하면 자동으로 빌드 타임에 생성됩니다. ISR은 fetch(url, { next: { revalidate: 3600 } })처럼 재검증 시간을 초 단위로 지정하거나, 라우트 세그먼트에 export const revalidate = 3600을 선언하는 방식을 씁니다. 블로그나 공식 문서처럼 자주 바뀌지 않지만 완전히 고정되지도 않은 콘텐츠에 ISR은 정말 탁월합니다.

ISR revalidate 시간은 "이 데이터가 얼마나 오래된 상태여도 사용자가 불편하지 않은가?"를 기준으로 설정하세요. 뉴스는 60초, 쇼핑몰 상품 목록은 300초, 블로그 글은 86400초(1일)가 현실적인 기준입니다.

💡 실전 팁: On-demand Revalidation을 활용하면 시간 기반 ISR 대신 CMS 콘텐츠 저장 시 즉시 캐시를 무효화할 수 있습니다. revalidatePath() 또는 revalidateTag()를 Route Handler에서 호출하는 패턴이 2025년 현재 가장 권장되는 방법입니다.


▶ 실전 코드 ③ — SSG 기본 구현 (force-cache + generateStaticParams)

블로그 상세 페이지처럼 경로가 미리 정해져 있는 경우, generateStaticParams로 빌드 타임에 모든 경로를 미리 생성합니다. CDN에 올라가면 응답 속도가 극적으로 빨라집니다.

// app/blog/[slug]/page.tsx — SSG로 블로그 상세 페이지 생성

// 1단계: 빌드 타임에 모든 slug를 미리 수집
export async function generateStaticParams() {
  const res = await fetch('https://api.example.com/posts', {
    cache: 'force-cache', // ✅ 빌드 타임 캐시 사용
  })
  const posts = await res.json()

  return posts.map((post: { slug: string }) => ({
    slug: post.slug,
  }))
  // 반환 예: [{ slug: 'nextjs-15-guide' }, { slug: 'react-19-tips' }, ...]
}

// 2단계: SEO를 위한 동적 메타데이터 생성
export async function generateMetadata({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug)
  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      images: [post.coverImage],
    },
  }
}

// 3단계: 실제 페이지 컴포넌트
async function getPost(slug: string) {
  const res = await fetch(
    `https://api.example.com/posts/${slug}`,
    { cache: 'force-cache' } // 빌드 타임 정적 생성
  )
  return res.json()
}

export default async function BlogPostPage({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug)

  return (
    <article>
      <h1>{post.title}</h1>
      <p>작성일: {post.publishedAt}</p>
      <div dangerouslySetInnerHTML={{ __html: post.contentHtml }} />
    </article>
  )
}

▶ 실전 코드 ④ — ISR 구현 + On-demand Revalidation (CMS 연동 패턴)

시간 기반 ISR은 revalidate로 설정하고, CMS에서 글을 저장하는 순간 즉시 캐시를 갱신하고 싶다면 revalidatePath / revalidateTag를 Route Handler에서 호출합니다.

// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// [방법 A] 시간 기반 ISR — 라우트 세그먼트 설정
// app/products/page.tsx
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

// 300초(5분)마다 백그라운드에서 재생성
export const revalidate = 300

async function getProducts() {
  const res = await fetch('https://api.example.com/products', {
    // fetch 레벨에서도 동일하게 설정 가능
    next: { revalidate: 300, tags: ['products'] },
  })
  return res.json()
}

export default async function ProductsPage() {
  const products = await getProducts()
  return (
    <ul>
      {products.map((p: any) => (
        <li key={p.id}>{p.name} — ₩{p.price.toLocaleString()}</li>
      ))}
    </ul>
  )
}

// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// [방법 B] On-demand Revalidation
// app/api/revalidate/route.ts
// CMS 웹훅에서 POST로 호출 → 즉시 캐시 무효화
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

import { revalidatePath, revalidateTag } from 'next/cache'
import { NextRequest, NextResponse } from 'next/server'

export async function POST(req: NextRequest) {
  // 보안: CMS에서 설정한 시크릿 토큰 검증
  const secret = req.nextUrl.searchParams.get('secret')
  if (secret !== process.env.REVALIDATE_SECRET) {
    return NextResponse.json({ message: 'Invalid token' }, { status: 401 })
  }

  const { type, slug } = await req.json()

  if (type === 'product') {
    // 태그 기반: 'products' 태그가 붙은 모든 캐시 무효화
    revalidateTag('products')
  }

  if (type === 'blog' && slug) {
    // 경로 기반: 특정 블로그 포스트만 재생성
    revalidatePath(`/blog/${slug}`)
  }

  return NextResponse.json({
    revalidated: true,
    now: new Date().toISOString(),
  })
}

💡 실전 팁: revalidateTag를 사용하면 여러 페이지에 걸쳐 흩어진 동일 데이터를 한 번에 무효화할 수 있습니다. 상품 정보가 상품 목록, 상세, 추천 위젯에 동시에 표시된다면 tags: ['product-123']처럼 상품 ID 단위로 태그를 달아두세요.


5. App Router 실전 코드 패턴 — Server Component vs Client Component 선택 기준

"use client"를 파일 상단에 붙이는 것, 언제 붙이고 언제 안 붙여야 할까요? 처음엔 저도 불안해서 거의 모든 컴포넌트에 붙이는 실수를 했습니다. 그렇게 하면 App Router를 쓰는 의미가 없어집니다. App Router의 기본값은 Server Component이고, 이것이 SSR·SSG·ISR의 실제 구현 기반이 됩니다. Client Component는 반드시 필요한 경우에만 최소 단위로 분리하는 것이 핵심 원칙입니다.

구분 Server Component Client Component
선언 방법 기본값 (선언 불필요) 'use client' 최상단 선언
데이터 패칭 직접 async/await fetch 가능 useEffect, SWR, React Query 사용
React Hook 사용 ❌ 불가 ✅ 가능 (useState, useEffect 등)
번들 사이즈 클라이언트 JS에 포함 안 됨 클라이언트 번들에 포함됨
주요 사용처 레이아웃, 데이터 목록, 정적 콘텐츠 폼, 버튼 이벤트, 애니메이션, 모달
보안 민감 코드 ✅ 안전 (서버에서만 실행) ⚠️ 노출 위험 (클라이언트 전송)

결론적으로, Server Component를 최대한 넓게 유지하고 인터랙션이 필요한 최소 단위만 Client Component로 분리하는 것이 Next.js 15 App Router의 올바른 아키텍처 방향입니다. 다음 섹션의 체크리스트를 통해 본인 프로젝트에 맞는 전략을 직접 점검해 보세요. ▶


▶ 실전 코드 ⑤ — Server Component + Client Component 올바른 분리 패턴

상품 목록 페이지를 예시로, 데이터 패칭과 정적 렌더링은 Server Component가 담당하고, "장바구니 담기" 버튼처럼 사용자 인터랙션이 필요한 최소 단위만 Client Component로 분리합니다.

// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// app/shop/page.tsx — Server Component (기본값, 선언 불필요)
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

import AddToCartButton from '@/components/AddToCartButton' // Client Component

async function getShopProducts() {
  const res = await fetch('https://api.example.com/shop', {
    next: { revalidate: 600, tags: ['shop'] }, // ISR 10분
  })
  return res.json()
}

export default async function ShopPage() {
  // ✅ Server Component에서 직접 async/await 데이터 패칭
  const products = await getShopProducts()

  return (
    <section>
      <h1>상품 목록</h1>
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: '1rem' }}>
        {products.map((product: any) => (
          <div key={product.id} style={{ border: '1px solid #ddd', padding: '1rem' }}>
            <h2>{product.name}</h2>
            <p>₩{product.price.toLocaleString()}</p>
            {/* ✅ 인터랙션이 필요한 부분만 Client Component로 분리 */}
            <AddToCartButton productId={product.id} productName={product.name} />
          </div>
        ))}
      </div>
    </section>
  )
}

// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// components/AddToCartButton.tsx — Client Component
// 인터랙션(클릭, 상태 변화)이 필요한 최소 단위만 분리
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

'use client'

import { useState } from 'react'

interface Props {
  productId: string
  productName: string
}

export default function AddToCartButton({ productId, productName }: Props) {
  const [added, setAdded] = useState(false)
  const [loading, setLoading] = useState(false)

  const handleAdd = async () => {
    setLoading(true)
    // 장바구니 API 호출
    await fetch('/api/cart', {
      method: 'POST',
      body: JSON.stringify({ productId }),
    })
    setAdded(true)
    setLoading(false)
  }

  return (
    <button
      onClick={handleAdd}
      disabled={added || loading}
      style={{
        backgroundColor: added ? '#4caf50' : '#2196f3',
        color: 'white',
        padding: '0.5rem 1rem',
        border: 'none',
        borderRadius: '4px',
        cursor: added ? 'default' : 'pointer',
      }}
    >
      {loading ? '추가 중...' : added ? '✓ 담겼어요!' : '장바구니 담기'}
    </button>
  )
}

▶ 실전 코드 ⑥ — Server Actions로 폼 처리 (API Route 없이 서버 로직 실행)

Next.js 15에서 Server Actions는 안정화 단계에 접어들었습니다. 별도의 API Route를 만들지 않고도 폼 제출·데이터 변경을 서버에서 직접 처리할 수 있습니다. 보안 민감한 로직(DB 쓰기, 이메일 발송 등)을 클라이언트에 노출하지 않아도 된다는 것이 가장 큰 장점입니다.

// app/contact/page.tsx — Server Action으로 폼 처리

import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'

// ✅ 'use server' 선언 — 이 함수는 오직 서버에서만 실행됩니다
async function submitContact(formData: FormData) {
  'use server'

  const name = formData.get('name') as string
  const email = formData.get('email') as string
  const message = formData.get('message') as string

  // DB 저장 또는 이메일 발송 — 서버에서만 실행, 클라이언트 노출 없음
  await fetch('https://api.example.com/contact', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ name, email, message }),
    cache: 'no-store',
  })

  // 제출 후 관련 캐시 무효화 및 리다이렉트
  revalidatePath('/contact')
  redirect('/contact/thanks')
}

// Server Component에서 action에 Server Action 직접 전달
export default function ContactPage() {
  return (
    <main style={{ maxWidth: '600px', margin: '0 auto' }}>
      <h1>문의하기</h1>
      {/* action에 Server Action 함수를 바로 전달 — API Route 불필요 */}
      <form action={submitContact} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
        <input
          name="name"
          placeholder="이름"
          required
          style={{ padding: '0.5rem', border: '1px solid #bbdefb', borderRadius: '4px' }}
        />
        <input
          name="email"
          type="email"
          placeholder="이메일"
          required
          style={{ padding: '0.5rem', border: '1px solid #bbdefb', borderRadius: '4px' }}
        />
        <textarea
          name="message"
          placeholder="문의 내용"
          rows={5}
          required
          style={{ padding: '0.5rem', border: '1px solid #bbdefb', borderRadius: '4px' }}
        />
        <button
          type="submit"
          style={{
            backgroundColor: '#2196f3', color: 'white',
            padding: '0.7rem 1.5rem', border: 'none',
            borderRadius: '4px', cursor: 'pointer', fontWeight: 700,
          }}
        >
          문의 보내기
        </button>
      </form>
    </main>
  )
}

💡 실전 팁: Server Actions는 JavaScript가 비활성화된 환경에서도 기본 폼 동작으로 작동합니다(Progressive Enhancement). 또한 민감한 환경변수나 DB 연결 정보가 클라이언트 번들에 절대 포함되지 않아 보안상 매우 유리합니다.

⚠️ 주의: Server Actions 내부에서 try-catch 없이 에러가 발생하면 사용자에게 에러 페이지가 노출됩니다. 반드시 에러 핸들링을 추가하고, error.tsx와 함께 사용하는 것을 권장합니다.

6. 프로젝트 규모별 렌더링 전략 추천 체크리스트

렌더링 전략은 기술적 우열의 문제가 아니라 서비스 특성과 팀 역량에 맞춘 선택의 문제입니다. 스타트업 초기 MVP라면 복잡한 ISR 구성보다 SSG로 빠르게 배포하는 것이 맞고, 트래픽 수십만의 미디어 플랫폼이라면 ISR과 On-demand Revalidation의 조합이 필수입니다. 아래 체크리스트로 지금 내 프로젝트의 상황을 점검해 보세요.

  • ☑ 랜딩·마케팅 페이지: SSG로 구현. 배포 후 콘텐츠가 거의 바뀌지 않는다면 빌드 타임 생성으로 충분합니다. CDN 캐싱과 결합하면 응답 속도가 극대화됩니다.
  • ☑ 블로그·문서·상품 목록: ISR 적용. revalidate를 콘텐츠 업데이트 빈도에 맞게 설정하고, CMS 연동 시 On-demand Revalidation을 추가합니다.
  • ☑ 마이페이지·대시보드·결제: SSR 또는 Client Component에서의 클라이언트 데이터 패칭. 인증 토큰과 사용자 세션이 필요한 페이지는 SSR이 불가피합니다.
  • ☑ 실시간 피드·채팅·알림: Client Component + WebSocket 또는 SSE. Next.js의 렌더링 전략보다는 실시간 통신 레이어를 별도로 설계해야 합니다.
  • ☑ 하이브리드 페이지(PPR 실험): 페이지의 헤더·내비는 정적, 개인화 영역만 동적으로 처리. Next.js 15의 Partial Prerendering 기능을 실험적으로 도입해 볼 수 있습니다.
  • ☑ 검색 결과 페이지: URL 파라미터(쿼리스트링)를 사용하는 검색 페이지는 기본적으로 SSR이 되며, 검색 UI를 Client Component로 분리하고 결과 표시는 Suspense로 감싸는 것이 Best Practice입니다.

7. 자주 묻는 질문 (FAQ)

Q Next.js 15에서 Pages Router와 App Router를 동시에 써도 되나요?

네, 가능합니다. Next.js 15는 pages/app/ 디렉토리를 동시에 지원하는 점진적 마이그레이션을 공식 지원합니다. 기존 Pages Router 프로젝트를 한 번에 전환하기보다 신규 라우트부터 App Router로 작성하는 방식이 현실적입니다. 단, 동일 경로에 중복 라우트가 생기면 App Router가 우선순위를 가집니다. 1번 섹션의 변화 정리도 함께 참고하세요.

Q ISR revalidate를 0으로 설정하면 SSR과 같아지나요?

동작이 비슷해 보이지만 엄밀히는 다릅니다. revalidate: 0은 ISR 캐시를 즉시 무효화하는 개념이고, 진정한 SSR은 cache: 'no-store' 또는 dynamic = 'force-dynamic'으로 명시해야 합니다. 2번 비교 테이블에서 각 구현 방식을 다시 확인해 보세요.

Q Server Component 안에서 Client Component를 자식으로 쓸 수 있나요?

가능합니다. Server Component가 부모, Client Component가 자식인 구조는 완전히 허용됩니다. 반대로, Client Component 안에 Server Component를 직접 import하는 것은 불가능합니다. 단, Server Component를 children props로 전달하는 방식은 우회적으로 사용할 수 있습니다. 5번 섹션의 선택 기준을 다시 보시면 도움이 됩니다.

Q Vercel이 아닌 자체 서버에서 Next.js 15를 운영할 때도 ISR이 작동하나요?

작동합니다. next start로 구동하는 Node.js 서버 환경에서도 ISR은 파일 시스템 캐시를 통해 정상 작동합니다. 다만 On-demand Revalidation의 글로벌 캐시 무효화나 엣지 캐싱은 Vercel 인프라에서 더 강력하게 지원됩니다. AWS, GCP, Docker 환경에서 운영할 경우 캐시 핸들러를 별도로 구성하는 것이 권장됩니다.

Q Next.js 15로 만든 사이트의 SEO는 SSG가 가장 유리한가요?

SEO 관점에서는 SSG와 SSR 모두 서버에서 완성된 HTML을 전달하므로 클라이언트 렌더링(CSR) 대비 우위에 있습니다. 구글 크롤러는 자바스크립트 실행도 가능하지만, 완성된 HTML이 크롤링 속도와 정확도 면에서 유리합니다. 콘텐츠 업데이트가 잦다면 ISR이 SEO와 성능 모두를 균형 있게 잡는 최선의 선택입니다. 더 궁금한 점은 댓글로 남겨주세요!

8. 마무리 요약

✅ Next.js 15, 렌더링 전략을 제대로 쓰는 것이 진짜 실력입니다

오늘 살펴본 것처럼 SSR·SSG·ISR은 각각 다른 상황에 맞는 도구입니다. 모든 페이지에 SSR을 쓰는 것은 마치 전부 손으로 요리하는 것처럼 비효율적이고, 반대로 모든 걸 SSG로만 처리하면 데이터 신선도 문제가 생깁니다. Next.js 15 App Router는 이 세 가지를 페이지 단위, 심지어 컴포넌트 단위로 유연하게 혼합할 수 있게 해줍니다.

지금 당장 할 수 있는 첫 행동은 하나입니다. 현재 진행 중인 프로젝트의 페이지 목록을 열고, 각 페이지에 "이 페이지에 정말 SSR이 필요한가?"라고 자문해 보세요. 그 질문 하나가 서버 비용을 절반으로 줄이고 응답 속도를 두 배로 올릴 수도 있습니다.

Next.js 15를 도입하면서 겪은 어려움이나, 렌더링 전략 선택에서 헷갈렸던 경험이 있으시면 댓글로 공유해 주세요! 다음 포스팅에서는 Next.js 15 미들웨어와 Edge Runtime 완벽 가이드 — 인증·A/B 테스트·지역화를 한 번에 처리하는 법을 다룰 예정입니다. 기대해 주세요!

댓글

이 블로그의 인기 게시물

(시큐어코딩)Express 기반 Node.js 앱 보안 강화를 위한 핵심 기능

Python Context Manager 이해와 with 문으로 자원 관리하기

React, Vue, Angular 비교 분석 – 내 프로젝트에 가장 적합한 JS 프레임워크는?