이미지 로딩 중...

Vite 성능 최적화 및 캐싱 전략 완벽 가이드 - 슬라이드 1/7
A

AI Generated

2025. 11. 25. · 5 Views

Vite 성능 최적화 및 캐싱 전략 완벽 가이드

Vite 프로젝트에서 실무에 바로 적용할 수 있는 성능 최적화 기법을 다룹니다. Long-term 캐싱부터 이미지/폰트 최적화, Lazy Loading까지 Lighthouse 점수를 획기적으로 개선하는 방법을 배워보세요.


목차

  1. Long-term 캐싱 설정
  2. Preload/Prefetch 디렉티브
  3. 이미지 최적화 (vite-imagetools)
  4. 폰트 최적화 및 서브셋팅
  5. Lazy Loading 패턴 적용
  6. Lighthouse 점수 개선 실습

1. Long-term 캐싱 설정

시작하며

여러분이 웹 애플리케이션을 배포했는데, 사용자들이 "업데이트했는데도 변경사항이 안 보여요"라고 문의한 적 있나요? 또는 반대로 매번 모든 파일을 다시 다운로드해서 로딩이 느리다는 불만을 받은 적이 있으신가요?

이런 문제는 브라우저 캐싱 전략이 제대로 설정되지 않아서 발생합니다. 캐시를 너무 길게 설정하면 업데이트가 반영되지 않고, 너무 짧게 설정하면 매번 불필요한 다운로드가 발생하죠.

바로 이럴 때 필요한 것이 Long-term 캐싱 전략입니다. Vite는 빌드 시 파일명에 해시값을 자동으로 추가해서 이 문제를 깔끔하게 해결합니다.

개요

간단히 말해서, Long-term 캐싱은 파일 내용이 변경되지 않는 한 브라우저가 오래된 캐시를 계속 사용하도록 하되, 내용이 바뀌면 자동으로 새 파일을 다운로드하게 만드는 전략입니다. 실제 서비스를 운영하다 보면 JavaScript, CSS, 이미지 파일 등을 매번 다운로드하면 엄청난 트래픽 비용이 발생합니다.

예를 들어, 하루 방문자가 10만 명인 서비스에서 1MB짜리 번들 파일을 매번 다운로드하면 하루에만 100GB의 트래픽이 발생하죠. 하지만 캐싱을 제대로 설정하면 이 트래픽을 90% 이상 줄일 수 있습니다.

기존에는 파일명에 버전 번호를 수동으로 추가하거나 쿼리 스트링으로 관리했다면, Vite는 빌드 시 파일 내용의 해시값을 자동으로 파일명에 포함시킵니다. Vite의 Long-term 캐싱은 콘텐츠 기반 해싱을 사용하고, 청크 분할과 자동으로 연동되며, 별도 설정 없이도 최적화된 결과를 제공합니다.

이러한 특징들이 배포 자동화와 성능 최적화를 동시에 가능하게 만듭니다.

코드 예제

// vite.config.ts
import { defineConfig } from 'vite'

export default defineConfig({
  build: {
    // 파일명에 해시를 포함시켜 캐시 무효화 전략 구현
    rollupOptions: {
      output: {
        // JS 파일: assets/[name].[hash].js 형태로 생성
        entryFileNames: 'assets/[name].[hash].js',
        // 청크 파일도 해시 포함
        chunkFileNames: 'assets/[name].[hash].js',
        // CSS, 이미지 등 정적 자산도 해시 포함
        assetFileNames: 'assets/[name].[hash].[ext]'
      }
    },
    // manifest.json 생성으로 해시된 파일명 추적
    manifest: true
  }
})

설명

이것이 하는 일: Vite 빌드 시 모든 파일명에 해시값을 추가하여 브라우저가 파일 변경 여부를 자동으로 감지하고 적절한 캐싱 전략을 적용하도록 만듭니다. 첫 번째로, rollupOptions의 output 설정이 파일 이름 패턴을 정의합니다.

[hash] 플레이스홀더는 파일 내용을 기반으로 생성된 고유한 해시값으로 자동 치환되죠. 예를 들어 main.js가 변경되면 main.abc123.js에서 main.xyz789.js로 파일명이 바뀝니다.

이렇게 하면 브라우저는 완전히 새로운 파일로 인식해서 캐시를 무시하고 새 파일을 다운로드합니다. 그 다음으로, entryFileNames, chunkFileNames, assetFileNames 각각이 다른 종류의 파일에 대한 네이밍 규칙을 설정합니다.

entryFileNames는 앱의 진입점 파일(보통 main.js)에 적용되고, chunkFileNames는 코드 스플리팅으로 분리된 청크 파일들에 적용됩니다. assetFileNames는 CSS, 이미지, 폰트 같은 정적 자산에 적용되어 모든 리소스에 일관된 캐싱 전략이 적용되도록 합니다.

마지막으로, manifest: true 옵션이 빌드 결과물의 매핑 정보를 담은 manifest.json 파일을 생성합니다. 이 파일은 원본 파일명과 해시가 포함된 실제 파일명의 관계를 기록하여, 서버 사이드 렌더링이나 백엔드에서 올바른 파일을 참조할 수 있게 해줍니다.

여러분이 이 설정을 사용하면 배포 후 브라우저 캐시 문제로 인한 버그 리포트가 사라지고, CDN 트래픽 비용이 크게 절감되며, 사용자는 변경된 파일만 다운로드하여 빠른 로딩 속도를 경험합니다. 특히 대규모 프로젝트에서 이미지나 폰트 같은 큰 파일들의 캐싱 효율이 극적으로 개선됩니다.

