배포 직후 모니터링 대시보드의 Core Web Vitals 지표가 빨간색 경고등을 켰습니다. FCP(First Contentful Paint)가 3.2초까지 치솟았고, 사용자 이탈률은 15% 증가했습니다. 원인은 명확했습니다. 과도한 클라이언트 사이드 하이드레이션(Hydration) 비용과 최적화되지 않은 마케팅 배너 이미지들이 메인 스레드를 점유하고 있었기 때문입니다. 이 글은 마케팅 용어 없이, 오직 엔지니어링 관점에서 Next.js 성능 최적화를 통해 FCP를 1초 미만으로 단축한 과정을 기록합니다.
클라이언트 번들 사이즈와 TBT의 상관관계
트래픽이 많은 e-커머스 메인 페이지를 분석하면서 발견한 가장 큰 병목은 거대한 JS 번들 사이즈였습니다. 초기 렌더링에 불필요한 인터랙션 로직까지 모두 클라이언트로 전송되고 있었고, 이는 필연적으로 TBT(Total Blocking Time)를 증가시켜 FCP 개선을 방해하고 있었습니다.
전통적인 리액트 최적화 방식인 `React.memo`나 `useCallback` 만으로는 DOM 렌더링 속도 자체를 물리적으로 줄이는 데 한계가 있었습니다. 우리는 근본적인 아키텍처 변경, 즉 프론트엔드 최적화의 패러다임을 바꿀 필요가 있었습니다.
React Server Components(RSC)로 번들 다이어트
해결책은 React Server Components의 적극적인 도입이었습니다. 상호작용이 필요 없는 데이터 표시용 컴포넌트(헤더, 푸터, 상품 리스트 래퍼 등)를 서버 컴포넌트로 전환함으로써, 해당 컴포넌트의 라이브러리 의존성을 클라이언트 번들에서 완전히 제거했습니다.
아래는 기존 클라이언트 컴포넌트를 서버 컴포넌트 패턴으로 리팩토링한 예시입니다. 마크다운 파서와 같은 무거운 라이브러리가 브라우저로 전송되지 않도록 격리했습니다.
// app/components/ProductDescription.tsx (Server Component)
import { compileMDX } from 'next-mdx-remote/rsc'; // 무거운 라이브러리
import { SanitizedHTML } from './SanitizedHTML';
// 이 컴포넌트는 서버에서만 실행되며, 결과 HTML만 클라이언트로 전송됩니다.
export default async function ProductDescription({ markdown }: { markdown: string }) {
// DB 혹은 API 호출 로직 직접 수행 가능
const { content } = await compileMDX({ source: markdown });
return (
<section className="prose lg:prose-xl">
{/* 클라이언트 JS 번들에 포함되지 않음 */}
{content}
</section>
);
}
이렇게 전환한 결과, 클라이언트가 다운로드해야 할 JS 번들 사이즈를 약 35% 절감했습니다. Next.js의 App Router 구조를 활용한다면, 기본적으로 모든 컴포넌트는 서버 컴포넌트임을 명심해야 합니다. `use client` 지시어는 꼭 필요한 리프(Leaf) 노드에만 사용해야 합니다.
차세대 이미지 포맷과 LCP 방어 전략
FCP와 LCP(Largest Contentful Paint)는 이미지 로딩 전략에 크게 좌우됩니다. 기존에는 PNG/JPG를 그대로 서빙하고 있었으나, 이를 AVIF와 WebP로 자동 변환하도록 설정을 변경했습니다. 특히 LCP 요소인 메인 배너 이미지에는 `priority` 속성을 부여하여 프리로드(Preload)를 강제했습니다.
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
// AVIF를 우선 지원하고, 브라우저 호환성에 따라 WebP로 폴백
formats: ['image/avif', 'image/webp'],
// 외부 이미지 최적화를 위한 리모트 패턴 정의
remotePatterns: [
{
protocol: 'https',
hostname: 'cdn.myshop.com',
pathname: '/uploads/**',
},
],
// 기기 너비에 따른 최적화된 사이즈 생성
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048],
},
};
module.exports = nextConfig;
컴포넌트 레벨에서는 다음과 같이 적용하여 레이아웃 시프트(CLS)를 방지하고 로딩 우선순위를 제어합니다.
// components/HeroBanner.tsx
import Image from 'next/image';
export default function HeroBanner({ src, alt }: { src: string; alt: string }) {
return (
<div className="relative w-full h-[600px]">
<Image
src={src}
alt={alt}
fill
priority // LCP 요소 필수: 지연 로딩 방지 및 프리로드 처리
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
className="object-cover"
placeholder="blur" // 로딩 중 UX 개선
blurDataURL="data:image/..." // base64 플레이스홀더
/>
</div>
);
}
최적화 전후 성능 비교
Next.js 성능 최적화 작업을 마친 후, Google Lighthouse와 Vercel Analytics를 통해 측정한 결과입니다. 단순히 수치만 개선된 것이 아니라, 체감 로딩 속도가 획기적으로 빨라졌습니다.
| 지표 (Metric) | 최적화 전 (Legacy) | 최적화 후 (Optimized) | 개선율 |
|---|---|---|---|
| FCP (First Contentful Paint) | 3.2s | 0.8s | ⚡ 75% 감소 |
| LCP (Largest Contentful Paint) | 4.5s | 1.2s | ⚡ 73% 감소 |
| JS Bundle Size (Gzipped) | 480KB | 155KB | 📉 67% 감소 |
| Lighthouse Score | 52 (Red) | 98 (Green) | ✅ 통과 |
결론
FCP와 Core Web Vitals 점수 개선은 단순한 설정 변경이 아닌, 컴포넌트 설계 단계에서의 의사결정이 중요합니다. 클라이언트 사이드 로직을 React Server Components로 과감하게 이관하고, `next/image`를 통해 브라우저에 최적화된 에셋을 전달하는 것만으로도 대부분의 성능 문제는 해결됩니다. 만약 하이드레이션 오류나 레이아웃 시프트로 고생하고 있다면, `use client`의 남용을 멈추고 서버가 할 일을 다시 점검해보시기 바랍니다.
Post a Comment