본문 바로가기

React

React에서 서버 사이드 렌더링(SSR)과 클라이언트 사이드 렌더링(CSR) 차이 분석

SSR와 CSR은 동일한 React 컴포넌트를 어디서 렌더링하느냐(서버 vs 브라우저)에 따라 사용자 경험, SEO, 성능 프로파일이 달라집니다. 프로젝트 특성과 트래픽 패턴에 따라 전략을 섞는 것이 실무에서 일반적입니다.

1. 개념 요약

CSR(Client-Side Rendering)은 서버가 최소한의 HTML과 번들 JS를 보내고, 브라우저가 최초 렌더와 데이터 요청, 이벤트 바인딩까지 모두 수행합니다. 초기 TTFB는 빠를 수 있으나, JS 다운로드/파싱/데이터 로딩 전까지 빈 화면이 보일 수 있습니다.

SSR(Server-Side Rendering)은 서버가 요청 시 HTML을 만들어 보내고, 브라우저는 즉시 마크업을 표시한 후 JS가 로드되면 하이드레이션으로 상호작용을 활성화합니다. 초기 인지 성능과 SEO에 유리합니다. 서버 부하와 캐시 전략이 중요합니다.

2. 사용자 경험과 성능 차이

- 초기 표시: SSR이 빠르게 첫 화면을 보여줍니다. CSR은 JS/데이터 로딩 전까지 비어 있거나 스켈레톤만 보일 수 있습니다.

- 상호작용 가능 시점: CSR은 번들 로딩 후 즉시, SSR은 하이드레이션 완료 후입니다. 컴포넌트가 무거울수록 SSR의 하이드레이션 비용이 커집니다.

- SEO: 검색엔진 크롤러는 SSR/SSG에 유리합니다. CSR만으로도 구글은 대부분 렌더링하지만, 렌더 대기시간이 길면 인덱싱이 지연될 수 있습니다.

- 서버/인프라: SSR은 요청마다 렌더링 비용이 발생합니다. 캐싱, 스트리밍, 엣지 런타임 도입으로 완화합니다. CSR은 CDN 캐시와 정적 호스팅이 단순합니다.

3. 최소 예시: CSR 구현

// index.jsx (CSR)
import React, { useEffect, useState } from 'react'
import { createRoot } from 'react-dom/client'

function App() {
  const [posts, setPosts] = useState(null)
  useEffect(() => {
    fetch('/api/posts').then(r => r.json()).then(setPosts)
  }, [])
  if (!posts) return <div>로딩중...</div>
  return (
    <ul>
      {posts.map(p => <li key={p.id}>{p.title}</li>)}
    </ul>
  )
}

createRoot(document.getElementById('root')).render(<App />)

특징: 서버는 정적 파일만 제공하고, 최초 데이터는 클라이언트에서 가져옵니다. CDN 캐싱이 쉽고 서버 부하가 낮습니다.

4. 최소 예시: Express + React SSR

// server.mjs (SSR)
import express from 'express'
import React from 'react'
import { renderToPipeableStream } from 'react-dom/server'
import App from './App.js'

const app = express()
app.use('/static', express.static('dist'))

app.get('/posts', async (req, res) => {
  const data = await fetch('https://example.com/api/posts').then(r => r.json())
  const { pipe } = renderToPipeableStream(
    <html lang="ko">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <link rel="preload" href="/static/client.js" as="script" />
        <title>게시글</title>
      </head>
      <body>
        <div id="root"><App initialData={data} /></div>
        <script>window.__INITIAL_DATA__ = ${JSON.stringify(data)};</script>
        <script src="/static/client.js" defer></script>
      </body>
    </html>,
    {
      onShellReady() { res.status(200); pipe(res) },
      onError(err) { console.error(err) }
    }
  )
})

app.listen(3000)
// App.jsx (공용)
import React, { useEffect, useState } from 'react'
export default function App({ initialData }) {
  const [posts, setPosts] = useState(initialData || null)
  useEffect(() => {
    if (!posts) { fetch('/api/posts').then(r => r.json()).then(setPosts) }
  }, [posts])
  if (!posts) return <div>로딩중...</div>
  return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>
}

특징: 서버가 HTML을 즉시 전송하고, 클라이언트는 같은 트리로 하이드레이션합니다. window.__INITIAL_DATA__로 이중 요청을 회피합니다.