실전 팁

💡 CDN을 사용한다면 Cache-Control 헤더를 "max-age=31536000, immutable"로 설정하세요. 해시가 있는 파일은 절대 변경되지 않으므로 1년간 캐싱해도 안전합니다.

💡 HTML 파일은 절대 캐싱하지 마세요. HTML은 해시가 없는 다른 파일들을 참조하므로 "no-cache"로 설정하여 항상 최신 버전을 확인하도록 해야 합니다.

💡 개발 중에는 manifest.json을 .gitignore에 추가하세요. 빌드할 때마다 생성되는 파일이므로 Git에 커밋할 필요가 없습니다.

💡 Nginx나 Apache를 사용한다면 파일 확장자별로 다른 캐시 정책을 설정하세요. .js, .css, .woff2는 장기 캐싱, .html은 no-cache로 구분합니다.

💡 빌드 후 dist 폴더의 파일명을 확인하여 해시가 제대로 적용되었는지 검증하세요. 해시가 없다면 설정이 제대로 적용되지 않은 것입니다.


2. Preload/Prefetch 디렉티브

시작하며

여러분의 웹앱에서 중요한 페이지로 이동할 때 1-2초간 하얀 화면이 보이거나 깜빡거리는 현상을 경험한 적 있나요? 사용자가 "로그인" 버튼을 클릭했는데 로그인 페이지의 JavaScript가 그제야 다운로드되기 시작한다면 사용자 경험은 형편없어집니다.

이런 문제는 브라우저가 리소스가 필요하다는 것을 너무 늦게 알아차려서 발생합니다. 브라우저는 HTML을 파싱하다가 <script> 태그를 만나야 JavaScript 파일을 다운로드하기 시작하죠.

이미 사용자는 클릭하고 기다리고 있는데 말이에요. 바로 이럴 때 필요한 것이 Preload와 Prefetch 디렉티브입니다.

브라우저에게 "이 파일은 곧 필요하니까 미리 준비해둬"라고 알려주는 똑똑한 방법입니다.

개요

간단히 말해서, Preload는 "지금 당장 필요한 리소스를 최우선으로 다운로드"하라는 명령이고, Prefetch는 "나중에 필요할 것 같으니 브라우저가 한가할 때 미리 다운로드"하라는 힌트입니다. 실무에서 보면 초기 로딩 성능과 페이지 전환 성능이 사용자 만족도에 직접적인 영향을 미칩니다.

예를 들어, 랜딩 페이지에서 사용하는 커스텀 폰트를 preload하면 FOUT(Flash of Unstyled Text) 현상을 막을 수 있고, 다음에 방문할 가능성이 높은 페이지의 JavaScript를 prefetch하면 페이지 전환이 즉각적으로 느껴집니다. 기존에는 모든 리소스가 같은 우선순위로 다운로드되어 중요한 파일이 늦게 로드되었다면, 이제는 개발자가 명시적으로 우선순위를 지정할 수 있습니다.

Preload의 핵심 특징은 즉시 다운로드 시작, 높은 우선순위 부여, 현재 페이지에 필수적인 리소스에 사용한다는 점입니다. Prefetch는 낮은 우선순위, 유휴 시간에 다운로드, 다음 페이지를 위한 예측적 로딩이라는 특징이 있습니다.

이러한 차이를 이해하고 적절히 사용하면 체감 성능이 크게 향상됩니다.

코드 예제

// vite.config.ts
import { defineConfig } from 'vite'
import { VitePluginPreload } from 'vite-plugin-preload'

export default defineConfig({
  plugins: [
    VitePluginPreload({
      // 폰트 파일은 preload로 즉시 로드
      preload: [
        {
          rel: 'preload',
          as: 'font',
          type: 'font/woff2',
          crossorigin: 'anonymous',
          href: '/fonts/main.woff2'
        }
      ]
    })
  ],
  build: {
    rollupOptions: {
      output: {
        // 중요한 청크를 별도로 분리하여 preload 대상으로 지정
        manualChunks: {
          vendor: ['react', 'react-dom'],
          // 자주 사용되는 컴포넌트
          common: ['./src/components/Button', './src/components/Modal']
        }
      }
    }
  }
})

설명

이것이 하는 일: 브라우저에게 리소스 로딩 우선순위를 명시적으로 알려주어 중요한 파일은 빠르게, 덜 중요한 파일은 효율적으로 로드하도록 최적화합니다. 첫 번째로, VitePluginPreload 플러그인이 빌드 시 HTML에 <link rel="preload"> 태그를 자동으로 삽입합니다.

예제에서 폰트 파일을 preload하는 것은 텍스트 렌더링 시 폰트가 없어서 기본 폰트로 먼저 보이다가 커스텀 폰트로 바뀌는 깜빡임을 방지하기 위함입니다. as='font'는 브라우저에게 이것이 폰트 파일임을 알려주고, crossorigin='anonymous'는 CORS 정책을 준수하며 다운로드하도록 지시합니다.

그 다음으로, manualChunks 설정이 코드를 논리적인 단위로 분할합니다. vendor 청크에는 거의 변경되지 않는 서드파티 라이브러리(React 등)를 모아두어 Long-term 캐싱 효과를 극대화하고, common 청크에는 여러 페이지에서 공통으로 사용하는 컴포넌트를 모아 중복 다운로드를 방지합니다.

