Navigation

Rendering Strategies and Hydration

CSR과 SSR 중 무엇을 쓰느냐는 질문은 답이 둘뿐인 것처럼 들리지만 실제 선택지는 넷을 넘고 한 페이지 안에서도 섞입니다. 2022년에 개인 사이트를 만들 때는 실시간 데이터가 없다는 이유로 빌드 시점 생성을 골랐고 그 뒤 서버 렌더링과 React Query를 함께 쓰는 프로젝트에서는 같은 데이터를 서버와 브라우저가 한 번씩 받는 문제가 따라왔습니다. 지금은 방식의 이름 대신 HTML을 어디서 만들고 데이터를 언제 넣느냐로 나누고 데이터의 신선도, 검색 노출, 인증, 하이드레이션 비용 네 가지로 고릅니다.

방식의 이름보다 HTML과 데이터의 시점으로 나눕니다

web.dev의 분류를 따르면 서버 렌더링은 요청마다 서버가 HTML을 만들고 정적 렌더링은 빌드 때 URL마다 HTML 파일을 만들며 클라이언트 렌더링은 브라우저가 JavaScript로 DOM을 만듭니다. 같은 글은 서버 HTML 위에서 클라이언트 앱을 다시 실행하는 방식을 재하이드레이션이라 부르고 “앱 하나 값으로 앱 두 개”를 보낸다고 설명합니다. Rendering on the Web

이름 대신 두 시점으로 보면 선택지가 한 줄로 정리됩니다.

방식HTML을 만드는 시점데이터가 HTML에 들어가는 시점CDN 같은 공유 캐시쿠키 같은 요청별 데이터
CSR빌드 때 빈 껍데기브라우저가 요청한 뒤HTML은 가능, 데이터는 별도브라우저에서 가능
SSR요청마다 서버요청 시기본적으로 불가가능
SSG빌드 때빌드 시가능불가
ISR빌드 때와 재검증 때재검증 시가능, 오래된 페이지를 먼저 주고 뒤에서 갱신불가
스트리밍 SSR요청 시, 껍데기부터Suspense 경계마다 준비되는 대로정적 껍데기만 가능경계 안에서 가능

ISR은 Next.js가 정한 이름이라 표에 넣었습니다. 재검증 시간이 지난 뒤의 첫 요청은 여전히 캐시된 페이지를 받고 그사이 서버가 뒤에서 새 버전을 만듭니다. 재검증이 실패하면 마지막으로 성공한 데이터를 계속 내보냅니다. Incremental Static Regeneration

페이지 단위로 하나를 고르던 시절은 지났습니다. Next.js App Router의 이전 모델에서는 cookies(), headers(), searchParams 같은 요청 시점 API나 캐시하지 않은 fetch가 하나라도 있으면 라우트 전체가 요청 시점 렌더링으로 바뀌었습니다. Next.js 16에서 들어온 Cache Components는 이 판단을 컴포넌트 단위로 내립니다. 캐시한 부분과 예측 가능한 값은 정적 껍데기에 들어가고 요청 시점 API를 읽는 컴포넌트는 <Suspense> 안에서 요청 때 스트리밍됩니다. Caching and Revalidating (Previous Model), Caching

flowchart TD
    Q1{"요청마다 다른 데이터가 있는가"} -- 아니오 --> SSG["빌드 때 만든다 (SSG)"]
    Q1 -- "있지만 몇 분 늦어도 된다" --> ISR["주기나 이벤트로 다시 만든다 (ISR)"]
    Q1 -- "요청마다 달라야 한다" --> Q2{"사용자별 데이터인가"}
    Q2 -- 아니오 --> SSR["요청 때 만들고 공유 캐시에 둔다"]
    Q2 -- 예 --> Q3{"첫 HTML에 꼭 들어가야 하는가"}
    Q3 -- 예 --> Stream["껍데기는 정적으로, 사용자 부분은 Suspense 경계에서 스트리밍"]
    Q3 -- 아니오 --> CSR["껍데기만 서버에서, 사용자 부분은 브라우저에서 받는다"]

하이드레이션은 다시 그리는 일이 아니라 이벤트를 붙이는 일입니다

