렌더링 & 프레임워크
Hydration과 반응성 — Svelte는 컴파일 타임에, React는 런타임에 일한다
한 줄 요약
Hydration은 서버가 그려 보낸 죽은 HTML에 JS가 "전기를 연결"하는 과정(새로 안 그리고 기존 노드를 집어들어 이벤트·반응성만 부착)이다. 그리고 그 반응성 자체를 Svelte는 컴파일러가 빌드 타임에 미리 코드로 박아두고, React는 런타임에 재실행+비교로 처리한다 — 이 "언제 일을 하느냐"의 차이에서 두 프레임워크의 거의 모든 장단점이 갈린다.
왜 알아야 했나
- SSR로 HTML+데이터가 내려오는 것까지는 이해했는데, **JS가 그걸 어떻게 이어받아 동작하게 만드는지(hydration)**가 막혀서.
- 실제 프로젝트(
ReviewCard.svelte)의 컴파일된 client 코드를 브라우저 Sources 탭에서 직접 보면서$.from_html,$.child,$.state,$.user_effect의 정체를 확인하려고. - "Svelte는 상태 자체를 컴파일한다"는 말의 의미와, 그게 React의 런타임 방식과 뭐가 다른지 비교하려고.
핵심 개념
A. HYDRATION
A-1. hydration이 푸는 두 가지 문제 (별개의 메커니즘)
- 구조 매칭: 서버 DOM 중 "어느 노드가 내 컴포넌트의 어느 부분인가" 알아내기
- 상태 복원: load 데이터를 클라이언트에서 "다시 fetch하지 않고" 동일하게 갖기
A-2. 구조 매칭 — "검색"이 아니라 "순서대로 짚기(walk)"
- 오해: 클라이언트가 DOM을 스캔/검색해서 매칭한다 (X)
- 실제: 서버와 클라이언트가 같은 컴포넌트 코드를 실행하면 노드 생성 순서도 같다. 그래서 매칭은 검색이 아니라 순서 맞추기.
- 클라이언트 컴포넌트는 DOM을 새로 만들지 않고, DOM 커서를 순서대로 전진시키며 기존 노드를 하나씩 집어든다(claim).
- 비유: "빨간 의자를 찾아라"(검색) ❌ vs "문 들어가 첫째가 소파, 다음이 의자"(순서) ✅
A-3. 동적 블록({#if}, {#each})은 어떻게? → 앵커 마커(anchor)
- 정적 노드는 "코드 순서상 N번째"로 위치 고정이라 쉽게 집어듦.
{#if}는 조건에 따라 내용이 있을 수도/없을 수도 있어, 클라이언트가 코드만 봐선 모름.- 그래서 서버가 렌더링 시 블록 양 끝에 보이지 않는 주석 앵커를 심는다.
<!-- if = true 였으면 -->
<!--[--> <p>환영합니다</p> <!--]-->
<!-- if = false 였으면 (내용 없이 앵커만) -->
<!--[!--> <!--]-->
- 커서가
<!--[-->를 만나면 "여기부터 if 블록, 서버에선 참이었구나"를 DOM 분석 없이 마커만 보고 파악 → 사이의<p>를 집어듦. - 앵커는 hydration 후에도 DOM에 남는다. 이후 조건이 바뀌면 "여기에 넣고 빼라"는 삽입/삭제 지점으로 평생 사용됨.
{#each}도 동일(리스트 양 끝·항목 사이 앵커).
A-4. 상태 복원 — 서버가 데이터를 HTML에 "글로 적어 보냄"(직렬화)
- 서버는 load 데이터를 JSON으로 직렬화해 HTML 안
<script>에 심음 (__sveltekit_data). - 클라이언트는 부팅 시 이 변수를 그냥 읽음(네트워크 요청 아님) → load 재실행/재fetch 없음.
- 앞서 본 streaming의
__sveltekit_dev.resolve(...)가 바로 이 직렬화 데이터(promise 버전). - devalue 라이브러리 사용:
JSON.stringify는Date/Map/Set/undefined/순환참조를 표현 못 함. devalue는 타입까지 보존해 복원 → 서버에서Date였으면 클라에서도Date. "똑같은 값으로 초기화"가 단순 JSON 복사가 아니라 타입 보존 복원인 이유.
A-5. ⚠️ 중요한 오해 교정 — hydration은 "데이터를 채워넣는 게" 아니다
- 텍스트는 서버가 이미 HTML에 그려놨다 (
<button>눌린 횟수: 0</button>). - hydration은 현재 값을 채우는 게 아니라, **앞으로 값이 바뀌면 갱신되도록 반응성에 "선 연결"**하는 것.
- 즉 hydration이 하는 일 = ① 이벤트 붙이기 + ② 반응성 연결 (채우기 X)
- (예외: streaming 데이터, 클라이언트 전용 effect는 hydration 이후 실제로 화면을 바꿈. 하지만 정적 텍스트는 이미 그려져 있어 다시 안 씀.)
A-6. Hydration mismatch
- 서버 DOM과 클라이언트가 기대하는 순서가 어긋나면(예: 서버
{#if}참인데 클라 거짓,new Date()시각 차이) 커서가 엉뚱한 노드를 집어 화면이 깨지거나 통째로 다시 그림. - 절대 조건: 서버/클라가 같은 입력으로 같은 결과를 내야 함.
B. "상태를 컴파일한다"는 말의 의미 (Svelte)
B-1. 핵심: "언제 무엇을 다시 그릴지"를 런타임이 아니라 빌드 타임에 결정
- React: 런타임에 "뭐가 바뀌었지?"를 그때그때 계산.
- Svelte: 컴파일러가 코드를 분석해 "이 상태가 바뀌면 정확히 이 DOM의 이 텍스트만 갱신하라"를 빌드 타임에 코드로 박아둠.
- 비유: 요리할 때마다 냉장고 다 뒤지기(런타임) vs "달걀 바뀌면 3번 선반만 확인"하는 지도를 미리 만들기(컴파일).
B-2. 컴파일러가 하는 두 가지
$state값을 읽고 쓸 때 추적되도록 변환 ($.get/$.set)- 그 값이 어느 DOM·어느 계산에 영향 주는지 분석해 변경 시 실행할 업데이트 코드 생성
C. React의 런타임 방식 (대조)
상태 변경 시 React가 하는 일:
setCount(1) 호출
↓ ① 컴포넌트 "dirty" 표시, 리렌더 예약
↓ ② 컴포넌트 함수 통째로 재실행 → 새 가상 DOM 생성 (console.log 다시 찍힘!)
↓ ③ 이전 가상 DOM과 비교(diffing / reconciliation) ← 런타임 비용
↓ ④ 달라진 부분만 실제 DOM에 반영(commit)
- 가상 DOM = "실제 DOM은 비싸니 가벼운 JS 객체로 먼저 그려보고 비교 후 최소한만 진짜로 바꾸자".
- 컴포넌트 함수가 상태 변경마다 통째로 재실행됨 →
useMemo/useCallback/React.memo같은 "불필요한 재실행 막는" 도구가 따로 필요(재실행이 기본값이라서). - state가 살아남는 이유: state는 함수 바깥(React 내부)에 저장. 재실행 때
useState가 그 값을 꺼내줌.
D. Svelte vs React 한눈에
| Svelte | React | |
|---|---|---|
| 상태 변경 시 | 컴파일러가 정한 코드로 직접 갱신 | 컴포넌트 함수 통째로 재실행 |
| 무엇을 바꿀지 | 컴파일 타임에 이미 정해짐 | 런타임에 비교해서 찾음 |
| 가상 DOM | 없음 | 있음(공유 런타임 엔진) |
count++ 결과 | set_text(p, count) 직접 호출 | 재실행 → diff → 텍스트 갱신 |
| "마법"의 위치 | 컴파일러 (작성≠실행) | 런타임 (작성≈실행) |
예시 / 코드
원본 컴포넌트
<script>
let { data } = $props();
let count = $state(0);
</script>
<h1>{data.title}</h1>
<button onclick={() => count++}>눌린 횟수: {count}</button>
서버 컴파일 결과 (HTML 문자열 생성 — 죽은 HTML)
export function render(data) {
return `<h1>${data.title}</h1>` +
`<button>눌린 횟수: 0</button>`;
}
클라이언트 컴파일 결과 (hydration) — 핵심
// 컴파일 타임에 만들어진 "모양 틀" (실제 코드의 $.from_html / $.template)
var template = $.template(`<h1></h1> <button></button>`);
export default function App($$anchor, $$props) {
let count = $.state(0); // 상태를 추적 가능한 형태로 변환
var fragment = template(); // hydrate:true면 새로 안 만들고 서버 노드를 집어듦
var h1 = $.first_child(fragment); // 커서: 첫 노드 = <h1>
var button = $.sibling(h1, 2); // 커서: 다음 형제 = <button>
$.template_effect(() => { // 동적 텍스트만 반응성에 연결 (채우는 게 아니라 '연결')
$.set_text(h1, $$props.data.title);
$.set_text(button, `눌린 횟수: ${$.get(count)}`);
});
$.event('click', button, () => $.set(count, $.get(count) + 1)); // 이벤트 부착
$.append($$anchor, fragment);
}
핵심: template() 한 줄이 두 모드를 가름. 첫 렌더링이면 createElement로 새로 생성, hydration이면 기존 서버 노드를 집어듦. 같은 컴파일 코드가 hydrate 플래그를 보고 분기한다.
부팅 스크립트 (HTML과 JS가 만나는 지점)
<body>
<h1>실제 제목</h1>
<button>눌린 횟수: 0</button> <!-- 먼저 그려짐(보이지만 죽음) -->
<script type="module">
import { start } from '/_app/.../start.js';
import App from '/_app/.../App.js';
start({
target: document.body,
hydrate: true, // ← 이 플래그가 "집어들기 모드" 켬
data: __sveltekit_data, // ← HTML에 심긴 직렬화 데이터
});
</script>
</body>
React 동일 기능 (대조)
function Counter() {
const [count, setCount] = useState(0);
console.log("재실행됨"); // 버튼 누를 때마다 다시 찍힘
return (
<div>
<p>현재: {count}</p>
<button onClick={() => setCount(count + 1)}>증가</button>
</div>
);
}
실제 프로젝트에서 본 것 (ReviewCard.svelte 컴파일 결과)
function ReviewCard($$anchor, $$props) {
$.push($$props, true, ReviewCard);
let contentEl = $.tag($.state(null), 'contentEl'); // $state() → $.state()
let expanded = $.tag($.state(false), 'expanded');
let clamped = $.tag($.state(false), 'clamped');
$.user_effect(() => { // $effect() → $.user_effect()
$$props.review.body; // 의존성 추적용 읽기
$.set(clamped,
$.get(contentEl)
? $.get(contentEl).scrollHeight > $.get(contentEl).clientHeight
: false, true);
});
// ... 아래에 root()/$.child()/$.sibling()로 서버 노드 집어드는 코드 ...
}
- 이 effect는 서버에선 절대 실행 안 됨:
scrollHeight/clientHeight는 실제 브라우저 레이아웃이 있어야 나오는 값. - 서버:
clamped=false로 렌더(→ "더보기" 안 보임) / 클라: hydration 후 effect 첫 실행 → 실제 높이 재서 잘렸으면 "더보기" 등장. → hydration 이후 클라이언트 전용 보정의 실전 사례.
컴파일된 코드 / hydration 직접 확인하는 법
- Svelte Playground:
svelte.dev/playground→ 코드 작성 → "JS output" → server/client 토글.$.template/$.first_child등이 보임. - 빌드 산출물:
npm run build→.svelte-kit/output/client/(hydration),server/(HTML 생성). 단 minify돼 읽기 힘듦. - dev + Sources 탭(실전):
npm run dev→ 브라우저 Sources에서.svelte찾기 →$.from_html/$.child등 비교적 안 뭉개진 코드 확인 → breakpoint 걸고 새로고침하면 hydration 한 줄씩 밟기.
결정적 증거 잡는 실험 3줄
- 노드 집어드는 줄(
root()/$.child(...))에 breakpoint → 새로고침 - 멈추면 콘솔에
$$props.review찍기 → 데이터가 이미 복원됐는지 확인(재fetch 없음) - Elements에서 해당
<article>을 "Store as global variable"(temp1)로 저장 → breakpoint에서article === temp1찍기 →true면 새로 만든 게 아니라 서버 DOM을 집어든 것(hydration의 결정적 증거)
- 보너스: Elements에서
{#if}주변에<!--[-->주석 노드 보기 → 그게 동적 블록 앵커.
헷갈렸던 점
- "hydration이 stream된 데이터를 채워넣는다"? → ❌. 텍스트는 서버가 이미 그려놨고, hydration은 미래 변경에 대비한 반응성 연결 + 이벤트 부착만. 채우는 게 아님.
- "클라이언트가 DOM을 검색해서 매칭한다"? → ❌. 같은 코드 → 같은 순서라 커서로 순서대로 집어듦. 동적 블록만 앵커로 보정.
- "
{#if}동적 요소는 어떻게 매칭?" → 서버가 양 끝에 심은 **주석 앵커(<!--[-->)**로 경계 파악, hydration 후엔 삽입/삭제 지점으로 계속 사용. - "Watch에
data: <not available>" → "Not paused" 상태라서. breakpoint에서 멈추면 값 들어옴. add_locations의 숫자 배열([20,0,[...]])? → dev 전용 소스 위치 맵(디버깅용), hydration 로직 아님.- "Svelte가 상태를 컴파일한다" → 상태를 추적 가능한 형태로 변환(
$.get/$.set) + 그 상태가 영향 주는 DOM을 빌드 타임에 분석해 갱신 코드 생성, 두 가지를 합친 말.
Svelte vs React 장단점 종합
Svelte 장점
- 적은 코드량/보일러플레이트(의존성 배열, useMemo 등 거의 불필요)
- 가벼운 런타임·작은 번들(가상 DOM 엔진 안 보냄), 작은~중간 앱에서 특히 유리
- 정밀·빠른 업데이트(컴포넌트 재실행 없음, 바뀐 지점만 직접 갱신)
- 스코프 CSS·트랜지션·store 내장, SvelteKit 통합성(라우팅/SSR/load/adapter 한 묶음)
Svelte 단점
- 작은 생태계(라이브러리·답변·채용·AI 학습량 모두 React에 밀림) — 가장 현실적 단점
- 컴파일러 의존: 작성≠실행이라 디버깅 시 컴파일 산출물 봐야 할 때 있음, rune 문법 정확히 써야 반응성 동작(평범한 변수는 반응 안 함)
- 잦은 큰 변화(Svelte 5 runes로 문법 대폭 변경)
- 초대형 앱에선 컴포넌트마다 코드 생성 → 공유 런타임 쓰는 React 대비 총량 이점 줄 수 있음(논쟁적)
React 장점
- 압도적 생태계(라이브러리·답변·채용·메타프레임워크·React Native)
- 유연성·명시성(재실행 모델이 단순·예측가능, 컴파일러 분석 한계 없음)
- 검증된 안정성·호환성, 범용성
React 단점
- 보일러플레이트·인지 부담(훅 규칙, 의존성 배열, 재실행 최적화 도구)
- 런타임 비용(상태 변경마다 재실행+가상 DOM 비교, 큰 트리·잦은 업데이트에서 직접 최적화 필요)
- 조립 부담(라우팅·상태관리·패칭 직접 선택. Next.js 등이 완화)
한 줄 정리
- Svelte: 적게 쓰고 가볍게 돌리는 대신 생태계가 작다.
- React: 무겁고 손이 더 가는 대신 생태계·안정성이 압도적이다.
- 둘 다 "실제 DOM은 비싸니 최소한만 건드리자"가 목표. 그 답을 React는 런타임에 찾고, Svelte는 빌드 타임에 미리 정해둔다.
실무 적용
- SvelteKit 디버깅 시 컴파일 산출물(
$.child등)을 읽고 breakpoint로 hydration 추적 가능. - hydration mismatch 경고가 뜨면 서버/클라 렌더 결과 불일치(시각, 랜덤, 브라우저 전용 값)를 의심.
scrollHeight같은 브라우저 전용 값은 effect로 hydration 이후에 처리(서버에선 못 잼).- 프레임워크 선택: 작은~중간/소규모팀/번들 민감 → Svelte, 큰 팀·장기·채용·풍부한 라이브러리 → React.
- 관련 작업: [[SSR & SPA]]
더 공부할 것
-
hydrating플래그가 Svelte 런타임 소스에서 어떻게 분기를 트는지 -
{#each}컴파일 코드 — key 기반 리스트 diff와 앵커 사용 - devalue가 순환참조를 실제로 어떻게 인코딩하는지(인덱스 기반 참조 테이블)
- Svelte 5 runes(
$state/$derived/$effect) 반응성 그래프 동작 - partial hydration / islands 아키텍처 (SvelteKit 기본은 full hydration)
- React Server Components / React Compiler(자동 메모이제이션)와의 비교
🔗 참고
- Svelte 공식 문서: Reactivity / runes ($state, $derived, $effect)
- Svelte Playground (svelte.dev/playground) — JS output server/client 비교
- SvelteKit 공식 문서: Hydration, Page data serialization
- devalue (GitHub) — 직렬화 라이브러리
- React 공식 문서: Render and Commit, Reconciliation