이렇게 분리된 청크는 각각 독립적으로 캐싱되고 preload/prefetch 대상으로 지정될 수 있습니다. 실제 사용에서는 동적 import와 함께 사용하면 더욱 효과적입니다.

예를 들어 메인 페이지에서 대시보드 페이지로의 전환이 빈번하다면, 메인 페이지 로드 후 <link rel="prefetch" href="dashboard.js">를 삽입하여 사용자가 대시보드로 이동하기 전에 미리 파일을 다운로드해둘 수 있습니다. 여러분이 이 기법을 사용하면 Lighthouse의 FCP(First Contentful Paint)와 LCP(Largest Contentful Paint) 점수가 개선되고, 폰트 깜빡임이 사라지며, 페이지 전환이 거의 즉각적으로 느껴집니다.

특히 모바일이나 느린 네트워크 환경에서 효과가 두드러집니다.

실전 팁

💡 Preload는 정말 중요한 리소스(폰트, 히어로 이미지, 초기 CSS)에만 사용하세요. 너무 많이 사용하면 오히려 대역폭을 낭비하고 중요한 리소스의 우선순위를 떨어뜨립니다.

💡 Prefetch는 사용자 행동 패턴을 분석하여 적용하세요. Google Analytics로 "로그인 페이지에서 대시보드로 90% 이동"을 확인했다면 대시보드를 prefetch 하는 식입니다.

💡 동적 import의 매직 코멘트를 활용하세요. import(/* webpackPrefetch: true */ './Dashboard')는 Webpack과 Vite 모두에서 자동으로 prefetch 힌트를 생성합니다.

💡 Chrome DevTools의 Network 탭에서 Priority 컬럼을 확인하여 preload가 제대로 작동하는지 검증하세요. Preload된 리소스는 "Highest" 우선순위를 가져야 합니다.

💡 폰트를 preload할 때는 반드시 crossorigin 속성을 추가하세요. 없으면 폰트가 두 번 다운로드되는 버그가 발생할 수 있습니다.


3. 이미지 최적화 (vite-imagetools)

시작하며

여러분의 웹사이트에 고화질 제품 사진을 업로드했더니 페이지 로딩이 5초 이상 걸린 경험 있나요? 디자이너가 전달한 5MB짜리 PNG 파일을 그대로 사용하다가 사용자들이 "사이트가 느려요"라고 불평하는 상황 말이죠.

이런 문제는 이미지 최적화 없이 원본 파일을 그대로 서빙해서 발생합니다. 실제로 웹 페이지 용량의 50% 이상이 이미지인 경우가 많은데, 이미지 최적화만 제대로 해도 페이지 로딩 속도를 2-3배 개선할 수 있습니다.

바로 이럴 때 필요한 것이 vite-imagetools입니다. 빌드 시 이미지를 자동으로 최적화하고, 여러 크기와 포맷으로 변환하며, 최신 브라우저에는 WebP/AVIF를 제공하는 강력한 플러그인입니다.

개요

간단히 말해서, vite-imagetools는 import 구문만으로 이미지를 자동 최적화하고, 반응형 이미지를 생성하며, 최신 포맷으로 변환하는 Vite 플러그인입니다. 실무에서는 다양한 디바이스와 화면 크기를 지원해야 합니다.

예를 들어, 데스크탑에서는 1920px 이미지가 필요하지만 모바일에서는 375px면 충분합니다. 5MB 원본을 모바일에도 그대로 보내면 데이터 낭비가 심하죠.

vite-imagetools는 하나의 원본에서 여러 크기를 자동 생성하고, srcset 속성으로 브라우저가 적절한 크기를 선택하도록 만듭니다. 기존에는 Photoshop이나 온라인 툴로 수동으로 이미지를 리사이징하고 포맷을 변환했다면, 이제는 코드 한 줄로 빌드 시 자동으로 처리됩니다.

vite-imagetools의 핵심 특징은 쿼리 파라미터 기반의 직관적인 API, Sharp 라이브러리 기반의 고성능 이미지 처리, WebP/AVIF 같은 최신 포맷 지원입니다. 이러한 특징들이 이미지 최적화를 개발 워크플로우에 자연스럽게 통합시켜줍니다.

코드 예제

// vite.config.ts
import { defineConfig } from 'vite'
import { imagetools } from 'vite-imagetools'

export default defineConfig({
  plugins: [
    imagetools({
      // 기본 설정: 모든 이미지에 적용
      defaultDirectives: (url) => {
        // JPEG/PNG는 WebP로도 변환
        if (url.searchParams.has('webp')) {
          return new URLSearchParams({
            format: 'webp;png',
            quality: '80'
          })
        }
        return new URLSearchParams()
      }
    })
  ]
})

// 컴포넌트에서 사용
// src/components/HeroImage.tsx
import heroImage from '../assets/hero.jpg?w=800;1200;1600&format=webp&quality=80'

export function HeroImage() {
  return (
    <img
      srcSet={heroImage.srcset}
      src={heroImage.src}
      alt="Hero"
      loading="lazy"
    />
  )
}

설명

이것이 하는 일: import 문의 쿼리 파라미터로 이미지 처리 옵션을 지정하면, 빌드 시 Sharp 라이브러리가 자동으로 최적화된 이미지를 생성하고 브라우저가 최적의 포맷과 크기를 선택하도록 만듭니다. 첫 번째로, imagetools 플러그인이 import 문에서 특수한 쿼리 파라미터를 감지합니다.

