React 하이드레이션, 모든 컴포넌트를 동시에 깨울 필요 없습니다 — Next.js에서 MRAH로 TTI를 개선하는 방법
"왜 이렇게 느려요?" SSR로 전환하고 나서 처음 받은 피드백이었습니다. FCP(First Contentful Paint)는 분명 빨라졌는데, 사용자는 화면이 떠도 버튼을 눌러도 아무 반응이 없다고 했습니다. 범인은 하이드레이션이었습니다. 서버에서 완성된 HTML이 브라우저에 도착하는 순간, React는 그 위에 이벤트 리스너를 붙이는 작업을 시작합니다. 문제는 중요한 "구매" 버튼이든, 아무도 스크롤하지 않는 푸터 위젯이든 모두 동시에 처리한다는 점입니다. 메인 스레드가 막히고, TTI(Time to Interactive)가 치솟고, 사용자는 멀뚱히 화면만 바라보게 됩니다.
MRAH(Modular Rendering and Adaptive Hydration)는 컴포넌트의 중요도·가시성·기기 환경에 따라 하이드레이션 시점과 순서를 동적으로 결정하는 아키텍처 패턴입니다. 2025년 4월 arxiv에서 공식 정의된 이 패턴의 핵심은 단순합니다. 중요한 것부터 측정하고, 화면을 독립된 모듈로 분리하고, 덜 중요한 것은 지연시키는 것입니다. 이 세 단계가 이 글 전체를 관통합니다.
이 글은 React/Next.js를 이미 사용하고 있는 분들을 대상으로 합니다. Suspense, React.lazy(), hydrateRoot 같은 개념이 낯설지 않다면 바로 따라오실 수 있습니다. 읽고 나면 Next.js 프로젝트에 바로 적용할 수 있는 세 가지 패턴을 가져가실 수 있습니다.
핵심 개념
하이드레이션이 왜 병목이 되는가
저도 처음엔 "SSR 하면 빠른 거 아닌가?"라고 생각했습니다. FCP는 빨라집니다. 그런데 TTI는 전혀 다른 이야기입니다. 브라우저가 HTML을 받아 화면에 그리는 건 순식간이지만, React가 그 위에 하이드레이션을 마칠 때까지 사용자의 클릭은 무시됩니다.
전통적인 하이드레이션은 이렇게 동작합니다.
// 루트 컴포넌트 전체를 한 번에 하이드레이션
hydrateRoot(document.getElementById('root'), <App />);
// → 네비게이션, 메인 콘텐츠, 추천 위젯, 댓글, 푸터 모두 동시 처리네비게이션 드롭다운과 페이지 맨 아래 "관련 기사" 슬라이더가 같은 우선순위로 메인 스레드를 점유합니다. 솔직히 사용자의 80%는 그 슬라이더까지 스크롤하지도 않을 텐데 말이죠.
FCP와 TTI의 차이가 바로 여기에 있습니다. FCP는 화면에 첫 콘텐츠가 그려지는 시점이고, TTI는 사용자가 실제로 인터랙션할 수 있게 되는 시점입니다. SSR은 FCP를 앞당기지만, 하이드레이션을 최적화하지 않으면 TTI 개선 효과는 제한적입니다.
Modular Rendering — 페이지를 독립된 섬으로 나누기
MRAH의 첫 번째 원칙은 페이지를 독립적으로 렌더링·하이드레이션할 수 있는 단위(모듈)로 분리하는 것입니다. Astro의 Islands Architecture에서 영감을 받은 방식인데, 핵심 아이디어는 단순합니다. 정적인 HTML 바다 위에 인터랙티브한 "섬(island)"만 JavaScript로 활성화하고, 섬 바깥은 순수 HTML로 유지해 불필요한 JS 실행을 원천 차단합니다.
React 생태계에서는 React.lazy()와 Suspense가 이 역할을 담당합니다.
import { lazy, Suspense } from 'react';
// 비핵심 섹션을 독립된 청크로 분리
const ReviewSection = lazy(() => import('./ReviewSection'));
const RecommendedProducts = lazy(() => import('./RecommendedProducts'));
const Footer = lazy(() => import('./Footer'));
function ProductPage() {
return (
<>
{/* HeroSection은 lazy() 없이 직접 import — 즉시 렌더링 필요 */}
<HeroSection />
{/* 각 모듈이 독립적으로 로드·하이드레이션됨 */}
<Suspense fallback={<ReviewSkeleton />}>
<ReviewSection />
</Suspense>
<Suspense fallback={<ProductSkeleton />}>
<RecommendedProducts />
</Suspense>
<Suspense fallback={null}>
<Footer />
</Suspense>
</>
);
}HeroSection을 lazy()로 감싸지 않은 점에 주목해 주시면 됩니다. 즉시 필요한 영역에 lazy()를 붙이면 오히려 로드가 지연되어 역효과가 납니다. 코드 스플리팅은 "나중에 필요한 것"에 적용하는 도구입니다.
Adaptive Hydration — 상황을 읽고 순서를 정하기
두 번째 원칙이 MRAH를 단순한 코드 스플리팅과 구분짓는 부분입니다. 적응형 하이드레이션은 다음 네 가지 신호를 읽어 하이드레이션 시점을 동적으로 결정합니다.
| 신호 | 감지 방법 | 활용 방식 |
|---|---|---|
| Visibility | IntersectionObserver |
뷰포트에 진입할 때까지 하이드레이션 보류 |
| Idle Time | requestIdleCallback |
브라우저가 한가할 때 비긴급 컴포넌트 처리 |
| Network | Network Information API | 2G/3G 환경에서 비핵심 위젯 하이드레이션 스킵 |
| Device | navigator.hardwareConcurrency |
저사양 기기에서 무거운 작업 지연 |
뷰포트 진입을 감지해 하이드레이션을 트리거하는 기본 패턴을 코드로 옮기면 이런 형태가 됩니다.
import { useEffect, useRef, useState } from 'react';
function useVisibilityHydration() {
const ref = useRef<HTMLDivElement>(null);
const [shouldHydrate, setShouldHydrate] = useState(false);
useEffect(() => {
if (!ref.current) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setShouldHydrate(true);
observer.disconnect(); // 한 번 진입하면 해제
}
},
{ rootMargin: '200px' } // 뷰포트 200px 전부터 준비
);
observer.observe(ref.current);
return () => observer.disconnect();
}, []);
return { ref, shouldHydrate };
}이 훅이 아래 실전 예시에서 직접 활용됩니다.
실전 적용
예시 1: 이커머스 상품 페이지 — 구매 버튼만 먼저 깨우기
실무에서 자주 맞닥뜨리는 상황입니다. 상품 상세 페이지는 "Add to Cart" 버튼과 리뷰 섹션이 공존합니다. 구매 버튼은 페이지 진입 즉시 동작해야 하지만, 리뷰는 사용자가 스크롤해야 보이는 영역입니다.
react-lazy-hydration 라이브러리를 활용하면 뷰포트 진입 시점·브라우저 유휴 시점 기반 하이드레이션을 선언적으로 처리할 수 있습니다.
import dynamic from 'next/dynamic';
import LazyHydrate from 'react-lazy-hydration';
// next/dynamic: 번들을 별도 청크로 분리 (코드 스플리팅 담당)
// LazyHydrate: 로드된 컴포넌트를 언제 하이드레이션할지 제어
// 두 역할이 서로 다르므로 함께 써도 충돌 없이 동작합니다.
const ReviewSection = dynamic(() => import('./ReviewSection'), {
// ssr 기본값은 true — SSR HTML을 그대로 유지합니다
loading: () => <ReviewSkeleton />,
});
const RecommendedSlider = dynamic(() => import('./RecommendedSlider'), {
loading: () => <SliderSkeleton />,
});
const Footer = dynamic(() => import('./Footer'), {
loading: () => null,
});
export default function ProductPage() {
return (
<main>
{/* 즉시 하이드레이션 — 구매 플로우 핵심 영역 */}
<ProductHero />
<AddToCartButton />
{/* 뷰포트 진입 시 하이드레이션 */}
<LazyHydrate whenVisible>
<ReviewSection />
</LazyHydrate>
<LazyHydrate whenVisible>
<RecommendedSlider />
</LazyHydrate>
{/* 브라우저 유휴 시 하이드레이션 */}
<LazyHydrate whenIdle>
<Footer />
</LazyHydrate>
</main>
);
}| 영역 | 하이드레이션 전략 | 이유 |
|---|---|---|
ProductHero / AddToCartButton |
즉시 | 구매 전환에 직결, 지연 불가 |
ReviewSection |
whenVisible |
스크롤 없이는 보이지 않음 |
RecommendedSlider |
whenVisible |
핵심 구매 플로우보다 낮은 우선순위 |
Footer |
whenIdle |
인터랙션 가능성 최소, 여유 시 처리 |
예시 2: 기기·네트워크 환경을 읽는 적응형 훅
네트워크 상태나 기기 성능에 따라 하이드레이션 전략 자체를 바꾸고 싶다면, 커스텀 훅으로 조건을 모아두는 패턴이 깔끔합니다.
한 가지 중요한 점이 있습니다. navigator.connection(Network Information API)은 현재 Chrome과 Edge에서만 지원되며, Firefox와 Safari에서는 동작하지 않습니다. navigator.hardwareConcurrency도 Firefox 프라이버시 모드에서 의도적으로 값을 제한할 수 있습니다. 프로덕션에서 쓰려면 반드시 방어 코드가 필요합니다.
// TypeScript strict mode에서 Network Information API를 안전하게 사용하는 타입 선언
type NetworkInformation = {
effectiveType?: '2g' | '3g' | '4g' | 'slow-2g';
};
function useAdaptiveHydration() {
const nav = navigator as Navigator & { connection?: NetworkInformation };
const connection = nav.connection;
// Firefox/Safari는 connection이 undefined — 없으면 고속 네트워크로 간주
const isSlowNetwork =
connection?.effectiveType === '2g' ||
connection?.effectiveType === 'slow-2g';
// 논리 코어 수로 저사양 기기 감지 (프라이버시 모드에서 제한될 수 있음)
const isLowEndDevice = (navigator?.hardwareConcurrency ?? 4) <= 2;
return {
shouldDefer: isSlowNetwork || isLowEndDevice,
isSlowNetwork,
isLowEndDevice,
};
}
function HeavyWidget() {
const { shouldDefer } = useAdaptiveHydration();
if (shouldDefer) {
// 저사양 환경: 정적 HTML만 렌더링, JS 하이드레이션 생략
return <StaticFallback />;
}
return <FullInteractiveWidget />;
}startTransition과 함께 쓰면 비긴급 하이드레이션을 React의 스케줄러에 위임할 수도 있습니다. 앞서 만든 가시성 감지 패턴과 조합하면 이런 형태가 됩니다.
import { startTransition, useState, useEffect, useRef } from 'react';
// IntersectionObserver를 래핑한 가시성 트리거 컴포넌트
function VisibilityTrigger({
onVisible,
children,
}: {
onVisible: () => void;
children: React.ReactNode;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!ref.current) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
onVisible();
observer.disconnect();
}
},
{ rootMargin: '200px' }
);
observer.observe(ref.current);
return () => observer.disconnect();
}, [onVisible]);
return <div ref={ref}>{children}</div>;
}
function ProductPage() {
const [showReviews, setShowReviews] = useState(false);
const handleReviewsVisible = () => {
// 리뷰 하이드레이션을 긴급하지 않은 작업으로 표시
startTransition(() => {
setShowReviews(true);
});
};
return (
<>
<ProductHero />
<VisibilityTrigger onVisible={handleReviewsVisible}>
{showReviews ? <ReviewSection /> : <ReviewSkeleton />}
</VisibilityTrigger>
</>
);
}startTransition으로 감싸면 리뷰 하이드레이션이 진행되는 도중에도 사용자가 버튼을 클릭하면 React가 즉시 응답할 수 있습니다. INP(Interaction to Next Paint) 개선에 직접적인 영향을 줍니다.
장단점 분석
장점
저는 이 패턴을 처음 적용했을 때 수치보다 "느낌"이 먼저 달라지는 걸 경험했습니다. 구매 버튼이 즉각 반응하기 시작했고, 팀원들도 Lighthouse 점수를 보기 전에 "확실히 빨라졌다"는 말을 먼저 했습니다.
| 항목 | 내용 |
|---|---|
| JavaScript 페이로드 감소 | 필요한 시점에만 코드가 로드되어 초기 번들 크기가 크게 줄어듭니다 |
| TTI 개선 | 핵심 컴포넌트만 먼저 하이드레이션해 사용자 반응성이 빨라집니다 |
| TBT(Total Blocking Time) 감소 | 하이드레이션을 분산시켜 메인 스레드 블로킹을 줄일 수 있습니다 |
| SEO 유지 | SSR HTML은 그대로 제공되므로 검색 엔진 크롤링에 영향 없습니다 |
| 저사양·저속 환경 | 네트워크·기기 조건에 따라 하이드레이션 범위를 조정해 실질적인 사용성을 개선합니다 |
참고로 일부 글에서 언급되는 "82% 페이로드 감소", "TTI 62% 단축"은 arxiv 논문에서 보고된 실험적 수치입니다. 프로젝트 규모와 구조에 따라 결과가 크게 달라질 수 있으니, 직접 Lighthouse로 측정한 Before/After 수치를 도입 근거로 삼는 것을 권장합니다.
단점 및 주의사항
저는 실제로 전역 Context 문제로 반나절을 날린 적이 있습니다. 인증 상태 Context가 하이드레이션되기도 전에 자식 모듈이 사용자 정보를 읽으려 해서 오류가 발생했는데, 원인을 찾는 데 꽤 걸렸습니다. 아래 주의사항은 그 경험에서 나온 것들입니다.
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 설계 복잡도 | 어떤 컴포넌트를 언제 하이드레이션할지 직접 설계해야 합니다 | 우선순위 기준을 팀 내 문서로 명문화하는 것을 권장합니다 |
| 전역 상태 공유 | 인증·장바구니 같은 공유 상태와 하이드레이션 순서가 충돌할 수 있습니다 | Zustand·Jotai 등 원자 단위 상태 관리로 의존성을 최소화하는 것이 좋습니다 |
| 브라우저 호환성 | Network Information API는 Firefox/Safari 미지원, hardwareConcurrency는 프라이버시 모드에서 제한될 수 있습니다 |
항상 방어 코드(?? 기본값)를 작성하는 것이 안전합니다 |
| 런타임 조건 불확실성 | 기기·네트워크 환경마다 최적 전략이 달라 일반화가 어렵습니다 | 최소 기준값을 설정한 뒤 단계적으로 조건을 세분화하는 것을 권장합니다 |
| E2E 테스트 복잡성 | 하이드레이션 타이밍에 따라 컴포넌트 동작이 달라집니다 | Playwright의 waitForSelector 등으로 하이드레이션 완료 시점을 명시적으로 대기하는 패턴이 유용합니다 |
| 초기 리팩터링 비용 | 기존 모놀리식 구조를 모듈 단위로 분리하는 작업이 필요합니다 | 페이지 단위로 점진적으로 적용하고, 한 번에 전체를 전환하는 것은 피하는 것이 좋습니다 |
| 검증 데이터 부족 | 공식 논문도 대규모 성능 평가를 "향후 과제"로 남겨두고 있습니다 | Lighthouse·WebPageTest로 직접 측정한 뒤 도입 여부를 결정하는 것을 권장합니다 |
실무에서 가장 흔한 실수
1. 모든 컴포넌트에 whenIdle을 붙이는 것
"지연이 좋으니 다 지연하자"는 접근이 처음에 굉장히 유혹적으로 느껴집니다. 저도 react-lazy-hydration을 처음 써볼 때 whenIdle을 전체에 뿌렸다가, 정작 "좋아요" 버튼도 한 박자 늦게 살아나는 걸 보고 황급히 되돌렸습니다. 우선순위 기준을 먼저 정의한 뒤 적용 범위를 결정하는 것이 중요합니다.
2. 전역 Context가 하이드레이션되기 전에 자식 모듈이 Context를 읽으려는 것
인증 상태·테마 같은 전역 Context Provider는 반드시 즉시 하이드레이션 영역에 두고, 그 하위에서 모듈을 지연시키는 구조로 설계하는 것이 좋습니다. Context가 준비되지 않은 상태에서 자식이 값을 읽으려 하면 예상치 못한 런타임 오류가 발생합니다.
3. 네트워크 조건 감지를 SSR 단계에서 시도하는 것
navigator.connection은 클라이언트 전용 API입니다. SSR 환경(서버)에서는 navigator 자체가 존재하지 않으므로, 반드시 useEffect 또는 클라이언트 컴포넌트 내에서만 사용해야 합니다.
마치며
당장 프로덕션에 전면 적용하기보다, 가장 무거운 페이지 하나에서 시작해보시면 좋습니다.
- 병목 페이지를 먼저 측정합니다. Chrome DevTools Performance 탭이나
npx lighthouse <URL> --view로 TBT와 TTI 수치를 기록해두시면 됩니다. 이 숫자가 도입 전 기준점이 됩니다. - 비핵심 컴포넌트를
next/dynamic으로 분리합니다. 뷰포트 아래 있는 섹션(리뷰, 추천 상품, 푸터)부터 dynamic import로 전환해보시면 됩니다. SSR HTML은 유지되면서 클라이언트 JS만 지연 로드됩니다. react-lazy-hydration을 설치해whenVisible/whenIdle을 붙여봅니다.pnpm add react-lazy-hydration한 줄이면 준비가 끝납니다. 이후 1단계에서 측정한 지표와 비교해보시면 효과를 직접 확인할 수 있습니다.
처음 SSR을 도입했을 때 "왜 아직도 느리냐"는 질문을 받았다면, 이제 그 답을 갖고 돌아갈 수 있습니다. 문제는 SSR이 아니라 하이드레이션 순서였고, 해법은 측정하고 분리하고 지연시키는 것입니다. React Server Components와 조합하면 클라이언트 JavaScript를 더욱 과감하게 줄일 수 있지만, 그것은 별도로 다루겠습니다.
용어 정리
| 용어 | 설명 |
|---|---|
| FCP (First Contentful Paint) | 브라우저가 화면에 첫 번째 콘텐츠를 그리는 시점. SSR은 이 값을 앞당긴다 |
| TTI (Time to Interactive) | 사용자가 실제로 인터랙션할 수 있게 되는 시점. SSR은 FCP를 앞당기지만 TTI는 하이드레이션이 결정한다 |
| INP (Interaction to Next Paint) | 2024년부터 Core Web Vitals 공식 지표. 사용자 입력(클릭, 키 입력)에 대해 브라우저가 다음 화면을 그리기까지 걸리는 시간. 하이드레이션이 메인 스레드를 점유하면 INP가 치솟는다 |
| TBT (Total Blocking Time) | FCP와 TTI 사이 구간에서 메인 스레드가 50ms 이상 블로킹된 시간의 합산. 하이드레이션이 길어질수록 TBT도 늘어난다 |
| startTransition | React 18에서 도입된 API. 해당 상태 업데이트를 "긴급하지 않음"으로 표시해 사용자 입력 같은 긴급 작업이 먼저 처리될 수 있게 한다 |
| Islands Architecture | 정적인 HTML 바다 위에 인터랙티브한 "섬"만 JavaScript로 활성화하는 패턴. Astro가 대표적이며, MRAH의 모듈형 렌더링 개념에 영향을 줬다 |
참고 자료
- Improving Front-end Performance through MRAH in React Applications | arXiv:2504.03884
- MRAH 논문 HTML 버전 | arXiv
- MRAH Architecture — Milad Abbasi | Medium
- Modular Rendering & Adaptive Hydration: Frontend's New Performance Frontier (2025) | JavaScript in Plain English
- Adaptive Hydration and Modular Rendering in React and Next.js | JavaScript in Plain English
- Supercharging React Apps: Strategic Hydration for Lightning-Fast Performance | Devmap/Medium
- Performance-First React: Adaptive Hydration & Modular Rendering Explained | Medium
- MRAH 논문 | TechRxiv
- react-lazy-hydration | GitHub
- Server Components vs. Islands Architecture | LogRocket Blog
- Advanced SSR 2025: Selective Hydration, RSCs, and Edge Rendering | madrigan.com