본문으로 건너뛰기
김신건의 로그

[Frontend] SPA Architecture: CSR, SSR, SSG, hydration

· 수정 · 📖 약 4분 · 1,338자/단어 #frontend #javascript #spa #csr #ssr #hydration #react #vue
SPA Architecture, SPA, Single Page Application, CSR, SSR, SSG, ISR, Hydration, Islands Architecture, client-side routing, 스파, 단일 페이지 앱

정의

SPA (Single Page Application) 는 첫 로딩 시 받은 HTML / JS 번들 하나로 모든 화면 전환을 클라이언트에서 처리하는 웹 앱 구조. URL 변경, 데이터 fetch, 화면 갱신 모두 JavaScript 가 담당. 전체 페이지 reload 없음.

기존 MPA (Multi Page App, 페이지마다 서버가 HTML 반환) 와 대비. React / Vue / Angular / Svelte 가 대표 framework.

이 페이지는 SPA 의 렌더링 전략 (CSR / SSR / SSG / ISR) 과 hydration 같은 핵심 메커니즘을 다룬다.

시각화

세 모드의 server / browser / user 시간순 흐름을 비교. CSR 은 빈 HTML + JS 후속, SSR 은 매 요청 서버 렌더링, SSG 은 빌드 시점 미리 생성.

4가지 렌더링 모드

모드HTML 생성 시점장점단점
CSR (Client-Side Rendering)브라우저에서 JS 실행단순 배포, 무거운 인터랙션 자연스러움초기 로딩 느림, SEO 어려움, FCP / LCP 나쁨
SSR (Server-Side Rendering)매 요청마다 서버에서SEO / FCP 좋음, 동적 콘텐츠서버 부하, TTFB 증가, hydration 비용
SSG (Static Site Generation)빌드 시점에 미리가장 빠름 (CDN 캐싱), 안전빌드 시간, 정적 내용만
ISR (Incremental Static Regeneration)빌드 + 주기적 재생성SSG 장점 + 일부 동적복잡, 캐시 무효화 어려움

대표 framework:

  • CSR: Create React App (legacy), Vite + React
  • SSR: Next.js, Nuxt, Remix, SvelteKit
  • SSG: Astro, Eleventy, Hugo, Gatsby (전통적)
  • ISR: Next.js, SvelteKit (revalidate 옵션)

대부분 모던 framework 는 하이브리드: 페이지마다 다른 모드 선택 가능.

CSR 흐름

요청        ← 빈 HTML + JS bundle (~몇 MB)
JS 실행     ← React/Vue 가 DOM 구축
API fetch   ← 데이터 로딩
화면 표시   ← 사용자에게 최종 UI

index.html 은 보통:

<!DOCTYPE html>
<html>
<head><title>App</title></head>
<body>
  <div id="root"></div>
  <script src="/bundle.js"></script>
</body>
</html>

#root 는 비어 있다. JS 가 React tree 렌더링.

문제: 첫 페인트 = JS 다운로드 + 파싱 + 실행 + API + render. 모바일 네트워크에서 10초+ 가능. SEO 봇은 빈 HTML 만 보고 떠남 (Google 은 부분 지원).

SSR 흐름

요청        → 서버가 React tree 를 HTML 로 렌더링
            ← 완성된 HTML + JS bundle
브라우저    ← HTML 표시 (즉시), JS 다운로드
hydration   ← JS 가 서버 DOM 에 이벤트 리스너 부착
완성        ← 인터랙티브

Next.js 예:

// pages/posts/[id].tsx
export async function getServerSideProps({ params }) {
    const post = await db.posts.findById(params.id);
    return { props: { post } };
}

export default function PostPage({ post }) {
    return <article><h1>{post.title}</h1><p>{post.body}</p></article>;
}

서버: SQL 조회 → React 컴포넌트 → HTML. 브라우저: 즉시 표시 + JS 로 인터랙티브.

Hydration

서버가 렌더링한 HTML 위에 클라이언트 JS 가 이벤트 / 상태를 “부착” 하는 과정.

