← 전체 글 보기

100개 도구 React 앱의 메인 번들을 6 MB에서 307 KB로 줄인 방법

2026-05-25

Vite 빌드 출력은 몇 달째 이걸 말해주고 있었습니다:

(!) Some chunks are larger than 500 kB after minification.

무시했습니다. 사이트는 광대역에서 잘 동작했습니다. 배경 제거 AI는 호출될 때 30 MB의 WebAssembly 파일을 로드합니다 — 그에 비하면 6 MB JavaScript 번들은 작다고 생각했죠.

아니었습니다.

친구가 호텔의 불안정한 WiFi로 Pixel에서 toolkoala.com을 열었습니다. 요소 렌더 지연만으로 Largest Contentful Paint가 2.5초 걸렸습니다 — 네트워크 시간도 포함하지 않고요. Lighthouse 감사는 "render-blocking resources"와 "reduce unused JavaScript"로 점수를 계속 깎았습니다. 가장 결정적이게: Google Search Console이 사이트를 Core Web Vitals 리포트에서 표시하기 시작했습니다.

그래서 빌드 출력을 제대로 읽어봤습니다.

dist/assets/index-CdRXzQJL.js  5,998 kB │ gzip: 1,755 kB
  1. 메가. 바이트. 의 JavaScript. 사용자가 무엇이든 클릭하기 전에, 홈페이지의 모든 방문자에게 배달됩니다.

이 글은 그것을 307 KB raw / 92 KB gzipped로 — 95% 감축 — 그리고 LCP를 ~2.5초에서 1.5초 미만으로 낮춘 엔지니어링 로그입니다. 감사 등급이 F에서 A로. 사이트 자체는 바뀌지 않았습니다.

6 MB 안에 무엇이 있었는가

처음으로 유용했던 것은 npx vite-bundle-visualizer를 실행하는 것(또는 빌드 출력을 크기순으로 보는 것). 주범:

  • 70개 이상의 도구 컴포넌트(RemoveBackground, PdfMerge, LoremIpsum…)
  • 8개의 i18n 로케일 JSON(영어 + 7개 번역, 각 ~300 KB)
  • pdf-lib + pdfjs-dist(합쳐서 ~500 KB) — 30개의 PDF 도구가 사용
  • UPNG(200 KB) — 전이적으로 끌어들임
  • marked, diff, jszip, pptxgen, xlsx, mammoth — 각 100-500 KB

이 대부분은 사용자가 아직 사용하지 않는 도구들의 것이었습니다. 사용자가 lorem ipsum 생성기를 본 뒤 PDF 머저를 클릭할지도 모르니까라는 이유로 다운로드되고 있었습니다.

이것이 기본 React 패턴이고, Vite는 기꺼이 그렇게 하도록 허락합니다:

import RemoveBackground from './tools/RemoveBackground'
import PdfMerge from './tools/PdfMerge'
// … 65줄 더

<Routes>
  <Route path="/remove-background" element={<RemoveBackground />} />
  <Route path="/pdf-merge" element={<PdfMerge />} />
  // …
</Routes>

모든 import 문은 "이 코드를 모든 페이지에 배달하세요"라고 말합니다. 이게 70개 도구가 하나의 6 MB 번들이 되는 방식입니다.

수정 1: 라우트 레벨 코드 분할 (~5.7 MB 절약)

이를 위한 표준 React 수정은 React.lazy()Suspense입니다. Vite의 이미 구성된 rollup 기반 번들러는 동적 import마다 자동으로 하나의 청크를 생성합니다.

모든 도구 import를 변환했습니다:

const RemoveBackground = lazy(() => import('./tools/RemoveBackground'))
const PdfMerge = lazy(() => import('./tools/PdfMerge'))
// …

그리고 라우트를 감쌌습니다:

<Suspense fallback={<div className="loading">Loading…</div>}>
  <Routes>
    <Route path="/" element={<Home />} />
    <Route path="/remove-background" element={<RemoveBackground />} />
    // …
  </Routes>
</Suspense>

HomeNav는 eagerly import 상태로 두었습니다. 홈은 가장 흔한 착륙지이고, 내비는 모든 페이지에서 즉시 렌더링되어야 합니다.

이 단일 변경 후: 메인 번들이 5,998 KB → 3,025 KB로 떨어졌습니다. 각 도구는 자체 청크가 됨: 각 3-50 KB, 사용자가 이동할 때 요청 시 가져옴.

트레이드오프: 처음 도구를 방문하면 이제 그 도구의 청크를 가져오기 위한 추가 네트워크 왕복이 필요합니다. 하지만 청크는 작고(종종 gzipped로 한 자릿수 KB), 현대 HTTP/2는 여러 병렬 요청에 걸쳐 왕복 비용을 분할 상각합니다. 실제 연결에서 지연 증가는 보이지 않습니다.

