홈페이지 한 번 여는 데 19.7MB를 내려받고 있었다

빌드할 때마다 Vite가 경고를 뱉고 있었다. 번들 하나가 500KB를 넘는다는 이야기였다. 계속 무시하다가 이번에 마음먹고 줄여보기로 했다.

그런데 막상 재보니 번들은 문제의 일부였다. 진짜 문제는 다른 데 있었고, 그건 재기 전까지 전혀 모르고 있었다.

먼저 정확히 재는 것부터

보통 "번들 사이즈를 줄인다"고 하면 빌드 결과물의 파일 크기를 본다. 그런데 그건 사용자가 실제로 내려받는 양과 다르다. 어떤 파일은 애초에 안 받고, 어떤 파일은 압축돼서 오고, 이미지나 폰트는 번들에 잡히지도 않는다.

그래서 브라우저가 실제로 전송받은 바이트를 쟀다. 페이지에서 이 코드를 실행하면 나온다.

const res = performance.getEntriesByType('resource');
const total = res.reduce((sum, e) => sum + (e.transferSize || 0), 0);

console.log((total / 1024).toFixed(0) + 'KB');

// 종류별로 묶어서 보면 어디가 무거운지 바로 보인다
res.sort((a, b) => b.transferSize - a.transferSize).slice(0, 5)
   .forEach(e => console.log((e.transferSize/1024).toFixed(0) + 'KB', e.name));

내 블로그 홈에서 돌렸더니 이렇게 나왔다.

홈 전체 19,682KB
   이미지    18,345KB   <- 여기가 문제
   폰트         691KB
   JSON         345KB
   JS           226KB
   CSS            6KB

19.7MB였다. 글 스무 편 남짓 걸린 개인 블로그 첫 화면이 영화 예고편만 한 용량을 내려받고 있었다. 그리고 그중 93%가 이미지였다. 나는 여태 번들 걱정을 하고 있었는데, 정작 JS는 전체의 1%였다. 재보기 전에는 이걸 전혀 몰랐다.

지금 홈 화면이다. 위쪽 인기 게시물 줄과 아래 최근 게시물 줄에 카드가 250px 폭으로 깔린다. 문제는 이 작은 카드에 들어가는 그림이었다.

홈페이지 한 번 여는 데 19.7MB를 내려받고 있었다 이미지 1

범인 1: 250px 카드에 2MB 원본 사진

이미지 목록을 보니 개별 파일이 2,134KB, 1,785KB, 1,688KB씩 됐다. 블로그 카드 썸네일이었다. 코드를 열어봤다.

// BlogCard.tsx, 카드 하나의 폭은 250px 이다
<div className='flex w-full aspect-[5/3] ...'>
  <img src={`${API_URL}/${blogData.files[0].path}`} ... />
</div>

업로드된 원본 사진을 그대로 250px짜리 카드에 넣고 있었다. 브라우저는 2MB 이미지를 다 받아서 CSS로 줄여 보여준다. 홈에는 카드가 열 개 넘게 깔리니 그게 18MB가 된 것이다.

필요한 건 250px짜리 이미지인데 2MB를 받고 있으니, 서버가 크기를 줄여서 내려주면 될 일이었다. 이미지는 어차피 백엔드가 서빙하고 있었다.

// app.js, 원래 이렇게 원본만 내려주고 있었다
app.use('/files', express.static(path.join(__dirname, 'files')));

여기에 리사이즈 레이어를 하나 끼웠다. ?w=500 처럼 폭을 요청하면 그 크기의 webp로 바꿔서 주고, 결과는 디스크에 캐시해서 다음부터는 변환 없이 파일만 내려준다.

const sharp = require('sharp');

// 임의의 폭을 다 받아주면 캐시가 무한정 늘어난다. 허용값만 처리한다.
const ALLOWED_WIDTHS = [300, 500, 800, 1200];

async function imageResizeHandler(req, res, next) {
  const width = parseInt(req.query.w, 10);
  if (!width || !ALLOWED_WIDTHS.includes(width)) return next();  // 원본 서빙으로

  const name = path.basename(req.path);          // 경로 조작(../) 차단
  const src = path.join(FILES, name);
  const cached = path.join(CACHE, `${name}_w${width}.webp`);

  if (!fs.existsSync(cached)) {
    await sharp(src)
      .rotate()                                   // EXIF 회전 반영 (안 하면 세로 사진이 눕는다)
      .resize({ width, withoutEnlargement: true })
      .webp({ quality: 80 })
      .toFile(cached);
  }

  res.set('Content-Type', 'image/webp');
  res.set('Cache-Control', 'public, max-age=31536000, immutable');
  return res.sendFile(cached);
}

