홈페이지 한 번 여는 데 19.7MB를 내려받고 있었다
19.7MB를 782KB로 줄인 기록
#성능최적화 #이미지최적화 #sharp #Vite #웹폰트 #React
홈페이지 한 번 여는 데 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%였던 것이다. 범인 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가 됐다. 그런데 화면은 하나도 안 바뀌었다. 썸네일도 그대로 보이고, 상단바 애니메이션도 돌고, 픽셀 폰트도 그대로다. 데스크탑이랑 모바일 둘 다 열어서 확인했다. 정리하면 제일 크게 배운 건 추측하지 말고 재라 는 것이다. 나는 몇 달 동안 Vite의 번들 경고를 보면서 "JS를 줄여야 하는데"라고 생각하고 있었다. 그런데 실제로 재보니 JS는 전체의 1%였고, 93%는 내가 한 번도 의심해본 적 없는 이미지였다. 그리고 무거운 것들은 대부분 원래 그 자리에 있으면 안 되는 것들 이었다. 250px 카드에 들어간 2MB 사진, JS 번들에 박힌 애니메이션 JSON, 애니메이션 JSON에 박힌 PNG, 글을 읽기만 하는 화면에 뜬 에디터, 한 번도 안 쓰는 한자 글리프. 무슨 대단한 기법을 쓴 게 아니라 있어야 할 곳에 돌려놓기만 했다. 혹시 성능 최적화를 고민하고 있다면, 코드를 고치기 전에 브라우저 콘솔에서 위의 performance 코드부터 한 번 돌려보길 추천한다. 내가 그랬듯이, 엉뚱한 걸 붙잡고 있었다는 걸 알게 될지도 모른다.