[Next.js] 서버 사이드 렌더링(SSR)이란?

jiny·2025년 11월 2일

기술 면접

목록 보기
72/78

🗣️ 서버 사이드 렌더링이란 무엇인가요?

  • 의도: 지원자가 서버 사이드 렌더링(SSR)의 개념과 필요성을 이해하고 있는지 평가

    • 서버 사이드 렌더링의 정의를 설명한다.
    • SSR의 주요 장점을 설명한다.
    • SSR을 사용하는 예를 떠올려 본다.
  • 주어진 답안 (모범 답안)

    서버 사이드 렌더링은 페이지 요청 시 서버에서 HTML을 렌더링하여 클라이언트에 전송하는 방식입니다.
    브라우저가 페이지를 요청하면, 서버에서 필요한 데이터를 가져와 HTML을 생성한 후 클라이언트에 전달하기 때문에 초기 로딩 속도가 빠르고, 검색 엔진이 미리 렌더링된 HTML을 크롤링할 수 있어 SEO에 유리합니다.

    Next.js에서는 getServerSideProps 함수를 사용하여 SSR을 구현할 수 있습니다.
    이 함수는 페이지 요청이 들어올 때마다 실행되며, 서버에서 데이터를 미리 가져와 렌더링할 수 있도록 도와줍니다.
    이를 통해 사용자에게 최신 데이터를 제공하면서도 성능과 SEO를 최적화할 수 있습니다.


📝 개념 정리

🌟 서버 사이드 렌더링(SSR)이란?

  • 요청이 들어올 때마다 서버가 데이터까지 조회해 완성된 HTML을 만들어 브라우저로 보내는 방식
  • 브라우저는 도착 즉시 화면을 렌더링하고, 이후에 클라이언트 JS가 하이드레이션으로 이벤트/상호작용을 붙임
  • Next.js(App Router)에서는 서버 컴포넌트React 18의 스트리밍 + Suspense를 활용해 준비된 부분부터 청크 단위로 전송할 수 있음

🌟 SSR의 장점

  • 초기 가시성/체감 속도 개선: HTML이 즉시 도착해 FCP(First Contentful Paint, 첫 콘텐츠 표시)가 빠름
  • SEO 유리: 크롤러가 완성된 HTML을 바로 수집할 수 있음
  • 요청별 개인화: 로그인/권한/지역·AB 테스트 등 사용자별로 다른 화면을 쉽게 제공함
  • 보안상 이점: 서버 전용 비밀 값/토큰을 클라이언트에 노출하지 않고 안전하게 사용 가능함
  • 스트리밍 대응: 느린 섹션을 fallback으로 먼저 보여주고 준비되는 대로 교체해 대기 체감을 줄임

🌟 SSR의 단점

  • 서버 비용, 지연 증가: 매 요청마다 서버 연산/데이터 I/O가 발생해 TTFB(Time To First Byte, 첫 바이트까지의 시간)가 늘고, 트래픽이 커지면 스케일링 부담이 커짐
  • 하이드레이션 비용: 초기 HTML을 다시 리액트가 연결해야 하므로 클라이언트 성능 비용이 추가됨
  • 복잡한 캐시 전략: 데이터 최신성·일관성을 유지하기 위한 캐시/재검증 관리가 필요함
  • 워터폴 위험: 서버 렌더 중 연쇄 fetch가 발생하면 응답 지연이 커질 수 있어 설계/프리패칭이 중요함

    📝 워터폴(waterfall)

    요청/연산이 서로 의존순차로 진행되며 전체 대기 시간이 누적되는 현상


🌟 SSR 예제

예제 1: 매 요청 새로 그리기

// 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인가?
    1. cookies() 사용 → 요청 의존성 → 정적 생성 불가 → 동적 렌더링
    2. dynamic = "force-dynamic" → 정적으로 만들 수 있더라도 강제로 매 요청 SSR
    3. fetch(..., { cache: "no-store" })데이터 캐시 미사용, 항상 원본 호출
  • 정리: 쿠키 기반 개인화 + 강제 동적 + 캐시 미사용 조합 → 항상 최신·요청별 HTML을 서버에서 생성

💡 요청 → 응답 타임라인

  1. 사용자가 /products 요청
  2. 서버가 Page() 실행: cookies()locale 읽음
  3. fetch로 API 호출(no-store) → 응답 수신
  4. 서버에서 HTML 생성 (리스트 포함)
  5. 스트리밍으로 HTML 전송 (상황에 따라 상단부터)
  6. 브라우저는 즉시 그려 사용자에게 목록이 보임
  7. (클라이언트 컴포넌트가 없으니 하이드레이션 비용 최소)

💡 에러 흐름

  • !res.ok에서 throw가장 가까운 app/products/error.tsx가 있으면 그것이 렌더, 없으면 상위 세그먼트의 error.tsx 또는 글로벌 에러 UI
  • 로깅/추적을 하려면 error.tsx에서 Sentry/Log 등과 연동해야 함

예제 2: 부분 스트리밍 + <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는 거의 보이지 않거나 아예 나타나지 않을 수 있음
  • 효과: 초기 빈 화면을 없애고 로딩 스켈레톤/문구를 먼저 보여줌

💡 실제 동작 (요청 → 스트리밍 → 교체)

  1. 사용자가 /products 요청
  2. 서버가 Page() 렌더 시작 → <h1>와 정적인 부분은 바로 출력 가능
  3. Products()에서 fetch(no-store) 대기 → 여기서 지연되면 <Products /> 바깥에 별도 Suspense가 없으므로 해당 블록이 준비될 때까지 그 아래 출력에 제약이 생길 수 있음
  4. 동시에 <Reviews /><Suspense> 경계 안이므로 fallback("후기 불러오는 중...")이 먼저 전송
  5. **Reviews 데이터가 준비되면 서버가 추가 청크를 보내 fallback 자리를 실제 후기 리스트로 교체**함
  6. 클라이언트 JS는 거의 내려가지 않으므로 하이드레이션 비용 최소

