로딩 3초 → 0.5초로 줄인 이야기 - 성능 최적화 & SEO
사이트 속도 최적화하면서 겪은 번들 분리, 이미지 최적화, SEO 삽질기
발단: "사이트 왜 이렇게 느려요?"
첫 배포 후 피드백:
"위키 페이지 들어가면 3초 넘게 걸려요" "모바일에서 버벅거려요"
Lighthouse 돌려봤다.
Performance: 34
First Contentful Paint: 2.8s
Largest Contentful Paint: 4.2s
34점. 빨간불 투성이. 이래서는 아무도 안 쓴다.
원인 분석
1. 번들 크기 폭발
dist/assets/index-abc123.js 645KB
Wiki 데이터만 500KB 넘음. 첫 진입 시 전부 다운로드.
2. 이미지 최적화 안 됨
wiki/images/
├── monument-01.png 2.3MB
├── monument-02.png 1.8MB
└── ... (총 150개)
PNG 원본 그대로 서빙.
3. 렌더링 블로킹
큰 JS 파일이 다 로드될 때까지 화면이 안 나옴.
해결 1: 코드 스플리팅
Wiki 데이터를 카테고리별로 분리.
// vite.config.ts
build: {
rollupOptions: {
output: {
manualChunks: (id) => {
// Wiki 카테고리별 분리
if (id.includes('wiki/basics')) return 'wiki-basics';
if (id.includes('wiki/combat')) return 'wiki-combat';
if (id.includes('wiki/building')) return 'wiki-building';
// ...10개 카테고리
// React 관련 라이브러리
if (id.includes('react')) return 'vendor-react';
// 아이콘
if (id.includes('lucide-react')) return 'vendor-icons';
},
},
},
}
결과:
- 초기 번들: 645KB → 89KB
- 위키 페이지 진입 시 해당 카테고리만 로드 (30~80KB)
해결 2: 이미지 최적화
WebP 변환
# 모든 PNG를 WebP로
for f in *.png; do
cwebp -q 80 "$f" -o "${f%.png}.webp"
done
2.3MB → 180KB (10배 감소)
Lazy Loading
화면에 보이는 이미지만 로드.
function WikiImage({ src, alt }: { src: string; alt: string }) {
const [isLoaded, setIsLoaded] = useState(false);
const [isInView, setIsInView] = useState(false);
const imgRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsInView(true);
observer.disconnect();
}
},
{ rootMargin: '100px' } // 100px 전에 미리 로드
);
if (imgRef.current) observer.observe(imgRef.current);
return () => observer.disconnect();
}, []);
return (
<div ref={imgRef} className="wiki-image">
{isInView ? (
<img
src={src}
alt={alt}
loading="lazy"
onLoad={() => setIsLoaded(true)}
style={{ opacity: isLoaded ? 1 : 0 }}
/>
) : (
<div className="skeleton" />
)}
</div>
);
}
외부 호스팅
대용량 이미지는 Imgur에 올리고 URL만 저장.
// 작은 아이콘: 로컬
iconSrc: '/icons/ak47.webp'
// 큰 스크린샷: Imgur (CDN 활용)
screenshotSrc: 'https://i.imgur.com/xxxxx.webp'
해결 3: 프리페칭
사용자가 링크에 마우스 올리면 미리 로드.
function WikiLink({ to, children }: { to: string; children: React.ReactNode }) {
const prefetch = () => {
// 해당 카테고리 청크 미리 로드
const category = to.split('/')[2];
import(`../data/wiki/${category}`);
};
return (
<Link
to={to}
onMouseEnter={prefetch}
onFocus={prefetch}
>
{children}
</Link>
);
}
마우스 올리면 200ms 안에 로드 완료. 클릭 시 즉시 표시.
해결 4: Critical CSS
첫 화면에 필요한 CSS만 인라인.
<!-- index.html -->
<head>
<style>
/* Critical CSS - 첫 화면에 필요한 최소 스타일 */
body { margin: 0; font-family: system-ui; }
.hero { min-height: 100vh; display: flex; }
/* ... */
</style>
<link rel="stylesheet" href="/main.css" media="print" onload="this.media='all'">
</head>
전체 CSS 로드 전에 화면 표시 가능.
SEO: 구글 검색 노출
메타 태그 최적화
// 각 페이지별 메타 태그
function RaidCalculator() {
return (
<>
<Helmet>
<title>러스트 레이드 계산기 - 최소 유황 비용 계산 | Pharos</title>
<meta
name="description"
content="러스트 레이드 비용을 자동으로 계산합니다. C4, 로켓, 폭발탄 등 모든 무기의 효율을 비교하세요."
/>
<meta name="keywords" content="러스트, 레이드, 계산기, 유황, C4, 로켓" />
{/* Open Graph */}
<meta property="og:title" content="러스트 레이드 계산기" />
<meta property="og:description" content="최소 비용으로 레이드하세요" />
<meta property="og:image" content="https://pharos.softopia.dev/og-raid.png" />
{/* 구조화된 데이터 */}
<script type="application/ld+json">
{JSON.stringify({
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "러스트 레이드 계산기",
"applicationCategory": "GameApplication",
"operatingSystem": "Web Browser"
})}
</script>
</Helmet>
{/* 페이지 내용 */}
</>
);
}
sitemap.xml
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://pharos.softopia.dev/</loc>
<lastmod>2025-01-10</lastmod>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://pharos.softopia.dev/raid-calculator</loc>
<lastmod>2025-01-10</lastmod>
<changefreq>weekly</changefreq>
<priority>0.9</priority>
</url>
<!-- 모든 페이지 -->
</urlset>
Google Search Console에 제출.
삽질: SPA는 SEO가 어렵다
React SPA는 기본적으로 빈 HTML을 보냄. 구글 봇이 JS 실행해야 내용 보임.
<!-- 구글 봇이 처음 보는 것 -->
<body>
<div id="root"></div>
<script src="/main.js"></script>
</body>
해결 1: 사전 렌더링 (Prerender)
빌드 시점에 각 페이지를 정적 HTML로 생성.
npm install vite-plugin-prerender
// vite.config.ts
import { prerender } from 'vite-plugin-prerender';
export default {
plugins: [
prerender({
routes: ['/', '/raid-calculator', '/wiki', '/teams', ...],
}),
],
}
해결 2: 메타 태그는 서버에서
react-snap 같은 도구로 빌드 시 메타 태그가 포함된 HTML 생성.
PWA 적용
오프라인에서도 동작 + 홈 화면 추가 가능.
manifest.json
{
"name": "Pharos - 러스트 도우미",
"short_name": "Pharos",
"start_url": "/",
"display": "standalone",
"background_color": "#0a0a0a",
"theme_color": "#10b981",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
Service Worker
// sw.js
const CACHE_NAME = 'pharos-v1';
const STATIC_ASSETS = [
'/',
'/raid-calculator',
'/main.js',
'/main.css',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(STATIC_ASSETS);
})
);
});
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((cached) => {
return cached || fetch(event.request);
})
);
});
결과
Lighthouse 점수
| 항목 | 전 | 후 | |------|-----|-----| | Performance | 34 | 92 | | FCP | 2.8s | 0.6s | | LCP | 4.2s | 1.1s | | TTI | 5.1s | 1.3s |
실제 로딩 시간
- 첫 방문: 3.2초 → 0.8초
- 재방문 (캐시): 0.3초
- 위키 페이지 전환: 1.5초 → 0.2초
SEO 성과
"러스트 레이드 계산기" 검색 결과:
1위: rustlabs.com
2위: pharos.softopia.dev ← 여기!
3위: rust.fandom.com
3개월 만에 구글 2위 달성!
실패한 시도들
1. SSR (Next.js 전환)
SEO를 위해 Next.js로 마이그레이션 시도.
포기 이유:
- 기존 코드 전부 수정해야 함
- Vite + 사전 렌더링으로 충분
- 서버 비용 발생
2. CDN 직접 구축
Cloudflare Workers로 엣지 캐싱.
포기 이유:
- GitHub Pages + Cloudflare 조합이 더 간단
- 무료로 충분한 성능
3. AVIF 이미지
WebP보다 더 좋은 압축.
포기 이유:
- Safari 지원 불완전
- WebP로도 충분
- 변환 도구가 불안정
배운 것
- 번들 크기가 핵심: 645KB → 89KB로 체감 속도 5배 향상
- 이미지가 주범: WebP 변환 + Lazy Loading 필수
- 측정 없이 최적화 없다: Lighthouse, Web Vitals 계속 체크
- SPA SEO는 어렵다: 사전 렌더링이나 SSR 필요
- PWA 쉽다: manifest + SW 추가만 하면 됨
유지 중인 모니터링
// Web Vitals 측정
import { onCLS, onFID, onLCP } from 'web-vitals';
function reportVitals(metric) {
// Google Analytics로 전송
gtag('event', metric.name, {
value: Math.round(metric.value),
metric_id: metric.id,
});
}
onCLS(reportVitals);
onFID(reportVitals);
onLCP(reportVitals);
매주 Web Vitals 리포트 확인. 느려지면 원인 분석.
시리즈 마무리
10편에 걸쳐 Pharos 개발 과정을 기록했다.
- 서버 운영에서 시작
- 웹사이트로 확장
- 실시간 연동
- 각종 기능 (위키, 계산기, 리더보드, 팀 파인더)
- 봇 자동화
- 성능 최적화
1년 동안 혼자 만들면서 배운 것들. 다음 프로젝트에서는 더 잘할 수 있을 것 같다.
질문 있으면 디스코드로: softopia