React 18 작업 그룹의 설명대로 하이드레이션은 서버가 보낸 HTML 위에서 컴포넌트를 렌더링하고 이벤트 핸들러를 붙이는 과정입니다. hydrateRoot는 이미 있는 HTML에 React를 붙이고 그 안의 DOM을 넘겨받습니다. 그래서 전제가 하나 붙습니다. 클라이언트 트리는 서버와 같은 출력을 만들어야 합니다. New Suspense SSR Architecture in React 18, hydrateRoot

불일치는 버그로 다룹니다. React는 일부 불일치에서 복구하지만 문서는 “가장 좋은 경우 느려지고 가장 나쁜 경우 이벤트 핸들러가 엉뚱한 요소에 붙을 수 있다”고 적습니다. 속성 차이를 고쳐 준다는 보장은 없고 텍스트 차이는 고치지 않습니다. React 19는 여러 줄로 흩어지던 경고를 서버와 클라이언트 출력의 diff가 담긴 메시지 하나로 바꿨고 브라우저 확장이 <head>와 <body>에 끼워 넣은 태그는 건너뜁니다. React v19

불일치의 원인어디서 생기는가
typeof window !== 'undefined' 분기서버와 브라우저가 다른 가지를 그립니다
Date.now(), Math.random()두 번 실행하면 값이 다릅니다
날짜와 숫자의 로케일 서식서버와 브라우저의 로케일이 다릅니다
스냅샷 없이 바뀌는 외부 데이터서버가 읽은 값과 브라우저가 읽은 값이 다릅니다
잘못된 태그 중첩브라우저가 HTML을 고쳐 파싱합니다
루트 안의 여분 공백서버 템플릿의 줄바꿈이 노드로 남습니다

값 하나가 달라질 수밖에 없다면 suppressHydrationWarning을 그 요소에만 둡니다. 한 단계 아래까지만 적용되고 텍스트를 고쳐 주지도 않습니다. 브라우저에서만 아는 값이 넓게 퍼져 있으면 두 번 렌더링하는 쪽이 정직합니다. 다만 문서가 밝히듯 컴포넌트가 두 번 그려지므로 하이드레이션은 그만큼 느려집니다.

브라우저 값은 하이드레이션이 끝난 뒤에 그립니다tsx
import { useEffect, useState } from 'react'

export function LocalTime({ iso }: { iso: string }) {
  const [isClient, setIsClient] = useState(false)
  useEffect(() => setIsClient(true), [])

  // 첫 렌더링은 서버와 같은 문자열을 내고 두 번째 렌더링에서 로케일 서식을 적용합니다
  const text = isClient ? new Date(iso).toLocaleString() : iso
  return <time dateTime={iso}>{text}</time>
}

비용은 두 가지입니다. web.dev가 말하는 중복 작업과 “로드된 것처럼 보이지만 입력에 반응하지 못하는” 구간입니다. 서버 HTML이 먼저 보이고 번들이 도착해 핸들러가 붙을 때까지 클릭은 버려집니다. React 18은 이 구간을 Suspense 경계 단위로 쪼갰습니다. 모든 JavaScript가 오기 전에 하이드레이션을 시작하고 사용자가 상호작용한 경계를 먼저 처리합니다. Next.js 문서는 각 <Suspense> 경계가 하이드레이션 단위이며 경계가 없으면 페이지 전체를 한 번에 막고 처리한다고 설명합니다. Streaming

스트리밍은 껍데기를 먼저 보내고 경계마다 채웁니다

renderToPipeableStream은 <Suspense> 바깥을 껍데기로 보고 onShellReady에서 응답을 흘려보내기 시작합니다. 경계 안의 내용은 먼저 fallback HTML로 나가고 데이터가 준비되면 완성된 HTML이 인라인 <script>와 함께 도착해 fallback을 바꿔 끼웁니다. 이 교체는 번들이 로드되기 전에도 일어납니다. renderToPipeableStream

