LCP가 28초인 React 앱을 본 적이 있는가. 농담이 아니다. 번들 사이즈를 신경 쓰지 않고, 이미지를 날것 그대로 쏘고, SSR 없이 클라이언트에서 모든 걸 렌더링하면 실제로 이런 수치가 나온다. 이 글에서는 LCP 28초를 1초대로 줄이는 4단계 프레임워크를 다룬다. 각 단계마다 구체적인 도구, 코드, 수치를 제시한다.
NOTE 잠깐! 이 용어는?
LCP(Largest Contentful Paint): 뷰포트에서 가장 큰 콘텐츠 요소가 렌더링되는 시점이다. Core Web Vitals의 핵심 지표로, 2.5초 이내가 "Good" 등급이다.
Phase 1: 번들 분석 & 최적화
목표: 불필요한 코드 제거, 코드 스플리팅으로 초기 로드 크기 축소
가장 먼저 할 일은 현재 번들이 얼마나 비대한지 파악하는 것이다. 병의 진단 없이 치료할 수는 없다.
번들 분석
webpack-bundle-analyzer 설치 및 실행
npm install --save-dev webpack-bundle-analyzer
npx webpack --profile --json=stats.json
npx webpack-bundle-analyzer stats.json
브라우저에서 트리맵이 열리면, 어떤 라이브러리가 번들의 대부분을 차지하는지 한눈에 보인다. moment.js가 500KB를 차지하고 있다면? dayjs(2KB)로 교체할 때다.
사용하지 않는 의존성 제거
depcheck으로 미사용 의존성 탐지
npx depcheck
depcheck은 package.json에는 있지만 코드에서 import하지 않는 패키지를 찾아준다. 프로젝트가 오래될수록 이런 유령 의존성이 쌓인다.
코드 스플리팅 & React.lazy
모든 페이지의 코드를 하나의 번들에 담을 이유가 없다. 사용자가 방문하는 페이지의 코드만 로드하면 된다.
React.lazy와 Suspense를 이용한 코드 스플리팅
import { lazy, Suspense } from 'react'
const Dashboard = lazy(() => import('./pages/Dashboard'))
const Settings = lazy(() => import('./pages/Settings'))
const Analytics = lazy(() => import('./pages/Analytics'))
function App() {
return (
<Suspense fallback={<LoadingSkeleton />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/analytics" element={<Analytics />} />
</Routes>
</Suspense>
)
}
Phase 1 결과: 번들 사이즈 1.71MB → 890KB, LCP 28초 → 21초. 번들을 절반으로 줄였지만 여전히 21초다. 갈 길이 멀다.
Phase 2: React 코드 최적화
목표: 불필요한 리렌더링 제거, 컴포넌트 레벨 최적화
React Compiler
React 19와 함께 등장한 React Compiler는 useMemo, useCallback, React.memo를 수동으로 작성할 필요를 없애준다. 컴파일 단계에서 자동으로 메모이제이션을 적용한다.
babel.config.js — React Compiler 설정
module.exports = {
plugins: [
['babel-plugin-react-compiler', {
target: '19'
}]
]
}
NOTE 잠깐! 이 용어는?
React Compiler: React 19에서 도입된 빌드 타임 최적화 도구다. 컴포넌트를 정적 분석하여 자동으로 메모이제이션 코드를 삽입한다. 수동useMemo/useCallback이 불필요해진다.
불필요한 useEffect 정리
useEffect는 React에서 가장 남용되는 훅이다. 렌더링 중에 계산할 수 있는 값을 useEffect로 처리하면 불필요한 추가 렌더 사이클이 발생한다.
useEffect 남용 vs 올바른 패턴
// 나쁜 패턴: useEffect로 파생 상태 계산
const [filteredItems, setFilteredItems] = useState([])
useEffect(() => {
setFilteredItems(items.filter(item => item.active))
}, [items])
// 좋은 패턴: 렌더링 중 직접 계산
const filteredItems = items.filter(item => item.active)
가상화 리스트
1,000개 이상의 아이템을 렌더링해야 한다면, DOM에 전부 올릴 필요가 없다. 화면에 보이는 것만 렌더링하면 된다.
react-window를 이용한 가상화 리스트
import { FixedSizeList } from 'react-window'
function VirtualizedList({ items }) {
return (
<FixedSizeList
height={600}
itemCount={items.length}
itemSize={50}
width="100%"
>
{({ index, style }) => (
<div style={style}>
{items[index].name}
</div>
)}
</FixedSizeList>
)
}
10,000개의 아이템이 있어도 DOM에는 화면에 보이는 12~15개만 존재한다. 스크롤하면 동적으로 교체된다. 엘리베이터 안의 층 표시판이 전체 층을 다 보여주지 않는 것과 같은 원리다.
React 19 Performance Tracks
React DevTools의 Performance Tracks는 컴포넌트별 렌더링 시간을 시각화해준다. 어떤 컴포넌트가 병목인지 정확히 파악할 수 있다.
Phase 3: 서버 사이드 렌더링(SSR)
목표: 서버에서 HTML을 생성하여 초기 렌더링 속도 대폭 개선
클라이언트 사이드 렌더링(CSR)의 근본적 문제는 빈 HTML을 받아서 JavaScript가 다 로드된 후에야 콘텐츠가 보인다는 점이다. SSR은 서버에서 완성된 HTML을 보내므로 사용자가 즉시 콘텐츠를 볼 수 있다.
Next.js App Router에서의 서버 컴포넌트
// app/products/page.tsx — 기본이 서버 컴포넌트
async function ProductsPage() {
const products = await fetchProducts() // 서버에서 데이터 페칭
return (
<main>
<h1>상품 목록</h1>
<ProductList products={products} />
</main>
)
}
Streaming SSR
전체 페이지가 준비될 때까지 기다리지 않고, 준비된 부분부터 스트리밍으로 전송한다.
Streaming SSR with Suspense
async function Page() {
return (
<main>
<Header /> {/* 즉시 전송 */}
<Suspense fallback={<ProductSkeleton />}>
<ProductList /> {/* 데이터 준비되면 스트리밍 */}
</Suspense>
<Suspense fallback={<ReviewSkeleton />}>
<Reviews /> {/* 독립적으로 스트리밍 */}
</Suspense>
</main>
)
}
프레임워크 선택지로는 Next.js, Remix, TanStack Start가 있다. 각각 장단점이 있지만, SSR과 스트리밍을 네이티브로 지원한다는 공통점이 있다.
Phase 3 결과: LCP 21초 → 13초. SSR 도입으로 크게 줄었지만, 이미지와 에셋이 여전히 발목을 잡고 있다.
Phase 4: 에셋 & 이미지 최적화
목표: CDN, 이미지 최적화, 리소스 힌트로 최종 마무리
CDN 활용
정적 에셋을 CDN에서 서빙하면 물리적 거리에 따른 지연을 최소화할 수 있다. 서울에서 미국 서버까지 왕복하는 대신, 가장 가까운 엣지 서버에서 응답을 받는다.
이미지 최적화
이미지 최적화 속성
<!-- LCP 대상 이미지: 높은 우선순위 -->
<img
src="/hero.webp"
alt="히어로 이미지"
fetchpriority="high"
width="1200"
height="600"
decoding="async"
/>
<!-- 뷰포트 밖 이미지: 지연 로딩 -->
<img
src="/feature.webp"
alt="기능 소개"
loading="lazy"
width="800"
height="400"
/>
NOTE 잠깐! 이 용어는?
fetchpriority: 브라우저에게 리소스의 로딩 우선순위를 힌트로 제공하는 HTML 속성이다.high,low,auto값을 가진다. LCP 대상 이미지에high를 설정하면 다른 리소스보다 먼저 로드된다.
리소스 프리로드
중요 리소스 프리로드
<head>
<!-- 크리티컬 폰트 프리로드 -->
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin />
<!-- LCP 이미지 프리로드 -->
<link rel="preload" href="/hero.webp" as="image" fetchpriority="high" />
<!-- 크리티컬 CSS 인라인 -->
<style>/* above-the-fold 스타일 인라인 */</style>
</head>
Phase 4 결과: LCP 13초 → 1.27초. 드디어 Core Web Vitals "Good" 등급 진입이다.
최종 요약
| Phase | 핵심 작업 | 번들/LCP 변화 |
|---|---|---|
| 1. 번들 최적화 | depcheck, 코드 스플리팅, React.lazy | 1.71MB→890KB, LCP 28→21초 |
| 2. React 최적화 | React Compiler, useEffect 정리, 가상화 | 리렌더링 감소, 런타임 개선 |
| 3. SSR | Next.js/Remix, Streaming SSR | LCP 21→13초 |
| 4. 에셋 최적화 | CDN, fetchpriority, preload, lazy loading | LCP 13→1.27초 |
마무리
성능 최적화는 하나의 마법 같은 해결책이 아니라, 여러 단계의 누적 효과다. 번들을 줄이고, React 코드를 정리하고, SSR로 초기 렌더를 앞당기고, 에셋 로딩을 최적화하는 각 단계가 조금씩 LCP를 깎아낸다. 28초에서 1초로의 여정은 결국 "기본을 제대로 하는 것"의 합이다.
중요한 건 측정 먼저, 최적화는 그다음이라는 점이다. Lighthouse, Chrome DevTools Performance 탭, Web Vitals 라이브러리로 현재 상태를 정확히 파악하고, 가장 임팩트가 큰 병목부터 공략하는 것이 올바른 순서다.
'노트 > 끄적끄적' 카테고리의 다른 글
| Nx 모노레포의 엔진을 Bun으로 교체하기 — 컬리의 마이그레이션 경험기 (0) | 2026.02.15 |
|---|---|
| Steve Yegge의 AI 에이전트 시대 전망: 50% Dial과 드라큘라 효과 (0) | 2026.02.15 |
| 장애 터졌을 때, 첫 수가 전부다 — 우아한형제들의 First Action 전략 (0) | 2026.02.15 |
| Redux를 서버에 올리면? 당근의 이벤트 소싱 라이브러리 Ventyd (0) | 2026.02.15 |