Published on

Next.js Partial Prerendering 도입 체크리스트

Authors

Next.js의 Partial Prerendering(PPR)은 “페이지 전체를 SSR 또는 SSG 중 하나로만” 선택하던 방식에서 벗어나, 정적(프리렌더) 셸동적(런타임) 영역을 한 페이지 안에서 분리해 제공하는 전략입니다. 사용자는 먼저 빠르게 렌더링되는 기본 UI를 보고, 이후 데이터가 필요한 일부 영역만 스트리밍/서스펜스로 채워집니다.

이 글은 PPR을 “켜면 빨라진다” 수준이 아니라, 도입 전/중/후에 반드시 확인해야 할 체크리스트 형태로 정리합니다. 특히 App Router 환경에서 캐시, 데이터 패칭, 서스펜스 경계, 관측성을 어떻게 설계해야 하는지에 초점을 맞춥니다.

참고: PPR은 Next.js 버전과 배포 환경에 따라 지원 범위가 달라질 수 있습니다. 아래 체크리스트는 App Router 기준의 일반적인 설계 원칙과 실무에서 자주 부딪히는 함정을 중심으로 구성했습니다.

1) 도입 전: PPR이 필요한 페이지부터 선별하기

모든 페이지가 PPR의 수혜자가 되진 않습니다. 먼저 아래 질문으로 후보를 추립니다.

  • 초기 화면은 빨리 보여주되, 일부 섹션은 데이터 의존도가 높아 늦게 떠도 되는가?
    • 예: 상품 상세에서 상단 타이틀/이미지/가격은 빠르게, 추천 상품/리뷰는 늦게
  • API 응답이 느리거나 변동이 잦아 전체 SSG가 어렵고, 그렇다고 전체 SSR이 부담스러운가?
  • 사용자별 데이터(로그인, 권한, 지역화)가 페이지 일부에만 필요한가?

반대로 아래 케이스는 PPR보다 다른 최적화가 먼저일 수 있습니다.

  • 페이지 대부분이 사용자별 데이터로 구성되어 정적 셸의 가치가 작음
  • 데이터 패칭이 이미 충분히 빠르고, 병목이 렌더링이 아니라 클라이언트 번들 크기
  • SEO가 페이지 전체 콘텐츠에 강하게 의존하는데, 동적 영역이 핵심 본문인 경우

2) 라우팅/렌더링 모델 점검: App Router 전제 확인

PPR은 App Router의 서버 컴포넌트, 서스펜스, 스트리밍과 궁합이 좋습니다. 도입 전 점검:

  • app/ 라우팅을 사용하고 있는가?
  • 서버 컴포넌트와 클라이언트 컴포넌트 경계가 명확한가?
  • 데이터 패칭이 fetch 기반으로 정리되어 있는가(또는 최소한 서버에서 일관되게 수행되는가)?

만약 Pages Router 위주라면, PPR 자체보다 점진적 App Router 마이그레이션을 먼저 계획하는 편이 안전합니다.

3) 핵심 체크리스트: “정적 셸”을 무엇으로 볼 것인가

PPR의 성패는 “정적 셸” 정의에 달려 있습니다. 정적 셸은 보통 다음을 포함합니다.

  • 레이아웃, 네비게이션, 탭, 스켈레톤
  • SEO에 중요한 최소 메타/헤딩(가능하면)
  • 즉시 반응해야 하는 입력 UI(단, 데이터 의존 로직은 분리)

반면 동적 슬롯으로 빼기 좋은 영역:

  • 사용자별 정보(장바구니, 위시리스트, 개인화 추천)
  • 느린 API 의존 섹션(리뷰, 추천, 재고, 배송 ETA)
  • 외부 시스템 호출(결제 가능 여부, 실시간 가격)

정적 셸을 잡을 때의 실전 팁:

  • Above-the-fold에 해당하는 요소 중 “데이터 없이도 형태가 의미 있는 것”을 우선 정적으로
  • 동적 영역은 반드시 서스펜스 경계로 감싸서, 그 지연이 전체 페이지를 막지 않게

4) 서스펜스 경계 설계: 느린 것부터 잘게 쪼개기

PPR은 “부분적으로 늦게 채우기”가 목표이므로, 서스펜스 경계를 어떻게 나누느냐가 관건입니다.

권장 패턴

  • 한 페이지에 서스펜스 경계를 1개만 두지 말고, 독립적으로 느린 섹션마다 분리
  • 각 경계의 fallback은 실제 레이아웃과 최대한 유사하게(레이아웃 점프 최소화)

아래는 개념 예시입니다.

// app/products/[id]/page.tsx
import { Suspense } from 'react'