예제에서 ?w=800;1200;1600은 원본 이미지로부터 800px, 1200px, 1600px 세 가지 너비의 이미지를 생성하라는 의미입니다. format=webp는 WebP 포맷으로 변환하고, quality=80은 압축 품질을 80%로 설정합니다.

이 모든 작업이 빌드 타임에 자동으로 수행되므로 런타임 오버헤드가 전혀 없습니다. 그 다음으로, 플러그인이 생성한 heroImage 객체는 src(기본 이미지 URL)와 srcset(여러 크기의 이미지 목록) 속성을 포함합니다.

브라우저는 현재 뷰포트 크기와 디바이스 픽셀 비율을 고려하여 srcset에서 가장 적절한 이미지를 자동으로 선택합니다. 예를 들어 iPhone에서는 800px 이미지를, 4K 모니터에서는 1600px 이미지를 다운로드하는 식이죠.

WebP 포맷은 JPEG 대비 25-35% 더 작은 파일 크기를 제공하면서도 시각적 품질은 거의 동일합니다. 최신 브라우저는 모두 WebP를 지원하며, 지원하지 않는 구형 브라우저를 위해서는 format=webp;png처럼 폴백을 지정할 수 있습니다.

브라우저가 자동으로 지원하는 포맷을 선택합니다. 여러분이 이 도구를 사용하면 이미지 용량이 평균 70% 줄어들고, 모바일 사용자의 데이터 소비가 감소하며, LCP(Largest Contentful Paint) 점수가 크게 개선됩니다.

특히 이커머스나 포트폴리오 사이트처럼 이미지가 많은 프로젝트에서 효과가 극적입니다.

실전 팁

💡 quality는 80-85가 최적입니다. 90 이상은 파일 크기만 커지고 시각적 차이는 거의 없으며, 70 이하는 눈에 띄는 품질 저하가 발생합니다.

💡 loading="lazy" 속성을 함께 사용하여 뷰포트에 보이지 않는 이미지는 나중에 로드하세요. Intersection Observer를 자동으로 사용하여 스크롤할 때 이미지를 로드합니다.

💡 히어로 이미지처럼 초기 화면에 보이는 중요한 이미지는 loading="eager"와 preload를 함께 사용하세요. lazy loading은 LCP를 해칠 수 있습니다.

💡 AVIF 포맷도 고려하세요. format=avif;webp;png로 설정하면 WebP보다 20% 더 작지만, 인코딩 시간이 길어 빌드가 느려질 수 있습니다.

💡 원본 이미지는 가능한 한 큰 크기로 준비하세요. 2000px 이상의 고해상도 원본에서 여러 크기를 생성하는 것이 작은 이미지를 확대하는 것보다 훨씬 좋은 결과를 냅니다.


4. 폰트 최적화 및 서브셋팅

시작하며

여러분의 웹사이트를 열었을 때 텍스트가 1-2초간 보이지 않다가 갑자기 나타나는 현상(FOIT)이나, 기본 폰트로 먼저 보이다가 커스텀 폰트로 바뀌면서 레이아웃이 흔들리는 현상(FOUT)을 경험한 적 있나요? 한글 웹 폰트는 보통 2-5MB나 되어서 로딩 시간이 길고, 그동안 텍스트가 제대로 보이지 않습니다.

이런 문제는 폰트 파일이 너무 크고, 브라우저가 폰트를 다운로드할 때까지 렌더링을 차단하기 때문에 발생합니다. 특히 한글 폰트는 11,172자의 완성형 한글을 모두 포함하므로 용량이 매우 큽니다.

바로 이럴 때 필요한 것이 폰트 서브셋팅과 최적화 전략입니다. 실제로 사용하는 2,350자만 포함하는 서브셋을 만들고, WOFF2 포맷으로 압축하며, 적절한 로딩 전략을 적용하면 폰트 용량을 80% 이상 줄일 수 있습니다.

개요

간단히 말해서, 폰트 서브셋팅은 전체 글자 중에서 실제로 사용하는 글자만 포함하는 작은 폰트 파일을 만드는 기법이고, 폰트 최적화는 최신 포맷(WOFF2)과 적절한 로딩 전략으로 폰트 로딩 성능을 개선하는 것입니다. 실무에서는 폰트가 페이지 성능에 미치는 영향이 매우 큽니다.

예를 들어, 나눔고딕 Regular의 원본 TTF 파일은 약 3MB인데, KS X 1001 표준에 포함된 2,350자만 추출하고 WOFF2로 압축하면 200KB 이하로 줄어듭니다. 이는 15배 이상의 용량 감소죠.

기존에는 전체 폰트 파일을 CDN에서 로드하거나, 디자이너가 제공한 폰트를 그대로 사용했다면, 이제는 프로젝트에서 실제로 사용하는 문자만 포함하는 최적화된 폰트를 자동으로 생성할 수 있습니다. 폰트 최적화의 핵심 특징은 서브셋팅으로 용량 감소, WOFF2 포맷으로 추가 압축, font-display 속성으로 렌더링 전략 제어, preload로 우선순위 상승입니다.

이러한 기법들을 조합하면 폰트로 인한 성능 저하를 최소화할 수 있습니다.

코드 예제

