LCP 28초짜리 React 앱을 1초로 깎아낸 기록 — 4단계 성능 수술 프레임워크

12 min read
React성능 최적화LCPSSR
LCP 28초짜리 React 앱을 1초로 깎아낸 기록 — 4단계 성능 수술 프레임워크

LCP가 28초인 React 앱이 실재한다. 농담이 아니다. 번들 사이즈를 방치하고, 이미지를 원본 그대로 쏘고, SSR 없이 클라이언트에서 모든 걸 렌더링하면 실제로 이런 수치가 나온다. 성능 최적화는 마법의 한 방이 아니라 4단계에 걸친 수술이다. 환자의 상태를 진단하고, 가장 심각한 부위부터 순서대로 메스를 댄다.

[💡 잠깐! 이 용어는?] LCP(Largest Contentful Paint): 뷰포트에서 가장 큰 콘텐츠 요소가 렌더링되는 시점. Core Web Vitals의 핵심 지표로, 2.5초 이내가 "Good" 등급이다.


Phase 1 — 번들 다이어트

목표: 불필요한 코드 제거, 코드 스플리팅으로 초기 로드 크기를 줄인다.

병원에 가면 가장 먼저 하는 게 검사다. 번들 최적화도 마찬가지로, 현재 번들이 얼마나 비대한지 파악하는 것이 첫 단계다.

번들 분석 — X-ray 찍기

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

depcheckpackage.json에는 있지만 코드에서 import하지 않는 패키지를 찾아준다. 프로젝트가 오래될수록 이런 유령 의존성이 쌓인다. 쓰지 않는 짐을 짊어지고 달리는 것과 같다.

코드 스플리팅 — 필요한 페이지만 로드

모든 페이지의 코드를 하나의 번들에 담을 이유가 없다. 사용자가 방문하는 페이지의 코드만 로드하면 된다.

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를 수동으로 작성할 필요를 없앤다. 컴파일 단계에서 자동으로 메모이제이션을 적용한다.

[💡 잠깐! 이 용어는?] React Compiler: React 19에서 도입된 빌드 타임 최적화 도구. 컴포넌트를 정적 분석하여 자동으로 메모이제이션 코드를 삽입한다. 수동 useMemo/useCallback이 불필요해진다.

babel.config.js — React Compiler 설정
module.exports = {
  plugins: [
    ['babel-plugin-react-compiler', {
      target: '19'
    }]
  ]
}

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에 전부 올릴 필요가 없다. 화면에 보이는 것만 렌더링하면 된다. 엘리베이터 안의 층 표시판이 전체 100층을 다 보여주지 않는 것과 같은 원리다.

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>
  )
}

[💡 잠깐! 이 용어는?] Streaming SSR: 서버에서 HTML을 한 번에 완성해 보내는 대신, 준비된 부분부터 청크 단위로 클라이언트에 전송하는 기법. React의 Suspense 경계를 기준으로 각 섹션이 독립적으로 스트리밍된다.

프레임워크 선택지로는 Next.js, Remix, TanStack Start가 있다. 각각 장단점이 있지만, SSR과 스트리밍을 네이티브로 지원한다는 공통점이 있다.

프레임워크특징적합한 상황
Next.jsApp Router, RSC 네이티브풀스택 앱, 대규모 프로덕션
Remix중첩 라우팅, 데이터 로딩 패턴데이터 중심 앱
TanStack Start타입 안전 라우팅TanStack 생태계 활용 시

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"
/>

[💡 잠깐! 이 용어는?] 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.lazy1.71MB→890KB, LCP 28→21초
2. React 코드 수술React Compiler, useEffect 정리, 가상화리렌더링 감소, 런타임 개선
3. SSR 도입Next.js/Remix, Streaming SSRLCP 21→13초
4. 에셋 최적화CDN, fetchpriority, preload, lazy loadingLCP 13→1.27초

마무리

성능 최적화에 마법의 한 방은 없다. 번들을 줄이고, React 코드를 정리하고, SSR로 초기 렌더를 앞당기고, 에셋 로딩을 최적화하는 각 단계가 조금씩 LCP를 깎아낸다. 28초에서 1초로의 여정은 결국 기본을 제대로 하는 것의 합이다. 중요한 건 측정이 먼저고, 최적화는 그 다음이라는 점이다. Lighthouse, Chrome DevTools Performance 탭, Web Vitals 라이브러리로 현재 상태를 정확히 파악하고, 가장 임팩트가 큰 병목부터 공략하는 것이 올바른 순서다.

관심 있을 만한 포스트

V8 WasmGC 투기적 최적화 — 가상 메서드를 인라인으로 만드는 법

V8이 WasmGC의 가상 메서드 디스패치에 투기적 인라이닝을 도입해 Dart와 Java 앱에서 최대 8% 성능을 끌어낸 방법.

V8WebAssembly

React Compiler의 한계 — 뭘 최적화하고 뭘 못 하는가

React Compiler가 자동 메모이제이션으로 해결하는 것과 해결하지 못하는 것. 컴파일러 기반 UI 프레임워크의 능력 경계를 정리했다.

ReactReact Compiler

Coaction v1.0 — Web Worker로 멀티스레딩 상태 관리하기

JavaScript 단일 스레드 한계를 극복하는 상태 관리 라이브러리 Coaction의 동작 방식, Zustand와의 차이, Standard/Shared 두 가지 모드 사용법을 정리한다.

CoactionWeb Worker

V8 Mutable Heap Numbers — 숫자 하나 바꿀 때마다 새 객체를 만들던 비효율을 잡다

V8 엔진이 스크립트 컨텍스트의 숫자 변수를 매번 새 HeapNumber로 할당하던 방식을 제자리 수정(mutable)으로 바꿔 최대 2.5배 성능 향상을 달성했다.

V8JavaScript

V8 Explicit Compile Hints — 주석 한 줄로 JavaScript 시작 속도를 630ms 줄이는 법

Chrome 136에 도입된 V8의 Explicit Compile Hints 기능으로 JavaScript 초기 로딩 성능을 개선하는 원리와 사용법을 분석한다.

V8성능 최적화

V8의 JSON.stringify가 2배 빨라졌다 — 6가지 최적화 기법 해부

V8 13.8(Chrome 138)에서 적용된 JSON.stringify 성능 개선의 기술적 배경과 6가지 핵심 최적화 전략을 분석한다.

V8JSON

Next.js 블로그 만들기 — 카드 그리드와 포스트 상세 페이지

Velog 스타일 카드 UI와 MDX 렌더링 상세 페이지 구현. 반응형 그리드, SEO 메타데이터, 정적 사이트 생성까지.

Next.jsReact

AI 코딩의 맹점 — Artifacts 없이 에이전트는 기억을 잃는다

PRD, ADR, TDD가 AI 코딩 워크플로우에서 왜 선택이 아닌 필수인지, 실전 구조와 함께 살펴본다.

AI 코딩Artifacts