이미지 로딩 중...

Vite SSR 완벽 가이드 - 서버사이드 렌더링 실전 구현 - 슬라이드 1/7
A

AI Generated

2025. 11. 25. · 6 Views

Vite SSR 완벽 가이드 - 서버사이드 렌더링 실전 구현

Vite를 활용한 서버사이드 렌더링(SSR)의 모든 것을 다룹니다. Vite SSR API부터 Express 통합, 프리렌더링, 하이드레이션 이슈 해결, 성능 최적화까지 실무에 바로 적용할 수 있는 완벽한 가이드입니다.


목차

  1. Vite_SSR_API_이해하기
  2. Express와_Vite_SSR_통합
  3. 프리렌더링_vs_동적_SSR
  4. 하이드레이션_이슈_해결
  5. SSR_성능_최적화
  6. Vite_React_Vue_SSR_실전_예제

1. Vite_SSR_API_이해하기

시작하며

여러분이 React나 Vue로 웹 애플리케이션을 만들 때, 처음 페이지를 열면 빈 화면이 잠깐 보이다가 내용이 나타나는 경험을 하신 적 있나요? 특히 느린 네트워크 환경에서는 사용자가 몇 초 동안 빈 화면만 보게 되는 상황이 발생합니다.

이런 문제는 클라이언트 사이드 렌더링(CSR)의 한계 때문에 발생합니다. 브라우저가 JavaScript를 다운로드하고, 파싱하고, 실행한 다음에야 비로소 화면을 그리기 때문입니다.

게다가 검색 엔진 최적화(SEO)에도 불리하죠. 바로 이럴 때 필요한 것이 서버사이드 렌더링(SSR)입니다.

서버에서 미리 HTML을 만들어서 보내주면, 사용자는 즉시 콘텐츠를 볼 수 있고, 검색 엔진도 내용을 잘 읽을 수 있게 됩니다. Vite는 이런 SSR을 아주 쉽게 구현할 수 있는 강력한 API를 제공합니다.

개요

간단히 말해서, Vite SSR API는 여러분의 애플리케이션을 서버에서 실행하고 HTML 문자열로 변환해주는 도구입니다. 왜 Vite SSR API가 필요한가요?

전통적인 SSR 설정은 복잡한 웹팩 설정과 바벨 트랜스파일 과정이 필요했습니다. 하지만 Vite는 esbuild와 Rollup을 기반으로 빠르고 간단한 SSR 환경을 제공합니다.

예를 들어, 대시보드나 블로그처럼 초기 로딩 속도가 중요한 서비스에서 매우 유용합니다. 기존에는 복잡한 웹팩 설정 파일을 작성하고 개발/프로덕션 환경을 따로 구성했다면, 이제는 Vite의 createServerssrLoadModule API만으로 간단히 구현할 수 있습니다.

Vite SSR API의 핵심 특징은 첫째, HMR(Hot Module Replacement)을 지원하여 개발 중에도 빠른 피드백을 받을 수 있습니다. 둘째, 프로덕션 빌드 시 자동으로 최적화된 번들을 생성합니다.

셋째, React, Vue, Svelte 등 다양한 프레임워크를 지원합니다. 이러한 특징들이 개발 생산성과 사용자 경험을 동시에 향상시켜줍니다.

코드 예제

// server.js - Vite SSR 기본 설정
import { createServer } from 'vite'

// 개발 모드에서 Vite 서버 생성
const vite = await createServer({
  server: { middlewareMode: true },
  appType: 'custom' // SSR을 위한 커스텀 모드
})

// SSR 엔트리 모듈 로드
const { render } = await vite.ssrLoadModule('/src/entry-server.js')

// HTML 렌더링
const appHtml = await render(url)
const html = `
  <!DOCTYPE html>
  <html>
    <body>
      <div id="app">${appHtml}</div>
      <script type="module" src="/src/entry-client.js"></script>
    </body>
  </html>
`

설명

이것이 하는 일: Vite SSR API는 여러분의 JavaScript 애플리케이션을 서버에서 실행 가능한 형태로 변환하고, 그 결과를 HTML 문자열로 만들어줍니다. 마치 레스토랑 주방에서 음식을 미리 만들어서 손님에게 바로 제공하는 것처럼, 서버에서 페이지를 미리 만들어서 브라우저에 전달합니다.

첫 번째로, createServer 함수는 Vite 개발 서버를 미들웨어 모드로 생성합니다. 여기서 middlewareMode: true는 Vite가 직접 HTTP 서버를 만들지 않고, Express 같은 기존 서버와 통합되도록 합니다.

appType: 'custom'은 Vite에게 "이건 일반적인 SPA가 아니라 SSR용이야"라고 알려주는 설정입니다. 이렇게 설정하면 Vite는 HTML 자동 주입을 하지 않고, 여러분이 직접 HTML을 구성할 수 있게 해줍니다.

그 다음으로, ssrLoadModule 함수가 실행되면서 서버용 엔트리 파일(entry-server.js)을 로드합니다. 이 과정에서 Vite는 파일을 즉시 트랜스파일하고, 의존성을 해결하며, 모듈을 실행 가능한 상태로 만듭니다.

일반 import와 달리, 이 함수는 브라우저가 아닌 Node.js 환경에서 프론트엔드 코드를 실행할 수 있게 변환합니다. 마지막으로, render 함수를 호출하여 현재 URL에 해당하는 페이지를 HTML 문자열로 변환합니다.

이 HTML을 템플릿에 삽입하고, 클라이언트 엔트리 스크립트를 추가하여 최종적으로 완전한 HTML 문서를 만들어냅니다. 클라이언트 스크립트는 나중에 브라우저에서 하이드레이션(hydration)을 담당합니다.

