React 19 애플리케이션의 성능을 극대화하는 전략을 다룹니다. 번들 최적화, 렌더링 성능, Core Web Vitals 개선, 측정 도구 활용법을 배웁���다.
지금까지 React 19의 핵심 기능들을 하나씩 살펴보았습니다. 이번 장에서는 이 기능들을 성능이라는 관점에서 종합합니다. Server Components로 번들을 줄이고, Suspense로 로딩을 최적화하고, React Compiler로 렌더링을 개선하는 실전 전략을 다룹니다.
React 19 성능 최적화의 첫 번째 축은 클라이언트 번들 크기 감소입니다. Server Components에서 사용하는 라이브러리는 클라이언트에 전송되지 않습니다.
// Server Component: 이 라이브러리들은 클라이언트에 포함되지 않음
import { marked } from 'marked'; // ~35KB gzipped
import sanitizeHtml from 'sanitize-html'; // ~40KB gzipped
import { format } from 'date-fns'; // ~7KB gzipped
import Prism from 'prismjs'; // ~15KB gzipped
async function ArticlePage({ params }: { params: { id: string } }) {
const article = await getArticle(params.id);
const html = sanitizeHtml(marked(article.content));
const formattedDate = format(article.createdAt, 'yyyy년 M월 d일');
const highlightedCode = Prism.highlight(
article.code,
Prism.languages.typescript,
'typescript'
);
return (
<article>
<time>{formattedDate}</time>
<div dangerouslySetInnerHTML={{ __html: html }} />
<pre dangerouslySetInnerHTML={{ __html: highlightedCode }} />
{/* Client Component는 인터랙션이 필요한 부분만 */}
<CommentSection articleId={article.id} />
</article>
);
}이 구성에서 약 100KB(gzipped) 이상의 JavaScript가 클라이언트 번들에서 제거됩니다.
'use client' 경계 최적화'use client' 경계의 위치가 번들 크기를 결정합니다.
// 나쁜 예: 페이지 전체를 Client Component로
// → 모든 하위 컴포넌트와 의존성이 번들에 포함
'use client';
function ProductPage() { /* ... */ }
// 좋은 예: 인터랙션이 필요한 최소 단위만 Client Component로
// app/products/[id]/page.tsx (Server Component)
async function ProductPage({ params }: { params: { id: string } }) {
const product = await getProduct(params.id);
return (
<div>
{/* 정적 콘텐츠: Server Component */}
<ProductInfo product={product} />
<ProductSpecs specs={product.specs} />
{/* 인터랙션 필요: Client Component */}
<AddToCartButton productId={product.id} price={product.price} />
<ImageCarousel images={product.images} />
</div>
);
}React.lazy와 Suspense를 활용한 동적 임포트로 초기 번들을 줄입니다.
import { lazy, Suspense } from 'react';
// 무거운 컴포넌트는 동적 임포트
const RichTextEditor = lazy(() => import('@/components/RichTextEditor'));
const ChartLibrary = lazy(() => import('@/components/Chart'));
function Dashboard() {
const [showEditor, setShowEditor] = useState(false);
return (
<div>
<button onClick={() => setShowEditor(true)}>에디터 열기</button>
{showEditor && (
<Suspense fallback={<EditorSkeleton />}>
<RichTextEditor />
</Suspense>
)}
<Suspense fallback={<ChartSkeleton />}>
<ChartLibrary data={chartData} />
</Suspense>
</div>
);
}Server Components에서 순차적 데이터 페칭(워터폴)을 방지하는 것이 렌더링 성능의 핵심입니다.
// 나쁜 예: 순차 실행 (워터폴)
async function Dashboard() {
const user = await getUser(); // 200ms
const posts = await getPosts(user.id); // 300ms (user 완료 후 시작)
const stats = await getStats(user.id); // 150ms (posts 완료 후 시작)
// 총 650ms
return <DashboardUI user={user} posts={posts} stats={stats} />;
}// 좋은 예: 병렬 실행
async function Dashboard() {
const user = await getUser(); // 200ms (이건 먼저 필요)
// user에 의존하는 두 요청을 병렬로
const [posts, stats] = await Promise.all([
getPosts(user.id), // 300ms
getStats(user.id), // 150ms (동시 시작)
]);
// 총 500ms
return <DashboardUI user={user} posts={posts} stats={stats} />;
}// 최적: 핵심 데이터만 블로킹, 나머지는 스트리밍
async function Dashboard() {
const user = await getUser(); // 200ms (블로킹)
// Promise를 생성하되 await하지 않음
const postsPromise = getPosts(user.id);
const statsPromise = getStats(user.id);
return (
<div>
<UserHeader user={user} />
<Suspense fallback={<PostsSkeleton />}>
<PostList postsPromise={postsPromise} />
</Suspense>
<Suspense fallback={<StatsSkeleton />}>
<StatsPanel statsPromise={statsPromise} />
</Suspense>
</div>
);
// 사용자는 200ms 후 헤더를 보고,
// 나머지는 준비되는 대로 스트리밍
}import { cache } from 'react';
// cache()로 감싸면 같은 인자로 호출된 결과가 캐싱됨
const getUser = cache(async (userId: string) => {
return db.users.findUnique({ where: { id: userId } });
});
// Header와 Sidebar가 같은 user를 필요로 해도 쿼리는 1회만 실행
async function Header() {
const user = await getUser('user-1');
return <nav>{user.name}</nav>;
}
async function Sidebar() {
const user = await getUser('user-1');
return <aside>{user.bio}</aside>;
}
async function Page() {
return (
<>
<Header />
<Sidebar />
</>
);
}cache의 스코프는 단일 요청(request)입니다. 서로 다른 사용자의 요청 간에는 공유되지 않으므로 보안 문제가 없습니다.
무거운 상태 업데이트는 useTransition으로 감싸서 UI 반응성을 유지합니다.
'use client';
import { useState, useTransition } from 'react';
function SearchableList({ items }: { items: Item[] }) {
const [query, setQuery] = useState('');
const [filteredItems, setFilteredItems] = useState(items);
const [isPending, startTransition] = useTransition();
function handleSearch(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value;
setQuery(value); // 즉시 업데이트 (입력 반응)
startTransition(() => {
// 비용이 큰 필터링은 낮은 우선순위로
const filtered = items.filter(item =>
item.name.toLowerCase().includes(value.toLowerCase()) ||
item.description.toLowerCase().includes(value.toLowerCase())
);
setFilteredItems(filtered);
});
}
return (
<div>
<input value={query} onChange={handleSearch} placeholder="검색..." />
<div className={isPending ? 'opacity-60' : ''}>
{filteredItems.map(item => (
<ItemCard key={item.id} item={item} />
))}
</div>
</div>
);
}startTransition으로 감싼 상태 업데이트는 중단 가능(interruptible)합니다. 사용자가 추가 입력을 하면 이전 필터링이 중단되고 새로운 필터링이 시작됩니다.
LCP를 개선하려면 주요 콘텐츠를 최대한 빠르게 표시해야 합니다.
import { preload } from 'react-dom';
async function ArticlePage({ params }: { params: { id: string } }) {
const article = await getArticle(params.id);
// 히어로 이미지 프리로드
preload(article.heroImage, { as: 'image' });
return (
<article>
{/* LCP 요소: 히어로 이미지 */}
<img
src={article.heroImage}
alt={article.title}
width={1200}
height={630}
fetchPriority="high" // 높은 우선순위로 로드
/>
<h1>{article.title}</h1>
<p>{article.content}</p>
{/* 부가 콘텐츠는 스트리밍 */}
<Suspense fallback={<RelatedSkeleton />}>
<RelatedArticles />
</Suspense>
</article>
);
}인터랙션 성능을 개선하려면 하이드레이션 비용을 줄여야 합니다.
// 1. Server Components로 하이드레이션 대상 줄이기
async function Page() {
return (
<div>
{/* 하이드레이션 불필요: Server Component */}
<StaticContent />
{/* 하이드레이션 필요: 최소한의 Client Component */}
<Suspense>
<InteractiveWidget />
</Suspense>
</div>
);
}
// 2. 이벤트 핸들러에서 무거운 작업은 transition으로
'use client';
function SearchButton() {
const [isPending, startTransition] = useTransition();
return (
<button onClick={() => {
startTransition(() => {
// 무거운 상태 업데이트
performSearch();
});
}}>
검색
</button>
);
}레이아웃 이동을 방지하려면 Suspense fallback의 크기를 실제 콘텐츠와 맞춰야 합니다.
// 나쁜 예: fallback과 실제 콘텐츠의 크기 불일치
<Suspense fallback={<p>로딩 중...</p>}> {/* 높이: ~20px */}
<ProductGrid /> {/* 높이: ~600px */}
</Suspense>
// 좋은 예: fallback이 실제 콘텐츠의 공간을 확보
<Suspense fallback={<ProductGridSkeleton />}> {/* 높이: ~600px */}
<ProductGrid /> {/* 높이: ~600px */}
</Suspense>function ProductGridSkeleton() {
return (
<div className="grid grid-cols-3 gap-4" style={{ minHeight: '600px' }}>
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="animate-pulse">
<div className="aspect-square bg-gray-200 rounded" />
<div className="mt-2 h-4 bg-gray-200 rounded w-3/4" />
<div className="mt-1 h-4 bg-gray-200 rounded w-1/2" />
</div>
))}
</div>
);
}// 정적 데이터: 빌드 타임에 캐싱
async function StaticPage() {
const data = await fetch('https://api.example.com/static', {
cache: 'force-cache', // 빌드 타임에 캐싱
});
return <div>{data.title}</div>;
}
// 주기적 갱신: ISR(Incremental Static Regeneration)
async function ISRPage() {
const data = await fetch('https://api.example.com/articles', {
next: { revalidate: 3600 }, // 1시간마다 갱신
});
return <ArticleList articles={data} />;
}
// 동적 데이터: 매 요청마다 새로 가져옴
async function DynamicPage() {
const data = await fetch('https://api.example.com/live', {
cache: 'no-store',
});
return <LiveDashboard data={data} />;
}// Next.js는 Client-side Navigation 시
// 방문한 페이지의 RSC Payload를 캐싱합니다.
// 뒤로 가기/앞으로 가기 시 네트워크 요청 없이 즉시 표시.
// prefetch로 다음 페이지를 미리 캐싱
import Link from 'next/link';
function ArticleList({ articles }: { articles: Article[] }) {
return (
<ul>
{articles.map(article => (
<li key={article.id}>
{/* hover 시 자동 prefetch */}
<Link href={`/articles/${article.slug}`} prefetch>
{article.title}
</Link>
</li>
))}
</ul>
);
}import { Profiler } from 'react';
function onRender(
id: string,
phase: 'mount' | 'update' | 'nested-update',
actualDuration: number,
baseDuration: number,
startTime: number,
commitTime: number,
) {
// 성능 모니터링 서비스에 전송
if (actualDuration > 16) { // 60fps 기준 초과
reportSlowRender({ id, phase, actualDuration, baseDuration });
}
}
function App() {
return (
<Profiler id="app" onRender={onRender}>
<Dashboard />
</Profiler>
);
}React 19.2에서 추가된 Performance Tracks를 활용하면 Chrome DevTools의 Performance 탭에서 React 전용 정보를 확인할 수 있습니다.
| 영역 | 체크 항목 |
|---|---|
| 번들 | Server Components에서만 사용하는 라이브러리가 클라이언트 번들에 포함되지 않는가? |
| 번들 | 'use client' 경계가 트리의 최하위에 위치하는가? |
| 번들 | 무거운 컴포넌트에 코드 스플리팅이 적용되었는가? |
| 렌더링 | 데이터 페칭이 병렬로 실행되는가? (워터폴 없음) |
| 렌더링 | 불필요한 데이터 재요청이 cache()로 방지되었는가? |
| 렌더링 | 무거운 상태 업데이트가 startTransition으로 감싸져 있는가? |
| Suspense | 경계가 적절히 세분화되어 있는가? |
| Suspense | skeleton 크기가 실제 콘텐츠와 일치하는가? (CLS 방지) |
| 리소스 | 핵심 리소스에 preload가 적용되었는가? |
| 리소스 | 이미지에 적절한 width/height와 fetchPriority가 설정되었는가? |
| 캐싱 | 정적/동적 데이터의 캐싱 전략이 적절한가? |
| Compiler | React Compiler가 활성화되어 있는가? |
'use client' 경계를 트리 하위에 배치합니다.Promise.all과 스트리밍 패턴으로 제거합니다.React.cache로 동일 요청 내 중복 데이터 호출을 방지합니다.useTransition으로 무거운 상태 업데이트를 낮은 우선순위로 처리하여 UI 반응성을 유지합니다.preload, preinit API로 핵심 리소스의 로딩을 앞당깁니다.다음 장에서는 React 18에서 19로의 마이그레이션 전략과 주의사항을 다룹니다.
이 글이 도움이 되셨나요?
React 18에서 19로 안전하게 업그레이드하는 단계별 가이드입니다. 제거된 API, 타입 변경, 동작 변화, 자동 마이그레이션 도구를 다룹니다.
React 19의 DX 개선사항을 다룹니다. ref를 일반 props로 전달하는 방법, 컴포넌트 내 메타데이터 태그, 리소스 프리로딩 API를 살펴봅니다.
React 19의 핵심 기능을 모두 활용한 풀스택 북마크 앱을 구축합니다. Server Components, Server Actions, 새로운 훅, Suspense 패턴을 실전에 적용합니다.