블로그 324MB를 17MB로 줄였다 (CRA → Vite 갈아타기)
들어가며
이 블로그는 몇 년 전 Create React App(CRA)으로 만든 평범한 SPA였다. 글이 늘어날수록 손볼 곳이 눈에 밟혔다.
public폴더가 324MB였다. 쓰지 않는 영상과 원본 PNG가 그대로 쌓여 있었다.- 첫 화면에서 받는 JS가 517KB(gzip)였다. 알림 패널, 방문자 수, 다국어, Firebase까지 한 번에 실렸다.
- 카카오톡이나 슬랙에 글 주소를 보내면 어떤 글이든 똑같은 미리보기가 떴다. HTML에는 빈
<div id="root">밖에 없었기 때문이다. - 새 글을 쓰려면 마크다운 파일을 넣고, 목록 JSON도 따로 고쳐야 했다.
그래서 이틀 동안 블로그를 거의 새로 지었다. 이 글은 그 과정을 순서대로 정리한 기록이다.
1단계: 먼저 덜어내기
새 기능보다 먼저 한 일은 안 쓰는 것을 지우는 것이었다.
| 항목 | 전 | 후 |
|---|---|---|
public 폴더 | 324MB | 17MB |
| 본문 이미지(PNG 50장) | 82MB | 2.7MB (WebP) |
| 첫 화면 JS (gzip) | 517KB | 64KB |
- 실제로 쓰는 PNG는 WebP로 바꾸고, 참조하지 않는 대용량 미디어 74개를 지웠다.
- 코드 하이라이터는 전체 언어 대신
PrismLight에 글에서 쓰는 언어만 등록했다. - 알림 패널, 방문자 카운터(Firebase), 유료·협업 페이지, 다국어(i18n), 광고 캐러셀처럼 아무도 쓰지 않던 기능을 걷어냈다.
기능을 지우는 건 아깝게 느껴지지만, 막상 지우고 나니 블로그가 무엇을 하는 곳인지 훨씬 선명해졌다. 글을 읽는 곳이다.
2단계: 읽기 좋은 화면
디자인은 허브 사이트(solfany.com)와 같은 색, Pretendard 글꼴, 보라색 포인트로 맞췄다.
- 글 상세: 본문 17px에 줄 간격 1.85로 잡았다. 넓은 화면에서는 본문 800px 옆에 목차 열을 두고, 지금 읽는 위치를 목차에 표시한다.
- 목차: 글마다 소제목 단계가 제각각이었다(어떤 글은
####부터 시작하고, 어떤 글은#####까지 내려간다). 그래서 고정된 h2·h3 대신 글에 실제로 있는 제목 단계를 기준으로 목차를 만든다. - 글 목록: 썸네일과 페이지네이션을 넣었다. 휴대폰에서는 썸네일을 72px 정사각형으로 줄이고 제목만 남겨, 한 화면에 더 많은 글이 보이게 했다.
- 연재 글:
-1.md,-2.md처럼 이어지는 글은 부모 글 아래 시리즈로 묶었다.
3단계: CRA에서 Vite로
CRA는 더 이상 관리되지 않고, npm audit을 돌릴 때마다 고칠 수 없는 경고가 쌓였다. 빌드도 느렸다. Vite로 옮기는 작업 자체는 생각보다 단순했다.
public/index.html을 프로젝트 최상위로 옮기고<script type="module" src="/src/index.jsx">를 넣는다.process.env.REACT_APP_*을import.meta.env.VITE_*로 바꾼다.- JSX가 들어 있는
.js파일은.jsx로 이름을 바꾼다.
한 가지 함정이 있었다. Vite는 기본값으로 CSS를 페이지(청크)별로 쪼갠다. 그런데 뒤에서 설명할 "미리 그린 HTML"은 JS가 실행되기 전에 이미 화면에 보인다. 이때 해당 페이지의 CSS가 아직 오지 않아 스타일 없는 화면이 번쩍이는 문제(FOUC)가 생겼다. 블로그 CSS는 전부 합쳐도 작아서 그냥 하나로 묶었다.
// vite.config.mjs
build: {
outDir: isSsrBuild ? "build-ssr" : "build",
cssCodeSplit: false, // 미리 그린 HTML 이 JS 보다 먼저 보이므로 CSS 는 한 파일로
},4단계: 빌드 때 모든 페이지를 미리 그리기
공유 미리보기와 검색 노출의 핵심은 HTML에 진짜 내용이 들어 있는가다. Next.js로 옮기는 방법도 있었지만, 블로그는 글이 바뀔 때만 다시 빌드하면 되는 정적 사이트라 빌드 시점 프리렌더로 충분했다.
빌드는 네 단계로 돈다.
node scripts/content.js # 1) 글 머리말 → posts.json, 이미지 최적화
vite build # 2) 브라우저용 앱
vite build --ssr src/entry-server.jsx # 3) 서버 렌더용 번들
node scripts/generate-seo.js # 4) 주소마다 HTML·메타·공유 이미지·sitemap·RSS서버 렌더용 입구는 주소를 받아 HTML 문자열을 돌려주는 함수 하나다.
// src/entry-server.jsx
export function render(url, data = {}) {
setPrerenderData(data); // 글 본문 마크다운처럼 브라우저가 따로 불러오던 데이터
try {
return renderToString(
<StrictMode>
<StaticRouter location={url}>
<App pages={pages} />
</StaticRouter>
</StrictMode>
);
} finally {
setPrerenderData(null);
}
}generate-seo.js는 이 결과를 <div id="root"> 안에 끼워 넣는다. 글 본문 마크다운도 함께 심어 두어서, 브라우저가 같은 글을 다시 불러오지 않고 그대로 이어받게 했다.
function inject(html, appHtml, data) {
const json = JSON.stringify(data).replace(/</g, "\\u003c"); // </script> 탈출 방지
return html.replace(
'<div id="root"></div>',
`<div id="root">${appHtml}</div><script>window.__PRERENDER__=${json}</script>`
);
}브라우저 쪽에서는 미리 그린 HTML이 있으면 hydrateRoot로 이어받고, 없으면 새로 그린다. 하이드레이션 과정에서 만난 문제들은 별도 글에 따로 정리했다.
코드 분할도 신경 썼다. 브라우저에서는 페이지를 lazy()로 나눠 불러오지만, 서버 렌더에서는 Suspense가 끼면 곤란하다. 그래서 페이지 목록을 pages.client.js(lazy import)와 pages.server.js(직접 import) 두 개로 나누고, App이 pages를 prop으로 받게 했다.
5단계: 마크다운 파일 하나로 글 쓰기
예전에는 글 하나를 올릴 때 마크다운을 넣고, 목록 JSON에 제목·날짜·주소를 따로 적어야 했다. 이제는 머리말(frontmatter)만 쓰면 끝이다.
---
title: "글 제목"
description: "목록과 공유 카드에 보일 한 줄 요약"
date: 2026-10-01T10:00:00+09:00
category: 기술 블로그
tags: [Spring Boot, JPA]
---scripts/content.js가 빌드 전에 모든 글의 머리말을 읽어 posts.json을 만든다. 이때 몇 가지를 같이 처리한다.
- 카테고리로 주소를 만든다 (
기술 블로그→/blog/tech/파일이름). - 본문 길이로 읽기 시간을 계산한다. 한국어는 분당 약 500자, 코드는 더 느리게 잡았다.
- 필수 값이 빠졌거나, 날짜 형식이 틀렸거나, 주소가 겹치면 빌드를 멈춘다. 잘못된 글이 조용히 배포되는 것보다 낫다.
- 머리말이 없거나
draft: true인 파일은 초안으로 보고 건너뛴다.
6단계: 이미지를 알아서 가볍게
글에 쓰인 이미지는 빌드 때 sharp로 세 가지 크기의 WebP를 만든다.
| 이름 | 가로 | 쓰는 곳 |
|---|---|---|
| thumb | 480px | 목록 썸네일 |
| medium | 800px | 휴대폰 화면의 표지 |
| large | 1600px | 본문, 넓은 화면의 표지 |
표지는 srcSet과 sizes로 화면에 맞는 크기를 고르게 했고, 첫 화면의 가장 큰 이미지에는 fetchpriority="high"를 줬다. 결과물은 원본보다 새로울 때 다시 만들지 않아서, 두 번째 빌드부터는 금방 끝난다.
7단계: 공유했을 때 깔끔하게
HTML에 내용이 생겼으니, 이제 글마다 제대로 된 메타 정보를 넣을 수 있다.
- 글마다
title,description,canonical, Open Graph, 트위터 카드를 넣었다. - 공유 이미지: 표지를 1200×630 JPG로 잘라
build/og/에 따로 만든다. 카카오톡이나 페이스북이 권장하는 비율이라 잘리지 않고 보인다. - 구조화 데이터(JSON-LD): 글에는
BlogPosting, 사이트에는WebSite를 넣어 검색 엔진이 글의 제목, 날짜, 작성자를 이해하게 했다. - sitemap.xml, rss.xml: 빌드할 때 자동으로 만든다. RSS에서는 연재편을 빼고 대표 글만 내보낸다.
- 404 페이지:
noindex를 붙여 검색 결과에 섞이지 않게 했다.
8단계: 찾기 쉽게
글이 늘면 결국 찾기가 문제다.
- 검색: 띄어쓰기로 나눈 모든 낱말이 제목, 요약, 태그, 카테고리 중 어딘가에 있으면 결과에 보여 준다.
- 태그: 글 상세의 태그를 누르면 같은 태그의 글만 모아 본다.
- 관련 글: 겹치는 태그가 많을수록, 같은 카테고리일수록 점수를 높게 매겨 글 끝에 비슷한 글을 추천한다.
- 카테고리, 태그, 검색어, 쪽 번호는 모두 주소(
?q=,?tag=,?page=)에 남는다. 그래서 새로고침하거나 링크를 공유해도 같은 화면이 나온다.
9단계: 보안과 점수
마지막으로 의존성 보안과 성능 점수를 확인했다.
npm audit취약점: 7개에서 0개로 줄였다. React Router를 v7로 올리고(react-router-dom에서react-router로 통합),sharp도 새 버전으로 올렸다.- Lighthouse 모바일 점수(로컬 측정):
| 페이지 | 전 | 후 |
|---|---|---|
| 홈 | 72 | 85~94 |
| 글 목록 | 73 | 79~81 |
| 글 상세 | 65 | 68~76 |
| 프로젝트 | 70 | 79~80 |
접근성과 SEO는 모든 페이지가 100점이다. 접근성 점수는 회색 글자의 명암 대비를 조금 올리고, 제목 단계(h1 → h2)를 바로잡고, 그래프 막대에 role="img"를 붙여서 채웠다.
글 상세가 상대적으로 낮은 이유는 대부분 한글 웹폰트(Pretendard) 용량 때문이다. 더 가벼운 글꼴 파일로 바꾸면 점수는 오르지만, 드물게 쓰는 글자가 다른 글꼴로 보일 수 있다. 그래서 지금은 글자가 제대로 보이는 쪽을 택했다.
마치며
돌아보면 가장 효과가 컸던 건 새 기술이 아니라 덜어내기였다. 324MB를 17MB로, 517KB를 64KB로 줄인 건 대부분 지우는 작업이었다.
두 번째로 효과가 컸던 건 빌드 때 미리 그리기다. Next.js로 옮기지 않고도 공유 미리보기, 검색 노출, 첫 화면 속도를 한 번에 챙길 수 있었다. 블로그처럼 내용이 빌드 시점에 정해지는 사이트라면, 프레임워크를 바꾸기 전에 먼저 고려해 볼 만한 선택지다.
이제 새 글은 마크다운 파일 하나로 끝난다. 남은 건 글을 꾸준히 쓰는 일뿐이다.