여러분이 이 코드를 사용하면 개발 중에는 HMR의 이점을 누리면서도, 프로덕션에서는 완전히 서버 렌더링된 페이지를 제공할 수 있습니다. 사용자는 JavaScript 로딩을 기다릴 필요 없이 즉시 콘텐츠를 볼 수 있고, 검색 엔진은 완전한 HTML을 크롤링할 수 있으며, 초기 로딩 성능이 크게 개선됩니다.

실전 팁

💡 개발 환경에서는 vite.ssrLoadModule을 사용하고, 프로덕션에서는 빌드된 파일을 import하세요. 개발 중에는 실시간 변환이 필요하지만, 프로덕션에서는 미리 빌드된 파일을 사용하는 것이 훨씬 빠릅니다.

💡 ssrLoadModule은 매번 새로운 모듈 인스턴스를 생성하므로, 요청마다 호출하지 말고 서버 시작 시 한 번만 로드하세요. 그렇지 않으면 메모리 누수가 발생할 수 있습니다.

💡 에러 핸들링을 반드시 추가하세요. SSR 중 에러가 발생하면 폴백으로 기본 HTML을 보내거나, CSR로 전환하는 방어 로직이 필요합니다.

💡 개발 모드에서 vite.middlewares를 Express 앱에 등록할 때는 SSR 라우트보다 먼저 등록해야 Vite의 변환 기능이 정상 작동합니다.

💡 환경 변수 import.meta.env.SSR을 활용하면 서버/클라이언트에서 다른 코드를 실행할 수 있습니다. 예를 들어, 브라우저 전용 API 호출을 조건부로 처리할 수 있습니다.


2. Express와_Vite_SSR_통합

시작하며

여러분이 이미 Express로 구축된 백엔드 API 서버가 있는데, 여기에 SSR 기능을 추가하고 싶다면 어떻게 해야 할까요? 완전히 새로운 서버를 만들어야 할까요?

아니면 기존 서버에 자연스럽게 통합할 수 있을까요? 실제 프로젝트에서는 인증, 데이터베이스 연결, API 엔드포인트 등 많은 기능이 Express에 이미 구현되어 있습니다.

이런 상황에서 SSR을 별도 서버로 분리하면 세션 관리, CORS 설정, 배포 복잡도 등 많은 문제가 생깁니다. 바로 이럴 때 필요한 것이 Express와 Vite SSR의 통합입니다.

Vite는 미들웨어 모드를 제공하여 기존 Express 앱에 SSR 기능을 쉽게 추가할 수 있게 해줍니다. 이렇게 하면 하나의 서버에서 API와 SSR을 모두 처리할 수 있습니다.

개요

간단히 말해서, Express와 Vite SSR 통합은 기존 Express 서버에 Vite를 미들웨어로 추가하여 SSR 기능을 제공하는 방식입니다. 왜 이 통합이 필요한가요?

마이크로서비스가 아닌 모놀리식 아키텍처에서는 하나의 서버가 모든 것을 처리하는 것이 관리하기 쉽습니다. 예를 들어, 사용자 인증이 필요한 대시보드 페이지를 SSR로 렌더링할 때, 같은 서버에서 세션을 확인하고 데이터를 가져와서 렌더링할 수 있습니다.

별도 서버라면 세션 공유나 내부 API 호출이 복잡해집니다. 기존에는 Express 서버와 별도로 렌더링 서버를 운영하거나, 복잡한 프록시 설정을 했다면, 이제는 Vite 미들웨어를 Express 앱에 추가하는 것만으로 SSR이 가능합니다.

핵심 특징은 첫째, 개발 환경에서 Vite의 HMR을 그대로 사용할 수 있습니다. 둘째, 프로덕션에서는 빌드된 정적 파일을 Express의 static 미들웨어로 제공합니다.

셋째, API 라우트와 SSR 라우트를 동일한 Express 앱에서 관리할 수 있어 코드 구조가 깔끔해집니다. 이러한 특징들이 개발 효율성과 배포 단순성을 모두 확보하게 해줍니다.

코드 예제

// server.js - Express + Vite SSR 통합
import express from 'express'
import { createServer as createViteServer } from 'vite'

const app = express()

// 개발 환경: Vite 미들웨어 추가
if (process.env.NODE_ENV !== 'production') {
  const vite = await createViteServer({
    server: { middlewareMode: true },
    appType: 'custom'
  })
  app.use(vite.middlewares) // Vite의 변환 미들웨어 등록
} else {
  // 프로덕션: 빌드된 정적 파일 제공
  app.use(express.static('dist/client'))
}

// API 라우트
app.get('/api/users', (req, res) => {
  res.json({ users: ['Alice', 'Bob'] })
})

// SSR 라우트 (모든 경로에 대해)
app.use('*', async (req, res) => {
  const url = req.originalUrl
  const { render } = await vite.ssrLoadModule('/src/entry-server.js')
  const appHtml = await render(url)
  const html = `<!DOCTYPE html>
    <html><body><div id="app">${appHtml}</div></body></html>`
  res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
})

app.listen(3000)

설명

이것이 하는 일: Express와 Vite SSR 통합은 하나의 서버 프로세스에서 API 엔드포인트와 서버사이드 렌더링을 모두 처리할 수 있게 해줍니다. 마치 한 건물에 식당과 주방이 함께 있어서 효율적으로 운영되는 것처럼, 모든 기능이 한 곳에 모여 있어 관리가 쉬워집니다.

첫 번째로, 환경 변수로 개발/프로덕션 모드를 구분합니다. 개발 환경에서는 createViteServer로 Vite 인스턴스를 생성하고, app.use(vite.middlewares)로 Express에 등록합니다.

이 미들웨어는 요청이 들어올 때마다 파일을 실시간으로 트랜스파일하고, HMR 웹소켓을 관리하며, 소스맵을 제공합니다. 개발 중에 파일을 수정하면 브라우저가 즉시 업데이트되는 마법이 바로 이 미들웨어 덕분입니다.