5. Next.js 관점(현행 App Router)

Next.js는 기본적으로 서버 컴포넌트 기반 SSR과 스트리밍을 제공합니다. 데이터는 서버에서 fetch하여 캐시/ISR를 적용하고, 클라이언트 컴포넌트는 상호작용을 담당합니다.

// app/page.jsx (서버 컴포넌트)
export default async function Page() {
  const res = await fetch('https://example.com/api/posts', { next: { revalidate: 60 } })
  const posts = await res.json()
  return (
    <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>
  )
}

장점: 자동 스트리밍, 캐시 제어(next.revalidate), SEO 최적화, 엣지 배포 용이성입니다. 클라이언트 컴포넌트는 'use client'로 분리합니다.

6. 하이드레이션 문제와 실무 팁

- 불일치 방지: 서버와 클라이언트 렌더 결과가 달라지지 않게 브라우저 전용 API(Date, window, 랜덤 등)는 useEffect에서만 사용합니다.

- 키 안정성: 리스트 key가 서버/클라이언트에서 동일해야 합니다. 임의 랜덤 키를 피합니다.

- 시간/로케일: 날짜 포맷이 타임존에 의존하면 mismatch가 발생합니다. 서버에서 포맷하거나 통일된 포맷을 사용합니다.

- 환경 분기: typeof window 체크로 분기하고, 조건부 렌더링으로 서버에서 제외할 UI를 명확히 합니다.

7. 캐싱과 배포 전략

- SSR HTML 캐시: 트래픽이 큰 페이지는 CDN에서 HTML을 단기 캐시(stale-while-revalidate)하고 사용자별 콘텐츠는 부분만 CSR로 전환합니다.

- 데이터 캐시: API 응답에 Cache-Control을 설정하고, 서버에서 재검증 헤더(ETag)를 사용합니다.

- 스트리밍: renderToPipeableStream 또는 Next.js 스트리밍으로 TTFB를 낮추면서도 큰 트리를 점진적으로 전송합니다.

- 엣지: 퍼스널라이제이션이 적고 지연 시간이 중요한 페이지는 엣지 런타임(Next.js Edge, Cloudflare Workers)에서 SSR합니다.

8. 선택 가이드

- 즉시 가시성/SEO가 핵심(상품 상세, 블로그): SSR/SSG 우선, 상호작용 영역만 CSR로 분리합니다.

- 대시보드/내부도구: CSR 우선, 필요 영역만 부분 SSR 또는 서버 컴포넌트로 데이터 프리패치합니다.

- 빈번히 변하는 공개 리스트: SSR + 짧은 캐시/ISR 조합으로 TTFB와 신선도를 균형 있게 유지합니다.

9. 측정 지표와 디버깅

- 지표: TTFB(SSR 민감), LCP, INP, CLS, Hydration 시간. 페이지별로 분리 측정합니다.

- 도구: Chrome DevTools Performance, Lighthouse, WebPageTest, Next.js 분석(next build --profile), React Profiler를 사용합니다.

- 로그: 서버 SSR 렌더 시간, 캐시 히트율, 하이드레이션 경고를 수집합니다.

10. 마이그레이션 체크리스트

- 데이터 패칭 위치 이동: useEffect에서 서버 패칭(Next 서버 컴포넌트 또는 getServerSideProps/route handlers)으로 이동합니다.

- 링크/라우팅: SSR 라우팅은 동적 세그먼트와 캐시 정책을 명확히 설계합니다.

- 에셋 최적화: critical CSS, preconnect, preload로 초기 경로를 최적화합니다.

- 점진 도입: 상단 폴드 영역만 SSR로 전환하고 나머지는 CSR 유지하는 하이브리드 접근을 권장합니다.

정리

SSR은 초기 표시와 SEO에, CSR은 단순 배포와 상호작용 속도에 강점이 있습니다. React 생태계(특히 Next.js)는 스트리밍, 서버 컴포넌트, 캐시를 통해 두 세계의 장점을 결합하도록 발전했습니다. 실제 프로젝트에서는 페이지 성격과 트래픽, 팀 운영 비용을 기준으로 하이브리드 전략을 설계하는 것이 최적입니다.