sequenceDiagram
    participant B as 브라우저
    participant S as 서버
    B->>S: GET /post/1
    S-->>B: 껍데기 HTML, fallback, 번들 script 태그
    Note over B: 껍데기를 그리고 번들 다운로드를 시작
    S-->>B: 경계 1의 HTML과 교체 script
    Note over B: 번들 없이 fallback을 교체
    B->>B: 번들 도착, 껍데기 하이드레이션
    B->>B: 사용자가 누른 경계부터 하이드레이션
    S-->>B: 경계 2의 HTML과 교체 script

서버에서 경계 안의 컴포넌트가 던진 오류는 렌더링을 멈추지 않습니다. React는 가장 가까운 경계의 fallback을 HTML에 넣고 브라우저에서 같은 컴포넌트를 다시 시도합니다. 성공하면 사용자는 서버 오류를 모르고 서버의 onError와 브라우저의 onRecoverableError만 호출됩니다. 브라우저에서도 실패하면 가장 가까운 에러 바운더리로 올라갑니다. Suspense

스트리밍이 바꾸는 계약이 하나 있습니다. 첫 청크를 보내는 순간 상태 코드와 헤더가 확정됩니다. Next.js 문서는 스트리밍 도중의 notFound()는 404 대신 noindex meta를 끼워 넣고 redirect()는 클라이언트 리다이렉트가 된다고 설명합니다. 실제 404가 필요하면 존재 확인을 await와 <Suspense> 앞에 둡니다. 중간에서 응답을 모아 두는 계층도 봐야 합니다. Nginx 같은 리버스 프록시는 기본으로 버퍼링하므로 X-Accel-Buffering: no 헤더로 끕니다.

경계를 넣는 비용도 있습니다. LCP 요소가 경계 안에 있으면 교체 script가 실행될 때까지 그려지지 않고 fallback과 실제 내용의 크기가 다르면 레이아웃이 밀립니다. 문서는 LCP 요소를 경계 밖에 두고 fallback의 크기를 내용과 맞추라고 조언합니다.

서버가 받은 데이터를 브라우저가 다시 받지 않게 합니다

서버 렌더링에 React Query를 더하면 같은 데이터를 두 번 받는 길이 열립니다. 서버에서 받아 HTML에 넣은 값을 브라우저의 useQuery가 모른 채 다시 요청하기 때문입니다. 공식 가이드는 두 가지 연결 방법을 비교합니다. Server Rendering & Hydration

방법하는 일가이드가 적은 단점
initialData서버 값을 useQuery의 초기값으로 넘김깊은 컴포넌트까지 props로 내려야 하고 같은 key의 useQuery가 여럿이면 깨지기 쉬움. dataUpdatedAt이 페이지 로드 시각이 되고 캐시에 이미 있는 값은 더 신선해도 덮어쓰지 않음
dehydrate와 HydrationBoundary서버의 QueryClient 상태를 직렬화해 브라우저 캐시에 복원설정이 한 단계 더 필요함

하이드레이션 방식에서 지킬 규칙은 셋입니다. QueryClient는 모듈 범위가 아니라 요청마다 새로 만듭니다. 가이드는 모듈 범위의 인스턴스가 모든 요청에 공유되어 사용자 사이에 데이터가 새어 나갈 수 있다고 경고합니다. staleTime은 0보다 크게 둡니다. 기본값 0이면 복원한 데이터를 즉시 오래된 것으로 보고 페이지 로드 직후 배경에서 다시 요청합니다. 실패한 조회는 기본으로 직렬화에서 빠지므로 브라우저가 다시 시도합니다.

App Router에서 서버 컴포넌트가 미리 받고 클라이언트가 이어받습니다tsx
// app/posts/page.tsx
import { QueryClient, dehydrate, HydrationBoundary } from '@tanstack/react-query'
import { PostList } from './post-list'

export default async function Page() {
  // 요청마다 새 인스턴스. 모듈 범위에 두면 사용자 사이에 캐시가 섞입니다
  const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 60 * 1000 } } })
  await queryClient.prefetchQuery({ queryKey: ['posts'], queryFn: fetchPosts })

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <PostList />
    </HydrationBoundary>
  )
}

// app/posts/post-list.tsx
'use client'
import { useQuery } from '@tanstack/react-query'