프로덕션 환경에서는 다르게 동작합니다. express.static('dist/client')로 빌드된 정적 파일(JS, CSS, 이미지 등)을 제공합니다.

이 파일들은 이미 최적화되고 압축되어 있어서 빠르게 전송됩니다. Vite 서버를 실행할 필요가 없으므로 메모리와 CPU를 절약할 수 있습니다.

API 라우트는 일반 Express 라우트와 동일하게 작성합니다. /api/users 같은 엔드포인트는 JSON을 반환하고, SSR 라우트는 HTML을 반환합니다.

중요한 점은 API 라우트를 SSR 라우트보다 먼저 정의해야 한다는 것입니다. 왜냐하면 SSR 라우트가 app.use('*')로 모든 경로를 처리하기 때문입니다.

마지막으로, SSR 라우트에서는 요청 URL을 받아서 해당 페이지를 렌더링합니다. vite.ssrLoadModule로 서버 엔트리를 로드하고, render 함수로 HTML을 생성한 뒤, 완전한 HTML 문서를 응답으로 보냅니다.

이때 Content-Typetext/html로 설정하는 것이 중요합니다. 여러분이 이 통합 방식을 사용하면 서버 관리가 단순해지고, 세션과 쿠키를 API와 SSR에서 공유할 수 있으며, 배포 시 하나의 프로세스만 관리하면 됩니다.

또한 개발 경험이 크게 향상되어 코드 변경이 즉시 반영되고, 디버깅도 쉬워집니다.

실전 팁

💡 미들웨어 순서가 매우 중요합니다. Vite 미들웨어 → API 라우트 → SSR 라우트 순으로 등록해야 정상 작동합니다. 순서가 바뀌면 API 요청이 SSR로 처리될 수 있습니다.

💡 프로덕션 빌드 시 vite buildvite build --ssr을 모두 실행해야 합니다. 첫 번째는 클라이언트 번들을, 두 번째는 서버 번들을 생성합니다. package.json 스크립트로 자동화하세요.

💡 에러 처리를 SSR 라우트에 반드시 추가하세요. 렌더링 중 에러가 발생하면 500 에러를 보내거나, 기본 HTML로 폴백하여 클라이언트에서 렌더링하도록 하세요.

💡 개발 환경에서 vite.ssrFixStacktrace(err)를 사용하면 에러 스택 트레이스가 원본 소스 코드를 가리키도록 수정되어 디버깅이 쉬워집니다.

💡 프로덕션에서는 압축 미들웨어(compression)를 추가하여 HTML 응답을 gzip으로 압축하세요. SSR로 생성된 HTML은 크기가 클 수 있어 압축이 중요합니다.


3. 프리렌더링_vs_동적_SSR

시작하며

여러분이 블로그나 문서 사이트를 만들 때, 모든 페이지를 매번 서버에서 렌더링해야 할까요? 사용자가 접속할 때마다 같은 내용을 반복해서 생성하는 것은 비효율적이지 않을까요?

이런 문제는 실제로 많은 서버 리소스를 낭비하게 만듭니다. 특히 콘텐츠가 자주 변하지 않는 페이지(블로그 포스트, 문서, 랜딩 페이지 등)는 매 요청마다 렌더링할 필요가 없습니다.

반면, 사용자별로 다른 내용을 보여줘야 하는 페이지(대시보드, 개인 설정 등)는 매번 렌더링이 필요합니다. 바로 이럴 때 필요한 것이 프리렌더링과 동적 SSR의 구분입니다.

프리렌더링은 빌드 타임에 미리 HTML을 생성해두고, 동적 SSR은 런타임에 요청마다 생성합니다. 각각의 특성을 이해하고 적절히 선택하면 성능과 비용을 크게 최적화할 수 있습니다.

개요

간단히 말해서, 프리렌더링은 빌드할 때 미리 HTML 파일을 생성해두는 방식이고, 동적 SSR은 사용자 요청이 올 때마다 실시간으로 HTML을 생성하는 방식입니다. 왜 이 구분이 필요한가요?

웹사이트의 모든 페이지가 같은 방식으로 처리될 필요는 없습니다. 예를 들어, 회사 소개 페이지나 블로그 포스트는 모든 사용자에게 동일한 내용을 보여주므로 프리렌더링이 적합합니다.

반면, 사용자의 장바구니나 알림 목록은 개인마다 다르므로 동적 SSR이 필요합니다. 적절한 방식을 선택하면 서버 부하를 줄이고 응답 속도를 높일 수 있습니다.

기존에는 모든 페이지를 동적 SSR로 처리하거나, 모든 것을 정적 사이트로 만들어야 했다면, 이제는 페이지별로 다른 전략을 선택할 수 있습니다. 핵심 특징은 첫째, 프리렌더링은 서버 부하가 없고 CDN으로 배포 가능하여 매우 빠릅니다.

둘째, 동적 SSR은 실시간 데이터를 반영할 수 있어 개인화된 콘텐츠 제공이 가능합니다. 셋째, 하이브리드 방식으로 페이지마다 다른 전략을 적용할 수 있습니다.

이러한 특징들이 최적의 성능과 사용자 경험을 동시에 제공하게 해줍니다.

코드 예제

// prerender.js - 프리렌더링 스크립트
import fs from 'fs'
import path from 'path'
import { render } from './dist/server/entry-server.js'

// 프리렌더링할 경로 목록
const routes = ['/', '/about', '/blog/post-1', '/blog/post-2']

// 각 경로를 HTML로 렌더링
for (const route of routes) {
  const appHtml = await render(route)
  const html = `<!DOCTYPE html>
    <html><body><div id="app">${appHtml}</div>
      <script type="module" src="/assets/client.js"></script>
    </body></html>`

  // 파일로 저장
  const filePath = path.resolve(`dist/client${route}/index.html`)
  fs.mkdirSync(path.dirname(filePath), { recursive: true })
  fs.writeFileSync(filePath, html)
  console.log(`Prerendered: ${route}`)
}