여기서 신경 쓴 게 몇 가지 있다. 우선 허용된 폭만 처리한다. ?w=1, ?w=2, ?w=3 으로 요청이 들어오면 그때마다 변환하고 캐시 파일을 만들 텐데, 그건 그냥 디스크를 채우는 공격이 된다. 그리고 파일명은 path.basename으로 한 번 걸러서 ../ 같은 걸로 다른 경로를 읽지 못하게 막았다.

EXIF 회전도 함정이었다. rotate()를 안 붙이면 휴대폰으로 찍은 세로 사진이 리사이즈 후에 눕는다. 원본에는 "이 사진은 90도 돌려서 보라"는 정보가 따로 들어있는데, 변환하면서 그게 날아가기 때문이다.

프론트는 크기만 요청하도록 고쳤다.

<img
  src={`${API_URL}/${blogData.files[0].path}?w=500`}   // 레티나 대비 2배
  loading='lazy'          // 화면 밖 이미지는 스크롤해서 닿을 때 받는다
  decoding='async'
/>

결과는 이랬다.

썸네일 1장:  2,184,789 bytes  ->  10,834 bytes   (-99.5%)
홈 이미지 전체:  18,345KB  ->  142KB

99.5%다. 2MB짜리 사진이 11KB가 됐는데 카드에서는 똑같아 보인다. 애초에 250px 안에 들어갈 그림이었으니 당연한 일이다.

그리고 사이트를 한 번 죽였다

sharp를 설치하고 서버를 재시작했더니 사이트가 통째로 내려갔다. 로그를 보니 이랬다.

Error: Could not load the "sharp" module using the linux-x64 runtime
- Please upgrade Node.js:
    Found 18.19.1
    Requires >=20.9.0

최신 sharp가 Node 20 이상을 요구하는데 이 서버는 Node 18이었다. 문제는 그게 require 단계에서 예외를 던진다는 것이다. 이미지 변환 하나 붙이려다가 블로그 전체가 안 뜨게 됐다.

버전을 낮춰서(0.34.5) 해결했지만, 더 중요한 건 구조였다. 부가 기능 하나가 서버 전체를 죽일 수 있게 짜여 있었다는 게 진짜 문제다. 그래서 이렇게 고쳤다.

let sharp = null;
try {
  sharp = require('sharp');
} catch (e) {
  console.error('[image] sharp 로드 실패, 리사이즈 없이 원본을 서빙한다');
}

async function imageResizeHandler(req, res, next) {
  if (!sharp) return next();   // 못 쓰면 그냥 원본으로 넘긴다
  ...
}

이제 sharp가 죽어도 이미지가 원본으로 나갈 뿐 사이트는 뜬다. 있으면 좋은 기능은, 없어도 서비스가 돌아가게 만들어야 한다. 이번에 제대로 배웠다.

범인 2: 번들에 통째로 박혀 있던 애니메이션

이미지를 잡고 나서 JS를 봤다. 빌드 결과에서 index 청크 하나가 1,041KB였다. 내가 쓴 코드가 그렇게 많을 리가 없어서 파일 안에 뭐가 들었는지 뒤졌더니, 길이가 388KB인 문자열 하나가 있었다.

Lottie 애니메이션이었다. 상단바 로고, 스플래시, 로딩 같은 걸 Lottie로 넣어뒀는데, 이렇게 쓰고 있었다.

import animationData from '../../lotties/logo.json';   // 548KB
import animationData from './splash.json';              // 306KB

JSON을 import 하면 그 내용이 JS 번들 안에 문자열로 통째로 들어간다. 다섯 개 합쳐서 1.1MB였고, 그게 매번 JS와 함께 내려가고 있었다. 애니메이션은 화면이 뜬 다음에 나타나도 되는 부가 요소인데 말이다.

그래서 JSON을 public 폴더로 빼고 필요할 때 받아오게 했다.

const cache = new Map();

export const useLottieJson = (name) => {
  const [data, setData] = useState(() => cache.get(name) ?? null);

  useEffect(() => {
    if (cache.has(name)) return;
    fetch(`/lotties/${name}.json`)
      .then((res) => res.json())
      .then((json) => { cache.set(name, json); setData(json); })
      .catch(() => {});   // 애니메이션은 못 받아와도 화면은 그대로 뜬다
  }, [name]);

  return data;
};