// vite.config.ts
import { defineConfig } from 'vite'
import { VitePluginFonts } from 'vite-plugin-fonts'

export default defineConfig({
  plugins: [
    VitePluginFonts({
      google: {
        // Google Fonts는 자동으로 서브셋팅 지원
        families: [
          {
            name: 'Noto Sans KR',
            styles: 'wght@400;700',
            // 사용하는 한글 범위만 로드
            subset: ['korean']
          }
        ]
      }
    })
  ]
})

// 로컬 폰트 최적화
// src/styles/fonts.css
@font-face {
  font-family: 'CustomFont';
  /* WOFF2를 최우선으로, WOFF를 폴백으로 */
  src: url('/fonts/custom.woff2') format('woff2'),
       url('/fonts/custom.woff') format('woff');
  font-weight: 400;
  font-style: normal;
  /* swap: 기본 폰트로 먼저 보여주다가 폰트 로드 완료 시 교체 */
  font-display: swap;
  /* 사용하는 유니코드 범위만 지정 (한글 KS X 1001) */
  unicode-range: U+AC00-D7A3, U+1100-11FF;
}

설명

이것이 하는 일: 폰트 파일에서 불필요한 글리프를 제거하여 용량을 줄이고, 최신 압축 포맷을 사용하며, 브라우저에게 폰트 로딩 중에도 텍스트를 보여주도록 지시합니다. 첫 번째로, VitePluginFonts가 Google Fonts API를 통해 자동으로 최적화된 폰트를 로드합니다.

subset: ['korean'] 옵션은 Google이 미리 준비한 한글 서브셋(KS X 1001 기준 2,350자)만 다운로드하도록 합니다. styles: 'wght@400;700'은 Regular(400)와 Bold(700) 두 가지 굵기만 로드하여, 사용하지 않는 Light(300)나 Black(900) 같은 굵기를 제외합니다.

이렇게 하면 필요한 스타일만 선택적으로 로드할 수 있습니다. 그 다음으로, 로컬 폰트를 사용하는 경우 @font-face에서 여러 최적화 기법을 적용합니다.

src 속성에서 WOFF2를 먼저 선언하면 브라우저는 지원하는 첫 번째 포맷을 사용합니다. WOFF2는 WOFF보다 30% 더 작고 모든 최신 브라우저가 지원하므로, WOFF는 IE11 같은 구형 브라우저를 위한 폴백으로만 유지합니다.

font-display: swap은 폰트 로딩 전략을 제어하는 핵심 속성입니다. 'swap'은 "폰트가 로드되기 전까지 시스템 폰트로 텍스트를 보여주고, 폰트가 준비되면 교체하라"는 의미입니다.

다른 옵션으로 'block'(3초까지 텍스트 숨김), 'fallback'(100ms 후 시스템 폰트 표시, 3초 후엔 교체 안 함), 'optional'(네트워크 상태에 따라 폰트 사용 여부 결정)이 있습니다. 대부분의 경우 'swap'이 최선입니다.

unicode-range는 이 폰트가 어떤 문자에 사용될지 지정합니다. U+AC00-D7A3은 한글 완성형 범위이고, U+1100-11FF는 한글 자모입니다.

브라우저는 페이지에 이 범위의 문자가 있을 때만 폰트를 다운로드하므로, 영어만 있는 페이지에서는 한글 폰트를 다운로드하지 않아 낭비를 방지합니다. 여러분이 이 기법들을 사용하면 CLS(Cumulative Layout Shift) 점수가 개선되고, 폰트 다운로드 시간이 5배 이상 빨라지며, 사용자가 텍스트를 즉시 읽을 수 있어 체감 성능이 크게 향상됩니다.

특히 모바일 환경에서 효과가 두드러집니다.

실전 팁

💡 서브셋 도구로는 pyftsubset(Python)이나 glyphhanger(Node.js)를 사용하세요. 프로젝트의 HTML/JSX를 분석하여 실제로 사용하는 글자만 추출할 수 있습니다.

💡 자주 쓰는 한글 2,350자(KS X 1001)만 포함해도 일반적인 웹사이트는 99% 이상 커버됩니다. 희귀한 한자나 특수 문자가 필요하면 별도 폰트로 분리하세요.

💡 가변 폰트(Variable Fonts)를 고려하세요. 하나의 파일에 여러 굵기를 포함하되 용량은 크게 늘지 않습니다. 'Noto Sans KR'의 가변 폰트는 400-900 전체 굵기가 포함되어도 일반 폰트 2-3개보다 작습니다.

💡 폰트를 CDN에 호스팅할 때는 Cache-Control을 1년 이상으로 설정하고, 폰트 URL에 버전을 포함하세요. 폰트는 거의 변경되지 않으므로 적극적인 캐싱이 유리합니다.

💡 preload를 사용할 때는 정말 중요한 폰트(보통 Regular 하나)만 preload 하세요. Bold, Italic 등은 필요할 때 로드되도록 두는 것이 전체 성능에 더 좋습니다.


5. Lazy Loading 패턴 적용

시작하며

여러분의 웹 애플리케이션에서 사용자가 관리자 페이지는 거의 방문하지 않는데도, 관리자 페이지의 JavaScript 코드가 메인 번들에 포함되어 모든 사용자가 다운로드하고 있다면 어떤가요? 10명 중 1명만 사용하는 기능 때문에 나머지 9명이 불필요한 코드를 다운로드하고 있는 상황입니다.