승리는 페이지 로드에 있습니다 — Largest Contentful Paint와 첫 방문자 이탈률에 중요한 것. 우리는 그것을 반으로 줄였습니다.

수정 2: import.meta.glob로 로케일 lazy-load (~2 MB 절약)

번들의 다음 큰 덩어리는 국제화였습니다. 8개 언어를 지원하며, 각 언어는 ~300 KB JSON 파일로 100+ 도구의 제목, FAQ, 기능 설명 번역을 포함합니다. 원래 설정은 뻔한 것이었습니다:

import en from './locales/en.json'
import zhCN from './locales/zh-CN.json'
import zhTW from './locales/zh-TW.json'
import ja from './locales/ja.json'
// … 4개 더

i18n.init({
  resources: {
    en: { translation: en },
    'zh-CN': { translation: zhCN },
    // …
  }
})

8개 파일 전체(~2.5 MB raw, ~700 KB gzip)가 메인 번들에 inline. 영어 홈을 방문하는 프랑스어 사용자가 한국어 번역을 다운로드.

Vite에는 import.meta.glob이라는 멋진 기능이 있어 약 10줄로 해결합니다:

const localeLoaders = import.meta.glob('./locales/*.json')

const initial = detectLangFromPath() || 'en'
const [enData, activeData] = await Promise.all([
  localeLoaders['./locales/en.json'](),
  initial !== 'en' ? localeLoaders[`./locales/${initial}.json`]() : null,
])

i18n.init({
  resources: {
    en: { translation: enData.default },
    [initial]: { translation: activeData?.default },
  },
  lng: initial,
})

Vite는 각 import('./locales/<x>.json') 호출을 보고 로케일당 별도 청크를 생성. 시작 시에는 사용자의 현재 언어(와 영어 fallback)만 로드.

Fallback은 작음(영어는 eagerly 로드되어 다른 언어에서 누락된 번역이 충돌하지 않도록). 사용자가 수동으로 언어를 전환하면 i18n.changeLanguage를 패치하여 새 로케일을 요청 시 가져오기:

const origChangeLanguage = i18n.changeLanguage.bind(i18n)
i18n.changeLanguage = async (lng) => {
  if (!i18n.hasResourceBundle(lng, 'translation')) {
    const data = await loadLocale(lng)
    i18n.addResourceBundle(lng, 'translation', data, true, true)
  }
  return origChangeLanguage(lng)
}

함정 하나: 이로 인해 i18n 초기화가 비동기가 되므로 로케일이 해결될 때까지 React가 렌더링할 수 없습니다. Vite의 ESM에서 top-level await 지원을 사용했습니다:

// main.jsx
import { initI18n } from './i18n'
await initI18n()
createRoot(...).render(<App />)

Top-level await는 현대 브라우저(Chrome 89+, Safari 15+, Firefox 89+)에서 지원됨.

이 수정 후: 메인 번들 3,025 KB → 307 KB(gzip 1,755 KB → 92 KB). 중요 경로에서 ~2.7 MB의 로케일 데이터를 내렸습니다.

수정 3: 2차 누수 — PdfToolShell (~600 KB 절약)

처음 두 수정 후, PageSpeed Insights는 여전히 "Reduce unused JavaScript"를 구체적 범인과 함께 표시했습니다:

  • pdf-*.js — 116 KB, 96% 미사용
  • UPNG-*.js — 118 KB, 28% 미사용

왜 PDF 라이브러리가 홈에서 로드되었을까?

30+ PDF 도구 라우트(PDF 병합, 분할, 회전 등)를 감싸는 공유 컴포넌트 PdfToolShell이 있었습니다. 그것 자체는 도구가 아니라 재사용 가능한 쉘입니다. App.jsx에서 정적으로 import되었습니다:

import PdfToolShell from './components/PdfToolShell'

PdfToolShell은 맨 위에서 이렇게 합니다:

import { PDFDocument, StandardFonts, degrees, rgb } from 'pdf-lib'
import * as pdfjsLib from 'pdfjs-dist'

그래서 전체 PDF 스택이 메인 번들로 끌어올려졌습니다 — 사용자가 /lorem-ipsum을 방문하더라도.

수정은 동일한 lazy() 처리:

const PdfToolShell = lazy(() => import('./components/PdfToolShell'))

교훈: 도구 컴포넌트를 lazy-load하는 것만으로는 충분하지 않습니다. 공유 "쉘" 컴포넌트가 여전히 무거운 의존성을 정적으로 import한다면. 모든 정적 import를 전이적으로 무엇을 끌어들이는지 감사하세요.