export function PostList() {
  // 복원된 캐시를 읽습니다. staleTime 안이면 다시 요청하지 않습니다
  const { data } = useQuery({ queryKey: ['posts'], queryFn: fetchPosts })
  return <ul>{data?.map((post) => <li key={post.id}>{post.title}</li>)}</ul>
}

Advanced SSR 가이드는 서버 컴포넌트를 “미리 받는 자리, 그 이상은 아님”으로 두라고 권하고 queryFn 안에서 Server Action으로 데이터를 읽지 말라고 적습니다. v5.40.0부터는 진행 중인 조회도 직렬화할 수 있어 await 없이 스트리밍으로 넘길 수 있습니다. Advanced Server Rendering

useSuspenseQuery를 쓰면 조건이 하나 더 붙습니다. enabled, placeholderData, throwOnError가 없고 data는 항상 정의된 값이며 서버에서는 retry가 0입니다. SSR 가이드는 모든 조회를 미리 받아 두라고 말합니다. 미리 받지 않은 조회는 서버에서 실행되고도 직렬화되지 않아 브라우저가 다시 받고 그사이 마크업이 어긋나 하이드레이션 불일치가 됩니다. 한 컴포넌트 안의 Suspense 조회는 직렬로 실행되므로 병렬이 필요하면 useSuspenseQueries로 묶습니다. useSuspenseQuery, Suspense

같은 항목을 여러 화면에서 갱신하는 문제는 React Query와 Zustand에서, RTK Query의 태그 무효화는 RTK Query Re-renders에서 다뤘습니다.

검색 노출은 첫 HTML에 무엇이 있느냐로 정해집니다

Google은 크롤링, 렌더링, 색인 세 단계로 JavaScript 페이지를 처리합니다. 렌더링은 대기열에 들어가며 문서는 “몇 초일 수도 있지만 더 오래 걸릴 수 있다”고만 적습니다. 그래서 서버 렌더링이나 사전 렌더링을 권하면서 이유를 덧붙입니다. 모든 봇이 JavaScript를 실행하지는 않습니다. JavaScript SEO basics

같은 문서에서 CSR 앱이 자주 놓치는 항목을 가져왔습니다.

링크 미리보기는 더 보수적입니다. Next.js는 HTML만 읽는 봇의 목록을 두고 user agent로 감지하면 generateMetadata가 끝날 때까지 스트리밍을 미룹니다. 기본 목록에는 Bingbot, Twitterbot, Slackbot과 일부 Google 크롤러가 있고 15.2.0부터 htmlLimitedBots로 바꿀 수 있습니다. 메타데이터는 브라우저에서 넣지 않고 서버 HTML의 <head>에 둔다는 뜻입니다. htmlLimitedBots

마크업 쪽의 준비는 예전 메모 그대로입니다. 의미에 맞는 태그로 구조를 드러내고 제목과 설명 meta, Open Graph 태그를 서버 HTML에 넣고 robots.txt와 sitemap을 둡니다. 이 준비는 어떤 렌더링 방식을 고르든 같습니다. 방식이 정하는 것은 그 HTML이 봇에게 언제 보이느냐입니다.

사용자별 데이터는 공유되는 HTML에 넣지 않습니다

getStaticProps는 요청에 접근하지 못합니다. 쿼리 파라미터도 HTTP 헤더도 없고 반환한 props는 초기 HTML에 그대로 들어가 브라우저에서 보입니다. 문서는 민감한 값을 props로 넘기지 말라고 적습니다. 빌드 때 만든 페이지는 모든 사용자에게 같은 파일이기 때문입니다. getStaticProps

요청 시점 렌더링으로 사용자별 HTML을 만들더라도 캐시 헤더가 남습니다. MDN은 로그인 뒤의 응답과 쿠키로 관리하는 세션처럼 개인화된 콘텐츠에 private을 붙이라고 안내하고 빠뜨리면 공유 캐시에 저장되어 다른 사용자에게 재사용될 수 있다고 경고합니다. CDN에 둘 수 있는 것은 public이나 s-maxage를 붙인 공용 페이지뿐입니다. Cache-Control

