의도: 지원자가 서버 사이드 렌더링(SSR)의 개념과 필요성을 이해하고 있는지 평가
팁
주어진 답안 (모범 답안)
서버 사이드 렌더링은 페이지 요청 시 서버에서 HTML을 렌더링하여 클라이언트에 전송하는 방식입니다.
브라우저가 페이지를 요청하면, 서버에서 필요한 데이터를 가져와 HTML을 생성한 후 클라이언트에 전달하기 때문에 초기 로딩 속도가 빠르고, 검색 엔진이 미리 렌더링된 HTML을 크롤링할 수 있어 SEO에 유리합니다.
Next.js에서는
getServerSideProps함수를 사용하여 SSR을 구현할 수 있습니다.
이 함수는 페이지 요청이 들어올 때마다 실행되며, 서버에서 데이터를 미리 가져와 렌더링할 수 있도록 도와줍니다.
이를 통해 사용자에게 최신 데이터를 제공하면서도 성능과 SEO를 최적화할 수 있습니다.
fetch가 발생하면 응답 지연이 커질 수 있어 설계/프리패칭이 중요함📝 워터폴(waterfall)
요청/연산이 서로 의존해 순차로 진행되며 전체 대기 시간이 누적되는 현상
// app/products/page.tsx (Server Component)
import { cookies } from "next/headers";
export const dynamic = "force-dynamic"; // 또는 아래 fetch에 no-store
async function getProducts() {
const locale = cookies().get("locale")?.value ?? "ko";
const res = await fetch(`https://api.example.com/products?locale=${locale}`, {
cache: "no-store", // 매 요청 서버 렌더
// next: { revalidate: 0 } 동일 의미
});
if (!res.ok) throw new Error("Failed to load");
return res.json();
}
export default async function Page() {
const products = await getProducts();
return (
<main>
<h1>상품 목록</h1>
<ul>{products.map((p: any) => <li key={p.id}>{p.name}</li>)}</ul>
</main>
);
}
💻
export const dynamic = "force-dynamic";
- 이 라우트 세그먼트를 항상 동적 렌더링하도록 지시함
- 동적 렌더링 (dynamic rendering)
- 요청 시점마다 서버가 HTML을 새로 생성하는 렌더링 방식
- 쿠키·헤더·URL 쿼리 등 요청 순간에만 알 수 있는 정보를 사용하거나, 데이터 캐시를 끈 경우에 해당
- 결과: 빌드 시 정적 HTML을 만들지 않고 매 요청 서버에서 렌더함
💻
const locale = cookies().get("locale")?.value ?? "ko";
- 요청마다
locale쿠키를 읽고, 값이 없으면 기본값'ko'로 폴백함- 쿠키값에 따라 다른 API URL이 만들어지므로 사용자 맞춤(개인화)이 가능함
💻
const res = await fetch(..., { cache: "no-store" });
- 서버에서 외부 API를 호출함
cache: "no-store"
- 데이터 캐시를 사용하지 않음
- 항상 원본 API로 요청함 (요청마다 최신 데이터)
next: { revalidate: 0 }도 같은 의미- 주의: 캐시를 끄면 TTFB(Time To First Byte)가 늘 수 있음 (서버/네트워크 비용 증가)
💡 이 코드의 렌더링/캐시 동작
- 왜 SSR인가?
cookies()사용 → 요청 의존성 → 정적 생성 불가 → 동적 렌더링dynamic = "force-dynamic"→ 정적으로 만들 수 있더라도 강제로 매 요청 SSRfetch(..., { cache: "no-store" })→ 데이터 캐시 미사용, 항상 원본 호출- 정리: 쿠키 기반 개인화 + 강제 동적 + 캐시 미사용 조합 → 항상 최신·요청별 HTML을 서버에서 생성
💡 요청 → 응답 타임라인
- 사용자가
/products요청- 서버가 Page() 실행:
cookies()로locale읽음fetch로 API 호출(no-store) → 응답 수신- 서버에서 HTML 생성 (리스트 포함)
- 스트리밍으로 HTML 전송 (상황에 따라 상단부터)
- 브라우저는 즉시 그려 사용자에게 목록이 보임
- (클라이언트 컴포넌트가 없으니 하이드레이션 비용 최소)
💡 에러 흐름
!res.ok에서throw→ 가장 가까운app/products/error.tsx가 있으면 그것이 렌더, 없으면 상위 세그먼트의error.tsx또는 글로벌 에러 UI- 로깅/추적을 하려면
error.tsx에서 Sentry/Log 등과 연동해야 함
<Suspense>// app/reviews/Reviews.tsx (Server Component, 느린 데이터)
async function Reviews() {
const res = await fetch("https://api.example.com/reviews", { cache: "no-store" });
const list = await res.json();
return <ul>{list.map((r: any) => <li key={r.id}>{r.text}</li>)}</ul>;
}
export default Reviews;
// app/products/page.tsx (Server Component)
import { Suspense } from "react";
import Reviews from "../reviews/Reviews";
async function Products() {
const res = await fetch("https://api.example.com/products", { cache: "no-store" });
const list = await res.json();
return <ul>{list.map((p: any) => <li key={p.id}>{p.name}</li>)}</ul>;
}
export default async function Page() {
return (
<>
<h1>상품 + 후기</h1>
<Products />
<Suspense fallback={<div className="skeleton">후기 불러오는 중...</div>}>
{/* 준비되면 서버가 추가 청크로 교체 */}
<Reviews />
</Suspense>
</>
);
}
// app/products/Loading.tsx
// 이 세그먼트에 대한 "경계"의 기본 fallback (자동 Suspense)
export default function Loading() {
return <div className="skeleton">상품 페이지 로딩...</div>;
}
💻
async function Reviews()
- App Router에서는 서버 컴포넌트가 async여도 JSX를 반환할 수 있음
- 서버 컴포넌트(RSC) 렌더러는 Promise를 대기(suspend)로 처리하고, 데이터가 준비되면 JSX를 만들어 스트리밍으로 보내는 방식이 설계에 포함되어 있음
➡️ 그래서 컴포넌트 자체가 비동기여도 OK- 클라이언트에서 실행되는 컴포넌트는 렌더 함수가 동기적이어야 하고, 데이터 로딩은
useEffect+state나 이벤트 핸들러, 또는 부모 서버 컴포넌트에서 데이터 받아오기(Props 전달)로 처리해야 함
💻 Suspense
- 자식 트리가 suspend(데이터 대기) 상태일 때 fallback을 먼저 렌더하고, 준비되면 대체함
<Products />는 Suspense 밖 ➡️ 여기에서 대기가 발생하면(네트워크 느림 등) 해당 부분이 준비될 때까지 그 아래 렌더가 지연될 수 있음<Reviews />는 Suspense 안 ➡️ 먼저fallback이 전송되고, 준비되면 추가 청크(스트리밍)로 교체됨
💻
loading.tsx
- 해당 폴더 세그먼트 전체를 감싸는
<Suspense>의 fallback 역할을 함app/products아래의 페이지/자식 서버 컴포넌트가 데이터를 기다려 suspend되면, Next.js가 자동으로 이 UI를 먼저 스트리밍하고, 준비가 끝나면 실제 콘텐츠로 교체됨- 만약 필요한 데이터가 캐시에서 즉시 응답되면, 로딩 UI는 거의 보이지 않거나 아예 나타나지 않을 수 있음
- 효과: 초기 빈 화면을 없애고 로딩 스켈레톤/문구를 먼저 보여줌
💡 실제 동작 (요청 → 스트리밍 → 교체)
- 사용자가
/products요청- 서버가
Page()렌더 시작 →<h1>와 정적인 부분은 바로 출력 가능Products()에서fetch(no-store)대기 → 여기서 지연되면<Products />바깥에 별도 Suspense가 없으므로 해당 블록이 준비될 때까지 그 아래 출력에 제약이 생길 수 있음- 동시에
<Reviews />는<Suspense>경계 안이므로 fallback("후기 불러오는 중...")이 먼저 전송됨**Reviews데이터가 준비되면 서버가 추가 청크를 보내 fallback 자리를 실제 후기 리스트로 교체**함- 클라이언트 JS는 거의 내려가지 않으므로 하이드레이션 비용 최소
// app/blog/page.tsx - 자주 안 바뀌면 정적 + 재검증으로 비용 낮춤
export const revalidate = 60; // 60초마다 백그라운드 갱신
async function getPosts() {
const res = await fetch("https://api.example.com/posts", {
next: { revalidate: 60 }, // fetch 레벨에서도 가능
});
return res.json();
}
💻
export const revalidate = 60;
- 페이지(HTML) 캐시 TTL
- 라우트(세그먼트) 단위를 설정함
- 이 페이지의 HTML(Full Route Cache)을 정적으로 보관하고, 최대 60초까지만 신선한 것으로 간주함
- 60초가 지나 첫 요청이 들어오면, 사용자는 기존 캐시 HTML을 바로 받고(빠른 응답), 서버는 백그라운드에서 새 HTML을 재생성함(ISR)
➡️ 다음 요청부터는 새 HTML이 제공됨
💻
next: { revalidate: 60 }
- 데이터(fetch) 캐시 TTL
- 데이터(fetch) 캐시의 TTL을 60초로 설정함
- 같은 URL/옵션의
fetch는 Data Cache에 60초동안 보관되고, 만료 시 백그라운드 재검증됨- 이 캐시는 여러 라우트에서 재사용될 수 있어, 동일 데이터를 여러 곳에서 쓰면 효율적임
💡 요청 → 응답 타임라인
- T0 (신선 기간 내)
- 사용자가
/blog요청 → 정적 HTML 캐시가 바로 응답 (서버 렌더 없음)- 내부에서 쓰인
fetch결과도 Data Cache 적중 시 바로 사용- T60+ 최초 요청 (만료 후 첫 요청)
- 사용자는 직전의 캐시 HTML을 즉시 받음 (빠른 응답)
- 백그라운드에서 Next.js가 새 HTML을 재생성함 (ISR)
- 다음 요청부터 새 HTML을 제공함
- fetch 레벨도 동일
- Data Cache가 만료되면 백그라운드로 재검증하고, 그 사이에는 기존 데이터가 사용됨
💡 캐시 계층과 두 revalidate의 관계
- Full Route Cache(HTML):
export const revalidate = 60
- 이 페이지 자체가 60초 동안 고정됨
- 이 TTL이 지나기 전에는 HTML이 갱신되지 않음
- Data Cache(fetch 응답):
fetch(..., { next: { revalidate: 60 } })
- 동일 요청의 데이터만 60초 TTL
- 다른 라우트/서버 액션에서도 같은 fetch를 재사용할 때 유리함
- 중요 포인트: 페이지가 정적 캐시(Full Route Cache)로 묶여 있으면, fetch 캐시가 더 자주 새로워져도 사용자가 보는 것은 페이지 HTML TTL에 의해 결정됨
- 즉, 이 예시처럼 둘 다 60초로 맞추면 데이터와 HTML TTL이 일치해서 혼선을 줄일 수 있음
- 특정 fetch를 여러 라우트에서 공유하거나 라우트 TTL과 별개로 관리하고 싶을 때만
next.revalidate를 따로 쓰는 경우가 많음
// 읽기: 태그 부여
const res = await fetch("https://api.example.com/products", {
next: { tags: ["products"] }, // 캐시 태그
});
// 쓰기(변경 시): 특정 태그만 무효화
import { revalidateTag } from "next/cache";
export async function POST() {
// ... DB 업데이트
revalidateTag("products");
return Response.json({ ok: true });
}
💻
next: { tags: ["products"] }
- 의미
- 이
fetch의 데이터 캐시 엔트리에"products"라는 캐시 태그를 붙임- 같은 태그를 단 요청들은 한 그룹으로 취급됨
- 적용 범위
- Next.js의 Data Cache(데이터 캐시)에 저장된 응답에 태그가 달림
- 동일 데이터가 여러 라우트에서 쓰여도 같은 태그만 달면 한 번에 관리할 수 있음
- 전제 조건
- 태그는 캐시 가능한 fetch에 의미가 있음
cache: "no-store"처럼 캐시를 끄면 태그가 붙어도 무효화할 캐시가 없음- 효과: 이후 쓰기 시점(변경 이벤트)에서 같은 태그로 무효화하면, 이 태그가 달린 모든 캐시 엔트리가 한 번에 재검증 대상이 됨
💻
revalidateTage("products")
- 의미
"products"태그가 달린 모든 캐시(해당 fetch 결과들, 그것들로 렌더된 정적 페이지 캐시까지)를 온디맨드로 무효화함- 다음 방문/요청 때 신규 데이터로 재검증이 일어남
- 호출 환경: 서버 환경에서만 호출 가능함 (서버 액션, 라우트 핸들러 등)
- 반영 시기
- 무효화는 다음 방문 시 적용됨
- 즉, 호출 즉시 모든 경로를 강제로 다시 만들지 않고, 다음 요청이 들어올 때 해당 태그와 연관된 캐시를 새로 갱신함 (과도한 대량 재빌드 방지)
- 클라이언트 Router Cache 주의
- 서버의 Data/Route 캐시를 무효화해도, 사용자의 브라우저 Router Cache(클라이언트 측 내비게이션 캐시)는 즉시 바뀌지 않을 수 있음
- 보자마자 바꾸고 싶다면 라우팅(페이지 이동) 또는 클라이언트에서 재요청/무효화(예: React Query
invalidateQueries, SWRmutate)를 함께 사용해야 함
💡 동작 원리
- 초기 렌더/빌드에서
fetch(..., { next: { tags: ["products"] } });가 실행되면, 그 응답이 캐시에 저장되면서products라는 태그가 같이 기록됨- 이후 같은 데이터/페이지는 캐시 적중으로 빠르게 응답함
- 데이터가 변경되면(예: 관리자 페이지에서 상품 추가/수정), 서버 액션, 라우트 핸들러 등 서버 코드에서
revalidateTage("products")를 호출함- 이 호출은 해당 태그가 달린 Data Cache(패치 결과)와, 그 데이터를 써서 만들어진 Full Route Cache(페이지 HTML)를 즉시 무효화함
- 다음 요청 시 새로
fetch가 실행되어 최신 데이터로 캐시를 재구성함
나쁜 예: 순차 요청
const a = await fetch("/api/a");
const b = await fetch(`/api/b?id=${(await a.json()).id}`);
b는 a의 결과(id)가 있어야 시작됨 ➡️ 연쇄(fetch A → fetch B)좋은 예: 독립 호출은 병렬로
const [aRes, bRes] = await Promise.all([
fetch("/api/a", { cache: "no-store" }),
fetch("/api/b", { cache: "no-store" }),
]);
export const revalidate = N 또는 fetch(..., { next: { revalidate: N } })로 정적 + 재검증을 활용함next: { tags: ["x"] } + revalidateTag("x")로 변경 시점에만 정확히 갱신 ➡️ TTFB 감소Promise.all, N+1은 집계 API/DB JOIN/IN으로 한 번에<Suspense>: 느린 섹션은 경계로 감싸 로딩 UI 먼저 보여주고, 준비되면 추가 청크로 교체함예시
// app/dashboard/page.tsx (Server Component)
import { cookies } from "next/headers";
export const dynamic = "force-dynamic"; // 요청별 개인화 보장
export default async function Page() {
const userId = cookies().get("uid")?.value;
const [profile, notices] = await Promise.all([
fetch(`https://api.example.com/profile?u=${userId}`, { cache: "no-store" }),
fetch(`https://api.example.com/notices`, { next: { revalidate: 300 } }), // 공용 데이터는 캐시
]).then(([p, n]) => Promise.all([p.json(), n.json()]));
return (
<main>
<h1>{profile.name}님, 환영합니다!</h1>
<NoticeList notices={notices} />
<ClientOnlyToggle defaultOn={profile.prefersToggle} />
</main>
);
}
// ClientOnlyToggle.tsx (Client Component: 꼭 필요한 상호작용만)
"use client";
export default function ClientOnlyToggle({ defaultOn }: { defaultOn: boolean }) {
// 상호작용이 필요한 최소 부분만 클라이언트로
}