← Blog
렌더링 & 프레임워크

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이 푸는 두 가지 문제 (별개의 메커니즘)

  1. 구조 매칭: 서버 DOM 중 "어느 노드가 내 컴포넌트의 어느 부분인가" 알아내기
  2. 상태 복원: 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.stringifyDate/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. 컴파일러가 하는 두 가지

  1. $state 값을 읽고 쓸 때 추적되도록 변환 ($.get / $.set)
  2. 그 값이 어느 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 한눈에

SvelteReact
상태 변경 시컴파일러가 정한 코드로 직접 갱신컴포넌트 함수 통째로 재실행
무엇을 바꿀지컴파일 타임에 이미 정해짐런타임에 비교해서 찾음
가상 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 직접 확인하는 법

  1. Svelte Playground: svelte.dev/playground → 코드 작성 → "JS output" → server/client 토글. $.template/$.first_child 등이 보임.
  2. 빌드 산출물: npm run build.svelte-kit/output/client/(hydration), server/(HTML 생성). 단 minify돼 읽기 힘듦.
  3. dev + Sources 탭(실전): npm run dev → 브라우저 Sources에서 .svelte 찾기 → $.from_html/$.child 등 비교적 안 뭉개진 코드 확인 → breakpoint 걸고 새로고침하면 hydration 한 줄씩 밟기.

결정적 증거 잡는 실험 3줄

  1. 노드 집어드는 줄(root()/$.child(...))에 breakpoint → 새로고침
  2. 멈추면 콘솔에 $$props.review 찍기 → 데이터가 이미 복원됐는지 확인(재fetch 없음)
  3. 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