import ProductShell from './ui/ProductShell'
import PriceBlock from './ui/PriceBlock'
import Reviews from './ui/Reviews'
import Recommendations from './ui/Recommendations'

export default async function ProductPage({ params }: { params: { id: string } }) {
  // 셸은 빠르게: params만으로 구성 가능한 UI를 먼저 렌더
  return (
    <ProductShell productId={params.id}>
      <Suspense fallback={<div className="skeleton price" />}
      >
        <PriceBlock productId={params.id} />
      </Suspense>

      <Suspense fallback={<div className="skeleton reviews" />}
      >
        <Reviews productId={params.id} />
      </Suspense>

      <Suspense fallback={<div className="skeleton reco" />}
      >
        <Recommendations productId={params.id} />
      </Suspense>
    </ProductShell>
  )
}

MDX 주의: 본문에서 <> 형태의 기호는 JSX로 오인될 수 있으니, 문서 설명에서는 반드시 인라인 코드로 감싸거나 &lt; &gt;로 치환하세요.

5) 데이터 패칭 체크리스트: 캐시 의도부터 문서화하기

PPR 도입 시 가장 많이 망가지는 지점이 “캐시 정책이 섞이는 것”입니다. 아래 항목을 페이지 단위로 표로 정리해두면 팀 합의가 쉬워집니다.

  • 이 데이터는 공용 캐시 가능한가, 사용자별인가?
  • 허용 가능한 신선도는? 예: 10초, 1분, 10분
  • 실패 시 UX는? 빈 상태, 이전 캐시, 에러 UI

fetch 캐시 옵션을 섞을 때의 주의

  • 사용자별 데이터에 force-cache가 들어가면 정보 누출 위험
  • 공용 데이터에 no-store가 들어가면 서버 부하/TTFB 악화

예시:

// 공용 데이터: 60초 재검증
export async function getPublicProduct(id: string) {
  const res = await fetch(`https://api.example.com/products/${id}`, {
    next: { revalidate: 60 },
  })
  if (!res.ok) throw new Error('Failed to load product')
  return res.json()
}

// 사용자별 데이터: 절대 캐시 금지
export async function getUserWishlist(productId: string, token: string) {
  const res = await fetch(`https://api.example.com/me/wishlist/${productId}`, {
    cache: 'no-store',
    headers: { Authorization: `Bearer ${token}` },
  })
  if (!res.ok) return null
  return res.json()
}

6) “동적 함수” 사용 점검: 전체 페이지를 동적으로 만들지 않기

서버 컴포넌트에서 cookies() headers() 같은 요청 의존 API를 사용하면, 의도치 않게 라우트 전체가 동적으로 전환될 수 있습니다. PPR 관점에서는 다음 전략이 유효합니다.

  • 요청 의존 로직을 동적 슬롯 내부로 격리
  • 정적 셸에서 꼭 필요하지 않다면, cookies() 사용을 피하고 서버 액션/라우트 핸들러로 이동

개념 예시:

// app/products/[id]/page.tsx
import { Suspense } from 'react'
import PublicOverview from './ui/PublicOverview'
import UserPanel from './ui/UserPanel'

export default function Page({ params }: { params: { id: string } }) {
  return (
    <div>
      <PublicOverview productId={params.id} />
      <Suspense fallback={<div className="skeleton user" />}
      >
        <UserPanel productId={params.id} />
      </Suspense>
    </div>
  )
}
// app/products/[id]/ui/UserPanel.tsx
import { cookies } from 'next/headers'

export default async function UserPanel({ productId }: { productId: string }) {
  const token = cookies().get('token')?.value
  if (!token) return null

  // 사용자별 데이터는 여기에서만 로드
  const res = await fetch(`https://api.example.com/me/panel/${productId}`, {
    cache: 'no-store',
    headers: { Authorization: `Bearer ${token}` },
  })

  if (!res.ok) return null
  const data = await res.json()

  return <section>{data.message}</section>
}

이렇게 하면 정적 셸은 안정적으로 유지하면서, 사용자별 영역만 늦게 채우는 구조를 만들 수 있습니다.

7) SEO 체크리스트: “봇이 보는 콘텐츠” 범위를 명확히

PPR은 UX에는 좋지만, SEO 관점에서는 “정적 셸에 무엇이 들어가느냐”가 중요합니다.

  • 페이지의 핵심 텍스트(제목, 본문 요약, 구조화 데이터)가 동적 슬롯에만 있지 않은가?
  • 메타데이터 생성이 요청 의존 로직에 묶여 전체가 동적으로 바뀌지 않는가?
  • 리뷰/추천처럼 부가적인 콘텐츠는 늦게 떠도 되지만, 본문 자체가 늦게 뜨면 검색 노출 품질에 악영향 가능