예제 3: SSR vs. ISR(정적 + 재검증) 스위칭

// 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/옵션의 fetchData Cache에 60초동안 보관되고, 만료 시 백그라운드 재검증
  • 이 캐시는 여러 라우트에서 재사용될 수 있어, 동일 데이터를 여러 곳에서 쓰면 효율적임

💡 요청 → 응답 타임라인

  1. T0 (신선 기간 내)
    • 사용자가 /blog 요청정적 HTML 캐시가 바로 응답 (서버 렌더 없음)
    • 내부에서 쓰인 fetch 결과Data Cache 적중 시 바로 사용
  2. T60+ 최초 요청 (만료 후 첫 요청)
    • 사용자는 직전의 캐시 HTML을 즉시 받음 (빠른 응답)
    • 백그라운드에서 Next.js가 새 HTML을 재생성함 (ISR)
    • 다음 요청부터 새 HTML을 제공함
  3. 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를 따로 쓰는 경우가 많음

예제 4: 태그 기반 캐싱과 무효화

// 읽기: 태그 부여
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, SWR mutate)를 함께 사용해야 함

💡 동작 원리

  1. 초기 렌더/빌드에서 fetch(..., { next: { tags: ["products"] } });가 실행되면, 그 응답이 캐시에 저장되면서 products라는 태그가 같이 기록
  2. 이후 같은 데이터/페이지캐시 적중으로 빠르게 응답
  3. 데이터가 변경되면(예: 관리자 페이지에서 상품 추가/수정), 서버 액션, 라우트 핸들러 등 서버 코드에서 revalidateTage("products")를 호출
  4. 이 호출은 해당 태그가 달린 Data Cache(패치 결과)와, 그 데이터를 써서 만들어진 Full Route Cache(페이지 HTML)즉시 무효화
  5. 다음 요청 시 새로 fetch가 실행되어 최신 데이터로 캐시를 재구성

🌟 성능/운영 팁

1. 워터폴 방지 (병렬 데이터 패칭)

  • 나쁜 예: 순차 요청

    const a = await fetch("/api/a");
    const b = await fetch(`/api/b?id=${(await a.json()).id}`);
    • ba의 결과(id)가 있어야 시작됨 ➡️ 연쇄(fetch A → fetch B)
    • 전체 시간 = A 지연 + B 지연 + 파싱 등 부가 시간
    • 워터폴(의존/순차 처리 때문에 대기 시간이 계단식으로 누적되는 현상) 발생
  • 좋은 예: 독립 호출은 병렬로

    const [aRes, bRes] = await Promise.all([
      fetch("/api/a", { cache: "no-store" }),
      fetch("/api/b", { cache: "no-store" }),
    ]);
    • 요청을 동시에 시작하고 함께 대기함
    • 전체 시간 = max(A 지연시간, B 지연시간)
    • 핵심: 의존성이 없는 호출은 항상 동시에 시작하라.

2. SSR이 항상 빠른 건 아님

  • 왜 느려지나? (원인)
    • TTFB 증가: 요청마다 서버가 렌더링/데이터 조회를 하므로 서버 처리 시간 + 네트워크 왕복이 초기 응답에 그대로 더해짐
    • 워터폴(fetch 연쇄): 서버 렌더 중 await A → await B처럼 순차 의존이 있으면 지연이 누적됨. 병렬화나 배칭이 안 되면 느려짐
    • 하이드레이션 비용: SSR로 HTML은 빨리 보이지만, 큰 클라이언트 번들을 다시 연결하는 Hydration이 무거우면 체감 상호작용이 늦음
  • 무엇으로 완화하나?
    • 캐시/ISR: 자주 안 바뀌는 데이터·페이지export const revalidate = N 또는 fetch(..., { next: { revalidate: N } })정적 + 재검증을 활용함
    • 태그 무효화: next: { tags: ["x"] } + revalidateTag("x")변경 시점에만 정확히 갱신 ➡️ TTFB 감소
    • 병렬화/배칭: 독립 호출Promise.all, N+1집계 API/DB JOIN/IN으로 한 번에
    • 스트리밍 + <Suspense>: 느린 섹션은 경계로 감싸 로딩 UI 먼저 보여주고, 준비되면 추가 청크로 교체
    • 데이터 로컬리티: DB를 가까운 리전/엣지에 두거나 Edge Runtime(가능한 경우)·CDN을 활용해 왕복 시간을 단축함
    • 하이드레이션 축소: 클라이언트 컴포넌트 최소화, 큰 라이브러리 분할/지연 로딩, RSC 우선 사용

3. 개인화는 서버에서, 상호작용은 최소화

  • 왜 서버에서 개인화하나?
    • 보안: 토큰·시크릿·권한 검증을 서버 컴포넌트/서버 액션에서 수행하면 노출 위험이 없음
    • 성능: 서버에서 바로 렌더해 맞춤 HTML을 보내면, 클라이언트에서 데이터 받아서 다시 그리는 과정을 줄일 수 있음
    • 캐시 제어: 요청별로 달라지는 페이지는 정적 캐시를 비활성/축소하고 필요한 부분만 동적화할 수 있음
  • 예시

    // 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 }) {
      // 상호작용이 필요한 최소 부분만 클라이언트로
    }

0개의 댓글