웹 렌더링 전략과 React Server Component
화면 하나를 띄우려면 HTML에 데이터가 채워져 있어야 합니다. 그 일을 언제 하느냐에 따라 전략이 네 가지로 갈립니다.
| 전략 | 데이터가 채워지는 시점 | 데이터 최신성 |
|---|---|---|
| CSR | 브라우저에서 JS가 돌고 나서 | 클라이언트가 직접 관리 |
| SSR | 요청이 올 때마다 서버에서 | 항상 최신 |
| SSG | 빌드할 때 한 번 | 빌드 시점에 고정 |
| ISR | 빌드 + 주기적 재생성 | 정한 주기만큼 지연 |
미리 만들어 둘수록 응답이 빠르고 데이터는 낡습니다. 요청 시점에 가까워질수록 데이터는 최신이 되고 서버가 매번 일합니다.
한 가지 오해를 먼저 걷어냅니다. App Router에서는 네 방식 모두 첫 HTML을 서버나 빌드 단계에서 만듭니다. 'use client'를 붙인 컴포넌트도 마크업은 서버에서 나옵니다. 브라우저가 채우는 것은 그 안에 들어갈 데이터뿐입니다.
전략은 페이지마다 따로 고릅니다. 대시보드는 CSR, 뉴스 피드는 SSR, 블로그 글은 SSG로 두는 식입니다.
CSR (Client-Side Rendering)
무엇인가
브라우저에서 데이터를 가져와 화면을 채우는 방식입니다.
서버가 보낸 HTML에는 데이터가 비어 있습니다. 브라우저가 JavaScript를 실행해 데이터를 요청하고 응답이 도착하면 그 값으로 화면을 채웁니다.
어떤 때 사용하는가
서버가 미리 만들어 둘 수 없거나, 만들어 둬도 쓸 일이 없는 데이터일 때입니다.
- 조작할 때마다 화면이 바뀔 때: 필터, 정렬, 차트 확대. 매번 서버를 거치면 느려집니다.
- 브라우저만 아는 값이 필요할 때: 화면 크기, 스크롤 위치,
localStorage - 사용자마다 데이터가 다를 때: 미리 만들어 둬도 다른 사람이 쓰지 못합니다.
- 실시간으로 계속 갱신될 때
장점 첫 로딩을 지나면 조작이 앱처럼 매끄럽습니다. 필터를 바꾸거나 목록을 더 불러올 때 서버 왕복이 없습니다. 서버 자원도 덜 씁니다.
단점 JS를 내려받은 다음에야 데이터 요청이 시작되므로 초기 로딩이 가장 느립니다. SEO에 불리합니다.
Google처럼 JavaScript를 실행하는 크롤러는 CSR 화면도 읽습니다. 색인 자체가 막히지는 않습니다. 실행을 기다리는 동안 Core Web Vitals가 나빠지는 쪽이 문제입니다.
예시 코드
'use client';
import { useEffect, useState } from 'react';
export default function DashboardPage() {
const [stats, setStats] = useState<Stats | null>(null);
useEffect(() => {
fetch('/api/stats')
.then((res) => res.json())
.then(setStats);
}, []);
if (!stats) return <p>불러오는 중입니다.</p>;
return <StatsView stats={stats} />;
}
페이지 전체를 이렇게 만들 일은 드뭅니다. CSR은 다른 전략 위에 얹는 방식이라, SSG로 만든 페이지에서 일부 영역만 클라이언트에서 받아오는 조합이 더 흔합니다.
SSR (Server-Side Rendering)
무엇인가
요청이 올 때마다 서버에서 HTML을 완성해 보내는 방식입니다.
서버가 데이터를 가져와 HTML에 채운 다음 보냅니다. 브라우저는 채워진 HTML을 받으니 JavaScript 없이도 내용이 보입니다.
어떤 때 사용하는가
지금 이 순간의 값이 맞아야 하는 화면입니다. 재고, 잔액, 실시간 순위가 여기 해당합니다. 미리 만들어 두면 이미 틀린 값이 되어버립니다.
사용자마다 다른 내용을 보여줄 때도 SSR이 됩니다. 누구인지 알려면 쿠키를 읽어야 하는데 그 쿠키는 요청이 와야 존재합니다.
장점 항상 최신 데이터를 보여줍니다. 개인화가 가능합니다. JS 없이도 내용이 보이니 SEO에 유리합니다.
단점 요청마다 렌더링하니 첫 응답이 늦습니다. 서버 자원을 가장 많이 씁니다.
예시 코드
export const dynamic = 'force-dynamic'; // 요청이 올 때마다 새로 렌더링한다
export default async function FeedPage() {
const res = await fetch('https://api.example.com/feed');
const feed: Feed = await res.json();
return <FeedList feed={feed} />;
}
cookies()를 읽는 경우에는 dynamic을 적지 않아도 됩니다. 요청 정보를 읽는 순간 Next.js가 알아서 요청 타임 렌더링으로 넘깁니다.
SSG (Static Site Generation)
무엇인가
빌드할 때 HTML을 완성해 두고 모든 요청에 같은 파일을 돌려주는 방식입니다.
서버가 매번 할 일이 없으니 CDN에서 바로 응답합니다.
어떤 때 사용하는가
문서, 블로그 글, 마케팅 페이지처럼 자주 바뀌지 않고 모두에게 같은 내용을 보여주는 화면입니다.
사용자마다 다른 화면이 필요한 순간 SSG는 후보에서 빠집니다. 빌드 시점에 내용이 확정되기 때문입니다.
장점 초기 로딩이 가장 빠릅니다. CDN에서 응답하니 서버 부하가 없습니다. SEO에 가장 유리합니다.
단점 개인화를 못 합니다. 내용을 바꾸려면 다시 빌드해야 합니다. 페이지가 많으면 빌드가 오래 걸립니다.
예시 코드
export const generateStaticParams = async () => {
const posts = await getPosts();
return posts.map((post) => ({ slug: post.slug }));
};
export default async function PostPage({ params }: PostPageProps) {
const { slug } = await params;
const res = await fetch(`https://api.example.com/posts/${slug}`, {
cache: 'force-cache', // 응답을 서버 캐시에 저장해 다음 빌드까지 재사용한다
});
const post: Post = await res.json();
return <Article post={post} />;
}
[slug]처럼 값이 정해지지 않은 경로는 빌드할 때 무엇을 만들어야 할지 알 수 없습니다. generateStaticParams가 그 목록을 알려주는 함수입니다.
ISR (Incremental Static Regeneration)
무엇인가
SSG로 만든 페이지를 정한 주기마다 다시 만들어 교체하는 방식입니다.
사용자는 항상 완성된 정적 파일을 받고 내용은 주기가 지나면 갱신됩니다.
어떤 때 사용하는가
내용이 바뀌긴 하지만 몇 분 정도 늦어도 괜찮은 화면입니다. 상품 목록, 기사, 통계 요약이 여기 해당합니다.
페이지가 수만 개라서 빌드할 때 전부 만들지 못하는 경우에도 ISR을 씁니다.
장점 응답 속도가 SSG와 같습니다. 전체 재빌드 없이 내용을 갱신합니다. 페이지가 많아도 감당됩니다.
단점 갱신 주기만큼 낡은 데이터가 보입니다. 개인화를 못 합니다. 캐시 무효화 시점을 관리해야 합니다.
예시 코드
export const revalidate = 60; // 60초마다 백그라운드에서 다시 만든다
export default async function ProductsPage() {
const products = await getProducts();
return <ProductGrid products={products} />;
}
revalidate 값은 빌드할 때 읽을 수 있는 숫자여야 합니다. revalidate = 600은 되지만 revalidate = 60 * 10은 동작하지 않습니다.
시간이 지나기를 기다리지 않고 특정 사건에 맞춰 갱신하려면 revalidatePath나 revalidateTag를 씁니다. 글을 수정한 직후에 해당 페이지만 새로 만드는 방식입니다.
무엇이 전략을 결정하는가
네 섹션의 예시 코드를 나란히 놓고 보면 전략을 결정한 것은 fetch에 준 옵션입니다.
fetch 옵션 | 라우트 | 서버 캐시 저장 |
|---|---|---|
| 생략 (기본값) | 정적. 빌드 때 한 번 가져옴 | 저장 안 함 |
cache: 'force-cache' | 정적 | 저장함 (무기한) |
next: { revalidate: 60 } | 정적 + 주기적 재생성 | 저장함 (60초) |
cache: 'no-store' | 동적. 매 요청마다 가져옴 | 저장 안 함 |
두 번째 열과 세 번째 열을 따로 봐야 합니다. 라우트가 정적인지와 응답을 캐시에 남기는지는 별개 문제입니다. 옵션을 생략한 첫 행이 그 예로, 라우트는 정적이면서 응답은 캐시에 남지 않습니다.
무기한은 revalidate: Infinity와 같은 의미입니다. 영원히 남는다는 보장까지는 아니고 오래된 항목은 시간이 지나며 밀려날 수 있습니다.
여기서 버전을 확인해야 합니다. fetch가 도입된 Next 13부터 14까지는 응답을 기본으로 서버 캐시에 저장했습니다. 15에서 이 기본값이 뒤집혔습니다. 지금 기준으로 쓰이지 않은 자료가 아직 많습니다.
fetch 대신 라우트 전체를 지정할 수도 있습니다.
dynamic 값 | 동작 |
|---|---|
'auto' | 기본값. 가능한 만큼 정적으로 만듭니다. |
'force-dynamic' | 항상 요청 타임에 렌더링합니다. |
'force-static' | 항상 정적으로 만듭니다. cookies()는 빈 값을 반환합니다. |
'error' | 정적으로 만들 수 없으면 에러를 냅니다. |
next dev는 항상 요청 타임에 렌더링합니다. 개발 서버에서는 캐시도 재생성도 관찰할 수 없으니 의도한 대로 적용됐는지는 빌드 결과로 확인해야 합니다.
무엇을 고를 것인가
Vercel은 이렇게 권합니다. SSG와 ISR을 최대한 쓰다가, 지금 이 순간의 데이터가 꼭 필요할 때만 SSR을 씁니다.
싼 것부터 시도하고 필요할 때만 비싼 쪽으로 옮기라는 뜻입니다. 판단할 때 보는 기준은 네 가지입니다.
| 기준 | 판단 방향 |
|---|---|
| 내용이 얼마나 자주 바뀌는가 | 실시간 → CSR·SSR, 주기적 → ISR, 거의 안 바뀜 → SSG |
| SEO가 중요한가 | 중요할수록 미리 만들어 두는 쪽(SSR·SSG) |
| 조작이 많은가 | 많음 → CSR·SSR, 열람 위주 → SSG·ISR |
| 개인화가 필요한가 | 필요하면 CSR·SSR (SSG는 불가) |
두 번째 기준에는 원칙이 하나 있습니다. JavaScript 없이도 내용과 메타데이터가 보여야 합니다. CSR만으로 만든 화면이 색인에 불리한 이유입니다.
Server Component
지금까지는 데이터를 언제 채우는지의 이야기였습니다. Server Component는 다른 질문을 다룹니다. 컴포넌트를 어디서 실행하느냐입니다.
왜 등장했는가
클라이언트에서 데이터를 받아오는 방식에는 두 가지 비용이 붙습니다.
첫째, 요청이 순서대로 일어납니다. 부모가 데이터를 받아 렌더링한 다음에야 자식이 자기 데이터를 요청합니다.
Note 렌더링 → Note 데이터 요청 → Author 렌더링 → Author 데이터 요청
둘째, 라이브러리가 번들에 들어갑니다. Markdown을 변환하는 라이브러리를 브라우저에서 쓰면 사용자는 화면을 보기 전에 그만큼을 내려받습니다. react.dev 문서는 이 조합을 압축 후 75KB로 계산합니다.
두 비용의 원인은 같습니다. 데이터가 필요한 곳이 브라우저라는 점입니다. 서버에서 렌더링하면 둘 다 사라집니다.
// 서버에서 처리하고 결과 HTML만 보낸다
async function Note({ id }: NoteProps) {
const note = await db.notes.get(id);
return (
<div>
<Author id={note.authorId} />
<p>{note.content}</p>
</div>
);
}
async function Author({ id }: AuthorProps) {
const author = await db.authors.get(id);
return <span>By: {author.name}</span>;
}
두 컴포넌트가 서버에서 이어 실행되니 왕복이 사라집니다. 라이브러리도 서버에 남고 클라이언트 번들에서는 빠집니다.
무엇인가
서버에서 미리 렌더링되는 컴포넌트입니다. 브라우저로 전송되지 않습니다.
실행 시점은 두 가지입니다.
- 빌드할 때: 파일이나 CMS를 읽어 정적 내용을 만듭니다. 서버가 없어도 됩니다.
- 요청이 올 때: 서버에서 실행되며 별도 API 없이 DB에 바로 접근합니다.
위 예시처럼 비동기 함수로 선언해 렌더링 중에 await을 씁니다. Client Component에서는 못 하는 일입니다.
SSR과는 다른 개념입니다
실행 시점이 두 가지라는 점이 SSR과의 차이를 만듭니다. 가장 자주 겹쳐 읽히는 부분입니다.
| Server Component | SSR | |
|---|---|---|
| 답하는 질문 | 컴포넌트가 어디서 실행되는가 | HTML을 언제 만드는가 |
| 클라이언트 번들 | 포함되지 않음 | Client Component는 포함됨 |
Server Component는 빌드할 때 실행되기도 합니다. 거꾸로 SSR로 그린 페이지에도 Client Component가 섞여 있습니다.
Server Component를 쓴다고 SSR이 되지는 않습니다. 반대 방향도 마찬가지입니다.
어떻게 사용하는가
App Router에서는 모든 컴포넌트가 기본적으로 Server Component입니다. 상호작용이 필요한 부분만 'use client'로 떼어내 자식으로 넣습니다.
// Server Component
import Expandable from './expandable';
export default async function NotesPage() {
const notes = await db.notes.getAll();
return (
<div>
{notes.map((note) => (
<Expandable key={note.id}>
<p>{note.content}</p>
</Expandable>
))}
</div>
);
}
// Client Component
'use client';
import { useState, type ReactNode } from 'react';
export default function Expandable({ children }: { children: ReactNode }) {
const [expanded, setExpanded] = useState(false);
return (
<div>
<button onClick={() => setExpanded(!expanded)}>Toggle</button>
{expanded && children}
</div>
);
}
경계를 이렇게 나누는 이유는 제약에서 나옵니다. 브라우저로 전송되지 않으니 브라우저에서만 되는 일은 못 합니다.
useState,useEffect사용 불가localStorage,window접근 불가- Client Component에 넘기는 props는 직렬화 가능한 값이어야 함 (함수는 불가)
지시어를 정리하면 이렇습니다. Server Component를 표시하는 지시어는 없습니다. 'use client'는 경계를 나누는 표시입니다. 'use server'는 서버 함수에 붙입니다.
정리
- 렌더링 전략은 데이터를 언제 채우고 얼마나 최신으로 유지할지를 정하는 문제입니다.
- 전략은 앱 단위가 아니라 페이지 단위로 고르고 섞어 씁니다.
- 전략을 실제로 결정하는 것은
fetch에 준 옵션과 라우트 설정입니다. - SSG와 ISR을 우선하다가 지금 이 순간의 데이터가 필요할 때만 SSR을 씁니다.
- Server Component는 실행 위치에 대한 개념입니다. HTML 생성 시점을 다루는 SSR과 다른 축입니다.
참고 자료
- Next.js - Rendering — Pages Router 문서입니다. 개념 비교는 유효하지만 코드는 App Router 기준으로 다시 확인해야 합니다.
- Vercel - How to choose the best rendering strategy for your app
- React - 서버 컴포넌트