렌더러(lottie-web) 자체도 77KB나 되는데, 상단바가 모든 페이지에 있으니 이것도 항상 따라왔다. 그래서 라이브러리도 lazy로 미뤘다.

const Lottie = lazy(() => import('lottie-react'));

const LazyLottie = ({ name, style, fallback = null }) => {
  const animationData = useLottieJson(name);
  if (!animationData) return <>{fallback}</>;   // 아직 안 왔으면 fallback

  return (
    <Suspense fallback={<>{fallback}</>}>
      <Lottie animationData={animationData} style={style} />
    </Suspense>
  );
};

범인 3: 라우트를 하나도 안 쪼개고 있었다

라우트 설정을 보니 모든 페이지를 정적으로 import 하고 있었다.

import Home from "../routes/Home";
import Write from "../routes/admin/Write";        // 관리자 글쓰기
import AdminBlog from "../routes/admin/AdminBlog";
// ... 전부 정적 import

이러면 홈에 들어온 사람도 관리자 글쓰기 페이지 코드까지 전부 받는다. 그 페이지는 Quill 에디터를 쓰는데, 그게 압축하고도 55KB다. 블로그를 읽으러 온 사람이 나만 쓰는 에디터를 내려받고 있었던 것이다.

// Home 만 정적으로 두고, 나머지는 lazy 로
const Blogs = lazy(() => import("../routes/Blogs"));
const Write = lazy(() => import("../routes/admin/Write"));
const AdminBlog = lazy(() => import("../routes/admin/AdminBlog"));

그런데 라우트를 쪼개고 나서도 글 상세 페이지에서 Quill이 계속 따라왔다. 이상해서 봤더니, 글을 보여주는 화면이 에디터를 띄우고 있었다.

// ContentBody.tsx, 글을 읽는 화면이다
<ReactQuill value={content} readOnly={true} theme="bubble" />

readOnly로 쓰고 있었으니 편집 기능은 하나도 안 썼다. 그냥 HTML을 보여주려고 에디터를 통째로 띄운 셈이다. 에디터가 만드는 DOM 구조를 그대로 그려주는 걸로 바꿨다.

<div className='quill'>
  <div className='ql-container ql-bubble'>
    <div className='ql-editor' dangerouslySetInnerHTML={{ __html: html }} />
  </div>
</div>

이걸로 글 페이지에서 Quill 55KB가 사라졌다. 화면은 픽셀 하나 안 바뀌었다.

범인 4: 애니메이션 파일 안에 숨어 있던 PNG

Lottie JSON을 밖으로 뺐더니 이번엔 그 JSON이 눈에 들어왔다. logo.json 하나가 548KB였다. 브로틀리로 압축해도 309KB밖에 안 줄었는데, 압축이 잘 안 된다는 건 안에 이미 압축된 데이터가 들어있다는 뜻이다. 열어봤다.

assets: 2개 | base64 이미지: 1개
base64 이미지가 차지하는 용량: 388KB  (전체의 71%)
크기: 570x505

570×505짜리 PNG가 base64로 박혀 있었다. Lottie로 내보낼 때 이미지가 파일 안에 통째로 들어간 것이다. 게다가 base64는 원본보다 33% 커진다. 291KB짜리 PNG가 388KB 문자열이 돼 있었다.

이미지를 빼내서 webp로 압축하고, Lottie가 외부 파일을 참조하게 바꿨다.

for (const asset of json.assets) {
  if (!asset.p || !asset.p.startsWith('data:')) continue;

  const raw = Buffer.from(asset.p.split(',')[1], 'base64');
  await sharp(raw).webp({ quality: 82 }).toFile(outPath);

  asset.e = 0;                      // 0 = 외부 파일
  asset.u = '/lotties/images/';     // lottie-web 이 u + p 에서 받아온다
  asset.p = 'logo_image_0.webp';
}
image_0: PNG 291KB -> webp 20KB
logo.json: 548KB -> 160KB

범인 5: 안 쓰는 글자까지 들어있던 폰트

마지막은 폰트였다. Galmuri라는 픽셀 폰트를 쓰는데, 두 개 합쳐서 716KB였다.