정리하면, SEO가 중요한 페이지는 검색에 필요한 최소 정보는 정적 셸에서 완결시키고, 부가 섹션만 동적 슬롯으로 보내는 편이 안전합니다.

8) 성능 지표 체크리스트: TTFB만 보지 말 것

PPR은 보통 TTFB와 초기 페인트를 개선하지만, 다음을 함께 봐야 합니다.

  • LCP: 정적 셸에 LCP 후보(히어로 이미지/타이틀)가 들어가 있는가?
  • CLS: fallback 스켈레톤이 실제 콘텐츠 크기와 유사한가?
  • INP: 동적 슬롯이 클라이언트 컴포넌트로 과도하게 내려가 인터랙션을 방해하지 않는가?

레이아웃 시프트가 커지면 PPR로 “빨라 보이게” 만들었어도 체감 품질이 떨어질 수 있습니다. 폰트 로딩으로 CLS가 커지는 케이스도 흔하니, 필요하면 Chrome Layout Shift 폭증? font-display로 해결도 함께 점검하세요.

9) 에러/타임아웃 설계: 부분 실패를 전체 실패로 만들지 않기

부분 렌더링에서는 “일부 섹션만 실패”하는 상황이 자연스럽습니다. 체크리스트:

  • 동적 슬롯별로 에러 경계를 둘 것인가?
  • 실패 시 null 처리 vs 에러 UI vs 재시도 버튼
  • 외부 API 타임아웃을 어디서 걸 것인가(서버에서 명시적으로)

간단한 타임아웃 유틸 예시:

export async function fetchWithTimeout(url: string, ms: number, init?: RequestInit) {
  const controller = new AbortController()
  const id = setTimeout(() => controller.abort(), ms)
  try {
    const res = await fetch(url, { ...init, signal: controller.signal })
    return res
  } finally {
    clearTimeout(id)
  }
}

이런 장치를 동적 슬롯에서 사용하면, 특정 API 지연이 페이지 전체를 끌어내리는 상황을 줄일 수 있습니다.

10) 배포/운영 체크리스트: 캐시 무효화와 관측성

PPR은 캐시와 스트리밍이 얽혀서, 운영 단계에서 “왜 어떤 사용자는 최신이고 어떤 사용자는 아닌지”가 이슈가 됩니다.

  • 어떤 데이터가 revalidate로 갱신되는지 문서화
  • 배포 후 변경이 즉시 반영되어야 하는 구간은 no-store 또는 온디맨드 revalidate를 고려
  • 서버 로그에서 슬롯별 지연을 분리해 볼 수 있는가?
    • 예: price reviews reco 섹션별 API latency

CI/CD에서 권한 문제로 배포 파이프라인이 흔들리면 성능 개선 이전에 운영 신뢰도가 무너집니다. GitHub Actions 권한 이슈가 있다면 GitHub Actions GITHUB_TOKEN 403 권한오류 해결도 참고해 두는 편이 좋습니다.

11) 팀 적용 체크리스트: 규칙을 코드로 강제하기

PPR은 “규칙이 느슨하면” 금방 퇴행합니다. 아래를 추천합니다.

  • 페이지별로 데이터 소스와 캐시 정책을 표준 템플릿으로 작성
  • cache: 'no-store' 사용 시 코드리뷰 체크 항목에 포함(특히 공용 데이터)
  • 동적 함수(cookies() 등) 사용 위치를 제한하는 팀 규칙
  • 성능 회귀를 막기 위한 라이트하우스/웹바이탈 측정 자동화

12) 최종 도입 체크리스트(요약)

  • 후보 페이지 선정: 정적 셸 가치가 큰가?
  • 정적 셸 정의: above-the-fold 중심으로 데이터 없는 UI를 먼저
  • 동적 슬롯 분리: 느린 섹션별 서스펜스 경계
  • 캐시 정책 문서화: 공용/사용자별/신선도/실패 UX
  • 요청 의존 로직 격리: cookies() headers()는 슬롯 내부로
  • SEO 검증: 핵심 본문/메타는 정적 셸에서 완결
  • CLS/LCP/INP 동시 관리: 스켈레톤 크기, 폰트 로딩, 번들 크기
  • 부분 실패 허용: 슬롯별 에러/타임아웃 전략
  • 운영 관측성: 섹션별 latency와 캐시 히트율 확인

PPR은 “한 번 켜는 기능”이라기보다, 페이지를 정적 셸과 동적 슬롯으로 재설계하는 방식에 가깝습니다. 위 체크리스트대로 적용하면, 전체 SSR의 비용을 줄이면서도 사용자에게는 즉시 반응하는 화면을 제공하는 균형점을 찾을 수 있습니다.