Next.jsFrontendPerformanceReact

App Router 렌더링 성능 최적화: Server Component와 Client Component의 적절한 경계 찾기

빠를 줄 알았던 Next.js App Router 환경에서 체감 로딩이 느려진 원인을 파악하고, 서버 컴포넌트와 클라이언트 컴포넌트를 적절하게 분리하여 TTI(Time To Interactive)를 개선한 기록입니다.

Srue2026년 4월 6일
App Router 렌더링 성능 최적화: Server Component와 Client Component의 적절한 경계 찾기

App Router 렌더링 성능 최적화: Server Component와 Client Component 경계 찾기

이전에 Next.js App Router 기반의 SEO 최적화 가이드를 포스팅하면서 프레임워크가 주는 이점에 대해 찬양(?)을 늘어놓았습니다.

분명히 검색 엔진 노출은 성공적이었는데, 시간이 지날수록 유저 동선 상에서 기묘한 딜레이를 느끼기 시작했습니다. 링크를 눌렀는데 페이지 이동에 1~2초씩 "멍 때리는" 이른바 프리징 현상이었습니다.

React 18과 Next.js App Router의 렌더링 생태계에 대한 이해가 부족한 채 무작정 개발한 탓이 컸습니다. 이번엔 Server Component(이하 RSC)와 Client Component(이하 RCC)를 제멋대로 섞어 쓰다가 무거워진 페이지 성능을 어떻게 최적화했는지 회고해 볼까 합니다.

문제의 발단: 일단 다 'use client' 박아보기

새 페이지를 짤 때, 조금이라도 useStateonClick 이벤트가 필요해지면 컴포넌트 최상단에 마법의 주문을 걸고 시작했습니다.

"use client";
 
export default function MyHeavyPage() {
  const [data, setData] = useState(null);
  
  // 각종 무거운 외부 라이브러리와 통째로 결합된 방대한 렌더링 트리
  return (
    <div>
      <ComplexChart />
      <HeavyTable />
      <button onClick={() => alert('조회')}>조회</button>
    </div>
  );
}

이 코드는 작동은 하지만 심각한 오판이었습니다. 페이지 최상단에서 "use client"를 선언해 버리면, 그 아래 달린 어마어마한 양의 자식 컴포넌트들까지 모조리 클라이언트 번들(JS)에 포함되어 유저 브라우저로 내려가게 됩니다. 다운받고 실행해야 할 자바스크립트 크기가 산더미처럼 커졌으니 로딩이 느린 건 당연했습니다.

해결의 축: 나뭇잎(Leaf)에서만 'use client' 사용하기

RSC의 핵심 컨셉은 "렌더링의 무거운 작업과 큰 라이브러리는 서버 쪽에 두고, 완성된 HTML만 브라우저로 내려주자" 입니다. 브라우저는 상호작용(Interaction)이 꼭 필요한 아주 작고 얇은 껍데기에만 JS 번들을 심으면 됩니다.

이 원칙에 입각해 페이지 모델을 다시 조립했습니다.

1. 상단 트리(서버 컴포넌트) 유지

최상위 페이지나 레이아웃 컴포넌트는 무조건 RSC로 두어, 데이터 Fetching의 이점을 챙겼습니다.

app/dashboard/page.tsx
import { getHeavyData } from "@/lib/api";
import { InteractiveButton } from "./interactive-button";
 
export default async function DashboardPage() {
  // DB 혹은 외부 호스트로부터 직접 데이터를 당겨옴 (서버 환경!)
  const data = await getHeavyData();
 
  return (
    <main>
      <h1>비즈니스 대시보드</h1>
      {/* 데이터 렌더링은 여전히 서버에서 해서 내려줌 */}
      <StaticTableView data={data} />
      
      {/* 상호작용이 필요한 아주 작은 조각상만 RCC로 분리 */}
      <InteractiveButton />
    </main>
  );
}

2. 말단(Leaf) 노드만 클라이언트 컴포넌트로 분리

클릭 이벤트 처리가 필요한 엘리먼트만 쏙 빼서 분리했습니다.

app/dashboard/interactive-button.tsx
"use client";
 
import { useState } from "react";
 
export function InteractiveButton() {
  const [loading, setLoading] = useState(false);
  
  return <button onClick={() => setLoading(true)}>새로고침</button>;
}

느낀 점과 눈에 띄게 개선된 수치

이렇게 '필요한 곳에서만 최소한으로 Client Boundary를 긋는다'는 전략으로 컴포넌트를 난도질(?) 하고 나니, 번들 뷰어 애널리틱스(@next/bundle-analyzer)를 돌렸을 때 충격적인 결과가 나왔습니다.

이전에는 클라이언트로 고스란히 전송되던 무거운 데이터 포맷팅 라이브러리(date-fns, lodash 등)가 서버 쪽에서 구동되고 증발해버리면서, 전달되는 JS 페이로드 크기가 대폭 하락했습니다. 이 덕에 처음 언급했던 기묘한 "멍 때림(hydration 딜레이)"은 사라지고 부드러운 화면 전환이 가능해졌습니다.

Next.js App Router 체제에서는 '동시 구동이 가능하냐(동형, Isomorphic)'의 관점이 아니라, 마치 햄버거 양상추 사이에 치즈를 끼워 넣듯이 서버와 클라이언트의 책임을 명확하게 분리하는 식견이 가장 중요한 자질임을 다시금 깨달았습니다.