1. 서버: <button>Click</button> HTML 반환
2. 브라우저: HTML 표시 (정적)
3. JS 로딩 완료
4. React: 같은 컴포넌트 트리 다시 생성
5. 기존 DOM 과 비교, 이벤트 핸들러 부착
6. 인터랙티브 (이제 클릭 동작)

함정:

  • hydration mismatch: 서버와 클라이언트의 렌더링 결과가 다르면 warning + 깜빡임. Date.now(), Math.random(), 브라우저-only API 사용 시 발생
  • 무거움: 전체 React tree 재생성. 큰 앱에선 TTI 가 SSR 의 의도와 다르게 오히려 늦어짐 (“hydration cost”)
  • streaming SSR + selective hydration: React 18 의 해결책. 일부만 priority 로 빠르게 hydration

Islands Architecture (Astro / Fresh)

전체 페이지를 hydration 하지 말고 인터랙티브한 부분만 (= island) JS 로:

---
// Astro 컴포넌트, 서버 only
const posts = await db.posts.list();
---
<html>
<body>
  <h1>Static heading</h1>
  <ul>{posts.map(p => <li>{p.title}</li>)}</ul>

  <!-- 이 컴포넌트만 client-side JS 로 hydration -->
  <Counter client:visible />
</body>
</html>

client:visible directive 는 viewport 진입 시 hydration. 다른 directive: client:idle, client:load, client:media.

장점: 대부분 정적 HTML = 빠름. 동적인 부분만 JS 비용.

Client-Side Routing

페이지 전환 시 서버 요청 없이 JS 가 처리:

javascript
// History API
window.history.pushState({}, '', '/users/42');

// popstate 이벤트 (뒤로 / 앞으로 버튼)
window.addEventListener('popstate', (e) => {
  renderForPath(window.location.pathname);
});

// 링크 클릭 가로채기
document.addEventListener('click', (e) => {
  const link = e.target.closest('a[href]');
  if (link && link.origin === location.origin) {
      e.preventDefault();
      window.history.pushState({}, '', link.href);
      renderForPath(link.pathname);
  }
});
브라우저 동작
/users/42 로 이동
URL 변경, 페이지 reload 없음
React Router / Vue Router / TanStack Router 가 위 로직을 추상화

history.pushState / popstate 가 핵심. 모든 SPA router 가 이 위에서 동작.

Link 컴포넌트 (React Router 의 <Link>, Next 의 <Link>) 가 일반 <a> 대신 사용되는 이유: 클릭 시 full reload 방지.

SPA 의 SEO

CSR 만 사용 시 봇이 빈 HTML 만 보고 인덱싱 못 함 (Google 은 JS 실행하지만 시간 / 자원 제한).

해결:

  1. SSR 또는 SSG: 봇이 완성된 HTML 받음 (가장 안전)
  2. Prerender.io 같은 정적 캐시 서비스: 봇만 정적 HTML 반환
  3. Dynamic rendering: User-Agent 따라 봇에게 SSR, 사용자에겐 CSR

<title>, <meta description>, structured data (JSON-LD) 모두 SSR / SSG 시점에 채워져야.

State Management 패턴

SPA 의 클라이언트 상태:

종류도구사용처
Local component stateuseState / ref폼 입력, UI toggle
Shared app stateContext API / Pinia / Vuex사용자 정보, 테마
Global complexRedux / Zustand / Jotai / Recoil큰 앱의 도메인 상태
Server state cacheTanStack Query (React Query), SWRAPI 응답 캐싱, refetch
URL stateRouter params / search params페이지 상태 (필터, 페이지 번호)

현대 권장: server state 는 TanStack Query / SWR 로 분리. 글로벌 store 에 server data 두지 말 것 (cache invalidation 지옥).

SSR + CSR 데이터 fetching

Next.js 패턴:

// Server Component (RSC)
async function PostList() {
    const posts = await db.posts.list();   // 서버에서 실행
    return <ul>{posts.map(p => <li>{p.title}</li>)}</ul>;
}

// Client Component
'use client';
import { useQuery } from '@tanstack/react-query';

