콘텐츠로 이동

8. 성능

효과 대비 노력이 가장 좋은 순서로 한다

사용자가 실제로 느끼는 세 가지

섹션 제목: “사용자가 실제로 느끼는 세 가지”
지표 무엇을 재나 목표
LCP 가장 큰 콘텐츠가 보이기까지 2.5초 이하
INP 클릭·입력에 대한 반응 지연 200ms 이하
CLS 화면이 얼마나 튀는가 0.1 이하
  • LCP — 보통 히어로 이미지나 첫 텍스트 블록. 서버 컴포넌트가 직접적으로 개선한다
  • INP — JS 실행량과 직결된다. 클라이언트 번들이 작을수록 좋다
  • CLS — 이미지·폰트·광고가 늦게 도착하며 레이아웃을 밀어내는 문제

셋 다 Chrome이 실제 사용자로부터 수집하며 검색 순위에도 반영된다. 이 셋 밖의 숫자를 쫓기 시작하면 대체로 시간을 낭비하는 중이다.

import Image from 'next/image'
import hero from './hero.png'
// 로컬 이미지 — 크기를 자동으로 안다
<Image src={hero} alt="제품 화면" priority />
// 원격 이미지 — 크기를 직접 알려준다
<Image
src="https://cdn.example.com/a.jpg"
alt="상품"
width={640}
height={480}
sizes="(max-width: 768px) 100vw, 640px"
/>
  • 포맷 변환 — 브라우저가 지원하면 AVIF/WebP로 자동 변환
  • 크기별 생성sizes에 맞춰 여러 해상도를 만들고 srcset으로 제공
  • 지연 로딩 — 뷰포트 밖 이미지는 기본적으로 나중에 로드
  • priority — LCP 이미지에는 반드시 붙인다. 이게 없으면 LCP가 나빠진다
app/layout.tsx
import { Inter } from 'next/font/google'
const inter = Inter({ subsets: ['latin'], display: 'swap' })
export default function RootLayout({ children }) {
return (
<html className={inter.className}>
<body>{children}</body>
</html>
)
}
  • 폰트 파일을 빌드 타임에 다운로드해 자체 호스팅한다 — 구글 서버로 요청이 안 나간다
  • size-adjust를 자동 계산해 폰트 교체 시 레이아웃이 튀지 않게 한다
  • 한글 폰트는 용량이 커서 서브셋 지정이 특히 중요하다

Pretendard 같은 로컬 폰트는 next/font/local로 같은 이점을 얻는다.

흔한 원인 대안
import _ from 'lodash' import debounce from 'lodash/debounce'
moment.js (locale 전체) date-fns 필요 함수만, 또는 내장 Intl
날짜 포맷 라이브러리 Intl.DateTimeFormat — 브라우저 내장, 0 kB
아이콘 라이브러리 전체 import 개별 import가 되는지 확인 (lucide-react는 된다)
차트·에디터·지도 next/dynamic으로 잘라낸다
// 차트, 에디터, 지도처럼 크고 즉시 필요 없는 것
import dynamic from 'next/dynamic'
const Chart = dynamic(() => import('@/components/chart'), {
loading: () => <ChartSkeleton />,
ssr: false, // 브라우저 API에 의존한다면
})
// 조건부로만 쓰이는 것
const AdminPanel = dynamic(() => import('./admin-panel'))
export function Page({ isAdmin }) {
return (
<>
<Dashboard />
{isAdmin && <AdminPanel />} {/* 관리자가 아니면 아예 안 받는다 */}
</>
)
}
Terminal window
# 번들 구성 시각화
pnpm add -D @next/bundle-analyzer
ANALYZE=true pnpm build
# 빌드 결과에 라우트별 First Load JS가 표시된다
pnpm build
Route (app) Size First Load JS
┌ ○ / 1.2 kB 92 kB
├ ○ /dashboard 45 kB 186 kB ← 여기를 본다
└ ● /posts/[slug] 2.1 kB 94 kB
○ (Static) 정적 생성
● (SSG) generateStaticParams로 생성

First Load JS가 라우트별로 크게 차이 난다면 그 라우트에 무거운 클라이언트 컴포넌트가 있다는 뜻이다. 4장의 경계 검토를 그 라우트부터 시작한다.

React 19와 함께 1.0이 정식 출시됐다. 메모이제이션을 자동으로 해준다.

const sorted = useMemo(() => items.sort(cmp), [items])
const onClick = useCallback(() => select(id), [id])

의존성 배열을 사람이 관리해야 했다. 빠뜨리면 stale closure, 과하면 무의미.

서버 컴포넌트에는 해당 없다 — 애초에 리렌더가 없기 때문이다. 클라이언트 컴포넌트가 많은 프로젝트에서 효과가 크다.

Next.js 16부터 기본 번들러다.

  • dev 서버 시작과 HMR이 눈에 띄게 빨라진다
  • next.config.tswebpack: 설정을 쓰고 있었다면 turbopack 설정으로 옮겨야 한다
  • 커스텀 로더가 있으면 대체 방법을 확인해야 한다

대부분의 프로젝트는 아무것도 안 해도 된다. webpack 플러그인을 쓰던 프로젝트만 작업이 필요하다.

flowchart TB
    A["1. 서버 컴포넌트로 옮길 수 있는가<br/>= 번들에서 완전히 제거"] --> B["2. 이미지 · 폰트를 최적화했는가<br/>= LCP · CLS"]
    B --> C["3. 무거운 의존성을 잘라냈는가<br/>= dynamic import · tree-shaking"]
    C --> D["4. 캐싱 전략이 맞는가<br/>= use cache · 정적 껍데기"]
    D --> E["5. 그래도 느린가<br/>= 프로파일러로 측정"]

    classDef ok   fill:#dcfce7,stroke:#16a34a,color:#14532d
    classDef warn fill:#fef3c7,stroke:#d97706,color:#78350f
    classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
    class A,B ok
    class C,D warn
    class E mute
  1. pnpm build로 라우트별 First Load JS를 본다

    가장 큰 라우트 하나를 고른다.

  2. 그 라우트의 use client를 전부 찾는다

    Terminal window
    grep -rl "use client" app/dashboard
  3. 트리 위쪽에 있는 것부터 잎사귀로 밀어본다 (4장)

  4. LCP 이미지에 priority가 붙어 있는지 확인한다

  5. 번들 분석기로 남은 무거운 의존성을 찾는다

    Terminal window
    ANALYZE=true pnpm build
  6. 여기까지 하고도 느리면 그때 프로파일러를 켠다

  • LCP · INP · CLS — 이 셋만 보면 된다
  • next/image는 크기 지정을 강제해 CLS를 원천 차단한다. LCP 이미지엔 priority
  • next/font는 폰트를 자체 호스팅하고 교체 시 레이아웃 시프트를 막는다
  • 번들은 서버 컴포넌트화 → tree-shaking → dynamic import 순으로 줄인다
  • React Compiler가 useMemo/useCallback을 대신해 준다
  • Next.js 16의 기본 번들러는 Turbopack