SPA와 웹 렌더링 패턴 완전 가이드
TL;DR
- SPA/MPA/SSR/SSG는 UX와 성능의 trade-off를 결정한다.
- Hydration, Streaming SSR, RSC 등이 최신 렌더링 흐름을 바꿨다.
- 서비스 특성에 맞는 패턴 선택이 핵심이다.
1. 개념
웹 렌더링 패턴은 클라이언트와 서버의 책임 분담 방식이다.
2. 배경
성능 지표와 SEO 요구가 커지며 렌더링 전략이 다양화됐다.
3. 이유
사용자 경험과 운영 비용을 균형 있게 맞추기 위해 전략 비교가 필요하다.
4. 특징
CSR/SSR/SSG/ISR, 하이드레이션, RSC, 아일랜드, 엣지 렌더링을 다룬다.
5. 상세 내용
SPA와 웹 렌더링 패턴 완전 가이드
작성일: 2026-03-05 카테고리: Frontend / Rendering Patterns 포함 내용: SPA, MPA, CSR, SSR, SSG, ISR, PPR, Hydration, Streaming SSR, React Server Components, Islands Architecture, Resumability, Edge Rendering, View Transitions API, Navigation API, Speculation Rules API, Core Web Vitals, React 19, Next.js 15/16, Astro 5, Svelte 5, Qwik, HTMX
목차
- 용어 사전
- SPA (Single Page Application) 심층 분석
- MPA (Multi-Page Application)와 비교
- 클라이언트 사이드 라우팅 (Client-Side Routing)
- 렌더링 전략 스펙트럼
- CSR (Client-Side Rendering)
- SSR (Server-Side Rendering)
- Hydration 심층 분석
- Streaming SSR과 Selective Hydration
- SSG (Static Site Generation)
- ISR (Incremental Static Regeneration)
- React Server Components (RSC)
- Islands Architecture
- Resumability (Qwik)
- Edge Rendering
- Partial Prerendering (PPR)
- 2025-2026 프론트엔드 프레임워크 트렌드
- SPA vs MPA 트레이드오프 결정 가이드
- 키워드 색인
1. 용어 사전
| 영문 용어 | 한국어 설명 | 어원/출처 |
|---|---|---|
| SPA (Single Page Application) | 단일 HTML 페이지에서 JavaScript로 모든 화면을 동적으로 렌더링하는 웹 애플리케이션 | 2000년대 후반 AJAX 발전과 함께 등장한 개념 |
| MPA (Multi-Page Application) | 각 URL마다 서버에서 완전한 HTML을 생성하여 전달하는 전통적 웹 애플리케이션 | 웹의 원래 동작 방식, SPA와 대비하기 위해 명명 |
| CSR (Client-Side Rendering) | 브라우저(클라이언트)에서 JavaScript가 DOM을 생성하여 화면을 렌더링하는 방식 | SPA의 기본 렌더링 방식 |
| SSR (Server-Side Rendering) | 서버에서 완전한 HTML을 생성하여 클라이언트로 전송하는 렌더링 방식 | PHP/JSP 시대의 전통 방식을 현대 프레임워크에 적용 |
| SSG (Static Site Generation) | 빌드 시점에 모든 페이지를 미리 HTML로 생성하는 방식 | Jekyll (2008), Gatsby (2015)에서 대중화 |
| ISR (Incremental Static Regeneration) | 배포 후에도 개별 페이지를 점진적으로 재생성하는 Next.js의 전략 | Next.js 9.5 (2020)에서 Vercel이 도입 |
| PPR (Partial Prerendering) | 하나의 요청에서 정적 셸과 동적 부분을 결합하는 Next.js의 렌더링 방식 | Next.js 14 (2023)에서 실험적 도입 |
| Hydration (하이드레이션) | 서버 렌더링된 HTML에 JavaScript 이벤트 리스너를 부착하여 상호작용 가능하게 만드는 과정 | “물을 부어 생명을 불어넣는다”는 메타포에서 유래 |
| Selective Hydration (선택적 하이드레이션) | Suspense 경계 단위로 독립적으로 하이드레이션하여 우선순위 부여가 가능한 방식 | React 18 (2022)에서 도입 |
| Progressive Hydration (점진적 하이드레이션) | 코드 청크가 도착하는 순서대로 점진적으로 하이드레이션하는 방식 | Google Chrome팀에서 제안 |
| Streaming SSR (스트리밍 SSR) | HTTP chunked transfer encoding으로 HTML을 점진적으로 전송하는 SSR 방식 | React 18의 renderToPipeableStream |
| RSC (React Server Components) | 서버에서만 실행되어 JS 번들 없이 렌더링 결과만 클라이언트에 전달하는 React 컴포넌트 | React팀이 2020년 RFC 발표, 2023년 Next.js 13에서 안정화 |
| Islands Architecture (아일랜드 아키텍처) | 정적 HTML “바다” 위에 상호작용 가능한 JS “섬”을 배치하는 아키텍처 | Katie Sylor-Miller (2020) 제안, Jason Miller가 대중화 |
| Resumability (재개 가능성) | 서버 실행 상태를 HTML에 직렬화하고 클라이언트에서 재실행 없이 이어가는 방식 | Qwik 프레임워크 (Misko Hevery, 2022) |
| Edge Rendering (엣지 렌더링) | CDN 엣지 노드(전 세계 100-300+곳)에서 서버 코드를 실행하는 렌더링 방식 | Cloudflare Workers (2017), Vercel Edge (2022) |
| Virtual DOM (가상 DOM) | 실제 DOM의 경량 JavaScript 객체 복사본으로, 변경 사항을 비교하여 최소한의 DOM 업데이트를 수행 | React (2013)에서 처음 대중화, 이전에 유사 개념 존재 |
| Fiber (파이버) | React의 재조정 엔진으로, 렌더링 작업을 작은 단위로 분할하여 중단/재개 가능하게 함 | React 16 (2017)에서 도입, “실(fiber)”처럼 가는 작업 단위 |
| Reconciliation (재조정) | 이전 Virtual DOM 트리와 새 트리를 비교하여 실제 DOM에 최소 변경을 적용하는 알고리즘 | React의 핵심 알고리즘, O(n) 휴리스틱 방식 |
| Client-Side Routing (클라이언트 사이드 라우팅) | 브라우저에서 JavaScript로 URL 변경과 화면 전환을 처리하는 방식 | SPA의 핵심 메커니즘 |
| Hash Routing (해시 라우팅) | URL의 # 프래그먼트를 이용한 라우팅 (example.com/#/about) |
HTML 앵커 태그의 프래그먼트 식별자 활용 |
| History API (히스토리 API) | pushState/replaceState로 브라우저 URL을 변경하는 HTML5 API |
HTML5 명세 (2014), 클린 URL 라우팅의 기반 |
| pushState | 브라우저 히스토리 스택에 새 항목을 추가하고 URL을 변경하는 메서드 (페이지 새로고침 없음) | History API의 핵심 메서드 |
| Navigation API (내비게이션 API) | 모든 종류의 탐색을 단일 navigate 이벤트로 통합하는 최신 브라우저 API |
Chrome 102+ (2022), History API의 후속 |
| View Transitions API (뷰 전환 API) | 브라우저 네이티브 애니메이션 전환을 제공하는 API | Chrome 111+ (2023), SPA/MPA 모두 지원 |
| Speculation Rules API (투기적 규칙 API) | JSON 기반으로 프리페치/프리렌더링 힌트를 제공하는 API | Chrome 121+ (2024), LCP P75 177ms 개선 보고 |
| TTFB (Time to First Byte) | 요청 후 서버에서 첫 번째 바이트를 수신하기까지의 시간 | 서버 응답 속도의 기본 지표 |
| FCP (First Contentful Paint) | 브라우저가 텍스트나 이미지 등 첫 번째 콘텐츠를 화면에 그리는 시점 | Lighthouse/Chrome DevTools 성능 지표 |
| LCP (Largest Contentful Paint) | 뷰포트에서 가장 큰 콘텐츠 요소가 렌더링 완료되는 시점 | Core Web Vitals 핵심 지표 (2020) |
| INP (Interaction to Next Paint) | 사용자 인터랙션 후 다음 화면 갱신까지의 지연 시간 | 2024년 3월 FID를 대체, Core Web Vitals |
| CLS (Cumulative Layout Shift) | 페이지 로드 중 예기치 않은 레이아웃 이동의 누적 점수 | Core Web Vitals 시각적 안정성 지표 |
| TTI (Time to Interactive) | 페이지가 완전히 상호작용 가능해지는 시점 | Lighthouse 지표, INP에 의해 중요도 감소 |
| Core Web Vitals (코어 웹 바이탈) | Google의 사용자 경험 핵심 지표 세트: LCP, INP, CLS | Google (2020), 검색 순위 요소로 활용 |
| Tree Shaking (트리 쉐이킹) | 사용하지 않는 코드를 번들에서 제거하는 최적화 기법 | Rollup (2015)에서 대중화, ES Module의 정적 분석 활용 |
| Code Splitting (코드 분할) | 번들을 여러 청크로 분할하여 필요할 때만 로드하는 기법 | webpack의 dynamic import() 지원으로 대중화 |
| Lazy Loading (지연 로딩) | 리소스를 즉시 로드하지 않고 필요한 시점에 로드하는 기법 | React.lazy(), IntersectionObserver API 활용 |
2. SPA (Single Page Application) 심층 분석
2.1 SPA 역사 타임라인
┌─────────────────────────────────────────────────────────────────────────┐
│ SPA 기술의 진화 타임라인 │
├──────┬──────────────────────────────────────────────────────────────────┤
│ 연도 │ 기술/프레임워크 │
├──────┼──────────────────────────────────────────────────────────────────┤
│ 1999 │ XMLHttpRequest (Microsoft, IE5) │
│ │ └── 페이지 새로고침 없이 서버와 통신하는 최초의 기술 │
├──────┼──────────────────────────────────────────────────────────────────┤
│ 2005 │ AJAX (Asynchronous JavaScript And XML) │
│ │ └── Jesse James Garrett가 명명, Gmail/Google Maps가 선구자 │
├──────┼──────────────────────────────────────────────────────────────────┤
│ 2006 │ jQuery │
│ │ └── DOM 조작 단순화, AJAX를 $.ajax()로 추상화 │
├──────┼──────────────────────────────────────────────────────────────────┤
│ 2009 │ Backbone.js (Jeremy Ashkenas) │
│ │ └── 최초의 구조화된 SPA 프레임워크, MVC 패턴 도입 │
├──────┼──────────────────────────────────────────────────────────────────┤
│ 2010 │ AngularJS (Google, Misko Hevery) │
│ │ └── 양방향 데이터 바인딩, 의존성 주입, 완전한 SPA 프레임워크 │
├──────┼──────────────────────────────────────────────────────────────────┤
│ 2011 │ Ember.js (Yehuda Katz) │
│ │ └── Convention over Configuration, 강력한 라우터 내장 │
├──────┼──────────────────────────────────────────────────────────────────┤
│ 2013 │ React (Facebook, Jordan Walke) │
│ │ └── Virtual DOM, 컴포넌트 기반, 단방향 데이터 흐름 │
├──────┼──────────────────────────────────────────────────────────────────┤
│ 2014 │ Vue.js (Evan You) │
│ │ └── 점진적 프레임워크, 반응형 데이터 바인딩, 쉬운 학습 곡선 │
├──────┼──────────────────────────────────────────────────────────────────┤
│ 2016 │ Angular 2 (Google, 완전히 재작성) │
│ │ └── TypeScript 기반, 컴포넌트 아키텍처, RxJS 통합 │
├──────┼──────────────────────────────────────────────────────────────────┤
│ 2019 │ Svelte 3 (Rich Harris) │
│ │ └── 컴파일러 접근: Virtual DOM 없이 직접 DOM 업데이트 │
├──────┼──────────────────────────────────────────────────────────────────┤
│ 2022 │ Qwik (Misko Hevery, Builder.io) │
│ │ └── Resumability: 하이드레이션 없이 서버 상태 이어받기 │
├──────┼──────────────────────────────────────────────────────────────────┤
│ 2023 │ Astro Islands (Astro 2.0+) │
│ │ └── 아일랜드 아키텍처: 기본 Zero JS, 필요한 곳만 상호작용 │
└──────┴──────────────────────────────────────────────────────────────────┘
2.2 SPA의 작동 원리
┌─────────────────────────────────────────────────────────────────────────┐
│ SPA 작동 방식 │
│ │
│ 1단계: 초기 로드 │
│ ┌──────────┐ ┌──────────┐ │
│ │ 브라우저 │ ──GET──→│ 서버 │ │
│ │ │←── HTML ─│ │ │
│ └──────────┘ └──────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────┐ │
│ │ <!DOCTYPE html> │ ← 거의 빈 HTML │
│ │ <html> │ │
│ │ <body> │ │
│ │ <div id="root"></div> │ ← 빈 컨테이너 │
│ │ <script src="app.js"> │ ← 전체 앱 로직이 담긴 JS 번들 │
│ │ </body> │ │
│ │ </html> │ │
│ └──────────────────────────────┘ │
│ │ │
│ ▼ │
│ 2단계: JavaScript 실행 │
│ ┌──────────────────────────────┐ │
│ │ app.js 다운로드 및 파싱 │ │
│ │ ├── React/Vue 프레임워크 초기화 │
│ │ ├── 컴포넌트 트리 구성 │
│ │ ├── API 호출로 데이터 가져오기 │
│ │ └── DOM에 렌더링 (#root 안에) │
│ └──────────────────────────────┘ │
│ │ │
│ ▼ │
│ 3단계: 이후 내비게이션 (페이지 새로고침 없음) │
│ ┌──────────────────────────────┐ │
│ │ 사용자가 /about 클릭 │ │
│ │ ├── JS가 URL을 pushState로 변경 │
│ │ ├── 라우터가 URL 매칭 │ │
│ │ ├── 해당 컴포넌트 렌더링 │ │
│ │ ├── 필요시 API 호출 │ │
│ │ └── DOM 업데이트 (부분적) │ │
│ │ │ │
│ │ ⚡ 서버에 HTML 요청 없음! │ │
│ │ ⚡ 전체 페이지 새로고침 없음! │ │
│ └──────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
2.3 컴포넌트 기반 아키텍처
SPA의 핵심 패러다임은 컴포넌트 기반 아키텍처다. UI를 독립적이고 재사용 가능한 조각으로 분해한다.
// 컴포넌트 트리 구조 예시
function App() {
return (
<Layout>
<Header>
<Logo />
<Navigation />
<UserMenu />
</Header>
<Main>
<Sidebar />
<Content>
<ArticleList>
<ArticleCard /> {/* 재사용 가능한 컴포넌트 */}
<ArticleCard />
<ArticleCard />
</ArticleList>
</Content>
</Main>
<Footer />
</Layout>
);
}
┌─────────────────────────────────────────────────────┐
│ App │
│ ┌───────────────────────────────────────────────┐ │
│ │ Header │ │
│ │ [Logo] [Navigation] [UserMenu] │ │
│ └───────────────────────────────────────────────┘ │
│ ┌─────────┐ ┌───────────────────────────────────┐ │
│ │ Sidebar │ │ Content │ │
│ │ │ │ ┌─────────┐┌─────────┐┌────────┐│ │
│ │ • Home │ │ │ArticleC ││ArticleC ││Article ││ │
│ │ • Blog │ │ │ard #1 ││ard #2 ││Card #3 ││ │
│ │ • About│ │ └─────────┘└─────────┘└────────┘│ │
│ └─────────┘ └───────────────────────────────────┘ │
│ ┌───────────────────────────────────────────────┐ │
│ │ Footer │ │
│ └───────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
2.4 Virtual DOM (가상 DOM)
Virtual DOM은 실제 DOM의 경량 JavaScript 객체 복사본이다. 직접 DOM을 조작하는 대신, 메모리 내 가상 트리에서 변경 사항을 계산한 후 최소한의 실제 DOM 업데이트만 수행한다.
┌─────────────────────────────────────────────────────────────────────┐
│ Virtual DOM 작동 과정 │
│ │
│ 상태 변경 발생 (setState) │
│ │ │
│ ▼ │
│ ┌──────────────┐ 비교(Diff) ┌──────────────┐ │
│ │ 이전 VDOM │ ◄──────────────► │ 새로운 VDOM │ │
│ │ │ │ │ │
│ │ <div> │ │ <div> │ │
│ │ <h1> │ │ <h1> │ │
│ │ "Hello" │ │ "Hello" │ │
│ │ </h1> │ │ </h1> │ │
│ │ <p> │ │ <p> │ │
│ │ "old" │ ← 차이 발견 → │ "NEW" │ │
│ │ </p> │ │ </p> │ │
│ │ </div> │ │ </div> │ │
│ └──────────────┘ └──────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ 실제 DOM │ │
│ │ <p> 내용만 │ ← 최소한의 업데이트만 적용 │
│ │ "NEW"로변경 │ │
│ └──────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
Virtual DOM 노드의 JavaScript 표현:
// JSX: <div className="container"><h1>제목</h1><p>내용</p></div>
// ↓ 컴파일 후 Virtual DOM 객체:
const vnode = {
type: 'div',
props: { className: 'container' },
children: [
{
type: 'h1',
props: {},
children: ['제목']
},
{
type: 'p',
props: {},
children: ['내용']
}
]
};
2.5 React Fiber 아키텍처
React 16에서 도입된 Fiber는 렌더링 작업을 작은 단위(fiber)로 분할하여 중단, 재개, 우선순위 지정이 가능하게 만든 재조정 엔진이다.
┌─────────────────────────────────────────────────────────────────────┐
│ React Fiber 아키텍처 핵심 개념 │
│ │
│ ┌─ 이전 (Stack Reconciler, React 15 이하) ─────────────────────┐ │
│ │ │ │
│ │ 렌더링 시작 ──────────────────────────────── 렌더링 완료 │ │
│ │ [████████████████████████████████████████] │ │
│ │ ↑ 동기적, 중단 불가, 메인 스레드 독점 │ │
│ │ 사용자 입력이 끝날 때까지 블로킹됨 │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ Fiber (React 16+) ──────────────────────────────────────────┐ │
│ │ │ │
│ │ [██] [██] [유저입력처리] [██] [██] [애니메이션] [██] [완료] │ │
│ │ ↑ ↑ ↑ ↑ ↑ ↑ ↑ │ │
│ │ 작업 양보 긴급작업 작업 양보 긴급작업 작업 │ │
│ │ 단위 (yield) 끼어들기 단위 (yield) 끼어들기 단위 │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │
│ 우선순위 레인 (Priority Lanes): │
│ ┌────────────────────────────────────────────────────┐ │
│ │ SyncLane │ 즉시 실행 (에러, 동기 업데이트) │ │
│ │ InputContinuous │ 연속 입력 (드래그, 스크롤) │ │
│ │ DefaultLane │ 일반 업데이트 (setState) │ │
│ │ TransitionLane │ 전환 (useTransition) │ │
│ │ IdleLane │ 유휴 시 실행 (프리페치) │ │
│ └────────────────────────────────────────────────────┘ │
│ │
│ Concurrent Mode (동시성 모드): │
│ - startTransition(): 비긴급 업데이트를 표시 │
│ - useDeferredValue(): 값의 업데이트를 지연 │
│ - Suspense: 비동기 작업의 로딩 상태 관리 │
└─────────────────────────────────────────────────────────────────────┘
2.6 재조정 알고리즘 (Reconciliation)
React의 재조정은 O(n) 휴리스틱 디핑 알고리즘을 사용한다. 일반적인 트리 비교는 O(n^3)이지만, 두 가지 가정으로 O(n)을 달성한다.
┌─────────────────────────────────────────────────────────────────────┐
│ 재조정 알고리즘의 두 가지 핵심 가정 │
│ │
│ 가정 1: 다른 타입의 요소는 다른 트리를 생성한다 │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ <div> → <span> │ │
│ │ <Counter /> <Counter /> │ │
│ │ </div> </span> │ │
│ │ │ │
│ │ 타입이 다르므로 (div → span) 하위 트리 전체를 파괴하고 │ │
│ │ 새로 생성한다. Counter의 state도 날아간다. │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
│ 가정 2: key prop으로 리스트 요소를 식별한다 │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ // BAD: index를 key로 사용 │ │
│ │ items.map((item, i) => <li key={i}>{item}</li>) │ │
│ │ → 삽입/삭제 시 모든 요소가 재렌더링됨 │ │
│ │ │ │
│ │ // GOOD: 고유 ID를 key로 사용 │ │
│ │ items.map(item => <li key={item.id}>{item.name}</li>) │ │
│ │ → 변경된 요소만 정확하게 업데이트 │ │
│ └──────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
3. MPA (Multi-Page Application)와 비교
3.1 MPA 작동 방식
MPA는 웹의 원래 설계 방식이다. 모든 내비게이션에서 서버가 완전한 HTML 문서를 생성하여 전달한다.
┌─────────────────────────────────────────────────────────────────────┐
│ MPA 요청 흐름 │
│ │
│ 사용자가 /home 방문 │
│ ┌────────┐ GET /home ┌────────┐ DB 조회 ┌────────┐ │
│ │브라우저 │ ───────────→ │ 서버 │ ────────→ │ DB │ │
│ │ │ ← 완전 HTML ─│(PHP/ │ ← 데이터 ─│ │ │
│ │ │ │ Rails/ │ │ │ │
│ └────────┘ │ Django)│ └────────┘ │
│ ✅ 화면 즉시 표시 └────────┘ │
│ │
│ 사용자가 /about 클릭 │
│ ┌────────┐ GET /about ┌────────┐ │
│ │브라우저 │ ───────────→ │ 서버 │ │
│ │ │ ← 완전 HTML ─│ │ ⚡ 전체 페이지 새로고침! │
│ │ 흰화면 │ │ │ ⚡ 모든 CSS/JS 재파싱! │
│ │ ↓ │ └────────┘ ⚡ 기존 상태 모두 소멸! │
│ │ 새화면 │ │
│ └────────┘ │
│ │
│ SPA 요청 흐름 │
│ │
│ 사용자가 /home 방문 │
│ ┌────────┐ GET / ┌────────┐ │
│ │브라우저 │ ───────────→ │ 서버 │ │
│ │ │ ← 빈 HTML ─│(정적) │ │
│ │ │ + app.js │ │ │
│ └────────┘ └────────┘ │
│ ⏳ JS 실행까지 빈 화면 │
│ ┌────────┐ API 호출 ┌────────┐ │
│ │브라우저 │ ───────────→ │ API │ │
│ │ │ ← JSON ──── │ 서버 │ │
│ └────────┘ └────────┘ │
│ ✅ JS가 DOM 생성하여 화면 표시 │
│ │
│ 사용자가 /about 클릭 │
│ ┌────────┐ │
│ │브라우저 │ ← 서버 요청 없음! │
│ │JS 라우터│ pushState('/about') │
│ │컴포넌트 │ About 컴포넌트 렌더링 │
│ │교체만! │ ⚡ 즉각적 전환! │
│ └────────┘ │
└─────────────────────────────────────────────────────────────────────┘
3.2 SPA vs MPA 비교표
| 비교 항목 | SPA | MPA |
|---|---|---|
| SEO | 기본적으로 불리 (빈 HTML). SSR/SSG로 해결 가능 | 우수 (서버 렌더 HTML을 크롤러가 직접 파싱) |
| 초기 로드 성능 | 느림 (JS 번들 다운로드+파싱+실행) | 빠름 (HTML 도착 즉시 렌더링) |
| 이후 내비게이션 | 매우 빠름 (서버 왕복 없음) | 느림 (매번 전체 페이지 리로드) |
| UX (사용자 경험) | 앱처럼 부드러운 전환, 로딩 상태 세밀 제어 | 페이지 전환 시 흰 화면 깜빡임 |
| 번들 크기 | 크다 (전체 앱 로직 포함), 코드 분할로 완화 | 페이지별로 필요한 JS만 포함, 작다 |
| 메모리 사용 | 누적 증가 위험 (메모리 누수 주의) | 내비게이션마다 초기화 |
| 상태 관리 | 전역 상태 유지 가능 (Redux, Zustand 등) | 페이지 전환 시 상태 소멸 (서버 세션 의존) |
| 복잡도 | 높음 (라우팅, 상태, 빌드 등 직접 관리) | 낮음 (서버가 대부분 처리) |
| 접근성 (a11y) | 라우트 변경 시 포커스 관리 직접 구현 필요 | 브라우저 기본 동작으로 대부분 충족 |
| 뒤로 가기 버튼 | History API로 직접 관리 (구현 필요) | 브라우저 기본 동작 |
| 오프라인 지원 | Service Worker로 가능 (PWA) | 어려움 |
| 서버 부하 | 낮음 (API만 제공) | 높음 (매 요청마다 HTML 생성) |
| 개발 속도 | 프레임워크 학습 비용 있음 | 빠른 프로토타이핑 가능 |
| 팀 확장성 | 컴포넌트 단위 분업 용이 | 페이지 단위 분업 |
4. 클라이언트 사이드 라우팅 (Client-Side Routing)
4.1 Hash Routing (#/path)
URL의 해시 프래그먼트(#) 뒤의 내용은 서버로 전송되지 않는 브라우저 전용 정보다. 이 특성을 이용해 페이지 새로고침 없이 라우팅을 구현한다.
┌─────────────────────────────────────────────────────────────────────┐
│ Hash Routing 동작 원리 │
│ │
│ URL: https://example.com/#/users/42 │
│ ↑ │
│ │ │
│ 서버에 전송되지 않는 부분 │
│ │
│ 서버가 받는 요청: GET / (해시 이후는 무시) │
│ 브라우저가 처리: #/users/42 → "users" 라우트, id=42 │
│ │
│ 이벤트: window.addEventListener('hashchange', callback) │
│ URL 변경: window.location.hash = '#/about' │
└─────────────────────────────────────────────────────────────────────┘
// Hash Router 최소 구현
class HashRouter {
constructor() {
this.routes = {};
window.addEventListener('hashchange', () => this.handleRoute());
}
addRoute(path, handler) {
this.routes[path] = handler;
}
handleRoute() {
const hash = window.location.hash.slice(1) || '/'; // '#/about' → '/about'
const handler = this.routes[hash];
if (handler) handler();
}
navigate(path) {
window.location.hash = path;
}
}
// 사용 예시
const router = new HashRouter();
router.addRoute('/', () => renderHome());
router.addRoute('/about', () => renderAbout());
router.addRoute('/users', () => renderUsers());
Hash Routing의 장단점:
| 장점 | 단점 |
|---|---|
| 서버 설정 불필요 | URL이 못생김 (/#/about) |
| 어떤 서버에서든 동작 | SEO 불리 (크롤러가 해시 무시) |
| 구현이 단순 | SSR과 호환 불가 |
| 새로고침해도 동작 | 브라우저 기본 앵커 기능 충돌 |
4.2 History API Routing (HTML5)
HTML5의 History API는 pushState/replaceState를 사용하여 해시 없이 깨끗한 URL로 라우팅한다.
// pushState: 히스토리에 새 항목 추가
history.pushState(
{ userId: 42 }, // state 객체 (popstate에서 접근 가능)
'', // title (대부분의 브라우저에서 무시됨)
'/users/42' // 새 URL
);
// 결과: URL이 /users/42로 변경, 페이지 새로고침 없음
// replaceState: 현재 히스토리 항목 교체
history.replaceState(
{ userId: 42, tab: 'profile' },
'',
'/users/42?tab=profile'
);
// 결과: 현재 URL만 교체, 뒤로가기 히스토리에 추가 안 됨
// popstate: 뒤로가기/앞으로가기 감지
window.addEventListener('popstate', (event) => {
console.log('이동한 URL:', window.location.pathname);
console.log('저장된 state:', event.state);
renderRoute(window.location.pathname);
});
서버 설정 필수 (Catch-All 라우트):
┌─────────────────────────────────────────────────────────────────────┐
│ History API의 서버 설정 필요성 │
│ │
│ 문제 시나리오: │
│ 1. 사용자가 SPA에서 /users/42 로 내비게이션 (pushState) │
│ 2. 사용자가 브라우저에서 새로고침(F5) 누름 │
│ 3. 브라우저가 서버에 GET /users/42 요청 │
│ 4. 서버에는 /users/42 경로가 없음 → 404 Not Found! │
│ │
│ 해결: 모든 경로를 index.html로 리다이렉트 │
│ │
│ # Nginx │
│ location / { │
│ try_files $uri $uri/ /index.html; │
│ } │
│ │
│ # Apache (.htaccess) │
│ RewriteEngine On │
│ RewriteCond %{REQUEST_FILENAME} !-f │
│ RewriteCond %{REQUEST_FILENAME} !-d │
│ RewriteRule ^ /index.html [L] │
│ │
│ # Express.js │
│ app.get('*', (req, res) => { │
│ res.sendFile(path.join(__dirname, 'index.html')); │
│ }); │
└─────────────────────────────────────────────────────────────────────┘
4.3 React Router 내부 동작 원리
React Router는 가장 널리 사용되는 클라이언트 사이드 라우터다. 내부적으로 다음과 같이 동작한다:
┌─────────────────────────────────────────────────────────────────────┐
│ React Router 내부 아키텍처 │
│ │
│ 1. 라우트 정의 트리 │
│ ┌──────────────────────────────────────┐ │
│ │ <Routes> │ │
│ │ <Route path="/" element={<Home/>}>│ │
│ │ <Route path="users" │ ← 중첩 라우트 │
│ │ element={<Users/>}> │ │
│ │ <Route path=":id" │ ← 동적 세그먼트 │
│ │ element={<User/>}/> │ │
│ │ </Route> │ │
│ │ </Route> │ │
│ │ </Routes> │ │
│ └──────────────────────────────────────┘ │
│ ↓ │
│ 2. URL 매칭 (Route Ranking) │
│ ┌──────────────────────────────────────┐ │
│ │ URL: /users/42 │ │
│ │ │ │
│ │ 매칭 후보들을 점수로 순위 매김: │ │
│ │ /users/:id → 점수: 정적(users) │ │
│ │ + 동적(:id) = 높음 │ │
│ │ /users/* → 점수: 정적(users) │ │
│ │ + 와일드카드 = 낮음 │ │
│ │ │ │
│ │ ※ 정적 세그먼트 > 동적 > 와일드카드 │ │
│ │ ※ 정의 순서가 아닌 구체성으로 결정 │ │
│ └──────────────────────────────────────┘ │
│ ↓ │
│ 3. 중첩 매칭과 Outlet │
│ ┌──────────────────────────────────────┐ │
│ │ / → <Home> │ │
│ │ └─ <Outlet/> → <Users> │ │
│ │ └─ <Outlet/> │ │
│ │ → <User id={42}/> │
│ │ │ │
│ │ 각 레벨의 <Outlet/>이 자식 라우트의 │ │
│ │ 렌더링 위치를 결정 │ │
│ └──────────────────────────────────────┘ │
│ ↓ │
│ 4. Link 컴포넌트의 클릭 인터셉트 │
│ ┌──────────────────────────────────────┐ │
│ │ <Link to="/users/42"> │ │
│ │ → <a href="/users/42"> 렌더링 │ │
│ │ → onClick에서 e.preventDefault() │ │
│ │ → history.pushState() 호출 │ │
│ │ → 라우터 state 업데이트 │ │
│ │ → 매칭되는 컴포넌트 렌더링 │ │
│ └──────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
4.4 Vue Router 내부 동작
// Vue Router: 정규식 패턴 매칭, 정의 순서 기반 (first-match-wins)
const router = createRouter({
routes: [
// 먼저 정의된 라우트가 우선 (정의 순서 중요!)
{ path: '/users/new', component: NewUser }, // 1순위
{ path: '/users/:id', component: UserDetail }, // 2순위
{ path: '/users/:id+', component: UserMulti }, // 3순위 (1개 이상 반복)
{ path: '/:pathMatch(.*)*', component: NotFound } // 폴백
]
});
// 내부적으로 각 path를 정규식으로 변환:
// '/users/:id' → /^\/users\/([^/]+?)\/?$/
// '/users/new' → /^\/users\/new\/?$/
4.5 Angular Router 내부 동작
// Angular Router: 깊이 우선 탐색(DFS), first-match-wins
const routes: Routes = [
{
path: 'users',
component: UsersComponent,
children: [
// Angular는 DFS로 트리를 순회하며 첫 매칭 반환
{ path: 'new', component: NewUserComponent }, // 구체적 경로 먼저!
{ path: ':id', component: UserDetailComponent },
]
},
{ path: '**', component: NotFoundComponent } // 와일드카드는 항상 마지막
];
// Angular Router의 특징:
// - loadChildren: () => import('./lazy.module') → 지연 로딩
// - CanActivate, CanDeactivate: 라우트 가드
// - Resolve: 컴포넌트 활성화 전 데이터 프리페치
4.6 Navigation API (최신)
모든 종류의 내비게이션을 단일 navigate 이벤트로 통합하는 최신 브라우저 API다.
// Navigation API: 모든 내비게이션 타입을 하나의 이벤트로 통합
navigation.addEventListener('navigate', (event) => {
// event.navigationType: 'push' | 'replace' | 'reload' | 'traverse'
// event.destination.url: 이동할 URL
// event.canIntercept: 인터셉트 가능 여부
if (!event.canIntercept) return;
const url = new URL(event.destination.url);
if (url.pathname.startsWith('/users')) {
event.intercept({
// 비동기 핸들러로 내비게이션 제어
async handler() {
const response = await fetch(`/api${url.pathname}`);
const data = await response.json();
renderUserPage(data);
},
// 스크롤 동작 제어
scroll: 'after-transition',
// 포커스 관리
focusReset: 'after-transition'
});
}
});
// 프로그래밍 방식 내비게이션
navigation.navigate('/users/42', {
state: { from: 'dashboard' },
info: { animation: 'slide-left' } // 일시적 정보 (히스토리에 저장 안 됨)
});
// 히스토리 항목 목록 접근
const entries = navigation.entries();
const currentEntry = navigation.currentEntry;
브라우저 지원 현황 (2025):
| 브라우저 | 지원 버전 | 상태 |
|---|---|---|
| Chrome | 102+ | 완전 지원 |
| Edge | 102+ | 완전 지원 |
| Firefox | 확대 중 | 실험적 |
| Safari | 확대 중 | 실험적 |
4.7 View Transitions API (최신)
브라우저 네이티브 애니메이션 전환을 제공하는 API. SPA와 MPA 모두에서 페이지 전환 시 부드러운 시각적 효과를 구현할 수 있다.
// SPA에서의 View Transitions (Same-Document)
document.startViewTransition(async () => {
// DOM 업데이트 수행
await updateDOM();
});
// 실제 React Router에서의 활용
function navigate(to) {
if (!document.startViewTransition) {
// 미지원 브라우저: 즉시 업데이트
updateRoute(to);
return;
}
document.startViewTransition(() => {
updateRoute(to);
});
}
/* View Transition CSS 애니메이션 커스터마이즈 */
::view-transition-old(root) {
animation: fade-out 0.3s ease-out;
}
::view-transition-new(root) {
animation: fade-in 0.3s ease-in;
}
/* 특정 요소에 이름 부여하여 개별 애니메이션 */
.hero-image {
view-transition-name: hero;
}
::view-transition-old(hero) {
animation: slide-out-left 0.4s ease;
}
::view-transition-new(hero) {
animation: slide-in-right 0.4s ease;
}
/* @view-transition at-rule (MPA Cross-Document) */
@view-transition {
navigation: auto; /* 모든 같은 출처 내비게이션에 적용 */
}
브라우저 지원 현황:
| 기능 | Chrome | Firefox | Safari |
|---|---|---|---|
| Same-Document (SPA) | 111+ | 133+ | 18+ |
| Cross-Document (MPA) | 126+ | 미지원 | 18.2+ |
4.8 Speculation Rules API (최신)
JSON 기반으로 프리페치(prefetch)와 프리렌더링(prerender) 힌트를 선언적으로 제공하는 API.
<!-- HTML에 Speculation Rules 삽입 -->
<script type="speculationrules">
{
"prefetch": [
{
"source": "list",
"urls": ["/about", "/contact"]
}
],
"prerender": [
{
"source": "document",
"where": {
"and": [
{ "href_matches": "/products/*" },
{ "not": { "selector_matches": ".no-prerender" } }
]
},
"eagerness": "moderate"
}
]
}
</script>
┌─────────────────────────────────────────────────────────────────────┐
│ Speculation Rules - eagerness 레벨 │
│ │
│ ┌────────────┬────────────────────────────────────────────────┐ │
│ │ eagerness │ 동작 │ │
│ ├────────────┼────────────────────────────────────────────────┤ │
│ │ immediate │ 규칙 발견 즉시 프리페치/프리렌더링 │ │
│ │ eager │ immediate와 유사, 향후 분화 예정 │ │
│ │ moderate │ 링크 위에 마우스를 200ms 호버하면 시작 │ │
│ │ conservative│ 링크를 mousedown/touchstart할 때 시작 │ │
│ └────────────┴────────────────────────────────────────────────┘ │
│ │
│ 성능 효과: │
│ - Chrome 연구: P75 LCP 177ms 개선 │
│ - 프리렌더링 적용 시 거의 즉각적 페이지 전환 │
│ - 네트워크/메모리 비용 고려 필요 │
└─────────────────────────────────────────────────────────────────────┘
5. 렌더링 전략 스펙트럼
┌─────────────────────────────────────────────────────────────────────┐
│ 렌더링 전략 스펙트럼 │
│ │
│ 가장 정적 ◄────────────────────────────────────► 가장 동적 │
│ │
│ SSG ISR PPR SSR RSC Edge CSR │
│ │ │ │ (Streaming) │ │ │ │
│ ▼ ▼ ▼ ▼ ▼ ▼ ▼ │
│ ┌────┐ ┌────┐ ┌────────┐ ┌──────┐ ┌──────┐ ┌─────┐ ┌──────┐ │
│ │빌드│ │빌드│ │빌드+ │ │요청 │ │서버 │ │CDN │ │브라우│ │
│ │시점│ │+ │ │요청시점│ │시점 │ │+ │ │엣지 │ │저에서│ │
│ │생성│ │점진│ │혼합 │ │생성 │ │클라이│ │노드 │ │생성 │ │
│ │ │ │재생│ │ │ │ │ │언트 │ │ │ │ │ │
│ └────┘ └────┘ └────────┘ └──────┘ └──────┘ └─────┘ └──────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 특성 비교: │ │
│ │ │ │
│ │ JS 번들 크기: 작음 ◄──────────────────────────► 큼 │ │
│ │ 서버 부하: 없음 ◄──────────────────────────► 높음 │ │
│ │ 데이터 신선도: 낮음 ◄──────────────────────────► 높음 │ │
│ │ CDN 캐시: 최적 ◄──────────────────────────► 불가 │ │
│ │ 개인화: 불가 ◄──────────────────────────► 가능 │ │
│ └─────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
전략 선택 기준:
| 전략 | 데이터 특성 | 적합한 콘텐츠 |
|---|---|---|
| SSG | 빌드 시점에 확정 | 블로그, 문서, 마케팅 페이지 |
| ISR | 주기적 업데이트 | 뉴스, 제품 카탈로그 |
| PPR | 정적 + 동적 혼합 | 이커머스 상품 페이지 |
| SSR (Streaming) | 요청마다 다름 | 소셜 피드, 검색 결과 |
| RSC | 서버 데이터 접근 필요 | DB 직접 쿼리, 무거운 라이브러리 |
| Edge | 지역/사용자별 다름 | A/B 테스트, 지오 라우팅 |
| CSR | 높은 상호작용 | SaaS 대시보드, 실시간 앱 |
6. CSR (Client-Side Rendering)
6.1 CSR 작동 원리
┌─────────────────────────────────────────────────────────────────────┐
│ CSR 타임라인 │
│ │
│ 시간 ────────────────────────────────────────────────────► │
│ │
│ ┌──────┐ │
│ │ TTFB │ 빈 HTML 수신 (빠름, 작은 파일) │
│ └──┬───┘ │
│ │ ┌─────────────────────┐ │
│ │ │ JS 번들 다운로드 │ 200KB ~ 2MB+ (프레임워크+앱) │
│ │ └─────────┬───────────┘ │
│ │ │ ┌──────────────┐ │
│ │ │ │ JS 파싱/실행 │ 메인 스레드 블로킹 │
│ │ │ └──────┬───────┘ │
│ │ │ │ ┌──────────┐ │
│ │ │ │ │ API 호출 │ 데이터 가져오기 │
│ │ │ │ └────┬─────┘ │
│ │ │ │ │ ┌──────┐ │
│ │ │ │ │ │ 렌더 │ │
│ │ │ │ │ └──┬───┘ │
│ │ │ │ │ │ │
│ ▼ ▼ ▼ ▼ ▼ │
│ ┌──────────────────────────────────────────────┐ │
│ │ 사용자가 보는 화면 │ │
│ │ │ │
│ │ [빈 화면] [빈 화면] [로딩...] [로딩] [콘텐츠!] │
│ │ ↑ ↑ │
│ │ FCP 없음 FCP = LCP │
│ │ (빈 div만 존재) (매우 늦음) │
│ └──────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
6.2 CSR의 SEO 문제
┌─────────────────────────────────────────────────────────────────────┐
│ CSR과 검색 엔진 크롤링 │
│ │
│ 크롤러가 보는 HTML: │
│ ┌────────────────────────────────┐ │
│ │ <!DOCTYPE html> │ │
│ │ <html> │ │
│ │ <head> │ │
│ │ <title>My App</title> │ │
│ │ </head> │ │
│ │ <body> │ │
│ │ <div id="root"></div> │ ← 내용 없음! │
│ │ <script src="app.js"> │ │
│ │ </body> │ │
│ │ </html> │ │
│ └────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ 크롤러별 JS 렌더링 능력: │ │
│ │ │ │
│ │ Googlebot ✅ JS 렌더링 가능 (Chromium 기반) │ │
│ │ ⚠️ 단, 렌더링 큐 대기 시간 있음 (초~일) │ │
│ │ ⚠️ 비동기 데이터는 누락될 수 있음 │ │
│ │ Bingbot ⚠️ 제한적 JS 렌더링 │ │
│ │ 소셜 크롤러 ❌ JS 미실행 (Facebook, Twitter, LinkedIn) │ │
│ │ 기타 크롤러 ❌ 대부분 JS 미실행 │ │
│ └───────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
6.3 CSR이 적합한 경우
// CSR이 최적인 앱: 인증 뒤의 대시보드
// - SEO 불필요 (로그인 뒤)
// - 높은 상호작용 (차트, 테이블, 필터)
// - 실시간 업데이트 (WebSocket)
// Vite + React CSR 예시
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom'],
charts: ['recharts'],
}
}
}
}
});
// index.html - CSR 엔트리포인트
// <!DOCTYPE html>
// <html>
// <body>
// <div id="root"></div>
// <script type="module" src="/src/main.tsx"></script>
// </body>
// </html>
// src/main.tsx
import { createRoot } from 'react-dom/client';
import { App } from './App';
createRoot(document.getElementById('root')!).render(<App />);
7. SSR (Server-Side Rendering)
7.1 SSR 작동 원리
┌─────────────────────────────────────────────────────────────────────┐
│ SSR 작동 흐름 │
│ │
│ ┌────────┐ GET /products ┌──────────────────────────────┐ │
│ │브라우저 │ ──────────────→ │ 서버 (Node.js) │ │
│ │ │ │ │ │
│ │ │ │ 1. 데이터 가져오기 (DB/API) │ │
│ │ │ │ 2. React 컴포넌트 렌더링 │ │
│ │ │ │ renderToString(<App/>) │ │
│ │ │ │ 3. 완전한 HTML 생성 │ │
│ │ │ ← 완전한 HTML ──│ │ │
│ │ │ └──────────────────────────────┘ │
│ │ │ │
│ │ 즉시 │ ← FCP! (HTML 도착 즉시 콘텐츠 표시) │
│ │ 화면 │ │
│ │ 표시! │ ← 아직 JS 로딩 중... 클릭 안 됨 (Uncanny Valley) │
│ │ │ │
│ │ JS로드 │ ← Hydration 완료! 이제 완전히 인터랙티브 │
│ └────────┘ │
└─────────────────────────────────────────────────────────────────────┘
7.2 Next.js Pages Router SSR 예시
// pages/products/[id].tsx (Pages Router - getServerSideProps)
import { GetServerSideProps } from 'next';
interface Product {
id: string;
name: string;
price: number;
description: string;
}
export const getServerSideProps: GetServerSideProps = async (context) => {
const { id } = context.params!;
// 서버에서만 실행됨 - DB 직접 접근 가능
const product = await db.products.findById(id);
if (!product) {
return { notFound: true };
}
return {
props: { product }, // 페이지 컴포넌트에 props로 전달
};
};
export default function ProductPage({ product }: { product: Product }) {
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<span>₩{product.price.toLocaleString()}</span>
<button onClick={() => addToCart(product)}>장바구니 담기</button>
</div>
);
}
7.3 Next.js App Router SSR 예시
// app/products/[id]/page.tsx (App Router - Server Component)
// 기본적으로 Server Component (별도 선언 불필요)
async function ProductPage({ params }: { params: { id: string } }) {
// 서버에서 직접 데이터 접근 - getServerSideProps 불필요
const product = await db.products.findById(params.id);
if (!product) {
notFound();
}
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<span>₩{product.price.toLocaleString()}</span>
{/* 클라이언트 인터랙션이 필요한 부분만 Client Component */}
<AddToCartButton product={product} />
</div>
);
}
export default ProductPage;
7.4 SSR 성능 특성
| 지표 | SSR | CSR 대비 |
|---|---|---|
| TTFB | 느림 (서버 렌더링 시간 포함) | CSR보다 느림 |
| FCP | 빠름 (HTML 도착 즉시) | CSR보다 훨씬 빠름 |
| LCP | 빠름 (주요 콘텐츠 HTML에 포함) | CSR보다 훨씬 빠름 |
| INP | Hydration 완료 후 양호 | CSR과 유사 |
| SEO | 우수 (완전한 HTML) | CSR보다 훨씬 유리 |
8. Hydration 심층 분석
8.1 Hydration이란 무엇인가
Hydration은 서버에서 렌더링된 정적 HTML에 JavaScript 이벤트 리스너를 부착하여 상호작용 가능하게 만드는 과정이다. HTML을 다시 렌더링하는 것이 아니라, 기존 DOM을 재사용하면서 이벤트 핸들러만 연결한다.
┌─────────────────────────────────────────────────────────────────────┐
│ Hydration 과정 상세 │
│ │
│ 서버 렌더링 결과 (정적 HTML): │
│ ┌──────────────────────────────────────┐ │
│ │ <button>좋아요 (42)</button> │ ← 보이지만 클릭 안 됨 │
│ │ <input type="text" value="검색"> │ ← 보이지만 입력 안 됨 │
│ │ <div class="dropdown">메뉴</div> │ ← 보이지만 열리지 않음 │
│ └──────────────────────────────────────┘ │
│ │ │
│ ▼ Hydration │
│ │
│ Hydration 후 (인터랙티브): │
│ ┌──────────────────────────────────────┐ │
│ │ <button onClick={handleLike}> │ ← 클릭 가능! │
│ │ 좋아요 (42) │ │
│ │ </button> │ │
│ │ <input onChange={handleSearch} │ ← 입력 가능! │
│ │ type="text" value="검색"> │ │
│ │ <div onClick={toggleDropdown} │ ← 열림! │
│ │ class="dropdown">메뉴</div> │ │
│ └──────────────────────────────────────┘ │
│ │
│ React가 하는 일: │
│ 1. 컴포넌트 트리 전체를 순회 (O(n)) │
│ 2. 서버 HTML과 클라이언트 렌더링 결과 비교 │
│ 3. 일치하면 기존 DOM 재사용 + 이벤트 리스너 부착 │
│ 4. 불일치하면 Hydration Mismatch 에러 발생 │
└─────────────────────────────────────────────────────────────────────┘
8.2 Hydration Mismatch 에러
서버와 클라이언트의 렌더링 결과가 다르면 발생하는 에러. 흔한 원인들:
// ❌ 문제 1: window/document 체크
function Component() {
// 서버에는 window가 없으므로 서버: "모바일 아님", 클라이언트: "모바일"
const isMobile = window.innerWidth < 768;
return <div>{isMobile ? '모바일' : '데스크톱'}</div>;
}
// ✅ 해결: useEffect로 클라이언트에서만 실행
function Component() {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
setIsMobile(window.innerWidth < 768);
}, []);
return <div>{isMobile ? '모바일' : '데스크톱'}</div>;
}
// ❌ 문제 2: Date.now() / 타임존
function Component() {
// 서버 시간 ≠ 클라이언트 시간
return <span>{new Date().toLocaleString()}</span>;
}
// ❌ 문제 3: localStorage 접근
function Component() {
// 서버에는 localStorage 없음
const theme = localStorage.getItem('theme') || 'light';
return <div className={theme}>...</div>;
}
// ❌ 문제 4: Math.random()
function Component() {
// 서버와 클라이언트에서 다른 값 생성
return <div id={`el-${Math.random()}`}>...</div>;
}
에러 메시지 예시:
Warning: Text content did not match.
Server: "2026-03-05 14:23:01"
Client: "2026-03-05 23:23:01"
Error: Hydration failed because the server rendered HTML didn't match
the client. As a result this tree will be regenerated on the client.
8.3 Hydration 비용
┌─────────────────────────────────────────────────────────────────────┐
│ Hydration의 성능 비용 │
│ │
│ 비용 요소: │
│ ├── 컴포넌트 트리 전체 순회: O(n) - n은 컴포넌트 수 │
│ ├── 모든 컴포넌트의 JS 코드 다운로드 필요 │
│ ├── 메인 스레드 블로킹 (동기적 실행) │
│ └── 이벤트 리스너 부착 완료까지 상호작용 불가 │
│ │
│ "Uncanny Valley" (불쾌한 골짜기): │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ HTML 도착 Hydration 시작 Hydration 완료 │ │
│ │ │ │ │ │ │
│ │ ▼ ▼ ▼ │ │
│ │ ┌────────────────────────────────┐ ┌─────┐ │ │
│ │ │ 화면은 보이지만 │ │ 완전│ │ │
│ │ │ 클릭해도 아무 반응 없음 │ │인터 │ │ │
│ │ │ ← "Uncanny Valley" → │ │랙티브│ │ │
│ │ └────────────────────────────────┘ └─────┘ │ │
│ │ │ │
│ │ 사용자: "버튼이 보이는데 왜 안 눌리지?!" │ │
│ └──────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
9. Streaming SSR과 Selective Hydration
9.1 Streaming SSR (React 18)
React 18의 renderToPipeableStream은 HTTP chunked transfer encoding을 사용하여 HTML을 점진적으로 전송한다.
┌─────────────────────────────────────────────────────────────────────┐
│ 기존 SSR vs Streaming SSR 비교 │
│ │
│ 기존 SSR (renderToString): │
│ 서버: [─── 전체 렌더링 대기 ───] → 완전한 HTML 한 번에 전송 │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ DB쿼리 API호출 렌더링 ─────────→ [전체 HTML] │ │
│ │ (2초) (1초) (0.5초) = 3.5초 └→ 브라우저 │ │
│ │ │ │
│ │ ⚠️ 가장 느린 데이터를 기다려야 전체 HTML 전송 가능 │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
│ Streaming SSR (renderToPipeableStream): │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ 셸 렌더링 (0.1초) ──→ [헤더+네비게이션 HTML] → 브라우저 │ │
│ │ (즉시 표시!) │ │
│ │ │ │
│ │ 메인 콘텐츠 (1초) ──→ [기사 목록 HTML] ────→ 브라우저 │ │
│ │ (셸에 삽입!) │ │
│ │ │ │
│ │ 추천 사이드바 (3초) → [추천 HTML] ────────→ 브라우저 │ │
│ │ (나머지에 삽입!) │ │
│ │ │ │
│ │ ⚡ 느린 부분을 기다리지 않고 준비된 순서대로 전송! │ │
│ └──────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
// Streaming SSR 코드 예시 (React 18)
import { renderToPipeableStream } from 'react-dom/server';
app.get('*', (req, res) => {
const { pipe, abort } = renderToPipeableStream(
<App url={req.url} />,
{
bootstrapScripts: ['/client.js'],
onShellReady() {
// 셸(Suspense 바깥 콘텐츠)이 준비되면 스트리밍 시작
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html');
pipe(res);
},
onShellError(error) {
// 셸 렌더링 실패 시 CSR 폴백
res.statusCode = 500;
res.send('<html><body><div id="root"></div></body></html>');
},
onError(error) {
console.error(error);
}
}
);
// 타임아웃 설정
setTimeout(() => abort(), 10000);
});
// Suspense 경계로 스트리밍 구간 정의
function App() {
return (
<html>
<body>
<Header /> {/* 즉시 스트리밍 */}
<Navigation /> {/* 즉시 스트리밍 */}
<Suspense fallback={<ArticleSkeleton />}>
<ArticleList /> {/* 데이터 준비되면 스트리밍 */}
</Suspense>
<Suspense fallback={<RecommendSkeleton />}>
<Recommendations />{/* 느린 API, 나중에 스트리밍 */}
</Suspense>
<Footer /> {/* 즉시 스트리밍 */}
</body>
</html>
);
}
9.2 Selective Hydration (React 18)
Suspense 경계 단위로 독립적으로 하이드레이션한다. 사용자 클릭이 발생하면 해당 영역의 하이드레이션 우선순위를 높인다.
┌─────────────────────────────────────────────────────────────────────┐
│ Selective Hydration 동작 │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Header [✅ Hydrated] │ │
│ ├─────────────────────────────────────────────────┤ │
│ │ ArticleList [🔄 Hydrating...] │ │
│ │ │ │
│ │ 기사1 │ 기사2 │ 기사3 │ │
│ ├─────────────────────────────────────────────────┤ │
│ │ Comments [⏳ 대기 중] │ │
│ │ │ │
│ │ 사용자가 여기를 클릭! 👆 │ │
│ │ → React가 Comments의 하이드레이션 우선순위를 │ │
│ │ ArticleList보다 높게 올림! │ │
│ │ → Comments가 먼저 하이드레이션 완료 │ │
│ ├─────────────────────────────────────────────────┤ │
│ │ Sidebar [⏳ 대기 중] │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ 순서: Header → (사용자 클릭) → Comments → ArticleList → Sidebar │
│ 원래: Header → ArticleList → Comments → Sidebar │
└─────────────────────────────────────────────────────────────────────┘
9.3 Progressive Hydration
코드 청크가 도착하는 순서대로 점진적으로 하이드레이션하는 방식. 전체 앱의 JS를 한 번에 로드하지 않고, 각 부분이 독립적으로 인터랙티브해진다.
Wix Engineering 연구 결과: Progressive Hydration 적용으로 상호작용 시간(TTI) 40% 개선 달성.
10. SSG (Static Site Generation)
10.1 SSG 작동 원리
┌─────────────────────────────────────────────────────────────────────┐
│ SSG 빌드 및 배포 흐름 │
│ │
│ 빌드 시점 (CI/CD): │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ $ next build │ │
│ │ │ │
│ │ 데이터 소스 빌드 프로세스 출력 │ │
│ │ ┌──────────┐ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ CMS API │ ──→ │ │ ──→ │ /index.html │ │ │
│ │ │ Markdown │ ──→ │ SSG 엔진 │ ──→ │ /about.html │ │ │
│ │ │ Database │ ──→ │ (빌드타임 │ ──→ │ /blog/1.html │ │ │
│ │ │ JSON │ ──→ │ 렌더링) │ ──→ │ /blog/2.html │ │ │
│ │ └──────────┘ └──────────────┘ │ ... │ │ │
│ │ │ /blog/N.html │ │ │
│ │ └──────────────┘ │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ 배포 │
│ 런타임 (사용자 요청): │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ ┌────────┐ GET /blog/1 ┌────────┐ │ │
│ │ │브라우저 │ ────────────→ │ CDN │ │ │
│ │ │ │ ← HTML ────── │ (엣지) │ ← 서버 실행 없음! │ │
│ │ └────────┘ └────────┘ 정적 파일 서빙만! │ │
│ │ │ │
│ │ ⚡ TTFB 거의 0 (CDN 캐시) │ │
│ │ ⚡ Lighthouse 100점 달성 가능 │ │
│ │ ⚡ 무한 확장 가능 (CDN 노드 수만큼) │ │
│ │ ⚡ 서버 비용 거의 없음 │ │
│ └─────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
10.2 Next.js SSG 코드 예시
// pages/blog/[slug].tsx (Pages Router SSG)
import { GetStaticPaths, GetStaticProps } from 'next';
export const getStaticPaths: GetStaticPaths = async () => {
// 빌드 시점에 모든 블로그 경로 수집
const posts = await cms.getAllPosts();
return {
paths: posts.map(post => ({
params: { slug: post.slug }
})),
fallback: false // 목록에 없는 경로는 404
// fallback: 'blocking' → 최초 요청 시 SSR 후 캐시
};
};
export const getStaticProps: GetStaticProps = async ({ params }) => {
// 빌드 시점에 각 페이지의 데이터 가져오기
const post = await cms.getPost(params!.slug as string);
return {
props: { post },
// revalidate: 3600 ← ISR: 1시간마다 재생성
};
};
export default function BlogPost({ post }) {
return (
<article>
<h1>{post.title}</h1>
<time>{post.date}</time>
<div dangerouslySetInnerHTML= />
</article>
);
}
10.3 SSG 프레임워크 비교
| 프레임워크 | 언어 | 특징 | 적합한 용도 |
|---|---|---|---|
| Next.js | React | SSG/SSR/ISR 모두 지원, 풀스택 | 하이브리드 앱 |
| Gatsby | React | GraphQL 데이터 레이어, 풍부한 플러그인 | 콘텐츠 중심 사이트 |
| Astro | 다중 프레임워크 | Zero JS 기본, 아일랜드, 콘텐츠 레이어 | 콘텐츠/마케팅 |
| Eleventy | JavaScript | 최소 의견, 다양한 템플릿 | 단순한 정적 사이트 |
| Hugo | Go | 빌드 속도 최고 (ms 단위) | 대규모 정적 사이트 |
10.4 SSG의 장단점
| 강점 | 약점 |
|---|---|
| TTFB 거의 0 (CDN 직접 서빙) | 데이터가 빌드 시점에 고정 (stale) |
| Lighthouse 만점 가능 | 페이지 수 증가 → 빌드 시간 급증 |
| 무한 확장 가능 | 사용자별 개인화 불가 |
| 서버 비용 매우 낮음 | 실시간 데이터 반영 불가 |
| 보안 (서버 없음) | 수만 페이지 → 빌드 수십 분 |
11. ISR (Incremental Static Regeneration)
11.1 ISR 작동 원리
Next.js에서 도입한 stale-while-revalidate 전략. SSG의 장점(빠른 응답)과 SSR의 장점(신선한 데이터)을 결합한다.
┌─────────────────────────────────────────────────────────────────────┐
│ ISR 작동 흐름 │
│ │
│ 빌드 시점: 페이지 미리 생성 (SSG와 동일) │
│ │
│ 시간 ──────────────────────────────────────────────────────► │
│ │
│ ┌──────────────┐ revalidate: 60 (60초마다 재검증) │
│ │ 빌드 완료 │ │
│ └──────┬───────┘ │
│ │ │
│ 요청 1 │ (t=0초) │
│ ├──────┤ 캐시된 페이지 즉시 반환 ✅ │
│ │ │
│ 요청 2 │ (t=30초, 60초 이내) │
│ ├──────┤ 캐시된 페이지 즉시 반환 ✅ (아직 유효) │
│ │ │
│ 요청 3 │ (t=70초, 60초 초과!) │
│ ├──────┤ ① 캐시된(stale) 페이지 즉시 반환 ✅ (사용자는 기다리지 않음)│
│ │ │ ② 백그라운드에서 새 페이지 생성 시작 🔄 │
│ │ │ └→ 서버가 데이터 다시 가져와서 HTML 재생성 │
│ │ │ └→ 완료되면 캐시 교체 │
│ │ │
│ 요청 4 │ (t=75초) │
│ ├──────┤ 새로 생성된 페이지 반환 ✅ (최신 데이터!) │
│ │ │
└─────────────────────────────────────────────────────────────────────┘
11.2 ISR 코드 예시
// pages/products/[id].tsx
export const getStaticProps: GetStaticProps = async ({ params }) => {
const product = await fetchProduct(params!.id as string);
return {
props: { product },
revalidate: 60, // 60초마다 재검증
};
};
// On-Demand ISR (Next.js 12.2+)
// pages/api/revalidate.ts - Webhook에서 호출
import { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
// CMS에서 콘텐츠 업데이트 시 webhook으로 호출
const { secret, path } = req.query;
if (secret !== process.env.REVALIDATION_SECRET) {
return res.status(401).json({ message: 'Invalid token' });
}
try {
// 특정 경로만 재생성
await res.revalidate(path as string);
// 또는 태그 기반 재검증 (App Router)
// revalidateTag('products');
// revalidatePath('/products');
return res.json({ revalidated: true });
} catch (err) {
return res.status(500).send('Error revalidating');
}
}
ISR 요구 사항: Node.js 런타임 필요 (순수 정적 내보내기로는 사용 불가). Vercel, AWS Lambda, Docker 등에서 실행 가능.
12. React Server Components (RSC)
12.1 RSC는 SSR이 아니다
RSC와 SSR은 근본적으로 다른 개념이다. 혼동하기 쉽지만 실행 시점, 출력, 전송되는 JS, 상태 유지 등에서 완전히 다르다.
┌─────────────────────────────────────────────────────────────────────┐
│ RSC vs SSR 핵심 차이점 │
│ │
│ ┌────────────┬──────────────────────┬────────────────────────┐ │
│ │ 구분 │ SSR │ RSC │ │
│ ├────────────┼──────────────────────┼────────────────────────┤ │
│ │ 실행 시점 │ 매 요청마다 서버에서 │ 서버에서 (빌드/요청) │ │
│ │ 출력 │ HTML 문자열 │ RSC Payload (JSON-like)│ │
│ │ JS 전송 │ 모든 컴포넌트 JS 전송│ 서버 컴포넌트 JS 없음 │ │
│ │ 재렌더링 │ 불가 (정적 HTML) │ 서버에서 재실행 가능 │ │
│ │ 상태 유지 │ Hydration으로 복원 │ 클라이언트 상태 유지됨 │ │
│ │ 목적 │ 초기 HTML 빠른 전달 │ JS 번들 크기 제거 │ │
│ │ 클라이언트 │ 전체 트리 Hydration │ Client Component만 │ │
│ │ 결합 │ SSR 단독 사용 가능 │ SSR과 함께 사용 │ │
│ └────────────┴──────────────────────┴────────────────────────┘ │
│ │
│ RSC의 실제 흐름: │
│ 서버 ──[RSC Payload]──→ 클라이언트 │
│ (React 트리의 직렬화된 표현, HTML이 아님!) │
│ 클라이언트 React가 기존 트리에 병합 → 상태 유지하면서 UI 업데이트 │
└─────────────────────────────────────────────────────────────────────┘
12.2 RSC 작동 방식
┌─────────────────────────────────────────────────────────────────────┐
│ RSC 실행 흐름 상세 │
│ │
│ 서버 클라이언트 │
│ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ Server Component │ │ Client Component │ │
│ │ ┌─────────────────┐ │ │ ┌─────────────────┐ │ │
│ │ │ DB 직접 접근 │ │ │ │ useState │ │ │
│ │ │ 파일시스템 읽기 │ │ │ │ useEffect │ │ │
│ │ │ API 키 사용 │ │ ──→ │ │ onClick │ │ │
│ │ │ 무거운 라이브러리│ │ RSC │ │ 브라우저 API │ │ │
│ │ │ (JS 전송 안 됨) │ │ Payload │ │ (JS 전송됨) │ │ │
│ │ └─────────────────┘ │ │ └─────────────────┘ │ │
│ └─────────────────────┘ └─────────────────────┘ │
│ │
│ "use client" 디렉티브가 경계를 결정: │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Server Component (기본값) │ │
│ │ ├── Server Component │ │
│ │ ├── Server Component │ │
│ │ └── "use client" ← 여기서부터 Client Component │ │
│ │ ├── Client Component (JS 번들 포함) │ │
│ │ └── Client Component (JS 번들 포함) │ │
│ │ └── Server Component도 자식 가능! (children으로) │ │
│ └─────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
12.3 RSC 코드 예시
// app/products/page.tsx - Server Component (기본값, 별도 선언 불필요)
import { db } from '@/lib/database'; // 서버 전용 모듈
import { marked } from 'marked'; // 300KB 라이브러리, 클라이언트에 전송 안 됨!
import { ProductFilter } from './filter'; // Client Component
export default async function ProductsPage() {
// 서버에서 직접 DB 접근 - API 라우트 불필요
const products = await db.product.findMany({
where: { published: true },
orderBy: { createdAt: 'desc' },
});
return (
<div>
<h1>상품 목록</h1>
{/* Client Component: 상호작용이 필요한 부분만 */}
<ProductFilter />
{/* 1000개 상품 렌더링 → HTML만 전송, 컴포넌트 JS 코드 전송 안 됨! */}
<ul>
{products.map(product => (
<li key={product.id}>
<h2>{product.name}</h2>
{/* marked 라이브러리가 서버에서 실행, 결과 HTML만 전송 */}
<div dangerouslySetInnerHTML= />
<span>₩{product.price.toLocaleString()}</span>
{/* 장바구니 버튼만 Client Component */}
<AddToCartButton productId={product.id} />
</li>
))}
</ul>
</div>
);
}
// app/products/filter.tsx - Client Component
'use client'; // ← 이 디렉티브가 클라이언트 경계를 표시
import { useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
export function ProductFilter() {
const [search, setSearch] = useState('');
const [isPending, startTransition] = useTransition();
const router = useRouter();
const handleSearch = (value: string) => {
setSearch(value);
startTransition(() => {
router.push(`/products?q=${value}`);
});
};
return (
<input
type="text"
value={search}
onChange={(e) => handleSearch(e.target.value)}
placeholder="상품 검색..."
/>
);
}
12.4 RSC Payload 형식
┌─────────────────────────────────────────────────────────────────────┐
│ RSC Payload 구조 │
│ │
│ 서버가 전송하는 RSC Payload (스트리밍 JSON-like 형식): │
│ │
│ 0:["$","div",null,{"children":[ │
│ ["$","h1",null,{"children":"상품 목록"}], │
│ ["$","$Lclient-filter",null,{}], ← Client Component 참조 │
│ ["$","ul",null,{"children":[ │
│ ["$","li","prod-1",{"children":[ │
│ ["$","h2",null,{"children":"React 완벽 가이드"}], │
│ ["$","div",null,{"dangerouslySetInnerHTML": │
│ {"__html":"<p>렌더링된 마크다운</p>"}}], │
│ ["$","span",null,{"children":"₩35,000"}], │
│ ["$","$Lclient-cart-btn",null,{"productId":"prod-1"}] │
│ ]}] │
│ ]}] │
│ ]}] │
│ │
│ 특징: │
│ - HTML이 아님! React 트리의 직렬화된 표현 │
│ - Server Component 코드는 포함되지 않음 │
│ - Client Component는 참조($L...)만 포함 │
│ - 클라이언트 React가 이를 받아 기존 트리에 병합 │
│ - 기존 Client Component의 state가 유지됨 │
└─────────────────────────────────────────────────────────────────────┘
13. Islands Architecture
13.1 아일랜드 아키텍처 개념
Katie Sylor-Miller (2020)가 제안하고 Jason Miller (Preact 창시자)가 대중화한 아키텍처. 정적 HTML의 “바다” 위에 상호작용 가능한 JavaScript “섬”을 배치한다.
┌─────────────────────────────────────────────────────────────────────┐
│ Islands Architecture 시각화 │
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ 정적 HTML (바다) 🌊 │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────────────┐ │ │
│ │ │ Header (정적 HTML, JS 없음) │ │ │
│ │ └──────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌────────────────┐ ┌───────────────────────┐ │ │
│ │ │ 🏝️ 검색 위젯 │ │ 🏝️ 사용자 메뉴 │ │ │
│ │ │ (Interactive │ │ (Interactive │ │ │
│ │ │ Island, JS) │ │ Island, JS) │ │ │
│ │ └────────────────┘ └───────────────────────┘ │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────────────┐ │ │
│ │ │ 기사 본문 (정적 HTML, JS 없음) │ │ │
│ │ │ 텍스트, 이미지, 마크다운 렌더링 결과... │ │ │
│ │ │ 수천 단어의 콘텐츠도 JS 비용 0! │ │ │
│ │ └──────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌────────────────┐ │ │
│ │ │ 🏝️ 댓글 섹션 │ │ │
│ │ │ (Interactive │ │ │
│ │ │ Island, JS) │ │ │
│ │ └────────────────┘ │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────────────┐ │ │
│ │ │ Footer (정적 HTML, JS 없음) │ │ │
│ │ └──────────────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
│ JS 전송량: 전체 페이지의 10-20%에 해당하는 아일랜드 코드만! │
│ 기존 SPA: 페이지 전체를 렌더링하는 JS 번들 전송 (100%) │
└─────────────────────────────────────────────────────────────────────┘
13.2 Astro의 Islands 구현
---
// src/pages/blog/[slug].astro
import Header from '../components/Header.astro'; // 정적 (JS 없음)
import SearchWidget from '../components/SearchWidget'; // React 컴포넌트
import UserMenu from '../components/UserMenu.vue'; // Vue 컴포넌트도 가능!
import Comments from '../components/Comments.svelte'; // Svelte도!
import Footer from '../components/Footer.astro'; // 정적
const { slug } = Astro.params;
const post = await getPost(slug);
---
<html>
<body>
<Header /> <!-- 정적 HTML, JS 0 -->
<!-- client:load → 즉시 하이드레이션 -->
<SearchWidget client:load />
<!-- client:idle → requestIdleCallback 때 하이드레이션 -->
<UserMenu client:idle />
<article>
<!-- 정적 콘텐츠, JS 0 -->
<h1>{post.title}</h1>
<div set:html={post.content} />
</article>
<!-- client:visible → IntersectionObserver, 뷰포트에 보일 때 하이드레이션 -->
<Comments client:visible postId={post.id} />
<!-- client:media → 미디어 쿼리 조건 충족 시 하이드레이션 -->
<!-- <MobileMenu client:media="(max-width: 768px)" /> -->
<!-- client:only="react" → SSR 건너뛰고 CSR만 (window 필요한 컴포넌트) -->
<!-- <MapWidget client:only="react" /> -->
<Footer /> <!-- 정적 HTML, JS 0 -->
</body>
</html>
Astro Hydration 디렉티브 요약:
| 디렉티브 | 하이드레이션 시점 | 사용 시나리오 |
|---|---|---|
client:load |
페이지 로드 즉시 | 즉시 상호작용 필요 (검색, 내비게이션) |
client:idle |
브라우저 유휴 시 (requestIdleCallback) |
중요하지만 즉시 필요하지 않은 UI |
client:visible |
뷰포트에 보일 때 (IntersectionObserver) |
스크롤해야 보이는 콘텐츠 (댓글, 하단 위젯) |
client:media |
미디어 쿼리 충족 시 | 모바일 전용 메뉴, 반응형 위젯 |
client:only |
CSR 전용 (SSR 안 함) | window/document 필수인 컴포넌트 |
14. Resumability (Qwik)
14.1 핵심 개념
Qwik의 Resumability는 하이드레이션의 근본적 문제를 해결하기 위한 패러다임이다. 서버의 전체 실행 상태를 HTML에 직렬화하고, 클라이언트에서 재실행 없이 이어서 시작한다.
┌─────────────────────────────────────────────────────────────────────┐
│ Hydration vs Resumability 비교 │
│ │
│ Hydration (React, Vue, Angular, Svelte): │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ 서버: 렌더링 → HTML 전송 │ │
│ │ 클라이언트: │ │
│ │ 1. 전체 프레임워크 JS 다운로드 ← O(n) 다운로드 │ │
│ │ 2. 모든 컴포넌트 코드 다운로드 ← O(n) 다운로드 │ │
│ │ 3. 컴포넌트 트리 처음부터 재실행 ← O(n) 실행 │ │
│ │ 4. 이벤트 리스너 부착 ← O(n) DOM 순회 │ │
│ │ │ │
│ │ 비유: 영화를 처음부터 다시 재생하여 │ │
│ │ 일시정지했던 장면까지 빨리감기 │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
│ Resumability (Qwik): │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ 서버: 렌더링 → HTML + 직렬화된 상태 전송 │ │
│ │ 클라이언트: │ │
│ │ 1. HTML에서 상태 읽기 ← O(1) 즉시 │ │
│ │ 2. 글로벌 이벤트 리스너 1개 등록 ← O(1) │ │
│ │ 3. 사용자 이벤트 발생 시 해당 핸들러만 ← 지연 로드 │ │
│ │ 다운로드하여 실행 │ │
│ │ │ │
│ │ 비유: 일시정지한 장면에서 바로 재생 버튼 누르기 │ │
│ └──────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
14.2 직렬화되는 정보
<!-- Qwik이 HTML에 직렬화하는 정보 -->
<button
on:click="./chunk-abc.js#handleClick"
q:id="1"
>
좋아요 (42)
</button>
<!--
직렬화되는 것들:
1. 컴포넌트 경계: 어디서 컴포넌트가 시작/끝나는지
2. 이벤트 리스너: URL 형태로 (./chunk-abc.js#handleClick)
→ 클릭 시 해당 청크만 다운로드!
3. 반응성 그래프: 어떤 상태가 어떤 UI에 연결되는지
4. 애플리케이션 상태: 컴포넌트 state 값
-->
<script type="qwik/json">
{
"ctx": { "1": { "count": 42 } },
"subs": [["1", "0", "click"]],
"objs": [42]
}
</script>
14.3 O(1) Startup vs O(n) Hydration
┌─────────────────────────────────────────────────────────────────────┐
│ 시작 비용 비교 (컴포넌트 수에 따른 확장) │
│ │
│ 시작 │ │
│ 시간 │ ╱ Hydration O(n) │
│ (ms) │ ╱ │
│ │ ╱ │
│ 1000 │ ╱ │
│ │ ╱ │
│ │ ╱ │
│ 500 │ ╱ │
│ │ ╱ │
│ │ ╱ │
│ 100 │──────────────────────────────── Resumability O(1) │
│ │ │
│ 0 └──────────────────────────────────────────── │
│ 10 50 100 500 1000 5000 10000 │
│ 컴포넌트 수 │
│ │
│ Resumability는 컴포넌트 수에 관계없이 시작 시간이 일정하다. │
│ 앱이 커질수록 Hydration과의 성능 격차가 커진다. │
└─────────────────────────────────────────────────────────────────────┘
14.4 Qwik의 지연 로딩
// Qwik: 이벤트 핸들러 JS는 사용자가 트리거할 때만 다운로드
import { component$, useSignal } from '@builder.io/qwik';
export const Counter = component$(() => {
const count = useSignal(0);
return (
<div>
<p>카운트: {count.value}</p>
{/* onClick$ 의 $ 기호 = 지연 로드 경계
이 핸들러의 JS는 사용자가 실제로 클릭할 때만 다운로드됨! */}
<button onClick$={() => count.value++}>
증가
</button>
</div>
);
});
// 결과:
// 1. 초기 로드: 0KB JS (HTML만 전송)
// 2. 사용자가 버튼 클릭
// 3. 클릭 핸들러 청크 다운로드 (~1KB)
// 4. 핸들러 실행, UI 업데이트
Qwik 채택 현황 (2025-2026): 성장 중이지만 아직 니치(niche) 시장. Builder.io가 주도적으로 개발. 커뮤니티와 에코시스템은 React/Vue/Svelte 대비 작지만, 성능 중심 프로젝트에서 점진적 채택 확대 중.
15. Edge Rendering
15.1 Edge Rendering 개념
서버 코드를 CDN 엣지 노드(전 세계 100-300+곳)에서 실행하여 사용자와 물리적으로 가까운 위치에서 응답을 생성한다.
┌─────────────────────────────────────────────────────────────────────┐
│ 기존 SSR vs Edge Rendering │
│ │
│ 기존 SSR (Origin Server): │
│ ┌────────┐ ┌───────┐ ┌──────────┐ │
│ │ 한국 │ ─────→ │ CDN │ ─────→ │ Origin │ │
│ │ 사용자 │ ←───── │ (캐시 │ ←───── │ 서버 │ │
│ │ │ │ 미스) │ │ (미국) │ │
│ └────────┘ └───────┘ └──────────┘ │
│ RTT: ~200ms 서버 렌더링: ~100ms │
│ 총 TTFB: ~300ms+ │
│ │
│ Edge Rendering: │
│ ┌────────┐ ┌───────────────────────┐ │
│ │ 한국 │ ─────→ │ Edge Node (서울) │ │
│ │ 사용자 │ ←───── │ 서버 코드 실행! │ │
│ └────────┘ │ V8 Isolate 기반 │ │
│ RTT: ~10ms │ <1ms 콜드 스타트 │ │
│ 총 TTFB: ~20ms └───────────────────────┘ │
│ │
│ ⚡ 10-15x 더 빠른 TTFB! │
└─────────────────────────────────────────────────────────────────────┘
15.2 Edge 플랫폼 비교
| 플랫폼 | 런타임 | 엣지 노드 수 | 콜드 스타트 | 특징 |
|---|---|---|---|---|
| Vercel Edge Runtime | V8 Isolate | 30+ 리전 | <1ms | Next.js 통합, Fluid Compute |
| Cloudflare Workers | V8 Isolate | 300+ | <1ms | 최대 노드 수, KV/D1/R2 스토리지 |
| Deno Deploy | V8 Isolate | 35+ 리전 | <1ms | TypeScript 네이티브, Fresh 프레임워크 |
| Netlify Edge | Deno | 30+ 리전 | <5ms | Netlify 생태계 통합 |
15.3 V8 Isolate 모델
┌─────────────────────────────────────────────────────────────────────┐
│ V8 Isolate vs Container/VM 비교 │
│ │
│ 전통적 서버리스 (AWS Lambda): │
│ ┌────────────────────────────────────────┐ │
│ │ VM / Container │ │
│ │ ┌──────────────────────────────────┐ │ │
│ │ │ OS + Node.js Runtime │ │ │
│ │ │ ┌────────────────────────────┐ │ │ │
│ │ │ │ Your Application Code │ │ │ │
│ │ │ └────────────────────────────┘ │ │ │
│ │ └──────────────────────────────────┘ │ │
│ └────────────────────────────────────────┘ │
│ 콜드 스타트: 100ms ~ 수초 │
│ 메모리: 128MB ~ 10GB │
│ API: 전체 Node.js │
│ │
│ V8 Isolate (Edge Runtime): │
│ ┌────────────────────────────────────────┐ │
│ │ 공유 V8 Engine │ │
│ │ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │ │
│ │ │Iso 1 │ │Iso 2 │ │Iso 3 │ │Iso 4 │ │ ← 수천 개 동시 실행 │
│ │ │(앱A) │ │(앱B) │ │(앱C) │ │(앱D) │ │ │
│ │ └──────┘ └──────┘ └──────┘ └──────┘ │ │
│ └────────────────────────────────────────┘ │
│ 콜드 스타트: <1ms (V8 컨텍스트 생성만) │
│ 메모리: 128MB 제한 │
│ API: 제한적 (Web API 기반, Node.js 일부만) │
└─────────────────────────────────────────────────────────────────────┘
15.4 Edge에서 적합한/부적합한 작업
| Edge에 적합 | Edge에 부적합 |
|---|---|
| A/B 테스트 분기 | 무거운 DB 쿼리 |
| JWT 토큰 검증 | CPU 집약적 연산 |
| 지역(geo) 기반 라우팅 | Node.js 전체 API 필요 |
| 경량 SSR / 미들웨어 | 대용량 파일 처리 |
| 헤더/쿠키 조작 | 장시간 실행 작업 |
| 리다이렉트/리라이트 | ORM (Prisma 등) 직접 사용 |
| 캐시 전략 결정 | 전체 Node.js 네이티브 모듈 |
Vercel Fluid Compute (2025): Vercel이 발표한 새로운 엣지 컴퓨팅 모델. Cloudflare Workers 대비 2.55x 빠른 성능을 보고. 연결 재사용, 지능적 라우팅, 스마트 캐싱으로 최적화.
16. Partial Prerendering (PPR)
16.1 PPR 개념
Next.js가 도입한 혁신적 렌더링 방식. 하나의 HTTP 요청에서 SSG(정적 셸)와 Streaming SSR(동적 부분)을 결합한다. 페이지 단위가 아닌 컴포넌트 단위로 렌더링 전략을 결정할 수 있다.
┌─────────────────────────────────────────────────────────────────────┐
│ PPR 작동 방식 │
│ │
│ 빌드 시점: │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ 정적 셸 생성 (Suspense 바깥 모든 것) │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────┐ │ │
│ │ │ 헤더, 네비게이션, 레이아웃 │ ← 빌드타임 │ │
│ │ │ 상품 이미지, 설명, 가격 │ HTML 생성 │ │
│ │ │ │ │ │
│ │ │ ┌─ Suspense ──────────────────────────┐ │ │ │
│ │ │ │ [placeholder / fallback] │ │ ← "구멍" │ │
│ │ │ └────────────────────────────────────┘ │ │ │
│ │ │ │ │ │
│ │ │ ┌─ Suspense ──────────────────────────┐ │ │ │
│ │ │ │ [placeholder / fallback] │ │ ← "구멍" │ │
│ │ │ └────────────────────────────────────┘ │ │ │
│ │ │ │ │ │
│ │ │ 푸터 │ ← 빌드타임 │ │
│ │ └──────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
│ 요청 시점: │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ 1. 정적 셸 즉시 전송 (CDN에서) ──→ FCP 매우 빠름! │ │
│ │ │ │
│ │ 2. Suspense "구멍"을 서버에서 동적으로 채움 (스트리밍) │ │
│ │ ├── 사용자별 장바구니 상태 │ │
│ │ ├── 실시간 재고 수량 │ │
│ │ └── 개인화된 추천 │ │
│ │ │ │
│ │ 3. 채워진 HTML이 스트리밍으로 도착하여 셸의 빈 곳에 삽입 │ │
│ └──────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
16.2 PPR 코드 예시
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
experimental: {
ppr: 'incremental', // 점진적 PPR 활성화
},
};
export default nextConfig;
// app/products/[id]/page.tsx
import { Suspense } from 'react';
// PPR 활성화 (라우트별)
export const experimental_ppr = true;
// 정적 부분: 빌드 시점에 생성
async function ProductInfo({ id }: { id: string }) {
const product = await db.product.findById(id);
return (
<div>
<h1>{product.name}</h1>
<img src={product.image} alt={product.name} />
<p>{product.description}</p>
<span>₩{product.price.toLocaleString()}</span>
</div>
);
}
// 동적 부분: 요청 시점에 스트리밍
async function StockStatus({ id }: { id: string }) {
const stock = await inventory.getStock(id); // 실시간 데이터
return <span>{stock > 0 ? `재고 ${stock}개` : '품절'}</span>;
}
async function PersonalizedRecommendations() {
const user = await auth.getUser(); // 요청별 다름
const recs = await ai.getRecommendations(user.id);
return (
<ul>
{recs.map(r => <li key={r.id}>{r.name}</li>)}
</ul>
);
}
export default function ProductPage({ params }: { params: { id: string } }) {
return (
<div>
{/* 정적: 빌드타임 생성, CDN 캐시 */}
<ProductInfo id={params.id} />
{/* 동적: Suspense 경계 = PPR의 "구멍" */}
<Suspense fallback={<span>재고 확인 중...</span>}>
<StockStatus id={params.id} />
</Suspense>
<Suspense fallback={<RecommendationSkeleton />}>
<PersonalizedRecommendations />
</Suspense>
</div>
);
}
PPR이 중요한 이유: 기존에는 페이지 전체를 SSG/SSR/ISR 중 하나로 선택해야 했다. PPR은 같은 페이지 내에서 컴포넌트 단위로 정적/동적을 결정할 수 있게 한다.
17. 2025-2026 프론트엔드 프레임워크 트렌드
17.1 React 19
React 19는 2024년 12월 정식 출시되어 2025-2026년의 핵심 기술이다.
┌─────────────────────────────────────────────────────────────────────┐
│ React 19 주요 변경 사항 │
│ │
│ 1. React Compiler (자동 메모이제이션) │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ 이전: 개발자가 수동으로 메모이제이션 관리 │ │
│ │ useMemo(), useCallback(), React.memo() │ │
│ │ │ │
│ │ React 19: 컴파일러가 자동으로 최적화 │ │
│ │ - 불필요한 재렌더링 자동 방지 │ │
│ │ - useMemo/useCallback 대부분 불필요 │ │
│ │ - 빌드 타임에 최적화 코드 생성 │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
│ 2. Actions API │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ // 폼 처리의 새로운 패러다임 │ │
│ │ async function submitForm(formData: FormData) { │ │
│ │ 'use server'; │ │
│ │ const name = formData.get('name'); │ │
│ │ await db.users.create({ name }); │ │
│ │ } │ │
│ │ │ │
│ │ function Form() { │ │
│ │ return ( │ │
│ │ <form action={submitForm}> │ │
│ │ <input name="name" /> │ │
│ │ <button type="submit">제출</button> │ │
│ │ </form> │ │
│ │ ); │ │
│ │ } │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
│ 3. use() Hook │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ // Promise를 직접 읽을 수 있는 새 훅 │ │
│ │ function UserProfile({ userPromise }) { │ │
│ │ const user = use(userPromise); // Suspense와 연동 │ │
│ │ return <h1>{user.name}</h1>; │ │
│ │ } │ │
│ │ │ │
│ │ // Context도 use()로 읽기 가능 │ │
│ │ function Theme() { │ │
│ │ const theme = use(ThemeContext); │ │
│ │ // 조건부 사용 가능! (useContext와 달리) │ │
│ │ } │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
│ 4. 기타 변경 사항 │
│ ├── ref가 일반 prop으로 전달 (forwardRef 불필요) │
│ ├── Context가 직접 Provider 역할 (<MyContext> vs <MyContext.Provider>)│
│ ├── 문서 메타데이터 호이스팅 (<title>, <meta> 컴포넌트 어디서든) │
│ ├── useFormStatus(), useFormState() → 폼 상태 관리 │
│ └── useOptimistic() → 낙관적 업데이트 │
└─────────────────────────────────────────────────────────────────────┘
17.2 Next.js 15/16
┌─────────────────────────────────────────────────────────────────────┐
│ Next.js 15/16 핵심 변화 │
│ │
│ Next.js 15: │
│ ├── Turbopack 기본 번들러 (webpack 대체, 10x 빠른 HMR) │
│ ├── React Compiler 내장 지원 │
│ ├── PPR (Partial Prerendering) 점진적 도입 │
│ ├── Async Request APIs (headers, cookies, params 비동기화) │
│ └── Enhanced caching (fetch 캐시 기본값 변경) │
│ │
│ Next.js 16 (2025-2026): │
│ ├── `use cache` 디렉티브 │
│ │ ┌────────────────────────────────────────────────┐ │
│ │ │ async function getProducts() { │ │
│ │ │ 'use cache'; │ │
│ │ │ // 자동 캐싱 + ISR-like 재검증 │ │
│ │ │ return await db.products.findMany(); │ │
│ │ │ } │ │
│ │ └────────────────────────────────────────────────┘ │
│ │ │
│ ├── proxy.ts (middleware.ts 대체) │
│ │ → Edge에서만 실행되던 미들웨어를 Node.js에서도 실행 가능 │
│ │ → 더 강력한 요청 프록시 기능 │
│ │ │
│ └── React Compiler 기본 활성화 │
└─────────────────────────────────────────────────────────────────────┘
17.3 Astro 5
┌─────────────────────────────────────────────────────────────────────┐
│ Astro 5 핵심 기능 │
│ │
│ 1. Content Layer (콘텐츠 레이어) │
│ ├── 모든 데이터 소스를 통합하는 타입 안전한 API │
│ ├── 빌드 속도 5배 향상 │
│ └── CMS, API, 로컬 파일 등 어디서든 데이터 가져오기 │
│ │
│ 2. Server Islands (서버 아일랜드) │
│ ├── 정적 페이지 안에 동적 서버 렌더링 컴포넌트 삽입 │
│ ├── Next.js PPR과 유사한 개념을 Astro 방식으로 구현 │
│ └── server:defer 디렉티브로 선언 │
│ │
│ 3. astro:env │
│ ├── 타입 안전한 환경 변수 관리 │
│ └── 런타임/빌드타임 환경 변수 구분 │
│ │
│ 4. Output 모드 단순화 │
│ ├── static (SSG 전용) │
│ ├── server (SSR, 하이브리드 가능) │
│ └── hybrid 모드를 server에 통합 │
└─────────────────────────────────────────────────────────────────────┘
17.4 Svelte 5 Runes
Svelte 5는 Runes 시스템으로 반응성 모델을 근본적으로 재설계했다.
// Svelte 4 (이전 방식 - 컴파일러 매직)
let count = 0; // 자동 반응성 (let 키워드만으로)
$: doubled = count * 2; // 파생 값 ($: 레이블)
// Svelte 5 Runes (새 방식 - 명시적 시그널)
let count = $state(0); // $state(): 반응성 상태 선언
let doubled = $derived(count * 2); // $derived(): 파생 값
// $effect(): 부수 효과 (React의 useEffect와 유사)
$effect(() => {
console.log(`count가 ${count}로 변경됨`);
// 의존성 자동 추적! 배열 불필요
});
// $props(): 컴포넌트 props 선언
let { name, age = 0 } = $props();
// $bindable(): 양방향 바인딩 가능한 prop
let { value = $bindable('') } = $props();
Svelte 5 Runes의 장점:
- 시그널 기반 미세 반응성 (fine-grained reactivity): DOM 노드 레벨 업데이트
- 15-30% 더 작은 번들: 컴파일러 최적화 개선
- .svelte.js 파일: 컴포넌트 밖에서도 반응성 사용 가능
- 더 예측 가능: 컴파일러 매직 대신 명시적 시그널
17.5 React Router v7 / Remix 합병
┌─────────────────────────────────────────────────────────────────────┐
│ React Router v7 / Remix 합병 구조 │
│ │
│ React Router v7 = Remix의 기능을 흡수 │
│ │
│ ┌─ Library Mode (기존 React Router처럼) ────────────────────────┐ │
│ │ npm install react-router │ │
│ │ → 클라이언트 라우팅만 (기존과 동일) │ │
│ │ → SPA에 적합 │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ Framework Mode (Remix의 기능 포함) ──────────────────────────┐ │
│ │ → SSR, 데이터 로딩 (loaders), 뮤테이션 (actions) │ │
│ │ → 파일 기반 라우팅 │ │
│ │ → 서버/클라이언트 코드 분리 │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │
│ Remix의 향후 방향: │
│ - Remix 3는 React에서 벗어나 프레임워크 독립적 방향 모색 │
│ - Preact, Svelte 등 다른 UI 라이브러리 지원 가능성 │
└─────────────────────────────────────────────────────────────────────┘
17.6 HTMX 르네상스
HTMX는 서버 중심 아키텍처의 부활을 이끌고 있다. 2024년 GitHub Star 기준 16,800개를 기록하며 급성장 중이다.
<!-- HTMX: 14KB로 SPA-like 경험 구현 -->
<!-- 클릭 시 서버에서 HTML 조각을 가져와 DOM에 삽입 -->
<button hx-get="/api/users"
hx-target="#user-list"
hx-swap="innerHTML"
hx-indicator="#loading">
사용자 목록 불러오기
</button>
<div id="loading" class="htmx-indicator">로딩 중...</div>
<div id="user-list"></div>
<!-- 폼 제출: 서버가 HTML 조각 반환 -->
<form hx-post="/api/users"
hx-target="#user-list"
hx-swap="beforeend">
<input name="name" placeholder="이름">
<button type="submit">추가</button>
</form>
<!-- 무한 스크롤 -->
<tr hx-get="/api/users?page=2"
hx-trigger="revealed"
hx-swap="afterend">
로딩 중...
</tr>
<!-- 실시간 검색 (debounce 포함) -->
<input type="search"
hx-get="/api/search"
hx-trigger="keyup changed delay:300ms"
hx-target="#results"
name="q">
┌─────────────────────────────────────────────────────────────────────┐
│ HTMX + HDA (Hypermedia-Driven Application) │
│ │
│ 전통 SPA: │
│ 클라이언트 ──JSON API──→ 서버 │
│ 클라이언트가 JSON을 받아 DOM 생성 (복잡한 JS 필요) │
│ │
│ HTMX / HDA: │
│ 클라이언트 ──HTMX 요청──→ 서버 │
│ 서버가 HTML 조각 반환 → 클라이언트가 DOM에 직접 삽입 │
│ (복잡한 JS 불필요!) │
│ │
│ 인기 스택: │
│ ├── HTMX + Alpine.js + Django/Rails/Go │
│ ├── 번들 크기: HTMX(14KB) + Alpine.js(15KB) = ~29KB │
│ ├── vs React SPA: 200KB+ (프레임워크만) │
│ └── 서버 렌더링이므로 SEO 자동 해결 │
└─────────────────────────────────────────────────────────────────────┘
17.7 Micro-Frontends 성숙화
┌─────────────────────────────────────────────────────────────────────┐
│ Micro-Frontends 아키텍처 │
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Host Application (Shell) │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ │
│ │ │ 팀 A: │ │ 팀 B: │ │ 팀 C: │ │ │
│ │ │ 상품 목록 │ │ 장바구니 │ │ 결제 │ │ │
│ │ │ React 18 │ │ Vue 3 │ │ Svelte 5 │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ 독립 배포 │ │ 독립 배포 │ │ 독립 배포 │ │ │
│ │ │ 독립 CI/CD │ │ 독립 CI/CD │ │ 독립 CI/CD │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
│ 구현 기술: │
│ ├── Module Federation (webpack 5 / Rspack) │
│ │ → 런타임에 다른 앱의 모듈을 동적 로드 │
│ ├── Single-SPA │
│ │ → 여러 프레임워크 앱을 하나의 페이지에 마운트 │
│ └── Web Components │
│ → 프레임워크 독립적 캡슐화 │
│ │
│ 2025-2026 트렌드: │
│ ├── "선택적 채택" 패턴: 전체 앱이 아닌 특정 영역만 마이크로화 │
│ ├── Module Federation 2.0: Rspack 기반, 더 빠른 빌드 │
│ └── 대기업 위주 채택 (Netflix, IKEA, Spotify) │
└─────────────────────────────────────────────────────────────────────┘
18. SPA vs MPA 트레이드오프 결정 가이드
18.1 시나리오별 추천 아키텍처
| 시나리오 | 추천 아키텍처 | 추천 프레임워크 | 이유 |
|---|---|---|---|
| 마케팅/랜딩 페이지 | SSG | Astro | 정적 콘텐츠, SEO 최우선, 최소 JS, Lighthouse 만점 |
| 블로그/문서 사이트 | SSG + ISR | Astro / Next.js | 콘텐츠 중심, 가끔 업데이트, 빠른 로드 |
| 이커머스 | SSR + PPR | Next.js | SEO + 개인화(장바구니) + 동적 재고, 컴포넌트별 전략 |
| SaaS 대시보드 | CSR (SPA) | React + Vite | 인증 뒤 SEO 불필요, 높은 상호작용, 복잡한 상태 관리 |
| 소셜 미디어 | SSR + Streaming | Next.js | 피드 동적, SEO 필요, Streaming으로 빠른 초기 로드 |
| 내부 관리 도구 | HTMX + Alpine.js | Django / Rails | 간단한 CRUD, 최소 JS, 빠른 개발 속도, 유지보수 용이 |
| 실시간 협업 도구 | CSR (SPA) | React / Svelte | WebSocket 중심, 높은 상호작용, 클라이언트 상태 유지 |
| 콘텐츠 중심 + 일부 인터랙션 | Islands | Astro | 대부분 정적, 검색/댓글만 인터랙티브, JS 최소화 |
| 대규모 기업 포탈 | Micro-Frontends | Module Federation | 다팀 독립 배포, 기술 스택 자유, 점진적 마이그레이션 |
| 성능 극한 최적화 | Resumability | Qwik | O(1) 시작, 대규모 앱에서 하이드레이션 비용 제거 |
18.2 렌더링 전략별 Core Web Vitals 비교
| 전략 | LCP | INP | CLS | 가장 적합한 용도 |
|---|---|---|---|---|
| CSR | 나쁨 (JS 실행 후) | 좋음 (Hydration 없음) | 보통 (JS 렌더링 시 이동) | 인증된 SPA, 대시보드 |
| SSR | 좋음 (HTML 즉시) | 보통 (Hydration 후) | 좋음 (서버 HTML 안정적) | 동적 콘텐츠, SEO 필요 |
| SSR + Streaming | 매우 좋음 (셸 즉시) | 좋음 (Selective Hydration) | 매우 좋음 | 복잡한 페이지, 느린 데이터 |
| SSG | 최고 (CDN 즉시) | 최고 (JS 최소) | 최고 (정적 레이아웃) | 정적 사이트, 블로그, 문서 |
| ISR | 최고 (캐시 히트 시) | 최고 | 최고 | 정적 + 주기적 업데이트 |
| PPR | 최고 (정적 셸 즉시) | 좋음 | 매우 좋음 | 정적 + 동적 혼합 |
| RSC | 좋음 | 매우 좋음 (JS 줄어듦) | 좋음 | 서버 데이터 중심, 무거운 라이브러리 |
| Islands | 최고 (정적 HTML) | 매우 좋음 (아일랜드만) | 최고 | 콘텐츠 중심 + 일부 인터랙션 |
| Resumability | 좋음 | 최고 (O(1) 시작) | 좋음 | 대규모 앱, 성능 극한 |
| Edge | 최고 (지리적 근접) | 보통 | 좋음 | 개인화, 지역 기반 콘텐츠 |
18.3 “Zero JS by Default” 트렌드
2025-2026년 프론트엔드의 핵심 방향은 기본적으로 JavaScript를 0으로 하고, 필요한 곳에만 추가하는 것이다.
┌─────────────────────────────────────────────────────────────────────┐
│ "Zero JS by Default" 패러다임 전환 │
│ │
│ 2015-2020년 (SPA 전성기): │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ 기본: 모든 것이 JavaScript │ │
│ │ → 200KB+ 프레임워크 번들 │ │
│ │ → 전체 앱 로직이 클라이언트에서 실행 │ │
│ │ → "JS가 기본, HTML은 빈 껍데기" │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ 2023-2026년 (탈SPA 시대): │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ 기본: HTML (JavaScript 0) │ │
│ │ 필요한 곳만: JavaScript 추가 │ │
│ │ │ │
│ │ 구현 방식: │ │
│ │ ├── Astro: 기본 0 JS, client:* 디렉티브로 선택적 추가 │ │
│ │ ├── RSC: Server Component = 0 JS, Client Component만 JS │ │
│ │ ├── Qwik: 기본 0 JS, 이벤트 발생 시 필요한 JS만 로드 │ │
│ │ ├── HTMX: 14KB만으로 동적 UI (서버가 HTML 반환) │ │
│ │ └── PPR: 정적 셸 + 필요한 동적 부분만 서버 렌더링 │ │
│ │ │ │
│ │ "HTML이 기본, JS는 선택" │ │
│ └──────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
19. 키워드 색인
| 키워드 | 관련 섹션 |
|---|---|
| AJAX | 2.1 SPA 역사 |
| Alpine.js | 17.6 HTMX |
| Angular Router | 4.5 Angular Router |
| App Router | 7.3 Next.js App Router SSR |
| Astro | 13.2 Astro Islands, 17.3 Astro 5 |
| Backbone.js | 2.1 SPA 역사 |
| CDN | 10, 15, 16 |
| CLS (Cumulative Layout Shift) | 1 용어사전, 18.2 Core Web Vitals |
| Client Component | 12.2 RSC 작동 방식 |
| Client-Side Routing | 4 전체 |
| Code Splitting | 1 용어사전 |
| Concurrent Mode | 2.5 Fiber 아키텍처 |
| Content Layer | 17.3 Astro 5 |
| Core Web Vitals | 1 용어사전, 18.2 비교표 |
| CSR (Client-Side Rendering) | 6 전체 |
| Deno Deploy | 15.2 Edge 플랫폼 |
| Edge Rendering | 15 전체 |
| Ember.js | 2.1 SPA 역사 |
| FCP (First Contentful Paint) | 1 용어사전, 6.1 CSR 타임라인 |
| Fiber | 2.5 Fiber 아키텍처 |
| Fluid Compute | 15.4 Vercel |
| forwardRef | 17.1 React 19 |
| getServerSideProps | 7.2 Pages Router SSR |
| getStaticPaths | 10.2 SSG 코드 |
| getStaticProps | 10.2 SSG 코드 |
| Hash Routing | 4.1 해시 라우팅 |
| hashchange | 4.1 해시 라우팅 |
| HDA (Hypermedia-Driven Application) | 17.6 HTMX |
| History API | 4.2 History API Routing |
| HTMX | 17.6 HTMX 르네상스 |
| Hugo | 10.3 SSG 프레임워크 |
| Hydration | 8 전체 |
| Hydration Mismatch | 8.2 Mismatch 에러 |
| INP (Interaction to Next Paint) | 1 용어사전, 18.2 Core Web Vitals |
| IntersectionObserver | 13.2 Astro client:visible |
| Islands Architecture | 13 전체 |
| ISR (Incremental Static Regeneration) | 11 전체 |
| Jason Miller | 13.1 아일랜드 역사 |
| jQuery | 2.1 SPA 역사 |
| Katie Sylor-Miller | 13.1 아일랜드 역사 |
| Lazy Loading | 1 용어사전 |
| LCP (Largest Contentful Paint) | 1 용어사전, 18.2 Core Web Vitals |
| Link 컴포넌트 | 4.3 React Router |
| Micro-Frontends | 17.7 마이크로 프론트엔드 |
| middleware.ts | 17.2 Next.js 16 |
| Misko Hevery | 14 Resumability |
| Module Federation | 17.7 마이크로 프론트엔드 |
| MPA (Multi-Page Application) | 3 전체 |
| Navigation API | 4.6 Navigation API |
| Next.js | 7, 10, 11, 16, 17.2 |
| O(1) 시작 | 14.3 Resumability 성능 |
| On-Demand ISR | 11.2 ISR 코드 |
| Outlet | 4.3 React Router 중첩 |
| Pages Router | 7.2, 10.2 |
| popstate | 4.2 History API |
| PPR (Partial Prerendering) | 16 전체 |
| Preact | 17.5 Remix |
| Progressive Hydration | 9.3 Progressive Hydration |
| proxy.ts | 17.2 Next.js 16 |
| pushState | 4.2 History API |
| Qwik | 14 전체, 17.6 |
| React 19 | 17.1 React 19 |
| React Compiler | 17.1 React 19 |
| React Router | 4.3 React Router |
| React Server Components | 12 전체 |
| Reconciliation | 2.6 재조정 알고리즘 |
| Remix | 17.5 React Router v7 |
| renderToPipeableStream | 9.1 Streaming SSR |
| renderToString | 9.1 비교 |
| replaceState | 4.2 History API |
| requestIdleCallback | 13.2 Astro client:idle |
| Resumability | 14 전체 |
| revalidate | 11.1 ISR |
| revalidatePath | 11.2 On-Demand ISR |
| revalidateTag | 11.2 On-Demand ISR |
| Route Ranking | 4.3 React Router URL 매칭 |
| RSC Payload | 12.4 RSC Payload |
| Runes | 17.4 Svelte 5 |
| Selective Hydration | 9.2 Selective Hydration |
| Server Component | 12 전체 |
| Server Islands | 17.3 Astro 5 |
| Single-SPA | 17.7 마이크로 프론트엔드 |
| SPA (Single Page Application) | 2 전체 |
| Speculation Rules API | 4.8 Speculation Rules |
| SSG (Static Site Generation) | 10 전체 |
| SSR (Server-Side Rendering) | 7 전체 |
| stale-while-revalidate | 11.1 ISR |
| startTransition | 2.5 Fiber, 17.1 React 19 |
| startViewTransition | 4.7 View Transitions |
| Streaming SSR | 9.1 Streaming SSR |
| Suspense | 9.1, 9.2, 16 |
| Svelte 5 | 17.4 Svelte 5 Runes |
| Tree Shaking | 1 용어사전 |
| try_files | 4.2 서버 Catch-All |
| TTFB (Time to First Byte) | 1 용어사전 |
| TTI (Time to Interactive) | 1 용어사전, 9.3 |
| Turbopack | 17.2 Next.js 15 |
| Uncanny Valley | 8.3 Hydration 비용 |
| use() Hook | 17.1 React 19 |
| “use cache” | 17.2 Next.js 16 |
| “use client” | 12.3 RSC 코드 |
| “use server” | 17.1 React 19 Actions |
| useDeferredValue | 2.5 Fiber |
| useFormState | 17.1 React 19 |
| useFormStatus | 17.1 React 19 |
| useOptimistic | 17.1 React 19 |
| useTransition | 2.5 Fiber |
| V8 Isolate | 15.3 V8 Isolate 모델 |
| Vercel Edge Runtime | 15.2 Edge 플랫폼 |
| View Transitions API | 4.7 View Transitions |
| Virtual DOM | 2.4 Virtual DOM |
| Vite | 6.3 CSR 예시 |
| Vue Router | 4.4 Vue Router |
| Web Components | 17.7 마이크로 프론트엔드 |
| XMLHttpRequest | 2.1 SPA 역사 |
| Zero JS by Default | 18.3 Zero JS 트렌드 |
참고: 이 문서는 2026년 3월 기준 최신 정보를 반영하고 있습니다. 프론트엔드 생태계는 빠르게 변화하므로, 각 프레임워크의 공식 문서를 함께 참고하시기 바랍니다.