Next.js App Router와 React 서버 컴포넌트를 도입하면서 더 빠른 렌더링을 기대했지만,
실제 개발 과정에서 여러 제약사항을 마주하게 되었습니다. 이론적 장점과 실무의 간극에 대해 정리하겠습니다.
서버 컴포넌트의 기대했던 장점
Next.js의 App Router를 선택한 장점은 분명했습니다.
1. 성능 최적화: JavaScript 번들 크기 감소로 초기 로딩 속도가 개선되고, 저사양 환경에서도 나은 사용자 경험을 제공합니다.
2. 직접적인 데이터 접근: API 레이어 없이 데이터베이스나 파일 시스템에 직접 접근할 수 있어 코드가 간결해지고 응답 시간이 단축됩니다.
3. 보안 강화: 민감한 로직과 API 키가 서버에서만 실행되어 클라이언트에 노출되지 않습니다. 다만 React 19에서 서버 컴포넌트 DoS 공격과 소스코드 노출 취약점이 발견되면서 이 부분에 대한 우려가 생겼습니다.
4. 자동 코드 분할과 SEO: 효율적인 코드 분할과 서버 렌더링된 콘텐츠로 검색 엔진 최적화에 유리합니다.
5. 스트리밍 지원: React Suspense를 활용해 점진적으로 콘텐츠를 전송할 수 있습니다.
실무에서 마주한 4가지 주요 문제
1. 낙관적 업데이트의 부재
서버 컴포넌트는 마운팅 후 상태를 가질 수 없는 구조입니다.
데이터를 fetch해서 렌더링하는 역할만 하기 때문에, 사용자 인터랙션에 즉각 반응해야 하는 UI는 결국 클라이언트 컴포넌트로 분리해야 합니다.
// 서버 컴포넌트는 데이터만 전달
export default async function PostsPage() {
const posts = await fetchPosts();
return <PostList posts={posts} />; // 클라이언트 컴포넌트로 위임
}
결과적으로 대부분의 인터랙티브한 페이지는 'use client'를 선언하게 되고, 서버 컴포넌트의 이점을 충분히 활용하기 어렵습니다.
2. 페이지 이동마다 반복되는 서버 요청
라우터 이동 시마다 서버에서 데이터를 다시 fetch합니다.
클라이언트 메모리에 이전 페이지 데이터가 남아있어도 서버에 재요청합니다.
// app/posts/page.tsx
export default async function PostsPage() {
const posts = await fetchPosts();
return (
<div>
{posts.map(p => (
<Link href={`/post/${p.id}`}>
<h2>{p.title}</h2>
</Link>
))}
</div>
);
}
실제 동작 흐름:
/posts방문 →fetchPosts()실행- 게시물 클릭 →
/post/1이동 - 뒤로가기 → 서버에서
fetchPosts()재실행
불과 몇 초 전에 봤던 목록임에도 서버에 다시 요청하고 로딩 상태를 표시합니다.
정적 콘텐츠(블로그 등)는 디스크 캐시로 해결되지만, 동적 콘텐츠(세션, 쿠키 사용)는 매번 로딩이 발생합니다.
// 동적 콘텐츠 예시
export default async function HomePage() {
const session = await getSession(); // 쿠키 의존
const posts = await fetchPosts(session.userId); // 사용자별 데이터
return <div>
<h1>{session.user.name}님의 피드</h1>
{posts.map(post => <Post post={post} />)}
</div>;
}
더 큰 문제는 페이지 간 데이터 공유가 불가능하다는 점입니다.
사용자 목록(/users)에서 이미 로드한 Alice의 정보를, 프로필 페이지(/user/alice)에서 재사용할 수 없습니다.
서버는 이전 페이지에 대한 정보가 없기 때문에 동일한 데이터를 다시 fetch해야 합니다.
3. 레이아웃의 제약사항
layout.tsx는 렌더링 결과가 캐싱되지만, 동적 API(cookies, headers 등)를 사용하면 캐싱이 무효화됩니다.
// app/layout.tsx
export default function RootLayout({ children }) {
// ❌ 불가능한 작업들
// const cookies = useCookies();
// const pathname = usePathname();
// children에게 props 전달 불가
return (
<html>
<body>
<Header /> {/* user 정보를 어떻게 가져올까? */}
{children}
</body>
</html>
);
}
두 가지 해결 방법 모두 trade-off가 있습니다:
- 서버 컴포넌트에서 동적 API 사용: 렌더링 결과 캐싱 불가
- 클라이언트 컴포넌트로 분리: 하이드레이션 중 깜빡임 발생
서버: user=null → 로그아웃 UI
클라이언트 마운트: user=null → 로그아웃 UI
fetch 완료: user=Alice → 로그인 UI (깜빡임!)
또한 Layout에서 children으로 props를 전달할 수 없어, 같은 데이터가 필요하면 각 컴포넌트에서 API를 재호출해야 합니다.
Next.js의 Request Memoization이 중복 호출을 방지해주지만, 여러 페이지에서 사용하다 보면 관리가 복잡해집니다.
4. 중복 전송되는 콘텐츠
사용자가 URL을 직접 입력하거나 새로고침할 때, 동일한 콘텐츠가 HTML과 RSC 페이로드로 두 번 전송됩니다.
// app/post/[id]/page.tsx
export default async function PostPage({ params }) {
const post = await fetchPost(params.id);
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
);
}
실제 응답 구조:
<!-- 1차: HTML -->
<article>
<h1>Next.js의 문제점</h1>
<p>서버 컴포넌트는 매우 답답하게 느껴집니다...</p>
</article>
<!-- 2차: RSC 페이로드 (동일한 내용) -->
<script>
self.__next_f.push([1, `
["$","h1",null,{"children":"Next.js의 문제점"}],
["$","p",null,{"children":"서버 컴포넌트는...""}]
`]);
</script>
왜 두 번 보내야 할까?
- Suspense 경계
- 클라이언트 컴포넌트 위치
- props 데이터
- 비동기 상태 등 React가 하이드레이션에 필요한 정보
Suspense를 활용한 스트리밍에서는 이 문제가 더 심화됩니다. 각 청크마다 HTML과 RSC 페이로드가 모두 전송되어, 동일한 텍스트가 여러 번 중복됩니다.
결론
App Router는 분명 강력한 기능을 제공하지만, 실제 개발에서는 이론과 다른 제약사항들이 존재했습니다. 특히 인터랙티브한 애플리케이션일수록 클라이언트 컴포넌트로 회귀하게 되어, 서버 컴포넌트의 이점을 누릴 수 없었던 것 같습니다.
프로젝트 특성에 따라 Pages Router가 더 적합할 수 있으니 trade-off를 확인하고 선택하는 것이 좋을 것 같습니다.
'Next.js' 카테고리의 다른 글
| Full Route Cache를 활용한 성능 최적화 (2) | 2025.08.06 |
|---|---|
| Server Component와 Client Component 차이 (3) | 2025.07.25 |
| Next 스트리밍 with Suspense (5) | 2025.07.24 |
| persistQueryClient를 활용해 초기렌더링 개선하기 (3) | 2025.07.08 |
| Next.js with CSS-in-JS (0) | 2024.12.26 |