수정 4: 로고 (640 KB 절약)

이건 부끄럽습니다. 사이트 로고는 1024×1024 PNG, 640 KB였습니다. 내비바에 32×32 픽셀로 표시. 아무도 의문을 가진 적이 없었습니다.

수정은 이 단락을 읽는 것보다 짧은 시간이 걸렸습니다: 자체 이미지 압축 도구에 logo.png를 드롭. 슬라이더는 이미 80% 품질(기본). Download를 눌렀습니다.

결과: 6.9 KB. 99% 감축, 32×32에서 보이는 차이 없음 — 같은 로고를 소셜 카드에 쓰는 256×256에서도 마찬가지.

소셜 카드(Twitter, Facebook, LinkedIn 미리보기 이미지)에서 ~256 px로 확대됐을 때 선명하게 보이도록 원본 1024×1024를 이미지 리사이즈 도구에 넣고 256×256으로 설정, 이미지 압축에서 90% 품질로 처리하여 64 KB의 logo-256.png를 얻었습니다. og:image 메타 태그가 이를 가리킵니다.

두 도구, 2분, 모든 페이지 로드에서 633 KB 절약.

지금 휴대폰으로 이걸 읽고 있고 위의 로고가 잘 보인다면: 그건 6.9 KB 이미지입니다. 640 KB 버전도 똑같이 보였습니다.

자신의 사이트에 50 KB 이상의 로고가 있다면, ToolKoala 이미지 압축기가 바로 거기 있습니다. 드롭하고, before/after 미리보기를 보고, 작은 것을 다운로드. 아무것도 업로드되지 않음 — 로고는 저희 서버로 가지 않고 브라우저에서 처리됩니다. 이게 프로젝트 전체의 핵심이고, 우리가 자기 자신에게 사용한 부분도 포함합니다.

수정 5: Google Fonts 의존 제거 (~1.2초 LCP 지연 절약)

<head>는 본 적 있는 모든 웹앱과 같았습니다:

<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">

PageSpeed는 이걸로 1,258 ms의 중요 경로 지연을 보고했습니다. 브라우저는:

  1. fonts.googleapis.com 해석 (DNS, TLS)
  2. CSS 파일 다운로드 (리다이렉트로 가득찬 @font-face 블록의 혼란)
  3. fonts.gstatic.com 해석 (DNS, TLS 다시, 다른 origin!)
  4. 실제 .woff2 파일 다운로드

유틸리티 사이트에 Inter는 좋지만 필수는 아닙니다. 우리 CSS에는 이미 완벽하게 좋은 fallback이 있습니다:

font-family: -apple-system, BlinkMacSystemFont, "Segoe UI",
             system-ui, Roboto, "Helvetica Neue", Arial, sans-serif;

macOS에서는 San Francisco(Apple 시스템 폰트)로, Windows에서는 Segoe UI로, Android에서는 Roboto로 해석. 모두 OS와 함께 사전 설치된 우수한 폰트들. 0 네트워크 대역폭과 0 로드 시간을 사용합니다.

Google Fonts 링크를 삭제했습니다. 사이트는 ~95% 같아 보입니다 — 좋은 sans-serif가 설치되지 않은 Linux에서는 다르지만, 그건 작은 청중이고 fallback도 우아하게 저하됩니다.

PageSpeed의 "중요 경로 지연"은 1.2초 떨어졌습니다.

수정 6: 번들 CSS 인라인 (160 ms 절약)

남은 render-blocking 리소스는 Vite가 발행한 CSS 번들:

<link rel="stylesheet" href="/assets/index-CcF97ram.css">

5.4 KB. 일반 연결에서 ~160 ms 왕복 — 첫 페인트를 차단.

이 정도 크기의 CSS 번들이면 HTML에 인라인하는 것이 캐싱보다 낫습니다. 인라인 비용은 모든 HTML 페이지가 이제 5.4 KB를 들고 다닌다는 것. 이점은 첫 로드에서 하나 적은 차단 요청.

빌드 스크립트(SSR을 위해 Vite 출력을 후처리)가 이제 CSS 파일을 읽고 <style> 블록으로 치환합니다:

const cssMatch = baseHtml.match(/<link\s+rel="stylesheet"[^>]*href="([^"]+)"[^>]*>/)
if (cssMatch) {
  const cssPath = join(DIST, cssMatch[1].replace(/^\//, ''))
  const css = readFileSync(cssPath, 'utf8')
  baseHtml = baseHtml.replace(cssMatch[0], `<style>${css}</style>`)
}

참고: 우리의 5 KB 스타일시트에는 괜찮습니다. 100 KB CSS가 있다면 인라인은 페이지 간 캐싱을 망치니 외부로 두세요(또는 critical CSS만 추출).

의도적으로 수정하지 않은 것

Google AdSenseGoogle Analytics gtag. 둘 다 async 스크립트로, PageSpeed는 여전히 forced reflow와 높은 전송 크기로 표시합니다. 둘이 합쳐 500+ ms의 중요 경로 지연.

하지만 AdSense는 사이트가 자금 조달되는 방식입니다. 그것을 제거하는 건 엔지니어링 트레이드오프가 아니라 비즈니스입니다. Analytics도 마찬가지 — 그것 없이는 어떤 도구가 인기 있고 어떤 버그를 아무도 마주치지 않는지 깜깜이로 날아갑니다.

원칙적으로 AdSense를 LCP 요소가 페인트된 후 로드되도록 지연시킬 수 있습니다. 30분간 시도했고; AdSense 자체의 주입 로직을 오버라이드해야 했고 광고가 페인트 후 로드되면서 시각적 깜빡임이 발생했습니다. ~200 ms 절약에 엔지니어링 복잡성을 들일 가치가 없습니다.

광고가 없는 사이트를 최적화한다면 우리보다 더 많은 옵션이 있습니다. 우리는 그것을 받아들여야 했습니다.

결과

기본 벤치마크의 https://www.toolkoala.com/에 대한 콜드 캐시 첫 페인트:

지표 이전 이후 변화
메인 번들 (raw) 5,998 KB 307 KB -95%
메인 번들 (gzip) 1,755 KB 92 KB -95%
로고 PNG 640 KB 7 KB -99%
홈에서 총 JS ~3 MB 544 KB -82%
Render-blocking CSS 160 ms 0 ms (인라인) -100%
Google Fonts 차단 1,258 ms 0 ms -100%
FCP ~2.4 s 256 ms -89%
LCP / DOM Complete ~2.5 s 1,295 ms -48%
Lighthouse 성능 등급 D A (6/6 예산)

사이트 자체는 변경되지 않음 — 동일한 React 컴포넌트, 동일한 UI, 동일한 백엔드. 모든 수정은 구성 또는 빌드 프로세스 변경이었습니다.

React + Vite 앱을 배포한다면

  1. 모든 릴리스에서 번들 출력을 보세요. CI 로그의 두 줄입니다. 500 KB를 초과하는 게 있으면 조사. Vite가 무료로 알려줍니다.

  2. React.lazy() 플러스 Suspense는 가장 저렴한 가능한 성능 승리. 라우트 레벨 분할은 구현에 10분 걸리고 보통 메인 번들의 50-90%를 절약합니다.

  3. import.meta.glob은 "같은 종류의 많은 파일"을 lazy-load하기 위한 Vite의 슈퍼파워 — 로케일, 아이콘, 블로그 글 등. 긴 정적 import 목록 대신 사용하세요.

  4. 전이적 import를 감사하세요. 무거운 라이브러리를 정적으로 import하는 공유 "쉘" 컴포넌트는 모든 라우트 레벨 lazy-loading 노력을 무력화합니다. PdfToolShell 버그는 우리가 알아챌 때까지 모든 페이지에서 600 KB 청크를 짊어지게 했습니다.

  5. 당신의 로고는 아마 너무 큽니다. 대부분 사이트의 로고는 ≤64 px로 표시됩니다. ≤10 KB여야 합니다. ImageMagick이 설치되지 않았다면 이 사이트의 리사이즈 도구를 사용하세요.

  6. 모든 외부 폰트 로드를 의심하세요. macOS, Windows, Android의 시스템 폰트 모두 우수합니다. 브랜드가 특정 서체에 의존하지 않는다면(대부분 그렇지 않음) Google Fonts 링크를 완전히 삭제하세요.

  7. 작은 CSS는 인라인. ~10 KB 미만이면 요청을 건너뛰어 절약된 지연이 외부 파일의 캐싱 이점을 능가합니다.

  8. AdSense와 Analytics는 대부분 제어 밖. 받아들이고 할 수 있는 것을 최적화하세요.

이것은 이틀에 걸친 총 약 6시간의 엔지니어링 작업이었습니다. 가장 큰 개선(라우트 분할 + 로케일 분할)은 각각 약 2시간 걸렸습니다. 나머지 4시간은 측정, 회귀 테스트, 이 글을 쓰는 것이었습니다.

이것이 유용하다고 생각된다면, 더 깨끗한 빌드에서 나온 도구들은 여전히 여기 있고, 여전히 무료이며, 여전히 브라우저에서 완전히 실행됩니다:

정상 연결에서 모두 1초 미만으로 로드됩니다. 지금은.