// server.js - 하이브리드 전략
app.get('/dashboard/*', async (req, res) => {
  // 동적 SSR: 매번 렌더링
  const html = await render(req.originalUrl, req.user)
  res.send(html)
})

설명

이것이 하는 일: 프리렌더링과 동적 SSR은 HTML 생성 시점이 다릅니다. 프리렌더링은 마치 책을 미리 인쇄해서 서점에 배치하는 것처럼 빌드 타임에 HTML을 생성하고, 동적 SSR은 주문이 들어올 때마다 즉석에서 요리하는 것처럼 런타임에 생성합니다.

첫 번째로, 프리렌더링 스크립트는 렌더링할 경로 목록을 정의합니다. 이 목록은 여러분의 사이트맵이나 CMS에서 가져올 수 있습니다.

각 경로에 대해 render 함수를 호출하여 HTML을 생성하고, 이를 파일 시스템에 저장합니다. 예를 들어, /blog/post-1 경로는 dist/client/blog/post-1/index.html 파일로 저장됩니다.

이 과정은 배포 전에 한 번만 실행되며, CI/CD 파이프라인의 빌드 단계에 포함시킵니다. 프리렌더링된 파일은 정적 파일이므로 Nginx나 CDN으로 직접 제공할 수 있습니다.

요청이 오면 파일 시스템에서 HTML을 읽어서 보내기만 하면 되므로 응답 시간이 밀리초 단위로 매우 빠릅니다. 또한 서버 CPU와 메모리를 전혀 사용하지 않아 무한대로 확장 가능합니다.