한글 폰트가 무거운 건 글자 수 때문이다. 한글 음절만 11,172자인데, 여기에 한자나 일본어 글자까지 들어있으면 파일이 쭉쭉 늘어난다. 그래서 안 쓰는 글리프를 덜어내는 서브셋을 만들었다.

글자 집합은 안전하게 잡았다. DB에 있는 실제 글 전부, 프론트 소스에 박힌 UI 문구, 그리고 한글 음절 11,172자 전체를 넣었다. 지금 글만 기준으로 자르면 나중에 새 글에서 처음 쓰는 글자가 깨질 수 있어서다.

const subsetFont = require('subset-font');

// 1) DB의 실제 글 + 2) 소스의 UI 문구 + 3) 한글 음절 전체 + 4) ASCII/기호
const chars = new Set([...dbText, ...srcText, ...ascii, ...punct, ...hangulSyllables()]);

const buf = await subsetFont(fs.readFileSync(ttf), [...chars].join(''), {
  targetFormat: 'woff2',
});
Galmuri11.woff2:       535KB -> 145KB  (-73%)
Galmuri11-Bold.woff2:  181KB -> 124KB  (-31%)

한글을 통째로 다 넣었는데도 73%가 줄었다. 원본에 한자나 가나처럼 내 블로그에서 한 번도 안 쓰는 글자가 잔뜩 들어있었다는 뜻이다.

결과

                 전          후
홈 전체       19,682KB  ->   782KB    (-96%)
  이미지      18,345KB  ->   142KB    (-99.2%)
  폰트           691KB  ->   270KB    (-61%)
  JS             682KB  ->   226KB    (-67%)
  Lottie JSON    345KB  ->    49KB    (-86%)

index 청크      1,041KB  ->    32KB    (-97%)

19.7MB가 782KB가 됐다. 그런데 화면은 하나도 안 바뀌었다. 썸네일도 그대로 보이고, 상단바 애니메이션도 돌고, 픽셀 폰트도 그대로다. 데스크탑이랑 모바일 둘 다 열어서 확인했다.

두 달 뒤에 다시 재봤다

이 글을 다시 손보면서 같은 방법으로 홈을 다시 재봤다. 그동안 글은 21편에서 35편이 됐고, 홈에는 인기 게시물과 최근 게시물 두 줄이 생겼고, 애드센스 스크립트도 붙었다.

홈페이지 한 번 여는 데 19.7MB를 내려받고 있었다 이미지 2

전체가 1,481KB 다. 7월의 782KB 보다 늘긴 했는데, 늘어난 이유는 두 가지고 둘 다 예상 안에 있다. 하나는 광고 스크립트 288KB 다. 이건 내 번들이 아니라 구글 쪽에서 내려오는 것이라 내가 줄일 수 있는 게 아니다. 광고를 빼고 보면 1,193KB 다. 다른 하나는 이미지가 142KB 에서 616KB 로 늘어난 것이다. 카드가 열 장 남짓에서 서른여섯 장이 됐고, 장당 6KB 에서 48KB 사이로 내려온다. 원본을 그대로 내려주던 때처럼 장당 2MB 가 아니라 여전히 리사이즈 레이어를 타고 있으니 구조가 무너진 건 아니다.

한 가지 눈에 걸리는 건 logo.png 다. public 폴더에 둔 정적 파일이라 리사이즈 레이어를 안 타고 95KB 원본이 그대로 나간다. 이미지 중에서 제일 크다. 다음에 손볼 때 webp 로 바꿔 두면 될 일이다.

JS 는 내 번들 기준 221KB 로 7월의 226KB 와 거의 같다. 홈에서 내려온 청크 목록을 뽑아보니 lottie-web, react-dom, framer-motion 순이고 quill 은 없다. 관리자 글쓰기 화면을 lazy 로 쪼갠 게 그대로 지켜지고 있다는 뜻이다. dist 폴더를 통째로 재면 quill 청크가 들어 있지만, 그건 파일이 존재한다는 것이지 방문자가 받는다는 뜻이 아니다. 폰트 270KB 도 관리자 전용이 아니라 이 블로그 전체가 쓰는 픽셀 폰트라 홈에서 받는 게 맞다.

결국 이런 작업은 한 번 재고 끝나는 게 아니었다. 글이 늘고 화면이 바뀌고 외부 스크립트가 붙을 때마다 숫자가 같이 움직인다. 뭔가 바꿀 때마다 저 한 줄짜리 코드를 콘솔에 다시 붙여넣어 보는 습관이 제일 남는 것 같다.