데이터두는 곳헤더
모든 사용자에게 같은 콘텐츠빌드 때 또는 ISR로 만든 HTMLpublic, s-maxage
로그인 사용자의 이름, 장바구니<Suspense> 안에서 요청 때 스트리밍하거나 브라우저에서 조회private 또는 no-store
토큰, 세션 식별자HTML과 props에 넣지 않음쿠키의 HttpOnly

Next.js의 cookies()를 읽는 컴포넌트를 <Suspense>로 감싸면 껍데기는 정적으로 남고 사용자 부분만 요청 때 채워집니다. 세션 값을 추출해 캐시된 함수의 인자로 넘기면 그 값이 캐시 키가 되어 같은 세션끼리만 결과를 나눕니다. 어느 쪽이든 기준은 같습니다. 두 사용자가 같은 바이트를 받아도 되는지 묻고 아니면 공유 캐시 밖으로 꺼냅니다.

오류 처리도 시점에 따라 갈립니다. 서버에서 받은 데이터가 없을 때 실제 404나 리다이렉트를 원하면 스트리밍이 시작되기 전에 판단합니다. 경계 안에서 난 오류는 그 경계의 에러 바운더리가 자리를 대신하고 나머지 페이지는 그대로입니다. React Query로 미리 받다 실패한 조회는 직렬화에서 빠져 브라우저가 다시 시도하므로 서버 실패가 곧 빈 화면은 아닙니다.

개인 사이트에서는 빌드 시점 생성으로 충분했습니다

2022년에 만든 바버샵 소개 사이트는 헤어스타일 목록과 상세가 파일 기반 JSON에서 나왔고 요청마다 달라질 데이터가 없었습니다. 목적은 검색 노출이었으므로 Next.js 12의 getStaticProps와 getStaticPaths로 목록과 상세를 빌드 때 만들고 정적 파일로 내보냈습니다. 검색 봇이 받는 HTML에 콘텐츠가 이미 들어 있고 서버가 필요 없어 호스팅도 단순해졌습니다. 검색 설정 파일과 메타 컴포넌트는 공동 작업자가 맡았습니다.

같은 사이트에서 next/image를 적용한 뒤 미리보기 빌드가 실패해 일반 img 요소로 되돌린 이력이 있습니다. 당시 원인은 기록하지 않았습니다. 지금 문서는 정적 내보내기에서 기본 로더의 이미지 최적화를 지원하지 않는다고 명시하고 커스텀 로더를 쓰라고 안내합니다. 정적 내보내기는 ISR, 쿠키, 헤더, 요청에 의존하는 Route Handler도 지원하지 않습니다. 빌드 시점 생성을 고른다는 것은 이 목록을 함께 고르는 일입니다. Static Exports

지금 같은 사이트를 만든다면 선택은 같습니다. 콘텐츠가 바뀌는 빈도가 배포 빈도와 같으면 빌드 시점 생성이 가장 싸고 가장 잘 캐시됩니다. 콘텐츠만 자주 바뀐다면 ISR을, 사용자별 영역이 생긴다면 그 영역만 경계 안으로 옮기겠습니다.

확인 방법과 한계

React 쪽 설명은 react.dev의 hydrateRoot, Suspense, renderToPipeableStream 문서와 2024년 12월의 React 19 발표 글을 따랐습니다. Next.js 설명은 2026년 10월 갱신 기준의 스트리밍과 ISR 문서, 8월 갱신의 정적 내보내기 문서를 기준으로 했고 Cache Components는 16에서 플래그로 들어온 기능이라 이전 모델과 함께 적었습니다. React Query는 v5 가이드와 참조 문서를 기준으로 했습니다. web.dev의 분류 글은 2026년 1월 갱신본입니다.

국내 검색 봇이 JavaScript를 실행하는지는 확인 필요로 남깁니다. 2022년 메모에는 네이버와 다음이 렌더링 뒤의 내용을 수집하지 않는다고 적었지만 이 글을 쓰며 공식 안내를 찾지 못했습니다. 바버샵 사이트의 빌드 실패 원인도 기록이 없어 지금 문서의 제약과 일치한다는 추정만 적었습니다. 방식별 성능 수치는 측정하지 않았고 공식 문서가 설명하는 동작만 옮겼습니다.

참고 자료