반면, 동적 SSR은 요청이 올 때마다 실행됩니다. 위 코드에서 /dashboard/* 경로는 사용자별로 다른 내용을 보여줘야 하므로 동적 SSR을 사용합니다.

req.user 같은 요청 컨텍스트를 render 함수에 전달하여 개인화된 HTML을 생성합니다. 이 방식은 실시간 데이터를 반영할 수 있지만, 서버 리소스를 사용하고 응답 시간이 프리렌더링보다 느립니다.

하이브리드 전략은 두 방식을 결합합니다. 정적 콘텐츠는 프리렌더링으로 CDN에서 제공하고, 동적 콘텐츠는 서버의 SSR로 처리합니다.

예를 들어, 블로그 포스트는 프리렌더링하고, 댓글 섹션은 클라이언트에서 동적으로 로드하는 방식입니다. 이렇게 하면 초기 로딩은 빠르면서도 실시간 데이터를 보여줄 수 있습니다.

여러분이 이 전략들을 적절히 사용하면 서버 비용을 크게 절감하면서도 우수한 사용자 경험을 제공할 수 있습니다. 프리렌더링으로 95%의 트래픽을 CDN에서 처리하고, 나머지 5%만 동적 SSR로 처리하는 것이 일반적인 최적 구조입니다.

실전 팁

💡 프리렌더링할 경로가 많다면 병렬 처리를 고려하세요. Promise.all을 사용하여 여러 경로를 동시에 렌더링하면 빌드 시간을 크게 단축할 수 있습니다.

💡 동적 데이터가 포함된 페이지는 ISR(Incremental Static Regeneration) 패턴을 고려하세요. 프리렌더링된 페이지를 제공하되, 백그라운드에서 주기적으로 재생성하여 최신 데이터를 반영합니다.

💡 프리렌더링된 페이지에는 Cache-Control 헤더를 설정하여 브라우저와 CDN 캐싱을 활용하세요. max-age=31536000, immutable로 설정하면 1년간 캐싱됩니다.

💡 동적 SSR 페이지에도 짧은 캐싱(예: 10초)을 적용하면 동일한 요청이 반복될 때 서버 부하를 줄일 수 있습니다. 단, 사용자별 데이터는 캐싱하지 마세요.

💡 프리렌더링 시 에러가 발생한 경로는 로그로 남기고 빌드를 중단하지 말고 계속 진행하세요. 일부 페이지 실패로 전체 배포가 막히는 것을 방지할 수 있습니다.


4. 하이드레이션_이슈_해결

시작하며

여러분이 SSR로 렌더링된 페이지를 열었는데, 콘솔에 "Hydration failed" 에러가 나타나거나, 버튼을 클릭해도 아무 반응이 없는 경험을 하신 적 있나요? 화면에는 내용이 보이는데 상호작용이 안 되는 이상한 상황이 발생합니다.

이런 문제는 SSR의 가장 까다로운 부분인 하이드레이션(hydration) 과정에서 발생합니다. 서버에서 생성한 HTML과 클라이언트에서 생성한 HTML이 일치하지 않으면 React나 Vue가 혼란스러워하며 에러를 발생시킵니다.

타임스탬프, 랜덤 값, 브라우저 전용 API 등이 주요 원인입니다. 바로 이럴 때 필요한 것이 하이드레이션 이슈 해결 전략입니다.

서버와 클라이언트에서 동일한 결과를 생성하도록 코드를 작성하고, 불가피한 차이는 클라이언트에서만 렌더링하며, 적절한 에러 핸들링을 추가하면 대부분의 문제를 해결할 수 있습니다.

개요

간단히 말해서, 하이드레이션은 서버에서 생성한 정적 HTML에 JavaScript 이벤트 리스너와 상태를 연결하여 상호작용 가능한 앱으로 만드는 과정입니다. 왜 하이드레이션 이슈가 발생하나요?

서버와 브라우저는 다른 환경입니다. 서버에는 window, document, localStorage 같은 브라우저 API가 없고, 시간대나 로케일도 다를 수 있습니다.

예를 들어, 현재 시간을 표시하는 컴포넌트는 서버에서 렌더링할 때와 클라이언트에서 하이드레이션할 때 다른 시간을 생성할 수 있습니다. React는 이 불일치를 감지하고 경고하거나 에러를 발생시킵니다.

기존에는 하이드레이션 에러가 발생하면 원인을 찾기 어려웠고, 임시방편으로 suppressHydrationWarning을 남용했다면, 이제는 체계적인 패턴으로 문제를 예방하고 해결할 수 있습니다. 핵심 특징은 첫째, 클라이언트 전용 렌더링으로 브라우저 API를 안전하게 사용할 수 있습니다.

둘째, 동일한 초기 상태를 서버와 클라이언트에 전달하여 일관성을 보장합니다. 셋째, 조건부 렌더링으로 환경별 차이를 처리합니다.

이러한 특징들이 안정적인 SSR 애플리케이션을 만들게 해줍니다.

코드 예제

// ClientOnly.jsx - 클라이언트 전용 컴포넌트
import { useState, useEffect } from 'react'

export function ClientOnly({ children }) {
  const [mounted, setMounted] = useState(false)

  useEffect(() => {
    setMounted(true) // 클라이언트에서만 true
  }, [])

  // 서버에서는 null 반환, 클라이언트에서만 children 렌더링
  return mounted ? children : null
}

// App.jsx - 하이드레이션 안전한 사용
function CurrentTime() {
  // 서버에서는 렌더링하지 않음
  return (
    <ClientOnly>
      <span>{new Date().toLocaleTimeString()}</span>
    </ClientOnly>
  )
}

// entry-server.js - 초기 상태 주입
const initialState = { user: await fetchUser() }
const appHtml = await renderToString(<App {...initialState} />)
const html = `
  <div id="app">${appHtml}</div>
  <script>window.__INITIAL_STATE__ = ${JSON.stringify(initialState)}</script>
`

// entry-client.js - 초기 상태 사용
hydrateRoot(document.getElementById('app'),
  <App {...window.__INITIAL_STATE__} />)

설명

이것이 하는 일: 하이드레이션 이슈 해결 패턴은 서버와 클라이언트에서 동일한 HTML을 생성하도록 보장하거나, 차이가 불가피한 부분은 클라이언트에서만 렌더링하도록 만듭니다. 마치 퍼즐의 두 조각이 정확히 맞아떨어지도록 하는 것처럼, 서버와 클라이언트의 출력을 일치시킵니다.

첫 번째로, ClientOnly 컴포넌트는 하이드레이션 이슈의 만능 해결책입니다. useStateuseEffect를 조합하여 서버 렌더링 시에는 null을 반환하고, 클라이언트에서만 실제 컨텐츠를 렌더링합니다.

useEffect는 브라우저에서만 실행되므로, mounted 상태는 서버에서는 절대 true가 되지 않습니다. 이렇게 하면 window.localStorage, navigator.geolocation 같은 브라우저 전용 API를 안전하게 사용할 수 있습니다.

CurrentTime 컴포넌트에서 볼 수 있듯이, 시간이나 랜덤 값처럼 매번 다른 결과를 생성하는 코드는 ClientOnly로 감싸야 합니다. 서버에서는 이 부분이 빈 공간으로 렌더링되고, 클라이언트에서 하이드레이션이 완료된 후에 실제 값이 채워집니다.

사용자는 잠깐의 깜빡임을 볼 수 있지만, 하이드레이션 에러는 발생하지 않습니다. 더 나은 방법은 초기 상태를 서버에서 생성하여 클라이언트에 전달하는 것입니다.

서버에서 데이터를 fetch하고, 이를 JSON으로 직렬화하여 HTML에 <script> 태그로 포함시킵니다. window.__INITIAL_STATE__ 같은 전역 변수에 저장하면 클라이언트에서 접근할 수 있습니다.

클라이언트는 이 상태를 사용하여 앱을 하이드레이션하므로, 서버와 동일한 결과를 생성하게 됩니다. JSON 직렬화 시 주의할 점은 Date, Map, Set, 함수 등은 직렬화되지 않는다는 것입니다.

이런 타입은 직렬화 가능한 형태(ISO 문자열, 배열, 객체 등)로 변환해야 합니다. 또한 XSS 공격을 방지하기 위해 사용자 입력이 포함된 상태는 반드시 이스케이프해야 합니다.

여러분이 이 패턴들을 사용하면 하이드레이션 에러 없이 안정적인 SSR 앱을 만들 수 있습니다. 사용자는 빠른 초기 로딩과 완벽한 상호작용을 모두 경험하게 되며, 개발자는 디버깅 시간을 크게 줄일 수 있습니다.

실전 팁

💡 React 18의 useId 훅을 사용하면 서버와 클라이언트에서 동일한 ID를 생성할 수 있어 하이드레이션 이슈를 방지할 수 있습니다. 랜덤 ID 생성 대신 이 훅을 사용하세요.

💡 CSS-in-JS 라이브러리 사용 시 클래스 이름이 서버와 클라이언트에서 다르게 생성될 수 있습니다. styled-components나 emotion의 SSR 설정을 반드시 적용하세요.

💡 개발 모드에서 React는 하이드레이션 불일치를 자세히 알려줍니다. 콘솔 에러 메시지를 무시하지 말고, 근본 원인을 찾아 수정하세요. suppressHydrationWarning은 최후의 수단입니다.

💡 서드파티 라이브러리가 하이드레이션 이슈를 일으킬 수 있습니다. 특히 광고 스크립트나 분석 도구는 ClientOnly로 감싸서 클라이언트에서만 로드하세요.

💡 하이드레이션 성능을 측정하려면 브라우저의 Performance 탭에서 "Hydration" 마커를 확인하세요. 시간이 오래 걸린다면 컴포넌트 트리를 최적화할 필요가 있습니다.


5. SSR_성능_최적화

시작하며

여러분이 SSR을 구현했는데, 서버 CPU 사용률이 80%를 넘고 응답 시간이 2초나 걸린다면 어떻게 해야 할까요? SSR은 사용자 경험을 개선하기 위한 것인데, 오히려 서버가 느려져서 사용자를 더 기다리게 만들 수 있습니다.

이런 문제는 대규모 트래픽에서 특히 심각합니다. 매 요청마다 React 컴포넌트 트리를 실행하고, 데이터를 fetch하며, HTML을 생성하는 작업은 생각보다 무겁습니다.

서버가 감당할 수 없을 정도로 요청이 몰리면 응답 시간이 늘어나거나 서버가 다운될 수 있습니다. 바로 이럴 때 필요한 것이 SSR 성능 최적화입니다.

스트리밍 SSR로 초기 바이트 시간을 줄이고, 캐싱으로 중복 렌더링을 방지하며, 컴포넌트 레벨 최적화로 렌더링 비용을 낮출 수 있습니다. 이런 기법들을 적용하면 서버 리소스를 절반으로 줄이면서도 더 빠른 응답을 제공할 수 있습니다.

개요

간단히 말해서, SSR 성능 최적화는 서버에서 HTML을 생성하는 속도를 높이고, 서버 리소스 사용을 줄이며, 사용자가 콘텐츠를 더 빨리 볼 수 있게 만드는 기술들의 집합입니다. 왜 이 최적화가 필요한가요?

SSR은 기본적으로 CPU 집약적인 작업입니다. 특히 대시보드처럼 복잡한 UI를 렌더링하거나, 수백 개의 상품 목록을 표시할 때 렌더링 시간이 급격히 증가합니다.

예를 들어, 초당 100개의 요청이 들어오고 각 요청이 500ms씩 걸린다면, 최소 50개의 CPU 코어가 필요합니다. 최적화 없이는 서버 비용이 감당하기 어려워집니다.

기존에는 모든 것을 렌더링한 후에 한 번에 HTML을 보냈다면, 이제는 스트리밍으로 조각조각 보내면서 사용자가 더 빨리 콘텐츠를 볼 수 있게 합니다. 핵심 특징은 첫째, 스트리밍 SSR로 Time to First Byte(TTFB)를 크게 줄일 수 있습니다.

둘째, Redis 같은 캐시로 동일한 페이지를 여러 번 렌더링하지 않습니다. 셋째, React.memo와 Suspense로 불필요한 렌더링을 최소화합니다.

이러한 특징들이 사용자 경험과 서버 효율성을 동시에 향상시킵니다.

코드 예제

// entry-server.js - 스트리밍 SSR
import { renderToPipeableStream } from 'react-dom/server'

export function render(url, res) {
  const { pipe } = renderToPipeableStream(<App url={url} />, {
    onShellReady() {
      // 초기 HTML 쉘을 즉시 전송
      res.setHeader('Content-Type', 'text/html')
      pipe(res)
    },
    onError(error) {
      console.error('SSR error:', error)
      res.statusCode = 500
    }
  })
}

// server.js - Redis 캐싱
import Redis from 'ioredis'
const redis = new Redis()

app.get('*', async (req, res) => {
  const cacheKey = `page:${req.path}`

  // 캐시 확인
  const cached = await redis.get(cacheKey)
  if (cached) {
    return res.send(cached)
  }

  // 렌더링 후 캐싱 (10분 TTL)
  const html = await render(req.originalUrl)
  await redis.setex(cacheKey, 600, html)
  res.send(html)
})

// Component.jsx - React.memo로 최적화
export const ProductCard = React.memo(({ product }) => {
  return <div>{product.name}: ${product.price}</div>
})

설명

이것이 하는 일: SSR 성능 최적화는 렌더링 파이프라인의 각 단계를 개선하여 전체 응답 시간을 단축하고 서버 리소스를 효율적으로 사용합니다. 마치 공장의 생산 라인을 최적화하여 제품을 더 빨리, 더 적은 비용으로 만드는 것과 같습니다.

첫 번째로, 스트리밍 SSR은 전통적인 renderToString 대신 renderToPipeableStream을 사용합니다. 가장 큰 차이는 onShellReady 콜백입니다.

이 콜백은 앱의 "쉘"(레이아웃, 헤더, 내비게이션 등)이 렌더링되는 즉시 호출되어, 사용자에게 초기 HTML을 전송합니다. 나머지 콘텐츠(데이터 로딩이 필요한 부분)는 준비되는 대로 스트리밍됩니다.

사용자는 몇 십 밀리초 만에 페이지 구조를 보고, 콘텐츠가 점진적으로 채워지는 것을 경험합니다. TTFB가 2초에서 200ms로 줄어들 수 있습니다.

두 번째로, Redis 캐싱은 가장 효과적인 최적화입니다. 동일한 URL에 대한 요청이 들어오면, 렌더링을 다시 하지 않고 캐시된 HTML을 즉시 반환합니다.

이 방식은 응답 시간을 500ms에서 5ms로 줄입니다. TTL(Time To Live)을 설정하여 캐시가 일정 시간 후 자동으로 만료되게 하면, 최신 데이터를 반영하면서도 대부분의 요청을 캐시로 처리할 수 있습니다.

단, 사용자별 개인화 콘텐츠는 캐싱하면 안 됩니다. 세 번째로, React.memo는 컴포넌트가 같은 props를 받으면 재렌더링을 건너뜁니다.

특히 리스트 렌더링에서 효과적입니다. 100개의 상품 카드가 있을 때, 하나의 상품만 변경되어도 전체가 재렌더링되는 것을 방지합니다.

SSR에서는 렌더링 비용이 클라이언트보다 높으므로, 이런 최적화가 더욱 중요합니다. 추가로, 데이터 fetch를 병렬화하면 렌더링 시간을 크게 줄일 수 있습니다.

여러 API 호출이 필요하다면 Promise.all로 동시에 실행하세요. 직렬로 fetch하면 각각 200ms씩 총 600ms가 걸리지만, 병렬로 하면 200ms만 걸립니다.

또한 React Server Components를 사용하면 컴포넌트 레벨에서 데이터 fetch를 최적화할 수 있습니다. 여러분이 이런 최적화를 적용하면 서버 하나로 처리할 수 있는 트래픽이 10배 이상 증가할 수 있습니다.

사용자는 더 빠른 페이지를 경험하고, 회사는 서버 비용을 절감하며, 개발자는 안정적인 시스템을 운영할 수 있습니다.

실전 팁

💡 스트리밍 SSR을 사용할 때는 <Suspense> 경계를 전략적으로 배치하세요. 중요한 콘텐츠(above the fold)는 쉘에 포함하고, 느린 데이터는 Suspense로 감싸서 나중에 스트리밍하세요.

💡 캐시 키를 설계할 때 URL뿐만 아니라 쿠키, 헤더(언어, 기기 타입 등)도 고려하세요. 모바일과 데스크톱 버전이 다르다면 별도로 캐싱해야 합니다.

💡 Node.js 클러스터 모드를 사용하여 모든 CPU 코어를 활용하세요. pm2node:cluster 모듈로 프로세스를 CPU 코어 수만큼 실행하면 처리량이 코어 수에 비례하여 증가합니다.

💡 렌더링 시간을 측정하고 모니터링하세요. New Relic이나 Datadog 같은 APM 도구로 느린 컴포넌트를 찾아 최적화하세요. 평균 렌더링 시간이 100ms를 넘으면 개선이 필요합니다.

💡 CDN에서 SSR 결과를 캐싱하려면 적절한 Cache-Control 헤더를 설정하세요. s-maxage=60으로 CDN에서 60초간 캐싱하되, 브라우저는 항상 최신 버전을 확인하도록 할 수 있습니다.


6. Vite_React_Vue_SSR_실전_예제

시작하며

여러분이 지금까지 배운 Vite SSR의 모든 개념을 실제 프로젝트에 어떻게 적용해야 할지 막막하신가요? 이론은 이해했지만 실제 코드를 어떻게 구성해야 할지, 어떤 파일이 필요한지, 빌드 설정은 어떻게 해야 할지 고민되실 겁니다.

실제 프로젝트에서는 단순히 SSR만 구현하는 것이 아니라, 라우팅, 상태 관리, 스타일링, 빌드 최적화 등 모든 것이 함께 작동해야 합니다. 특히 React와 Vue는 SSR 구현 방식이 다르므로, 각 프레임워크에 맞는 패턴을 알아야 합니다.

바로 이럴 때 필요한 것이 완전한 실전 예제입니다. 프로젝트 구조부터 설정 파일, 엔트리 포인트, 서버 코드, 빌드 스크립트까지 모든 것을 포함한 예제를 보면 실제로 구현할 때 많은 시행착오를 줄일 수 있습니다.

React와 Vue 두 가지 예제를 모두 제공하여 여러분의 프레임워크 선택에 맞게 적용할 수 있습니다.

개요

간단히 말해서, 이 섹션은 Vite + React 및 Vite + Vue로 SSR을 구현하는 완전한 예제 프로젝트를 제공합니다. 실무에서 바로 사용할 수 있는 수준의 코드입니다.

왜 이런 실전 예제가 필요한가요? 공식 문서는 개별 API를 설명하지만, 전체 시스템이 어떻게 조합되는지는 보여주지 않습니다.

예를 들어, React Router와 SSR을 함께 사용할 때 서버에서 어떻게 라우팅을 처리하고, 클라이언트에서 하이드레이션할 때 어떻게 같은 라우트를 매칭하는지는 직접 구현해봐야 알 수 있습니다. 이런 실전 지식을 예제로 제공합니다.

기존에는 여러 블로그와 문서를 뒤지며 조각조각 정보를 모아야 했다면, 이제는 하나의 완전한 예제로 전체 구조를 이해할 수 있습니다. 핵심 특징은 첫째, 개발과 프로덕션 환경 모두를 포함한 완전한 설정입니다.

둘째, React Router / Vue Router와의 통합 방법을 보여줍니다. 셋째, 빌드 스크립트와 배포 준비까지 포함되어 있습니다.

이러한 특징들이 여러분이 즉시 실무에 적용할 수 있게 해줍니다.

코드 예제

// vite.config.js - SSR 빌드 설정
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  build: {
    manifest: true,
    rollupOptions: {
      input: '/src/entry-client.jsx'
    }
  }
})

// src/entry-server.jsx - React SSR 엔트리
import { renderToString } from 'react-dom/server'
import { StaticRouter } from 'react-router-dom/server'
import App from './App'

export function render(url) {
  const html = renderToString(
    <StaticRouter location={url}>
      <App />
    </StaticRouter>
  )
  return html
}

// src/entry-client.jsx - 클라이언트 엔트리
import { hydrateRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'

hydrateRoot(
  document.getElementById('app'),
  <BrowserRouter>
    <App />
  </BrowserRouter>
)

// package.json - 빌드 스크립트
{
  "scripts": {
    "dev": "node server",
    "build": "npm run build:client && npm run build:server",
    "build:client": "vite build --outDir dist/client",
    "build:server": "vite build --ssr src/entry-server.jsx --outDir dist/server"
  }
}

설명

이것이 하는 일: 이 예제는 Vite SSR 프로젝트의 전체 구조를 보여줍니다. 마치 집을 짓는 설계도처럼, 어떤 파일이 어디에 위치하고 어떻게 연결되는지 한눈에 볼 수 있습니다.

실제로 작동하는 완전한 프로젝트이므로 그대로 사용하거나 필요에 맞게 수정할 수 있습니다. 첫 번째로, vite.config.js는 Vite의 빌드 설정을 정의합니다.

manifest: true는 빌드된 파일들의 매핑 정보를 JSON 파일로 생성합니다. 이것은 프로덕션에서 해시된 파일명을 찾을 때 사용됩니다.

rollupOptions.input은 클라이언트 엔트리 포인트를 지정합니다. 서버 빌드는 별도 명령어(--ssr)로 실행하므로 여기서는 클라이언트만 설정합니다.

두 번째로, entry-server.jsx는 서버에서 실행되는 엔트리 포인트입니다. StaticRouter는 React Router의 서버용 라우터로, URL을 받아서 매칭되는 컴포넌트를 렌더링합니다.

브라우저의 히스토리 API가 없는 서버 환경에서도 라우팅이 작동하도록 합니다. renderToString은 React 컴포넌트 트리를 HTML 문자열로 변환합니다.

이 함수는 서버의 Express 라우트에서 호출됩니다. 세 번째로, entry-client.jsx는 브라우저에서 실행되는 엔트리 포인트입니다.

hydrateRoot는 서버에서 렌더링된 HTML에 이벤트 리스너와 상태를 연결합니다. BrowserRouter는 브라우저의 History API를 사용하는 클라이언트용 라우터입니다.

서버와 클라이언트가 같은 <App /> 컴포넌트를 사용하지만, 라우터만 다릅니다. 이것이 SSR의 핵심 패턴입니다.

네 번째로, package.json의 빌드 스크립트는 두 번의 빌드를 실행합니다. build:client는 브라우저에서 실행될 JavaScript 번들을 생성하고, build:server는 Node.js에서 실행될 서버 번들을 생성합니다.

--outDir로 출력 디렉토리를 분리하여 클라이언트와 서버 파일이 섞이지 않게 합니다. 프로덕션 배포 시에는 dist/client를 정적 파일로 제공하고, dist/server/entry-server.js를 서버에서 import합니다.

Vue를 사용한다면 거의 동일한 구조를 사용하되, react-router-dom 대신 vue-router를 사용하고, renderToString@vue/server-renderer에서 import합니다. createSSRApp으로 SSR용 Vue 앱을 생성하고, app.use(router)로 라우터를 등록합니다.

나머지 구조는 React와 거의 같습니다. 여러분이 이 예제를 기반으로 시작하면 SSR 프로젝트를 몇 시간 만에 구축할 수 있습니다.

라우팅, 코드 스플리팅, HMR 등 모든 것이 자동으로 작동하며, 프로덕션 배포도 간단합니다. 이제 여러분만의 컴포넌트와 비즈니스 로직을 추가하기만 하면 됩니다.

실전 팁

💡 프로젝트 시작 시 Vite의 공식 SSR 템플릿을 사용하세요. npm create vite@latest my-app -- --template react-ssr로 기본 구조를 생성한 후 커스터마이징하면 더 빠릅니다.

💡 라우트별로 코드 스플리팅을 적용하려면 React.lazy<Suspense>를 사용하세요. 서버에서는 모든 lazy 컴포넌트가 즉시 렌더링되고, 클라이언트에서는 필요할 때만 로드됩니다.

💡 메타 태그(title, description 등)를 동적으로 설정하려면 react-helmet-async@vueuse/head 같은 라이브러리를 사용하세요. SSR에서도 정상 작동하도록 설계되어 있습니다.

💡 환경 변수를 사용할 때는 import.meta.env를 사용하고, 서버 전용 변수는 클라이언트 번들에 포함되지 않도록 주의하세요. Vite는 VITE_ 접두사가 있는 변수만 클라이언트에 노출합니다.

💡 실전 배포 전에 Lighthouse로 성능을 측정하세요. SSR의 목표는 FCP(First Contentful Paint)와 LCP(Largest Contentful Paint)를 개선하는 것입니다. 각각 1.8초 이하를 목표로 하세요.


#Vite#SSR#ServerSideRendering#Express#Hydration#Vite,빌드도구,프론트엔드,SSR

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.

함께 보면 좋은 카드 뉴스

WebSocket과 Server-Sent Events 실시간 통신 완벽 가이드

웹 애플리케이션에서 실시간 데이터 통신을 구현하는 핵심 기술인 WebSocket과 Server-Sent Events를 다룹니다. 채팅, 알림, 실시간 업데이트 등 현대 웹 서비스의 필수 기능을 구현하는 방법을 배워봅니다.

API 테스트 전략과 자동화 완벽 가이드

API 개발에서 필수적인 테스트 전략을 단계별로 알아봅니다. 단위 테스트부터 부하 테스트까지, 실무에서 바로 적용할 수 있는 자동화 기법을 익혀보세요.

효과적인 API 문서 작성법 완벽 가이드

API 문서는 개발자와 개발자 사이의 가장 중요한 소통 수단입니다. 이 가이드에서는 좋은 API 문서가 갖춰야 할 조건부터 Getting Started, 엔드포인트 설명, 에러 코드 문서화, 인증 가이드, 변경 이력 관리까지 체계적으로 배워봅니다.

API 캐싱과 성능 최적화 완벽 가이드

웹 서비스의 응답 속도를 획기적으로 개선하는 캐싱 전략과 성능 최적화 기법을 다룹니다. HTTP 캐싱부터 Redis, 데이터베이스 최적화, CDN까지 실무에서 바로 적용할 수 있는 핵심 기술을 초급자 눈높이에서 설명합니다.

OAuth 2.0과 소셜 로그인 완벽 가이드

OAuth 2.0의 핵심 개념부터 구글, 카카오 소셜 로그인 구현까지 초급 개발자를 위해 쉽게 설명합니다. 인증과 인가의 차이점, 다양한 Flow의 특징, 그리고 보안 고려사항까지 실무에 바로 적용할 수 있는 내용을 다룹니다.