블로그로 돌아가기
성능최적화SEOViteWeb Vitals

로딩 3초 → 0.5초로 줄인 이야기 - 성능 최적화 & SEO

사이트 속도 최적화하면서 겪은 번들 분리, 이미지 최적화, SEO 삽질기

Softopia
2024년 12월 4일
6 min read

발단: "사이트 왜 이렇게 느려요?"

첫 배포 후 피드백:

"위키 페이지 들어가면 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로도 충분
  • 변환 도구가 불안정

배운 것

  1. 번들 크기가 핵심: 645KB → 89KB로 체감 속도 5배 향상
  2. 이미지가 주범: WebP 변환 + Lazy Loading 필수
  3. 측정 없이 최적화 없다: Lighthouse, Web Vitals 계속 체크
  4. SPA SEO는 어렵다: 사전 렌더링이나 SSR 필요
  5. 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. 서버 운영에서 시작
  2. 웹사이트로 확장
  3. 실시간 연동
  4. 각종 기능 (위키, 계산기, 리더보드, 팀 파인더)
  5. 봇 자동화
  6. 성능 최적화

1년 동안 혼자 만들면서 배운 것들. 다음 프로젝트에서는 더 잘할 수 있을 것 같다.


질문 있으면 디스코드로: softopia