Next.js의 숨겨진 최적화: 렌더링 지연과 Preload 자동화 메커니즘
이벤트 루프 제어를 통한 동적 임포트 분석과 Link Preload 헤더 생성 전략
Next.js 13에서 도입된 App Router는 React Server Components(RSC) 를 기본값으로 채택하여 렌더링 패러다임의 근본적인 변화를 가져왔습니다. 서버 컴포넌트는 클라이언트로 전송되는 JavaScript 번들을 제로(0)로 만들면서도, 클라이언트 컴포넌트와 유연하게 조합하여 최적의 사용자 경험을 제공합니다.
이 글에서는 서버 컴포넌트의 내부 동작 원리, RSC Payload 메커니즘, 그리고 서버-클라이언트 하이브리드 렌더링 전략을 살펴봅니다.
기존 SSR의 한계:
// 기존 SSR: 클라이언트에서도 전체 컴포넌트 재실행
export default function Page() {
const data = await fetchData(); // 서버에서 실행
return <div>{data}</div>; // 클라이언트에서도 hydration 위해 재실행
}
// 번들에 포함됨:
// - fetchData 함수
// - Page 컴포넌트 전체 로직
// 결과: 불필요한 JavaScript 전송RSC의 혁신:
// 서버 컴포넌트: 클라이언트 번들에서 완전히 제외
export default async function Page() {
const data = await fetchData(); // 서버에서만 실행
return <div>{data}</div>; // 렌더링 결과만 클라이언트로 전송
}
// 번들에 포함 안 됨:
// - fetchData 함수 [권장] 제외
// - Page 컴포넌트 로직 [권장] 제외
// 결과: JavaScript 번들 크기 0KB!서버 컴포넌트는 HTML이 아닌 RSC Payload라는 특수한 JSON-like 형식으로 클라이언트에 전송됩니다.
실제 RSC Payload 예시:
{
"0": ["$","div",null,{
"children": ["$","h1",null,{"children":"Hello"}]
}],
"1": ["$","@1",null,{"children":["$0"]}]
}특징:
Before (전통적인 React):
// ProductList.tsx
import { useState, useEffect } from 'react';
export default function ProductList() {
const [products, setProducts] = useState([]);
useEffect(() => {
fetch('/api/products')
.then(res => res.json())
.then(setProducts);
}, []);
return (
<div>
{products.map(p => <ProductCard key={p.id} product={p} />)}
</div>
);
}
// 번들 크기: ~15KB (컴포넌트 + useState/useEffect + fetch 로직)After (서버 컴포넌트):
// ProductList.tsx (서버 컴포넌트)
export default async function ProductList() {
const products = await db.product.findMany(); // 직접 DB 접근!
return (
<div>
{products.map(p => <ProductCard key={p.id} product={p} />)}
</div>
);
}
// 번들 크기: 0KB (서버에서만 실행)
// 클라이언트는 렌더링된 결과만 받음// 서버 컴포넌트에서 가능한 작업들
export default async function Dashboard() {
// 1. 데이터베이스 직접 접근
const users = await prisma.user.findMany();
// 2. 환경 변수 안전하게 사용 (클라이언트 노출 없음)
const apiKey = process.env.SECRET_API_KEY;
// 3. 파일 시스템 접근
const config = await fs.readFile('config.json');
// 4. 서버 전용 라이브러리 사용
const marked = await import('marked'); // 클라이언트 번들에 포함 안 됨
return <div>{/* ... */}</div>;
}// 모든 서버 컴포넌트는 자동으로 코드 스플리팅됨
import HeavyChart from './HeavyChart'; // 서버 컴포넌트
import Analytics from './Analytics'; // 서버 컴포넌트
export default function Dashboard() {
return (
<div>
<HeavyChart /> {/* 0KB 번들 */}
<Analytics /> {/* 0KB 번들 */}
</div>
);
}
// 전체 번들 크기: 0KB (RSC Payload만 전송)클라이언트 컴포넌트는 파일 최상단에 'use client' 지시어를 명시해야 합니다.
'use client';
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
}중요한 점:
'use client'는 파일의 경계를 정의합니다1. 상태(State) 관리
'use client';
export default function SearchInput() {
const [query, setQuery] = useState('');
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
);
}2. 이벤트 핸들러
'use client';
export default function LikeButton() {
const handleClick = () => {
alert('Liked!');
};
return <button onClick={handleClick}>Like</button>;
}3. 브라우저 API 사용
'use client';
import { useEffect } from 'react';
export default function GeoLocation() {
useEffect(() => {
navigator.geolocation.getCurrentPosition((position) => {
console.log(position.coords);
});
}, []);
return <div>Getting your location...</div>;
}4. Context 제공/소비
'use client';
import { createContext, useContext } from 'react';
const ThemeContext = createContext('light');
export function ThemeProvider({ children }) {
return (
<ThemeContext.Provider value="dark">
{children}
</ThemeContext.Provider>
);
}1. 초기 페이지 로드 (SSR):
서버에서 HTML 생성 → 클라이언트로 전송 → Hydration
2. 이후 네비게이션:
클라이언트에서만 렌더링 (CSR)
가장 일반적인 패턴은 서버에서 데이터를 가져와 클라이언트 컴포넌트에 전달하는 것입니다.
// app/page.tsx (서버 컴포넌트)
import ProductList from './ProductList'; // 클라이언트 컴포넌트
export default async function Page() {
// 서버에서 데이터 페칭
const products = await db.product.findMany();
// 클라이언트 컴포넌트에 전달
return <ProductList products={products} />;
}
// app/ProductList.tsx (클라이언트 컴포넌트)
'use client';
export default function ProductList({ products }) {
const [filter, setFilter] = useState('');
return (
<div>
<input value={filter} onChange={(e) => setFilter(e.target.value)} />
{products
.filter(p => p.name.includes(filter))
.map(p => <ProductCard key={p.id} product={p} />)}
</div>
);
}이점:
클라이언트 컴포넌트는 서버 컴포넌트를 직접 import할 수 없지만, children prop으로 받을 수 있습니다.
// ClientWrapper.tsx (클라이언트 컴포넌트)
'use client';
export default function ClientWrapper({ children }) {
const [isCollapsed, setIsCollapsed] = useState(false);
return (
<div>
<button onClick={() => setIsCollapsed(!isCollapsed)}>
Toggle
</button>
{!isCollapsed && children}
</div>
);
}
// ServerContent.tsx (서버 컴포넌트)
export default async function ServerContent() {
const data = await fetchHeavyData(); // 서버에서만 실행
return <div>{data}</div>;
}
// Page.tsx (서버 컴포넌트)
export default function Page() {
return (
<ClientWrapper>
<ServerContent /> {/* 서버 특성 유지! */}
</ClientWrapper>
);
}동작 원리:
1. Page 렌더링 (서버)
2. ServerContent 렌더링 (서버) → RSC Payload 생성
3. ClientWrapper 렌더링 (서버) → children 위치에 ServerContent placeholder
4. 클라이언트로 전송
5. ClientWrapper hydration → ServerContent는 이미 렌더링된 상태
클라이언트 컴포넌트를 가능한 한 트리의 하단(leaf)에 배치합니다.
[주의] 비효율적:
'use client';
// 전체 페이지가 클라이언트 컴포넌트
export default function Page() {
const [query, setQuery] = useState('');
return (
<div>
<Header /> {/* 번들에 포함 */}
<Sidebar /> {/* 번들에 포함 */}
<SearchInput value={query} onChange={setQuery} />
<Footer /> {/* 번들에 포함 */}
</div>
);
}
// 번들 크기: ~50KB[권장] 효율적:
// 페이지는 서버 컴포넌트 (기본값)
export default function Page() {
return (
<div>
<Header /> {/* 0KB */}
<Sidebar /> {/* 0KB */}
<SearchInput /> {/* 클라이언트 컴포넌트만 */}
<Footer /> {/* 0KB */}
</div>
);
}
// SearchInput.tsx
'use client';
export default function SearchInput() {
const [query, setQuery] = useState('');
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}
// 번들 크기: ~2KB (SearchInput만)// app/dashboard/page.tsx (서버 컴포넌트)
export default async function Dashboard() {
// 서버에서 여러 데이터 병렬 페칭
const [stats, users, revenue] = await Promise.all([
db.stats.findFirst(),
db.user.count(),
db.order.aggregate({ _sum: { total: true } })
]);
return (
<div>
<StatCard title="Users" value={users} /> {/* 서버 컴포넌트 */}
<InteractiveChart data={stats} /> {/* 클라이언트 컴포넌트 */}
<RevenueDisplay total={revenue._sum.total} /> {/* 서버 컴포넌트 */}
</div>
);
}
// InteractiveChart.tsx (클라이언트 컴포넌트)
'use client';
import { Line } from 'recharts';
export default function InteractiveChart({ data }) {
const [period, setPeriod] = useState('week');
return (
<div>
<select value={period} onChange={(e) => setPeriod(e.target.value)}>
<option value="week">Week</option>
<option value="month">Month</option>
</select>
<Line data={data} />
</div>
);
}// app/posts/[id]/page.tsx (서버 컴포넌트)
import { marked } from 'marked'; // 클라이언트 번들에 포함 안 됨!
export default async function Post({ params }) {
const post = await db.post.findUnique({
where: { id: params.id }
});
// 서버에서 Markdown → HTML 변환
const html = marked(post.content);
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: html }} />
<CommentSection postId={post.id} /> {/* 클라이언트 컴포넌트 */}
</article>
);
}
// CommentSection.tsx (클라이언트 컴포넌트)
'use client';
export default function CommentSection({ postId }) {
const [comments, setComments] = useState([]);
useEffect(() => {
fetch(`/api/comments?postId=${postId}`)
.then(res => res.json())
.then(setComments);
}, [postId]);
return (
<div>
{comments.map(c => <Comment key={c.id} comment={c} />)}
</div>
);
}// app/admin/page.tsx (서버 컴포넌트)
import { redirect } from 'next/navigation';
import { auth } from '@/lib/auth';
export default async function AdminPage() {
const session = await auth(); // 서버에서 인증 확인
if (!session || session.user.role !== 'admin') {
redirect('/login'); // 서버 리다이렉트
}
// 민감한 데이터는 서버에서만 접근
const adminData = await db.admin.findMany({
include: { sensitiveInfo: true }
});
return (
<div>
<h1>Admin Dashboard</h1>
<AdminPanel data={adminData} />
</div>
);
}1. React Hooks
// [주의] 서버 컴포넌트에서 불가능
export default async function Page() {
const [state, setState] = useState(0); // Error!
useEffect(() => {}, []); // Error!
const context = useContext(MyContext); // Error!
return <div>{state}</div>;
}2. 브라우저 API
// [주의] 서버 컴포넌트에서 불가능
export default async function Page() {
const width = window.innerWidth; // Error: window is not defined
localStorage.setItem('key', 'value'); // Error!
return <div>{width}</div>;
}3. 이벤트 핸들러
// [주의] 서버 컴포넌트에서 불가능
export default async function Page() {
return (
<button onClick={() => alert('Hi')}> {/* Error! */}
Click me
</button>
);
}이러한 기능이 필요하면 해당 부분만 클라이언트 컴포넌트로 분리합니다.
// Page.tsx (서버 컴포넌트)
export default async function Page() {
const data = await fetchData();
return (
<div>
<h1>Title</h1>
<InteractiveButton data={data} /> {/* 클라이언트 컴포넌트 */}
</div>
);
}
// InteractiveButton.tsx (클라이언트 컴포넌트)
'use client';
export default function InteractiveButton({ data }) {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
{data.title}: {count}
</button>
);
}// [권장] 권장: 서버 컴포넌트에서 페칭
export default async function Page() {
const data = await db.posts.findMany();
return <PostList posts={data} />;
}
// [주의] 비권장: 클라이언트에서 페칭
'use client';
export default function Page() {
const [data, setData] = useState([]);
useEffect(() => {
fetch('/api/posts')
.then(res => res.json())
.then(setData);
}, []);
return <PostList posts={data} />;
}import { Suspense } from 'react';
export default function Page() {
return (
<div>
<Header />
<Suspense fallback={<LoadingSkeleton />}>
<SlowComponent /> {/* 느린 데이터 페칭 */}
</Suspense>
<Footer />
</div>
);
}
// SlowComponent는 독립적으로 스트리밍됨
async function SlowComponent() {
const data = await fetchSlowData(); // 3초 소요
return <div>{data}</div>;
}# Next.js 빌드 시 번들 크기 확인
npm run build
# 출력 예시:
Route (app) Size First Load JS
┌ ○ / 1.2 kB 85 kB
├ ○ /dashboard 0 kB 83 kB # 서버 컴포넌트만
└ ○ /interactive 15 kB 98 kB # 클라이언트 컴포넌트 포함Next.js Server Components는 클라이언트로 전송되는 JavaScript를 최소화하면서도, 클라이언트 컴포넌트와의 유연한 조합을 통해 최적의 사용자 경험을 제공합니다. RSC Payload 메커니즘을 통해 서버에서 렌더링된 결과를 효율적으로 전송하며, Composition Pattern을 활용하여 서버와 클라이언트 컴포넌트를 자연스럽게 조합할 수 있습니다.
핵심 권장사항:
참고 자료:
이벤트 루프 제어를 통한 동적 임포트 분석과 Link Preload 헤더 생성 전략
React lazy와 Next.js dynamic의 SSR 동작 차이와 최적의 코드 스플리팅 전략
Next.js의 장단점과 실무 도입 시 고려해야 할 핵심 요소