이런 문제는 모든 코드를 하나의 번들로 빌드하여 초기 로딩 시 한꺼번에 다운로드하기 때문에 발생합니다. 특히 SPA(Single Page Application)에서는 수십 개의 페이지와 컴포넌트가 모두 포함되어 번들 크기가 수 MB에 달할 수 있습니다.

바로 이럴 때 필요한 것이 Lazy Loading(지연 로딩) 패턴입니다. 사용자가 실제로 필요로 할 때만 코드를 다운로드하여 초기 번들 크기를 크게 줄이고 로딩 속도를 개선합니다.

개요

간단히 말해서, Lazy Loading은 앱 실행 시 모든 코드를 로드하는 대신, 사용자가 특정 페이지나 기능을 사용할 때 해당 코드만 동적으로 다운로드하는 기법입니다. 실무에서는 코드 분할(Code Splitting)과 라우트 기반 Lazy Loading이 핵심입니다.

예를 들어, 대시보드, 설정, 분석 페이지가 있다면 각 페이지를 별도 청크로 분리하고, 사용자가 해당 페이지로 이동할 때만 다운로드합니다. 이렇게 하면 초기 번들이 수백 KB 줄어들어 Time to Interactive가 크게 개선됩니다.

기존에는 import 문으로 모든 모듈을 정적으로 임포트했다면, 이제는 동적 import()와 React.lazy()를 사용하여 런타임에 필요한 모듈만 로드할 수 있습니다. Lazy Loading의 핵심 특징은 초기 번들 크기 감소, 필요한 시점에만 네트워크 요청 발생, 자동 코드 분할과 청크 생성, Suspense와의 통합으로 로딩 상태 관리입니다.

이러한 특징들이 사용자 경험을 해치지 않으면서도 성능을 최적화합니다.

코드 예제

// src/App.tsx
import { lazy, Suspense } from 'react'
import { BrowserRouter, Routes, Route } from 'react-router-dom'

// 메인 페이지는 즉시 로드 (초기 렌더링에 필수)
import HomePage from './pages/HomePage'

// 나머지 페이지는 lazy loading으로 분리
const Dashboard = lazy(() => import('./pages/Dashboard'))
const Settings = lazy(() => import('./pages/Settings'))
// 특정 조건에서만 사용되는 무거운 컴포넌트
const AdminPanel = lazy(() => import('./pages/AdminPanel'))

// 로딩 중 표시할 폴백 컴포넌트
function LoadingSpinner() {
  return <div className="spinner">로딩 중...</div>
}

