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

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' 박아보기
새 페이지를 짤 때, 조금이라도 useState나 onClick 이벤트가 필요해지면 컴포넌트 최상단에 마법의 주문을 걸고 시작했습니다. 차트도, 표도, 날짜 선택기도 한 파일 안에 다 욱여넣고 맨 위에 "use client" 한 줄을 붙이는 식이었죠.
"use client";
import { useState } from "react";
import { LineChart, Line, XAxis, YAxis } from "recharts"; // 무거운 차트 라이브러리
import { format } from "date-fns"; // 날짜 포매팅
import DatePicker from "react-datepicker"; // 캘린더 위젯
export default function DashboardPage() {
const [range, setRange] = useState(new Date());
// 페이지 전체가 클라이언트 컴포넌트라 아래 모든 import가 통째로 번들에 들어간다
return (
<main>
<h1>비즈니스 대시보드</h1>
<DatePicker selected={range} onChange={setRange} />
<LineChart width={640} height={320} data={[]}>
<XAxis dataKey="date" />
<YAxis />
<Line dataKey="revenue" />
</LineChart>
<p>마지막 갱신: {format(range, "yyyy-MM-dd HH:mm")}</p>
</main>
);
}이 코드는 작동은 하지만 심각한 오판이었습니다. 페이지 최상단에서 "use client"를 선언해 버리면, 그 경계 아래에 import된 모든 모듈(recharts, date-fns, react-datepicker)이 모조리 클라이언트 번들(JS)에 포함되어 유저 브라우저로 내려갑니다. 정작 사용자에게 상호작용이 필요한 부분은 날짜 선택기 하나뿐인데, 정적으로 보여줘도 충분한 차트와 텍스트까지 전부 JS로 내려가 브라우저에서 다시 hydration되니 로딩이 느린 건 당연했습니다.
'use client'는 경계이지 스위치가 아니다
제가 처음에 오해했던 지점이 바로 이것입니다. "use client"는 "이 파일 하나를 클라이언트로 만든다"가 아니라, **"여기서부터 import 그래프를 타고 내려가는 모든 것을 클라이언트 묶음으로 본다"**는 경계 선언입니다.
즉 경계를 트리의 위쪽(page, layout)에 그으면 그 밑에 매달린 무거운 라이브러리가 전부 따라 내려갑니다. 반대로 경계를 트리의 말단(leaf)에 가깝게 그을수록, 클라이언트로 보내야 하는 JS는 딱 그 말단 조각으로 줄어듭니다.
RSC의 핵심 컨셉은 "렌더링의 무거운 작업과 큰 라이브러리는 서버 쪽에 두고, 완성된 HTML만 브라우저로 내려주자" 입니다. 브라우저는 상호작용(Interaction)이 꼭 필요한 아주 작고 얇은 껍데기에만 JS 번들을 심으면 됩니다.
개선: 경계를 나뭇잎(Leaf)으로 내리기
이 원칙에 입각해 페이지 모델을 다시 조립했습니다. 페이지(page.tsx)는 다시 서버 컴포넌트로 되돌리고, 상호작용이 필요한 조각만 별도 파일로 떼어내 그 파일에만 "use client"를 붙였습니다.
import { getRevenue } from "@/lib/api";
import { format } from "date-fns"; // 서버에서만 실행 → 클라이언트로 안 내려감
import { RevenueChart } from "@/components/revenue-chart";
import { RangePicker } from "@/components/range-picker";
export default async function DashboardPage() {
// DB 혹은 외부 호스트에서 직접 데이터를 당겨옴 (서버 환경!)
const points = await getRevenue();
const updatedAt = format(new Date(), "yyyy-MM-dd HH:mm");
return (
<main>
<h1>비즈니스 대시보드</h1>
{/* 상호작용이 필요한 아주 작은 조각만 RCC로 분리 */}
<RangePicker />
{/* recharts 번들은 이 컴포넌트 청크에만 갇힌다 */}
<RevenueChart points={points} />
{/* 날짜 포매팅은 서버에서 끝내고 문자열만 내려보냄 */}
<p>마지막 갱신: {updatedAt}</p>
</main>
);
}여기서 핵심은 date-fns의 format 호출이 서버 컴포넌트 안에 남았다는 점입니다. 결과 문자열만 HTML에 박혀 내려가므로 date-fns 자체는 클라이언트 번들에서 증발합니다.
상호작용이 필요한 날짜 선택기는 자기 파일로 떼어내 그 파일에만 경계를 그었습니다.
"use client";
import { useState } from "react";
import DatePicker from "react-datepicker";
export function RangePicker() {
const [range, setRange] = useState<Date>(new Date());
// react-datepicker 번들은 딱 이 leaf 파일 청크에만 포함된다
return <DatePicker selected={range} onChange={(d) => d && setRange(d)} />;
}패턴 1: client 전용 무거운 컴포넌트를 leaf 파일로 분리
차트, 마크다운 에디터, 캘린더처럼 브라우저 API에 의존하는 client 전용 컴포넌트는 별도 파일 + 파일 상단 "use client" 가 가장 깔끔했습니다. 서버 페이지는 이 컴포넌트를 그냥 일반 import처럼 가져다 쓰면 되고, React가 경계를 알아서 처리합니다.
"use client";
import { LineChart, Line, XAxis, YAxis, Tooltip } from "recharts";
type Point = { date: string; revenue: number };
export function RevenueChart({ points }: { points: Point[] }) {
return (
<LineChart width={640} height={320} data={points}>
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Line type="monotone" dataKey="revenue" />
</LineChart>
);
}이렇게 두면 recharts는 RevenueChart 청크 안에만 들어가고, 이 차트를 쓰지 않는 다른 페이지에는 단 1KB도 따라붙지 않습니다. "무거운 라이브러리는 그것을 실제로 쓰는 leaf 파일에 가둔다"가 1번 패턴의 전부입니다.
패턴 2: 상호작용 직전까지 로딩을 미루기 (dynamic import)
한 발 더 나아가, 첫 화면에 당장 필요 없는 무거운 위젯은 사용자가 버튼을 누른 순간에만 내려받도록 미뤘습니다. 글쓰기 화면의 마크다운 에디터가 대표적이었습니다.
주의할 점은 ssr: false 옵션이 달린 next/dynamic은 서버 컴포넌트 안에서는 사용할 수 없다는 것입니다. 그래서 client 경계를 가진 작은 런처 컴포넌트로 감쌌습니다.
"use client";
import { useState } from "react";
import dynamic from "next/dynamic";
// 무거운 에디터는 버튼을 누른 순간에만 청크를 내려받는다
const MarkdownEditor = dynamic(() => import("./markdown-editor"), {
ssr: false,
loading: () => <p className="text-sm text-zinc-500">에디터 불러오는 중…</p>,
});
export function EditorLauncher() {
const [open, setOpen] = useState(false);
if (!open) {
return <button onClick={() => setOpen(true)}>글쓰기</button>;
}
return <MarkdownEditor />;
}이 패턴 덕분에 에디터 라이브러리는 초기 번들(First Load JS)에서 빠지고, 실제로 글을 쓰려는 사용자에게만 추가로 전송됩니다. 첫 진입 속도와 무거운 기능을 둘 다 챙길 수 있었습니다.
추측을 멈추고 번들을 직접 열어보기: @next/bundle-analyzer
여기까지는 "그럴듯한 이론"이었습니다. 정말로 번들이 줄었는지 눈으로 확인하려고 @next/bundle-analyzer를 붙였습니다.
npm install --save-dev @next/bundle-analyzernext.config.mjs를 분석기로 한 번 감싸주면 됩니다. ANALYZE 환경 변수가 켜졌을 때만 동작하도록 해서 평소 빌드에는 영향을 주지 않게 했습니다.
import withBundleAnalyzer from "@next/bundle-analyzer";
const bundleAnalyzer = withBundleAnalyzer({
enabled: process.env.ANALYZE === "true",
});
/** @type {import('next').NextConfig} */
const nextConfig = {
// ... 기존 설정 그대로
};
export default bundleAnalyzer(nextConfig);이제 아래 명령으로 빌드하면, 빌드가 끝난 뒤 브라우저에 클라이언트/서버 번들의 트리맵(treemap)이 자동으로 열립니다.
ANALYZE=true npm run build트리맵에서는 어떤 모듈이 번들의 몇 퍼센트를 차지하는지 면적으로 보여줍니다. 개선 전 빌드에서는 client 묶음 한가운데에 recharts와 date-fns가 커다란 사각형으로 자리 잡고 있었는데, 경계를 leaf로 내린 뒤 다시 돌려보니 date-fns 블록은 client 트리맵에서 사라지고 recharts는 차트 페이지 전용 청크로 떨어져 나가 있었습니다. "어림짐작"이 아니라 면적으로 확인되니 작업의 방향이 맞았다는 확신이 들었습니다.
무엇이 얼마나 줄었는지 (측정 항목)
분석기 트리맵으로 "방향"을 확인했다면, 그다음은 실제 사용자 체감 지표를 숫자로 남길 차례였습니다. 저는 두 가지를 기준으로 삼았습니다.
- First Load JS:
next build가 라우트별로 출력해 주는 초기 전송 JS 크기. 경계를 내리면 이 값이 작아져야 정상입니다. - Lighthouse 지표: TTI(Time To Interactive)와 LCP(Largest Contentful Paint). 번들이 줄면 브라우저가 hydration을 끝내는 시점이 앞당겨지므로 TTI가 먼저 반응합니다.
정성적으로는, 처음 글에서 언급했던 그 기묘한 "멍 때림(hydration 딜레이)"이 눈에 띄게 줄어 페이지 전환이 한결 부드러워졌습니다. 다만 "체감상 빨라졌다"는 말은 객관적 근거가 되지 못하므로, 같은 환경(같은 기기·네트워크 스로틀링)에서 개선 전후를 각각 측정한 값을 표로 남겨두는 것을 권합니다.
이런 번들 크기 회귀는 한 번 잡아도 새 라이브러리를 import하는 순간 슬그머니 되돌아오기 때문에, 저는 배포 전 점검 체크리스트에 "ANALYZE 빌드로 First Load JS 확인" 항목을 따로 넣어두었습니다.
마무리하며 느낀 점
이번 작업으로 가장 크게 바뀐 건 코드보다 사고방식이었습니다. 예전에는 "상호작용이 필요하니까 이 페이지는 클라이언트 컴포넌트"라고 뭉뚱그렸다면, 지금은 **"이 라이브러리를 정말 브라우저로 내려보낼 필요가 있나?"**를 컴포넌트 단위로 따져 묻습니다.
Next.js App Router 체제에서는 '동시 구동이 가능하냐(동형, Isomorphic)'의 관점이 아니라, 마치 햄버거 양상추 사이에 치즈를 끼워 넣듯이 서버와 클라이언트의 책임을 명확하게 분리하는 식견이 가장 중요한 자질임을 다시금 깨달았습니다.
그리고 무엇보다, 성능 최적화는 "느낌"으로 하는 게 아니라 @next/bundle-analyzer와 Lighthouse 같은 도구로 측정 → 분리 → 재측정의 루프를 도는 일이라는 걸 배웠습니다. 검색 순위에도 로딩 속도가 영향을 주는 만큼, 이 작업은 SEO 최적화의 연장선이기도 했습니다.