선택적 Hydration 파헤치기 — client:* 지시어는 내부에서 어떻게 동작하는가
1편에서 Astro가 <astro-island> 태그를 심고, ~1kb 런타임이 Custom Element API로 그 태그를 감지한다는 흐름을 정리했다.
이번 글에서는 그 안으로 더 들어간다. client:load, client:idle, client:visible, client:media가 각각 어떤 브라우저 API를 사용하는지, 실제 코드 수준에서 파헤친다.
전체 구조 먼저
<astro-island>는 Custom Elements API로 정의된 HTML 태그다. 브라우저가 이 태그를 DOM에 삽입하는 순간 connectedCallback이 발화하고, 여기서 client 속성을 읽어 전략을 분기한다.
flowchart TD
A["<astro-island> DOM 삽입"] --> B["connectedCallback() 호출"]
B --> C{client 속성?}
C -->|load| D["즉시 hydrate()"]
C -->|idle| E["requestIdleCallback()\n+ timeout 폴백"]
C -->|visible| F["IntersectionObserver\n뷰포트 진입 감지"]
C -->|media| G["window.matchMedia()\n조건 충족 감지"]
D & E & F & G --> H["dynamic import()\nJS 번들 로드"]
H --> I["props JSON.parse()"]
I --> J["renderer.hydrate()\nReactDOM.hydrateRoot 등"]
J --> K["island 인터랙티브 완료"]모든 전략의 끝은 동일하다. 차이는 "언제 hydrate()를 호출하느냐" 뿐이다.
hydrate() — 공통 실행부
전략마다 타이밍이 달라도, 실행되는 hydrate()는 동일하다.
async hydrate() {
// 이미 hydrate됐으면 건너뜀 (중복 방지)
if (this.hasAttribute('ssr')) return;
const componentUrl = this.getAttribute('component-url');
const rendererUrl = this.getAttribute('renderer-url');
// JS 번들을 동적으로 로드 — 캐시되면 즉시 resolve
const [{ default: Component }, { default: renderer }] = await Promise.all([
import(componentUrl), // 예: /_astro/SearchBar.js
import(rendererUrl), // 예: /_astro/client.react.js
]);
// HTML에 박힌 props를 꺼냄
const props = JSON.parse(this.getAttribute('props') ?? '{}');
const slots = JSON.parse(this.getAttribute('slots') ?? '{}');
// 실제 프레임워크의 hydrate 호출
await renderer.hydrate(Component, props, slots, this);
// 완료 마킹 — 재실행 방지
this.removeAttribute('ssr');
}renderer.hydrate()가 React라면 내부적으로 이렇게 된다.
// packages/renderer-react/client.js
export async function hydrate(Component, props, slots, el) {
const { hydrateRoot, createElement } = await import("react-dom/client");
const root = hydrateRoot(el, createElement(Component, props));
// island가 DOM에서 제거될 때 React root도 정리
el._root = root;
}createRoot().render()가 아니라 hydrateRoot()를 쓰는 게 핵심이다.
createRoot().render() | hydrateRoot() | |
|---|---|---|
| DOM 처리 | 기존 DOM 삭제 후 새로 생성 | 기존 SSR DOM 재사용 |
| Layout Shift | 발생 | 없음 |
| 속도 | 느림 | 빠름 (diff만) |
| 용도 | CSR | SSR 후 hydrate |
SSR로 미리 그려진 HTML이 있기 때문에, hydrate 전에도 콘텐츠가 보인다. hydrate는 그 DOM 위에 React를 "붙이는" 것이다.
client:load — connectedCallback 즉시 실행
가장 단순한 전략이다.
async connectedCallback() {
const strategy = this.getAttribute('client');
if (strategy === 'load') {
await this.hydrate(); // 아무 조건 없이 바로
return;
}
// ...
}중요한 것은 "즉시"가 정확히 언제인가다. HTML 파서가 <astro-island> 태그를 DOM에 삽입하는 순간 connectedCallback이 호출된다. DOMContentLoaded나 window.onload를 기다리지 않는다.
sequenceDiagram participant P as HTML 파서 participant D as DOM participant R as island 런타임 P->>D: astro-island 태그 삽입 D->>R: connectedCallback() 즉시 호출 R->>R: strategy === 'load' R->>R: dynamic import() 시작 Note over R: 네트워크 요청 → 번들 도착 → hydrate
island가 여러 개라면 각각이 독립적인 Promise 체인으로 동작한다. 무거운 island가 가벼운 island를 블로킹하지 않는다.
island A (8kb) ──fetch──▶ ~80ms → hydrate
island B (42kb) ──fetch──────────▶ ~300ms → hydrate
island C (2kb) ──fetch─▶ ~40ms → hydrate
완료 순서: C → A → B (서로 무관)client:idle — requestIdleCallback
브라우저 메인 스레드의 구조
client:idle을 이해하려면 먼저 브라우저 메인 스레드가 어떻게 동작하는지 알아야 한다.
브라우저는 JS 실행 → Style → Layout → Paint를 한 프레임(16.7ms, 60fps 기준)에 처리한다. 이 작업들이 끝나고 남은 시간이 idle time이다.
gantt title 브라우저 프레임 (16.7ms) dateFormat X axisFormat %Lms section 프레임 1 JS 실행 :0, 6 Style :6, 3 Layout :9, 3 Paint :12, 2 Idle time :crit, 14, 3 section 프레임 2 JS 실행 :17, 4 Style :21, 2 Layout :23, 2 Paint :25, 2 Idle time :crit, 27, 7
requestIdleCallback은 이 idle time에만 실행된다. 사용자 인터랙션, 애니메이션, 렌더링을 절대 방해하지 않는다.
구현
if (strategy === "idle") {
if ("requestIdleCallback" in window) {
requestIdleCallback(
() => this.hydrate(),
{ timeout: 200 }, // 최대 200ms 안에는 강제 실행
);
} else {
// Safari 등 미지원 브라우저 폴백
setTimeout(() => this.hydrate(), 200);
}
return;
}deadline 객체
requestIdleCallback의 콜백은 deadline 객체를 받는다.
requestIdleCallback((deadline) => {
console.log(deadline.timeRemaining()); // 이번 idle 구간에 남은 시간 (ms)
console.log(deadline.didTimeout); // timeout으로 강제 실행됐는지 여부
});Astro는 deadline을 직접 활용하지 않는다. island 하나를 hydrate하는 작업이 수십ms 이내라서 굳이 쪼갤 필요가 없기 때문이다. 그냥 idle time에 hydrate()를 통째로 실행한다.
timeout: 200의 역할
timeout 없이 쓰면 브라우저가 바쁠 때 무기한 지연될 수 있다.
사용자가 페이지 로드 직후 스크롤을 계속 내린다면?
timeout 없음:
requestIdleCallback 등록
→ 스크롤 이벤트 처리 (idle time 없음)
→ 스크롤 이벤트 처리 (idle time 없음)
→ ... 몇 초가 지나도 hydrate 안 됨 ← 문제
timeout: 200 설정:
requestIdleCallback 등록
→ 스크롤 이벤트 처리
→ 200ms 경과 → deadline.didTimeout = true → 강제 실행didTimeout이 true면 idle time이 아닌 상황에서 강제 실행된 것이다. 이때 timeRemaining()은 0을 반환한다.
Safari 폴백
Safari는 아직 requestIdleCallback을 지원하지 않는다. Astro는 setTimeout(fn, 200)으로 폴백한다. 진짜 idle time을 감지하는 건 아니지만, 초기 렌더링이 200ms 안에 대부분 끝나기 때문에 실제로는 비슷한 효과를 낸다.
client:visible — IntersectionObserver
뷰포트에 island가 진입할 때 hydrate한다. 스크롤해서 보일 때까지 JS를 전혀 로드하지 않는다.
if (strategy === "visible") {
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
this.hydrate();
observer.unobserve(this); // 1회만 실행, 이후 감시 중단
}
});
observer.observe(this);
return;
}IntersectionObserver는 특정 요소가 뷰포트와 교차하는지를 비동기로 감지하는 API다. 스크롤 이벤트를 직접 리스닝하는 것보다 훨씬 성능이 좋다.
sequenceDiagram participant U as 사용자 participant IO as IntersectionObserver participant R as island 런타임 Note over R: 페이지 로드 — island JS 없음 R->>IO: observe(astro-island 요소) U->>U: 스크롤 IO->>R: isIntersecting = true R->>R: hydrate() 호출 R->>IO: unobserve() — 감시 중단 Note over R: island 인터랙티브 완료
observer.unobserve(this)가 중요하다. 한 번 hydrate되고 나면 더 이상 감시할 필요가 없다. 스크롤할 때마다 hydrate()가 재실행되는 걸 방지한다.
client:media — matchMedia
특정 미디어 쿼리 조건이 충족될 때 hydrate한다. 모바일에서만 쓰는 컴포넌트 등에 유용하다.
if (strategy === "media") {
const query = this.getAttribute("client-media");
const mq = window.matchMedia(query);
const handler = () => {
if (mq.matches) {
this.hydrate();
mq.removeEventListener("change", handler); // 1회만
}
};
mq.addEventListener("change", handler);
// 이미 조건이 충족된 상태라면 즉시 실행
if (mq.matches) {
this.hydrate();
}
return;
}<!-- 768px 이상일 때만 hydrate -->
<DesktopMenu client:media="(min-width: 768px)" />
<!-- 다크모드일 때만 hydrate -->
<DarkModeWidget client:media="(prefers-color-scheme: dark)" />client:only — SSR을 건너뛴다
client:only는 다른 전략과 달리 SSR 자체를 하지 않는다. 서버에서 렌더링 불가능한 컴포넌트(예: window 직접 참조, 브라우저 전용 API 사용)에 쓴다.
// client:only는 서버에서 아예 실행되지 않음
// HTML에 placeholder HTML이 없음 — 완전히 빈 상태에서 시작
if (strategy === "only") {
await this.hydrate(); // client:load와 동일하게 즉시 실행
return;
}<!-- window.localStorage를 쓰는 컴포넌트 — SSR하면 에러 -->
<ThemeToggle client:only="react" />hydrateRoot 대신 createRoot().render()를 사용한다. SSR HTML이 없으니 "붙일" DOM이 없기 때문이다.
await-children — 부모/자식 island 순서 보장
부모-자식 관계의 island가 있을 때 문제가 생길 수 있다. 부모가 먼저 hydrate되면서 자식 DOM을 덮어쓰면 자식 island가 날아갈 수 있다.
Astro는 await-children 속성으로 이를 해결한다.
<astro-island client="load" await-children="">
<div>
<astro-island client="load" component-url="/Child.js"> ... </astro-island>
</div>
</astro-island>async hydrate() {
if (this.hasAttribute('await-children')) {
// 내부의 모든 astro-island가 완료될 때까지 대기
const innerIslands = this.querySelectorAll('astro-island');
await Promise.all(
[...innerIslands].map(island => island.hydrated)
// island.hydrated는 각 island가 hydrate 완료 시 resolve하는 Promise
);
}
await this.doHydrate();
}island 간 통신 — 격리의 트레이드오프
선택적 hydration의 트레이드오프는 island들이 서로 격리된다는 점이다. React의 Context나 상태가 트리를 타고 내려가는 구조가 불가능하다.
island A (SearchBar) ──┐
├── 서로 React 상태 공유 불가
island B (CartIcon) ──┘Astro는 이를 위해 nanostores 같은 프레임워크 무관 스토어를 공식 권장한다.
// store.js — 프레임워크 무관
import { atom } from "nanostores";
export const cartCount = atom(0);
// SearchBar.tsx (island A)
import { cartCount } from "./store";
cartCount.set(cartCount.get() + 1);
// CartIcon.tsx (island B) — 완전히 다른 island지만 같은 store 구독
import { useStore } from "@nanostores/react";
import { cartCount } from "./store";
const count = useStore(cartCount); // 반응형으로 업데이트island들이 React 트리 밖에서 공유 상태를 구독하는 방식이다. 어떤 UI 프레임워크를 쓰든 동작한다.
전략 선택 기준
flowchart TD A[이 컴포넌트에 JS가 필요한가?] -->|아니오| B[지시어 없음 — 정적 HTML] A -->|예| C[언제 필요한가?] C -->|페이지 로드 즉시| D["client:load\n검색창, 메뉴, 핵심 위젯"] C -->|있으면 좋지만 급하지 않음| E["client:idle\n채팅 버튼, 추천 섹션"] C -->|스크롤해서 보일 때| F["client:visible\n댓글, 하단 위젯"] C -->|특정 화면 크기/조건| G["client:media\n모바일 전용 컴포넌트"] C -->|브라우저 전용 API 사용| H["client:only\ntheme toggle, canvas"]
정리
선택적 Hydration은 특별한 마법이 아니다. 브라우저의 네이티브 API들을 조합해서 "언제 JS를 실행할지"를 제어하는 것이다.
| 전략 | 브라우저 API | 특징 |
|---|---|---|
client:load | Custom Element connectedCallback | DOM 삽입 즉시 |
client:idle | requestIdleCallback + setTimeout 폴백 | 메인 스레드 여유 시 |
client:visible | IntersectionObserver | 뷰포트 진입 시, 1회 |
client:media | window.matchMedia | 조건 충족 시 |
client:only | connectedCallback (SSR 없음) | 브라우저 전용 |
그리고 모든 전략의 끝에는 동일한 hydrate() — dynamic import() → JSON.parse(props) → hydrateRoot() — 가 있다.
다음 글에서는 Next.js RSC의 Selective Hydration, Qwik의 Resumability 등 다른 프레임워크들이 hydration 문제를 어떻게 다르게 풀었는지 비교한다.