function PostList() {
    const { data } = useQuery({
        queryKey: ['posts'],
        queryFn: () => fetch('/api/posts').then(r => r.json())
    });
    return <ul>{data?.map(p => <li>{p.title}</li>)}</ul>;
}

RSC (React Server Components) 는 서버에서만 실행, 브라우저로 JS 안 보냄. 데이터 fetching 의 새로운 패러다임.

성능 최적화

  • Code splitting: 라우트 별 bundle 분리 (import() dynamic import)
  • Tree shaking: 사용 안 하는 코드 제거 (ESM + production build)
  • Lazy loading 컴포넌트: React.lazy(() => import('./Heavy'))
  • Image optimization: <img loading="lazy">, Next.js <Image>
  • Preload critical resources: <link rel="preload" as="font">
  • Service Worker / PWA: offline cache, background sync
  • Compression: Brotli > gzip
  • Bundle size budget: ~200KB JS (gzipped) for fast 3G

webpack-bundle-analyzer, source-map-explorer 로 bundle 분석.

함정과 베스트 프랙티스

  • CSR-only 는 SEO 가 critical 한 사이트에 부적합: SSR / SSG 선택
  • hydration mismatch 디버깅 어렵다: 서버와 클라이언트에서 동일한 deterministic 렌더링 강제
  • back / forward 버튼 동작 보장: popstate 처리, scroll 위치 복원
  • 상태 영속성: 페이지 reload 시 사라지면 안 되는 데이터는 localStorage / sessionStorage / URL
  • API CORS: SPA 가 다른 도메인 API 호출 시 CORS 설정 필수
  • Auth token 저장 위치: localStorage (XSS 위험) vs httpOnly cookie (CSRF 위험) - 보통 후자 + CSRF token
  • Error boundary: 컴포넌트 에러가 전체 앱 죽이지 않게
  • A11y: focus management, ARIA, 키보드 네비게이션 (router 전환 시 focus / announce)
  • Bundle size 감시: CI 에서 size limit 강제

관련 위키

이 글의 용어 (7개)
[Javascript] 이벤트 루프javascript
정의 이벤트 루프 (Event Loop) 는 싱글 스레드 JavaScript 엔진이 비동기 작업을 처리하기 위해 돌리는 무한 루프다. 콜 스택이 비어있을 때마다 마이크로태스크 큐…
[Javascript] ES Modules (import / export)javascript
정의 ES Modules (ESM) 은 표준 JS 모듈 시스템 (ES6). / 키워드. 브라우저와 Node.js 16+ 모두 지원. export named export defau…
[JavaScript] fetch API: 현대 HTTP 클라이언트javascript
정의 는 Promise 기반의 표준 HTTP 클라이언트. XMLHttpRequest (2000) 를 대체. 기본 사용 POST + JSON 함정 1: 4xx/5xx 는 rejec…
[Javascript] Promisejavascript
정의 Promise 는 미래의 어떤 시점에 결정될 값을 나타내는 객체다. ECMAScript 2015 (ES6) 에 도입되어 기반 비동기의 가독성과 에러 처리 문제를 해결했다. …
[Spring] RestClient, WebClient, RestTemplatespring
정의 Spring HTTP 클라이언트 3종: - RestTemplate: 동기, 전통 (5.0+ maintenance) - WebClient: reactive, async/syn…
[Spring] Spring MVC: @RestController, @RequestMappingspring
정의 Spring MVC는 Servlet 기반 동기 웹 프레임워크. 이 요청을 받아 핸들러(컨트롤러 메서드)에 라우팅. REST API와 전통 MVC(HTML) 모두 지원. We…
[Spring] WebFlux: Reactive, Mono, Fluxspring
정의 Spring WebFlux는 Project Reactor 기반의 비동기·non-blocking 웹 프레임워크. Spring MVC와 별개. Netty 기본 (Tomcat도 …

💬 댓글

사이트 검색 / 명령어

검색

스크롤 = 확대/축소 · 드래그 = 이동 · 0 = 원래 크기 · ESC = 닫기