렌더링 전략이라는 것
"어떻게 페이지를 렌더링할 것인가"는 프론트엔드 프레임워크의 근본적인 질문 중 하나입니다. 같은 React 컴포넌트 트리를 어떻게 HTML로 만들어서 사용자에게 전달할지는, 생각보다 많은 선택지가 있습니다. 각 선택에는 장단점이 있고, 어떤 것은 특정 상황에 최적이지만 다른 상황에서는 오답이 됩니다.
Next.js는 오랫동안 다양한 렌더링 전략을 제공해왔습니다. SSR, SSG, ISR, 그리고 최근의 Partial Prerendering (PPR) 과 Cache Components까지. 각 전략을 이해하려면, 그것이 어떤 문제를 풀기 위해 나왔는지를 보는 것이 중요합니다.
SSG, 가장 빠른 해법
Static Site Generation (SSG) 은 가장 단순하고 가장 빠른 전략입니다. 빌드 시점에 HTML을 모두 생성해두고, 요청이 오면 그 정적 파일을 그대로 전달합니다.
// App Router에서는 기본적으로 정적 생성
export default async function Page() {
const data = await fetch("https://api.example.com/data").then((r) => r.json());
return <div>{data.title}</div>;
}장점:
- 매우 빠름 , HTML이 이미 생성되어 있어서 요청 처리에 거의 시간이 안 걸립니다
- CDN 친화적 , 정적 파일이므로 전 세계 CDN 엣지에 복제할 수 있습니다
- 서버 부하 없음 , 요청마다 연산이 필요하지 않습니다
단점:
- 빌드 시점에 데이터를 알아야 합니다 , 동적인 데이터는 반영할 수 없습니다
- 데이터가 바뀌면 재빌드가 필요합니다 , 수천 페이지짜리 사이트의 재빌드는 오래 걸립니다
- 사용자별 콘텐츠를 표현할 수 없습니다 , 로그인한 사용자의 이름, 개인화된 추천 등은 불가능합니다
SSG는 블로그, 문서 사이트, 마케팅 페이지처럼 콘텐츠가 자주 바뀌지 않고 모든 사용자에게 같은 내용을 보여주는 경우 에 이상적입니다. 하지만 실시간 데이터나 사용자별 콘텐츠가 필요한 앱에는 부족합니다.
SSR, 요청마다 새로 만들기
Server-Side Rendering (SSR) 은 각 요청마다 서버에서 HTML을 새로 생성합니다.
// 동적 데이터를 쓰면 자동으로 SSR이 됩니다
import { cookies } from "next/headers";
export default async function Page() {
const session = (await cookies()).get("session"); // 요청별 쿠키
const user = await db.users.findBySession(session?.value);
return <div>안녕하세요, {user.name}님</div>;
}장점:
- 항상 최신 데이터 , 요청 시점의 상태를 반영합니다
- 사용자별 콘텐츠 가능 , 쿠키, 세션, 인증 정보를 사용할 수 있습니다
- SEO 가능 , 완전히 렌더링된 HTML이 전달되므로 검색 엔진이 잘 읽습니다
단점:
- 서버 부하 , 요청마다 렌더링 비용이 발생합니다
- 느릴 수 있음 , 데이터 소스가 느리면 첫 응답도 느려집니다 (TTFB 악화)
- 캐싱이 어려움 , 매 요청이 다를 수 있어서 CDN 캐싱이 제한적입니다
SSR은 대시보드, 개인화된 피드, 실시간 가격 표시 같은 곳에 적합합니다. 하지만 모든 페이지에 SSR을 쓰면 서버 비용과 응답 시간이 부담됩니다.
ISR, 정적과 동적 사이
SSG는 빠르지만 데이터가 정적이고, SSR은 최신이지만 느립니다. 이 둘 사이의 스펙트럼을 채우는 전략이 Incremental Static Regeneration (ISR) 입니다.
ISR의 아이디어는 단순합니다, 정적으로 생성해두되, 일정 시간이 지나면 백그라운드에서 재생성 합니다.
export default async function Page() {
const data = await fetch("https://api.example.com/data", {
next: { revalidate: 60 }, // 60초마다 재검증
});
return <div>{data.title}</div>;
}이 페이지는 처음 요청 시 정적으로 생성되어 캐시됩니다. 60초 동안 같은 페이지 요청은 캐시에서 즉시 반환됩니다. 60초 이후 첫 요청이 오면, Next.js는 캐시된 버전을 먼저 반환 하고 백그라운드에서 새 버전을 생성 합니다. 다음 요청부터는 새 버전이 사용됩니다.
이것을 stale-while-revalidate (SWR) 패턴이라고 합니다. 사용자는 항상 빠르게 응답을 받고, 데이터는 계속 갱신됩니다.
ISR은 Next.js가 처음 제시한 독창적인 개념이었습니다. 블로그 글, 상품 목록, 뉴스 피드처럼 자주 바뀌지만 실시간은 아닌 데이터에 완벽하게 맞습니다.
On-demand Revalidation
ISR의 시간 기반 재검증 외에도, 특정 이벤트에 따라 즉시 재검증을 트리거할 수 있습니다.
// Server Action에서
"use server";
import { revalidatePath, revalidateTag } from "next/cache";
export async function publishPost(postId: string) {
await db.posts.update(postId, { published: true });
revalidatePath("/posts"); // /posts 페이지 캐시 무효화
revalidateTag("posts"); // 'posts' 태그 캐시 무효화
}CMS에서 글을 발행하거나, 상품 정보가 업데이트되면 즉시 관련 캐시를 갱신할 수 있습니다. 시간 기반 재검증과 결합하면 유연한 캐싱 전략을 만들 수 있습니다.
각 전략의 한계, 왜 또 바뀌어야 했나
SSG, SSR, ISR, 이 세 가지가 오랫동안 Next.js의 기본 렌더링 모델이었습니다. 하지만 실제 사용해보면 각 전략의 경계가 아쉬웠습니다.
가장 큰 문제는 "페이지 단위" 라는 것입니다. 한 페이지가 통째로 SSG이거나, 통째로 SSR이거나, 통째로 ISR이어야 했습니다. 하지만 실제 페이지는 대부분 혼합적입니다.
블로그 글 페이지를 생각해봅시다.
- 글의 제목, 본문 , 거의 변하지 않음, SSG가 적합
- 좋아요 수 , 수시로 바뀜, 실시간이면 좋음
- 댓글 목록 , 자주 바뀜, SSR이 적합
- 개인화된 추천 글 , 사용자마다 다름, SSR 필요
이 중 하나라도 SSR이 필요하면, 전체 페이지가 SSR로 처리되어야 했습니다. 글 본문처럼 정적인 부분조차 매 요청마다 다시 렌더링되는 것입니다. 정적과 동적을 한 페이지 안에서 섞고 싶었지만, 그것은 불가능했습니다.
Partial Prerendering, 혼합 렌더링의 가능성
Next.js는 이 문제를 해결하기 위해 Partial Prerendering (PPR) 이라는 새로운 개념을 실험적으로 도입했습니다 (Next.js 14, 2023년).
PPR의 핵심 아이디어는 이렇습니다, 한 페이지의 정적 부분은 미리 생성하고, 동적 부분은 Suspense 경계로 감싸서 요청 시에 실행한다.
import { Suspense } from "react";
export default function Page() {
return (
<main>
<Header /> {/* 정적, 미리 생성 */}
<BlogPost /> {/* 정적, 미리 생성 */}
<Suspense fallback={<LikesSkeleton />}>
<Likes /> {/* 동적, 요청 시 실행 */}
</Suspense>
<Suspense fallback={<CommentsSkeleton />}>
<Comments /> {/* 동적, 요청 시 실행 */}
</Suspense>
<Footer /> {/* 정적, 미리 생성 */}
</main>
);
}빌드 시에 Next.js는 페이지의 정적 셸 을 생성합니다, Header, BlogPost, Footer, 그리고 각 Suspense 경계의 fallback UI가 포함된 HTML입니다. 요청이 오면:
- 정적 셸이 즉시 전송됨 (매우 빠름)
- 서버에서 동적 부분 (
<Likes>,<Comments>) 이 실행됨 - 결과가 Streaming으로 뒤이어 전송됨
- 클라이언트에서 fallback이 실제 콘텐츠로 교체됨
사용자는 정적 부분을 즉시 보면서, 동적 부분이 점진적으로 채워지는 것을 경험합니다. SSG의 빠른 초기 응답과 SSR의 최신 데이터를 한 페이지 안에서 동시에 얻는 것입니다.
Next.js 16, Cache Components로의 통합
Next.js 16은 이 모든 렌더링 전략을 Cache Components 라는 하나의 모델로 통합했습니다. 이제 "SSR이냐 SSG냐 ISR이냐"를 선택하는 대신, "무엇을 캐시할 것인가" 를 선언하는 것으로 모든 전략을 표현할 수 있습니다.
기본은 동적, 캐시는 명시적
Cache Components 모델에서 모든 페이지는 기본적으로 동적 입니다. 즉, 요청마다 서버에서 실행됩니다. SSG처럼 동작하게 하려면 "use cache" 지시문으로 캐시를 선언합니다.
// next.config.ts
const nextConfig = {
cacheComponents: true,
};// 전체 페이지를 정적으로 캐시 (SSG와 동등)
"use cache";
export default async function Page() {
const posts = await db.posts.findAll();
return <PostList posts={posts} />;
}// 특정 컴포넌트만 캐시 (부분 정적)
"use cache";
async function StaticHeader() {
const settings = await db.settings.get();
return <header>{settings.siteName}</header>;
}
// 페이지 자체는 동적
export default async function Page() {
return (
<>
<StaticHeader />
<Suspense fallback={<Skeleton />}>
<DynamicFeed /> {/* 캐시 안 됨, 요청마다 실행 */}
</Suspense>
</>
);
}이 모델이 통합하는 것들
이전의 각 전략이 Cache Components 모델에서 어떻게 표현되는지 봅시다.
SSG (빌드 시 정적 생성) , 페이지 최상단에 "use cache" 붙이면 됩니다. Next.js가 빌드 시에 미리 렌더링하고 캐시합니다.
SSR (요청마다 새로 생성) , 아무 "use cache"도 없으면 기본값으로 매 요청마다 서버에서 실행됩니다.
ISR (일정 주기로 재생성) , "use cache"와 cacheLife 프로필을 조합합니다.
"use cache";
import { cacheLife } from "next/cache";
export default async function Page() {
cacheLife("hours"); // 시간 단위로 재검증
const data = await fetch(...);
return <div>...</div>;
}PPR (정적 + 동적 혼합) , 페이지는 동적이되, 내부의 특정 컴포넌트에만 "use cache"를 붙입니다. 자연스럽게 부분 정적/부분 동적 페이지가 만들어집니다.
이렇게 네 가지 전략이 하나의 메커니즘으로 통합됩니다. 개발자는 "어떤 전략을 쓸까"가 아니라 "무엇을 캐시할까"를 생각하면 됩니다.
재검증 API의 새 모델
Next.js 16에서 재검증 API가 세분화되고, revalidateTag의 시그니처도 바뀌었습니다. 16부터 두 번째 인자 (cacheLife 프로필) 가 필수이며, 기존의 단일 인자 형태는 deprecated 되었습니다.
revalidateTag(tag, profile), stale-while-revalidate 방식. 사용자에게 캐시를 주면서 백그라운드에서 갱신. 16부터 profile 인자 필수updateTag(tag), Server Actions 전용. 캐시 즉시 무효화 + 바로 새 값 읽기 (read-your-writes)refresh(), Server Actions 전용. 캐시를 건드리지 않고 비캐시 데이터만 다시 가져오기
각 API는 서로 다른 UX 요구사항에 대응합니다.
- 블로그 글을 발행했을 때 →
revalidateTag("posts", "max")(사용자는 조금 나중에 새 글을 봐도 됨) - 사용자가 자기 프로필을 수정했을 때 →
updateTag(user-$)(본인은 즉시 봐야 함) - 알림 개수를 업데이트할 때 →
refresh()(캐시는 유지하고 최신 개수만)
정리, 하나의 스펙트럼
SSG, SSR, ISR, PPR을 각각 다른 전략으로 보는 것보다, "정적 ↔ 동적" 스펙트럼의 다른 지점들 로 보는 것이 자연스럽습니다.
- SSG , 완전히 정적. 빌드 시 한 번
- ISR , 정적이지만 주기적으로 갱신
- PPR , 정적 셸 + 동적 섬
- SSR , 완전히 동적. 매 요청마다
Cache Components는 이 스펙트럼 전체를 하나의 선언적 모델 ("use cache") 로 커버합니다. 개발자는 "어떤 데이터가 얼마나 자주 바뀌고, 사용자가 얼마나 실시간성을 기대하는가"를 기준으로 캐시 여부를 결정할 수 있습니다.
이것은 단순한 API 개선이 아닙니다. 렌더링 전략을 선택하는 것에서, 데이터의 수명과 갱신 방식을 선언하는 것 으로 사고방식 자체가 바뀐 것입니다.
다음 단계
렌더링과 캐싱은 "데이터를 보여주는" 쪽의 이야기였습니다. 다음 글에서는 반대 방향, 데이터를 변경하는 쪽 을 다룹니다. Server Actions는 Next.js가 폼 제출과 mutation을 다루는 방식을 근본적으로 바꿨습니다. API 라우트를 만들지 않고도 서버 함수를 직접 호출할 수 있게 된 이 변화의 의미를 살펴봅니다.