Next.js App Router 기반 프로젝트를 시작하면서, 요구사항 정의서와 기술 명세서를 AI와 함께 구체화하고 있었다. 라우트 경로마다 렌더링 전략을 수립하는 과정에서, AI가 어느 경로에는 ‘SSR’, 다른 어느 경로에는 ‘CSR’, ‘ISR’이라고 딱 정해 주었다. 묘한 위화감이 들었다. App Router에서는 서버 컴포넌트와 클라이언트 컴포넌트가 한 페이지에 섞이는데, 경로 전체를 하나의 렌더링 전략으로 정의하는 게 맞나 싶었다. 그래서 이 용어들이 어디서 온 건지, 정말 맞는 구분이었는지 파헤쳐보겠다.
Page Router 시절의 구분법
Page Router에서는 렌더링 전략이 페이지 단위로 결정됐다. getServerSideProps를 쓰면 SSR, getStaticProps를 쓰면 SSG, 둘 다 안 쓰면 CSR. 페이지 하나에 렌더링 방식 하나가 대응됐고, 개발자가 직접 선택하는 구조였다.
예를 들어 매 요청마다 서버에서 데이터를 가져와 HTML을 생성하려면 getServerSideProps를 썼다.
// 매 요청마다 서버에서 데이터를 가져와 HTML 생성
export async function getServerSideProps() {
const res = await fetch("https://api.example.com/products");
const products = await res.json();
return { props: { products } };
}
export default function ProductsPage({ products }) {
return (
<ul>
{products.map(product => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}pages/products.tsx
빌드 시점에 HTML을 미리 만들어두는 SSG는 getStaticProps로, 주기적 재생성이 필요하면 revalidate 옵션을 추가하는 ISR로 구현했다. 데이터 패칭 함수를 쓰지 않는 패턴은 CSR이라 불렸다.
// 데이터 패칭 함수 없이, 브라우저에서 데이터를 가져와 UI 구성
import { useEffect, useState } from "react";
export default function DashboardPage() {
const [data, setData] = useState(null);
useEffect(() => {
fetch("/api/dashboard")
.then(res => res.json())
.then(setData);
}, []);
if (!data) return <p>Loading...</p>;
return <div>{data.summary}</div>;
}pages/dashboard.tsx
엄밀히 말하면 이것도 순수한 CSR은 아니었다. Next.js는 데이터 패칭 함수가 없는 페이지도 빌드 시점에 HTML로 프리렌더링했다1. 위 코드에서 <p>Loading...</p>까지는 HTML에 포함되고, 실제 데이터만 브라우저에서 가져오는 구조였다. CRA처럼 <div id="root"></div>만 보내는 진짜 빈 껍데기와는 달랐다.
그래도 CSR과 SSR이라는 라벨은 충분히 통했다. 정확한 구분은 아니더라도, 페이지 하나에 전략 하나가 대응됐기 때문이다.
App Router가 바꿔놓은 것
App Router로 넘어오면서 이 구분이 무너졌다. 핵심 변화는 서버 컴포넌트가 기본이 된 것이다.
App Router에서 모든 컴포넌트는 기본적으로 서버 컴포넌트다. 서버에서만 실행되고, 자바스크립트 번들에 포함되지 않는다. 데이터를 가져오는 로직이나 무거운 라이브러리 의존성을 클라이언트에 보내지 않아도 되니 번들 사이즈가 줄어든다.
// 별도 지시어가 없으면 서버 컴포넌트
// 서버에서만 실행되고, 클라이언트에 자바스크립트 미전송
import db from "@/db";
export default async function Page() {
const products = await db.product.findMany();
return (
<ul>
{products.map(product => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}app/page.tsx
인터랙션이 필요한 컴포넌트에는 "use client" 지시어를 붙인다. 정확히 말하면 "use client"는 개별 컴포넌트를 클라이언트 컴포넌트로 지정하는 게 아니라, 모듈 의존성 트리에서 서버와 클라이언트의 경계를 정의한다. 이 지시어가 붙은 파일에서 export된 컴포넌트가 클라이언트 모듈의 진입점이 되고, 그 파일이 import하는 모든 의존성도 클라이언트에서 평가된다. 그래서 모든 클라이언트 컴포넌트 파일에 "use client"를 붙일 필요는 없다. 경계 지점에만 붙이면 된다2.
"use client";
import { useState } from "react";
export default function Counter() {
const [count, setCount] = useState(0);
return (
<>
<button onClick={() => setCount(count + 1)}>+</button>
<p>현재 카운트: {count}</p>
</>
);
}app/counter.tsx
// 서버 컴포넌트 안에서 클라이언트 컴포넌트를 import
// 한 페이지에 두 종류의 컴포넌트가 공존
import Counter from "./counter";
export default async function Page() {
const data = await fetch("https://api.example.com/stats");
const stats = await data.json();
return (
<div>
<h1>대시보드</h1>
<p>전체 유저 수: {stats.userCount}</p>
<Counter />
</div>
);
}app/page.tsx
그런데 여기서 혼란이 생긴다. 클라이언트 컴포넌트라는 이름과 달리, 이 컴포넌트도 서버에서 렌더링된 HTML로 전달된다. 라우트가 정적이면 빌드 시점에, 동적이면 요청 시점에 렌더링이 일어난다3. 어느 쪽이든 서버에서 HTML을 생성하고, 클라이언트에서 하이드레이션을 통해 인터랙티브하게 만드는 구조다. 다만 이건 초기 로드4에만 해당한다. Link 컴포넌트 등을 통한 클라이언트 네비게이션에서는 클라이언트에서만 렌더링된다.
클라이언트 컴포넌트는 ‘클라이언트에서만 실행되는 컴포넌트’가 아니다. 상황에 따라 서버에서도 렌더링되고, 항상 클라이언트에서 실행된다.
그래서 CSR/SSR이 안 맞는 이유
App Router에서는 이 페이지 단위의 전제가 무너진다.
next/동적에 ssr: false를 줘서 특정 컴포넌트의 서버 렌더링을 끌 수는 있다. 하지만 이건 브라우저 API 의존성 같은 예외를 위한 opt-out이지, 페이지의 렌더링 전략을 선택하는 수단이 아니다.
import 동적 from "next/동적";
// ssr: false로 서버 렌더링을 명시적으로 비활성화
// 브라우저 API에 의존하는 라이브러리 등에서 쓰이는 예외적 패턴
const ChartWidget = 동적(() => import("./chart-widget"), {
ssr: false,
});
export default function AnalyticsPage() {
return (
<div>
<h1>Analytics</h1>
<ChartWidget />
</div>
);
}
그러면 어차피 서버에서 렌더링이 일어나니까 전부 SSR인가? 그것도 아니다. 서버 컴포넌트는 서버에서만 실행되고 자바스크립트를 아예 안 보내지만, 클라이언트 컴포넌트는 서버에서 프리렌더한 뒤에 자바스크립트 번들도 함께 보낸다. 한 페이지 안에 동작 방식이 다른 컴포넌트들이 공존하는데, 페이지 전체를 하나의 라벨로 분류할 수 없다. 렌더링 단위가 페이지에서 컴포넌트로 바뀌었기 때문이다.
페이지가 아니라 컴포넌트에게 묻는다
App Router에서는 CSR/SSR 대신 다른 질문을 해야 한다.
‘이 컴포넌트는 서버에서만 실행되면 충분한가, 아니면 클라이언트에서도 실행돼야 하는가?’
이게 서버 컴포넌트와 클라이언트 컴포넌트의 구분 기준이다. 상태 관리(useState), 이벤트 핸들러(onClick), 브라우저 API(window, localStorage) 같은 것들이 필요하면 클라이언트 컴포넌트, 아니면 서버 컴포넌트로 두면 된다.
이 변화를 가장 잘 보여주는 게 Partial Prerendering(PPR)이다. PPR 이전에는 페이지 단위로 정적 렌더링과 동적 렌더링 중 하나를 택해야 했다. PPR은 이 양자택일을 없앤다. 정적 셸을 먼저 생성해서 초기 HTML로 보내고, 동적 부분은 Suspense 경계 안에서 스트리밍한다. 한 페이지 안에 정적인 부분과 동적인 부분이 공존하는 것이다.
import { Suspense } from "react";
import { cookies } from "next/headers";
// 빌드 시점에 미리 생성되는 정적 셸
function Header() {
return <h1>Product Store</h1>;
}
// 요청 시점에 스트리밍되는 동적 콘텐츠
async function UserGreeting() {
const username = (await cookies()).get("username")?.value;
return <p>Welcome back, {username}!</p>;
}
// 한 페이지 안에 정적 콘텐츠와 동적 콘텐츠가 공존
// SSR이라고도, SSG라고도 부를 수 없다
export default function Page() {
return (
<>
<Header />
<Suspense fallback={<p>Loading...</p>}>
<UserGreeting />
</Suspense>
</>
);
}app/page.tsx
페이지를 SSR이냐 SSG냐로 분류하는 게 왜 안 맞는지, PPR이 직접 보여준다.
캐싱도 컴포넌트 단위가 됐다
렌더링뿐만 아니라 캐싱도 같은 방향으로 바뀌었다. 기존의 SSG나 ISR에 해당하는 개념이 사라진 건 아니다. App Router에서도 정적 생성, 재검증 같은 전략을 쓸 수 있다. 다만 이걸 결정하는 단위가 페이지에서 컴포넌트와 함수로 바뀌었다.
Next.js 16에서는 "use cache" 지시어를 중심으로 한 캐시 모델이 도입됐다. 기본적으로 아무것도 캐싱되지 않고, 캐싱이 필요한 컴포넌트나 함수에 "use cache"를 붙여서 명시적으로 opt-in하는 구조다.
import { cacheLife, cacheTag, updateTag } from "next/cache";
// 컴포넌트 단위 캐싱 opt-in
// cacheLife로 캐시 수명 지정, cacheTag로 선택적 무효화용 태그 부여
async function BlogPosts() {
"use cache";
cacheLife("hours");
cacheTag("posts");
const res = await fetch("https://api.example.com/posts");
const posts = await res.json();
return (
<ul>
{posts.map((p: any) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
);
}
// Server Action에서 updateTag 호출 시 해당 태그의 캐시를 즉시 무효화하고 UI 반영
// 라우트 핸들러 등에서는 revalidateTag 사용 (stale-while-revalidate 방식)
async function createPost(formData: FormData) {
"use server";
await db.post.create({ data: { title: formData.get("title") } });
updateTag("posts");
}
이전 버전의 unstable_cache는 "use cache"로 대체됐지만, fetch의 캐시 옵션은 여전히 유효하다. 이 둘은 서로 다른 레이어를 캐싱한다. fetch 옵션은 HTTP 응답 데이터를 캐싱하는 Data Cache고, "use cache"는 컴포넌트나 함수의 반환값을 캐싱하는 레이어다. "use cache"가 바깥 레이어 역할을 하기 때문에, 컴포넌트 캐시가 살아있는 동안은 안쪽의 fetch가 호출되지 않는다. "use cache"를 쓴다면 캐시 수명은 cacheLife로, 무효화는 revalidateTag나 updateTag로 관리하는 것이 권장 패턴이다.
렌더링이든 캐싱이든, App Router의 방향은 일관적이다. 페이지 단위의 선택에서 컴포넌트 단위의 선언으로.
용어가 주는 착각
AI가 명세서에 ‘SSR’, ‘CSR’이라는 단어를 썼을 때 느꼈던 위화감의 정체가 이제 선명해졌다. 그 용어들이 틀려서가 아니었다. 렌더링을 바라보는 단위 자체가 페이지에서 컴포넌트로 바뀌었는데, 단어만 그대로 남아 있었기 때문이다.
이건 AI와의 소통에서만 생기는 문제가 아니다. 팀원과 아키텍처를 논의할 때, 기술 명세서를 작성할 때, 코드 리뷰에서 렌더링 전략을 이야기할 때도 마찬가지다. “SSR로 하자”라는 한마디가, Page Router 시절의 getServerSideProps를 떠올리는 사람과 App Router의 서버 컴포넌트를 떠올리는 사람 사이에서 서로 다른 그림을 그리게 만든다. 공유하는 어휘가 같은 것을 가리키지 않으면, 논의는 겉으로만 맞아 보이고 속은 어긋난다.
각주
-
자동 정적 최적화 라고 한다.
getServerSideProps나getStaticProps가 없으면 Next.js가 빌드 시점에 자동으로 페이지를 정적 HTML로 프리렌더링한다. ↩ -
서버 컴포넌트의 ‘클라이언트로 JS를 전달하지 않는다’라는 장점을 잘 활용할 수 있도록 경계를 신중하게 잘 설정해야 한다. ↩
-
"use client"자체가 라우트를 동적으로 만들지는 않는다.cookies(),headers(),searchParams같은 동적 API를 사용해야 동적 라우트로 전환된다. ↩ -
도메인에 처음 접속하거나 새로고침할 때 ↩