export default function App() {
  return (
    <BrowserRouter>
      {/* Suspense로 lazy 컴포넌트를 감싸서 로딩 상태 처리 */}
      <Suspense fallback={<LoadingSpinner />}>
        <Routes>
          <Route path="/" element={<HomePage />} />
          <Route path="/dashboard" element={<Dashboard />} />
          <Route path="/settings" element={<Settings />} />
          <Route path="/admin" element={<AdminPanel />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  )
}

설명

이것이 하는 일: React.lazy()가 동적 import()를 사용하여 컴포넌트를 별도 청크로 분리하고, 사용자가 해당 라우트로 이동할 때 Suspense가 로딩 상태를 관리하며 청크를 다운로드합니다. 첫 번째로, lazy() 함수가 일반 import 문을 동적 import()로 감싸서 별도 청크를 생성합니다.

빌드 시 Vite는 Dashboard, Settings, AdminPanel을 각각 독립적인 JavaScript 파일(dashboard.abc123.js 같은 형태)로 분리합니다. 이렇게 분리된 파일들은 메인 번들에 포함되지 않으므로 초기 다운로드 크기가 크게 줄어듭니다.

그 다음으로, 사용자가 /dashboard로 이동하면 React Router가 Dashboard 컴포넌트를 렌더링하려고 시도합니다. 이때 Dashboard 청크가 아직 다운로드되지 않았으므로 Promise가 반환되고, Suspense 컴포넌트가 이 Promise를 감지하여 fallback prop에 지정된 LoadingSpinner를 대신 표시합니다.

네트워크 요청이 완료되고 청크가 파싱되면 Suspense가 LoadingSpinner를 제거하고 실제 Dashboard 컴포넌트를 렌더링합니다. Suspense의 fallback은 사용자 경험에 매우 중요합니다.

적절한 로딩 인디케이터가 없으면 사용자는 클릭이 작동하지 않는다고 느낄 수 있습니다. 스켈레톤 UI나 스피너를 제공하여 "로딩 중"임을 명확히 알려야 합니다.

또한 Suspense를 라우트 레벨이 아닌 컴포넌트 레벨에서 사용하면 더 세밀한 제어가 가능합니다. 중요한 점은 HomePage는 lazy loading하지 않는다는 것입니다.

초기 렌더링에 반드시 필요한 컴포넌트를 lazy loading하면 오히려 추가 네트워크 요청으로 인해 성능이 나빠집니다. 초기 화면은 즉시 로드하고, 사용자가 탐색할 가능성이 있는 다른 페이지들만 lazy loading 해야 합니다.

여러분이 이 패턴을 사용하면 초기 JavaScript 번들이 50-70% 줄어들고, First Load JS 메트릭이 개선되며, 사용자는 더 빠르게 상호작용할 수 있는 페이지를 경험합니다. 특히 관리자 페이지처럼 일부 사용자만 접근하는 기능을 분리하면 효과가 극대화됩니다.

실전 팁

💡 라우트 레벨에서 lazy loading하는 것이 가장 효과적입니다. 개별 컴포넌트보다는 페이지 단위로 분리하여 네트워크 요청 횟수를 줄이세요.

💡 무거운 서드파티 라이브러리(차트, 에디터 등)도 lazy loading하세요. const Chart = lazy(() => import('react-chartjs-2'))처럼 라이브러리 자체를 지연 로딩할 수 있습니다.

💡 Prefetch를 함께 사용하면 더욱 좋습니다. 사용자가 메인 페이지에 있을 때 대시보드를 미리 다운로드하면 클릭 시 즉시 렌더링됩니다. React Router의 <Link prefetch="intent">를 활용하세요.

💡 에러 바운더리를 추가하여 청크 로딩 실패를 처리하세요. 네트워크 오류나 배포 중 발생하는 청크 404 에러를 사용자 친화적으로 처리해야 합니다.

💡 Bundle Analyzer를 사용하여 어떤 청크가 얼마나 큰지 시각화하세요. vite-plugin-bundle-analyzer로 불필요하게 큰 청크를 찾아 추가로 분할할 수 있습니다.


6. Lighthouse 점수 개선 실습

시작하며

여러분이 위의 모든 최적화 기법을 적용했다면, 이제 실제로 얼마나 개선되었는지 측정하고 추가로 개선할 부분을 찾아야 합니다. 하지만 "성능이 좋아졌다"는 주관적인 느낌만으로는 부족하고, 객관적인 지표가 필요합니다.

이런 상황에서 Google Lighthouse는 웹 성능을 종합적으로 평가하고 구체적인 개선 방안을 제시하는 최고의 도구입니다. Lighthouse는 Performance, Accessibility, Best Practices, SEO 네 가지 영역에서 0-100점 점수를 매기고, 어떤 부분이 느린지 정확히 짚어줍니다.

바로 이럴 때 필요한 것이 Lighthouse를 활용한 체계적인 성능 분석과 개선 프로세스입니다. 이 실습에서는 실제 프로젝트에 Lighthouse를 실행하고, 점수를 분석하며, 단계적으로 개선하는 방법을 배웁니다.

개요

간단히 말해서, Lighthouse는 웹 페이지의 성능, 접근성, SEO를 자동으로 측정하고 개선 제안을 제공하는 Chrome 내장 도구입니다. 실무에서는 Lighthouse 점수가 사용자 경험과 직접 연결됩니다.

예를 들어, Performance 점수가 50점이라면 FCP(First Contentful Paint)가 3초 이상 걸리고, LCP(Largest Contentful Paint)가 4초 이상이라는 의미입니다. 이는 사용자의 53%가 페이지를 떠난다는 Google의 연구 결과와 일치합니다.

반면 90점 이상이면 대부분의 지표가 우수하여 사용자가 즉각적인 반응을 느낍니다. 기존에는 각 메트릭을 개별적으로 확인하고 무엇을 개선해야 할지 막연했다면, Lighthouse는 중요도 순서대로 정렬된 개선 항목과 예상되는 성능 향상을 함께 보여줍니다.

Lighthouse 점수의 핵심 지표는 FCP(첫 콘텐츠 표시 시간), LCP(최대 콘텐츠 표시 시간), TBT(총 차단 시간), CLS(누적 레이아웃 이동)입니다. 이러한 지표들은 실제 사용자가 느끼는 로딩 속도와 안정성을 수치화합니다.

코드 예제

// package.json에 Lighthouse CI 추가
{
  "scripts": {
    "build": "vite build",
    "preview": "vite preview",
    "lighthouse": "lighthouse http://localhost:4173 --view"
  },
  "devDependencies": {
    "@lhci/cli": "^0.12.0"
  }
}

// lighthouserc.json - CI에서 자동으로 성능 체크
{
  "ci": {
    "collect": {
      "startServerCommand": "npm run preview",
      "url": ["http://localhost:4173"],
      "numberOfRuns": 3
    },
    "assert": {
      "assertions": {
        // Performance 점수 90 이상 강제
        "categories:performance": ["error", {"minScore": 0.9}],
        // FCP 1.8초 이내
        "first-contentful-paint": ["error", {"maxNumericValue": 1800}],
        // LCP 2.5초 이내
        "largest-contentful-paint": ["error", {"maxNumericValue": 2500}],
        // CLS 0.1 이하
        "cumulative-layout-shift": ["error", {"maxNumericValue": 0.1}]
      }
    }
  }
}

// 성능 모니터링 코드
// src/utils/performance.ts
export function measureWebVitals() {
  // Web Vitals 라이브러리로 실제 사용자 성능 측정
  import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
    getCLS(console.log)  // Cumulative Layout Shift
    getFID(console.log)  // First Input Delay
    getFCP(console.log)  // First Contentful Paint
    getLCP(console.log)  // Largest Contentful Paint
    getTTFB(console.log) // Time to First Byte
  })
}

설명

이것이 하는 일: Chrome DevTools의 Lighthouse 탭이나 CLI로 웹 페이지를 분석하여 핵심 성능 지표를 측정하고, 각 지표별로 구체적인 개선 방안과 예상 성능 향상을 제시합니다. 첫 번째로, Lighthouse를 실행하는 방법은 여러 가지입니다.

Chrome DevTools(F12)의 Lighthouse 탭에서 "Generate report"를 클릭하는 것이 가장 간단하고, CLI로 npx lighthouse https://example.com을 실행하면 HTML 리포트가 생성됩니다. 예제의 package.json 스크립트는 로컬 개발 서버에 대해 Lighthouse를 실행하는 편리한 방법입니다.

npm run build && npm run preview && npm run lighthouse로 빌드 결과물을 검증할 수 있습니다. 그 다음으로, Lighthouse CI(lighthouserc.json)는 CI/CD 파이프라인에 성능 테스트를 통합합니다.

numberOfRuns: 3은 세 번 측정하여 중간값을 사용하므로 네트워크 변동의 영향을 줄입니다. assertions 섹션에서 최소 기준을 설정하여, Performance 점수가 90점 미만이거나 LCP가 2.5초를 초과하면 빌드를 실패시킵니다.

이렇게 하면 성능 저하를 배포 전에 자동으로 감지할 수 있습니다. Web Vitals 라이브러리는 실제 사용자 환경(Real User Monitoring)에서 성능을 측정합니다.

Lighthouse는 실험실 환경(Lab)에서 측정하므로 일관성은 있지만 실제 사용자의 네트워크나 디바이스를 반영하지 못합니다. 반면 Web Vitals는 프로덕션에서 실제 사용자의 성능 데이터를 수집하여 Google Analytics 같은 도구로 전송할 수 있습니다.

두 가지를 함께 사용하는 것이 이상적입니다. Lighthouse 리포트의 "Opportunities" 섹션이 가장 중요합니다.

여기에는 "Eliminate render-blocking resources(렌더링 차단 리소스 제거)", "Properly size images(이미지 크기 최적화)", "Reduce unused JavaScript(사용하지 않는 JavaScript 제거)" 같은 구체적인 항목이 예상 성능 개선 시간과 함께 표시됩니다. 예를 들어 "이 항목을 개선하면 LCP가 1.2초 빨라집니다"라고 알려줍니다.

여러분이 Lighthouse를 정기적으로 실행하고 점수를 추적하면 성능 회귀(Performance Regression)를 조기에 발견하고, 어떤 최적화가 효과적인지 데이터 기반으로 판단하며, 팀 내에서 성능 목표를 공유할 수 있습니다. 특히 CI/CD에 통합하면 성능이 자동으로 관리됩니다.

실전 팁

💡 모바일과 데스크탑을 별도로 측정하세요. Lighthouse의 "Mobile" 옵션은 느린 4G 네트워크와 중급 스펙 모바일을 시뮬레이션하여 더 엄격한 조건에서 테스트합니다.

💡 "View Treemap"으로 번들 구성을 시각화하세요. 어떤 라이브러리가 번들의 몇 %를 차지하는지 보면 불필요한 의존성을 찾기 쉽습니다.

💡 Performance 점수만 보지 말고 각 메트릭의 절대값을 확인하세요. 90점이어도 LCP가 2.4초라면 여전히 개선 여지가 있습니다. 목표는 FCP < 1.8s, LCP < 2.5s, TBT < 200ms, CLS < 0.1입니다.

💡 Throttling 설정을 조정하여 실제 사용자 환경과 유사하게 테스트하세요. 기본값은 4G지만, 사용자층이 3G가 많다면 더 느린 환경에서 테스트하는 것이 현실적입니다.

💡 PageSpeed Insights(https://pagespeed.web.dev/)로 실제 사용자 데이터(Field Data)를 확인하세요. 지난 28일간 실제 사용자들이 경험한 성능 분포를 보여주므로 Lab 데이터와 비교할 수 있습니다.


#Vite#Performance#Caching#ImageOptimization#LazyLoading#Vite,빌드도구,프론트엔드

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.

함께 보면 좋은 카드 뉴스

OpenAPI/Swagger로 API 문서화 완벽 가이드

API 문서화의 표준인 OpenAPI와 Swagger를 활용하여 프론트엔드 개발자와 원활하게 협업하는 방법을 배웁니다. 스펙 작성부터 자동 문서 생성, 버전 관리까지 실무에서 바로 적용할 수 있는 내용을 다룹니다.

API 캐싱과 성능 최적화 완벽 가이드

웹 서비스의 응답 속도를 획기적으로 개선하는 캐싱 전략과 성능 최적화 기법을 다룹니다. HTTP 캐싱부터 Redis, 데이터베이스 최적화, CDN까지 실무에서 바로 적용할 수 있는 핵심 기술을 초급자 눈높이에서 설명합니다.

예외 처리와 로깅 전략 완벽 가이드

초급 개발자를 위한 예외 처리와 로깅 전략 가이드입니다. Try-Catch부터 Sentry까지, 실무에서 바로 적용할 수 있는 에러 관리 기법을 단계별로 설명합니다.

일관된 에러 응답 설계 완벽 가이드

API 개발에서 가장 중요하면서도 간과하기 쉬운 에러 응답 설계를 다룹니다. 클라이언트와 서버가 명확하게 소통할 수 있는 표준화된 에러 응답 체계를 구축하는 방법을 배웁니다.

Vite 라이브러리 모드로 패키지 빌드하기 완벽 가이드

Vite의 라이브러리 모드를 활용하여 재사용 가능한 패키지를 빌드하는 방법을 배웁니다. lib 모드 설정부터 다양한 모듈 포맷 생성, TypeScript 선언 파일까지 실무에서 바로 적용할 수 있는 내용을 다룹니다.