web-performance

포케코리아 웹 성능 최적화 3편 — srcset과 DPR로 이미지 대역폭 44% 절감하기

PageSpeed Insights의 "Properly size images" 경고를 해결하기 위해 srcset과 DPR 기반 반응형 이미지를 구현하고, fetchPriority 전략을 최적화하여 대역폭을 44% 절감한 과정을 정리합니다.

image-optimization performance responsive nextjs

들어가며

이 글은 포케코리아 웹 성능 최적화 시리즈의 세 번째 글입니다.

1편 CSS 리팩토링 → 2편 폰트 서브셋팅 → 3편 이미지 최적화 → 4편 Lighthouse 결과 비교

포케코리아의 포켓몬 카드 리스트는 이미지 중심 UI다. 각 카드에 포켓몬 이미지가 들어가고, 한 페이지에 수십 개가 노출된다. PageSpeed Insights에서 다음 경고가 반복적으로 나타나고 있었다.

“Properly size images — 표시된 크기 대비 다운로드된 이미지가 큼”

원인은 명확했다. 모든 디바이스에 단일 해상도 이미지를 제공하고 있었기 때문이다.

DPR과 srcset

DPR이란

DPR(Device Pixel Ratio)은 CSS 1픽셀을 렌더링하는 데 사용되는 물리적 픽셀의 수다.

디바이스DPR
일반 데스크톱 모니터1x
MacBook, iMac2x
iPhone 13~153x
Samsung Galaxy S 시리즈약 3x

DPR=1 디바이스에서 160px 이미지를 보여줄 때와 DPR=2 디바이스에서 같은 이미지를 보여줄 때, 필요한 실제 픽셀 수가 다르다. DPR=1이면 160px이면 충분하지만, DPR=2에서는 320px가 필요하다.

참고: MDN — Window.devicePixelRatio

srcset의 x 디스크립터

srcset 속성의 x 디스크립터를 사용하면 DPR에 따라 브라우저가 자동으로 적절한 이미지를 선택한다.

<img
  src="pokemon.webp?w=160&h=160&q=85"
  srcset="pokemon.webp?w=160&h=160&q=85 1x, pokemon.webp?w=320&h=320&q=85 2x"
  sizes="10rem"
  alt="이상해씨"
/>

DPR 3x까지 커버하면 파일 크기가 과도해지므로, 2x까지만 제공하는 것이 비용 대비 효과적이다. DPR=3 디바이스에서 2x 이미지를 보여줘도 시각적 차이는 거의 느끼지 못한다.

참고: MDN — Responsive images, web.dev — Serve responsive images

rem 기반 정확한 크기 계산

문제: 잘못된 크기 계산

기존 코드에서 모바일 카드 이미지 크기를 140px로 설정하고 있었는데, 이 계산이 틀려 있었다.

포케코리아는 모바일에서 루트 폰트 크기를 12px로 설정한다. 카드 이미지의 CSS 크기는 10rem이다.

데스크톱: 10rem × 16px/rem = 160px ✅
모바일:   10rem × 12px/rem = 120px (기존 코드: 140px ❌)

모바일에서 120px로 표시되는 이미지에 180px짜리를 다운로드하고 있었으니, 50% 이상의 데이터가 낭비되고 있었다.

수정된 크기 매핑

환경CSS 크기1rem실제 크기1x 이미지2x 이미지
데스크톱 (10rem)10rem16px160px160×160320×320
모바일 (10rem)10rem12px120px120×120240×240
모바일 (8rem)8rem12px96px96×96192×192
데스크톱 (8rem)8rem16px128px128×128256×256

srcset 자동 생성 구현

ImageComponent에 densitiesquality props를 추가하고, srcset을 자동 생성하는 로직을 구현했다.

interface ImageComponentProps extends ImgHTMLAttributes<HTMLImageElement> {
  width: string;
  height: string;
  imageSize?: { width: number; height: number };
  densities?: number[];
  quality?: number;
}
const generateSrcSet = () => {
  if (!src || !densities || densities.length === 0) return undefined;

  const baseUrl = src.split('?')[0];
  const baseSize = imageSize?.width || 160;

  return densities
    .map((density) => {
      const size = Math.round(baseSize * density);
      return `${baseUrl}?w=${size}&h=${size}&q=${quality} ${density}x`;
    })
    .join(', ');
};

포케코리아는 이미지 CDN을 사용하고 있어, URL 파라미터(?w=160&h=160&q=85)로 실시간 리사이징과 품질 조절이 가능하다. 이 점을 활용하여 클라이언트에서 URL만 조합하면 CDN이 알아서 최적화된 이미지를 반환한다.

카드 컴포넌트 적용 예시

// Before
<ImageComponent
  src={`${imageMode}/${pokemonData.number}.webp?w=240&h=240`}
  sizes="10rem"
  fetchPriority="high"
/>

// After
<ImageComponent
  imageSize={{ width: 160, height: 160 }}
  densities={[1, 1.5]}
  src={`${imageMode}/${pokemonData.number}.webp`}
  sizes="10rem"
  fetchPriority={isHighPriority ? 'high' : undefined}
  loading={isHighPriority ? undefined : 'lazy'}
/>

생성되는 HTML은 다음과 같다.

<img
  src="https://image-cdn.poke-korea.com/1.webp?w=160&h=160&q=85"
  srcset="
    https://image-cdn.poke-korea.com/1.webp?w=160&h=160&q=85 1x,
    https://image-cdn.poke-korea.com/1.webp?w=240&h=240&q=85 1.5x
  "
  sizes="10rem"
  loading="lazy"
  alt="이상해씨"
/>

fetchPriority 최적화

문제: 모든 이미지가 high

기존 코드에서는 데스크톱 리스트의 첫 20개 카드 이미지에 fetchPriority="high"를 적용하고 있었다. 이는 브라우저에게 20개 이미지를 모두 최우선으로 다운로드하라는 요청으로, 오히려 네트워크 경쟁을 유발한다.

fetchPriority는 브라우저에게 상대적 우선순위 힌트를 주는 속성이다.

의미
high다른 동일 유형 리소스보다 높은 우선순위
low다른 동일 유형 리소스보다 낮은 우선순위
auto브라우저가 자동 판단 (기본값)

LCP 이미지에만 high를 적용하는 것이 핵심이다. 너무 많은 이미지에 적용하면 실질적으로 아무 이미지도 우선순위를 갖지 못하게 된다.

적용 전략

첫 화면에 보이는 이미지 수만큼만 high를 적용하고, 나머지는 lazy 로딩으로 전환했다.

환경fetchPriority=“high”loading=“lazy”
데스크톱첫 15개16번째부터
모바일첫 6개 (2열 × 3행)7번째부터
<ImageComponent
  fetchPriority={isHighPriority ? 'high' : undefined}
  loading={isHighPriority ? undefined : 'lazy'}
/>

loading="lazy"fetchPriority="high"는 상반되는 속성이므로 동시에 사용하지 않는다. isHighPrioritytrue이면 둘 다 기본값(undefined)으로 두고, false이면 lazy만 적용한다.

참고: Chrome Developers — Fetch Priority

이미지 품질 기본값

기존에는 quality=75를 사용하고 있었는데, 포켓몬 일러스트 특성상 약간 흐릿하게 보이는 경우가 있었다. WebP 포맷에서 quality 85는 시각적 손실이 거의 없으면서 용량 효율이 우수한 구간이다.

Quality용도특징
60~70매우 작은 썸네일눈에 띄는 손실
75~85일반 카드 이미지 (권장)손실 미미
85~95주요 이미지손실 거의 없음
95~100원본 수준불필요한 용량

기본값을 85로 변경하고, 개별 컴포넌트에서 quality prop을 제거하여 일관된 품질을 적용했다.

참고: Google Developers — WebP

결과

이미지 크기 비교

데스크톱 (10rem = 160px)

DPRBeforeAfter개선 효과
DPR=1240×240 (~18KB)160×160 (~10KB)44% 대역폭 감소
DPR=2240×240 (~18KB)320×320 (~28KB)선명도 33% 향상

모바일 (10rem = 120px)

DPRBeforeAfter개선 효과
DPR=1180×180 (~14KB)120×120 (~8KB)43% 대역폭 감소
DPR=2180×180 (~14KB)240×240 (~20KB)선명도 33% 향상

DPR=1 환경에서는 불필요하게 큰 이미지를 다운로드하지 않게 되었고, DPR=2 환경에서는 오히려 더 선명한 이미지를 제공하게 되었다.

fetchPriority 최적화 효과

환경BeforeAfter
데스크톱20개 high15개 high (25% 감소)
모바일6개 high6개 high (유지)

초기 네트워크 경쟁이 줄어들어 LCP 이미지의 실질적인 로딩 속도가 개선되었다. 4편에서 다룰 Lighthouse 결과에서 이 효과를 확인할 수 있다.

PageSpeed Insights

참고: Chrome Developers — Properly size images

정리하며

이미지 최적화에서 가장 중요한 것은 정확한 크기 계산이다. rem 단위가 디바이스별로 다른 픽셀 값을 갖는다는 점을 간과하면, 의도와 다른 크기의 이미지를 제공하게 된다.

이번 작업에서 적용한 전략을 정리하면 다음과 같다.

전략내용
srcset + x 디스크립터DPR 1x/2x 대응 이미지 자동 선택
정확한 크기 계산rem → px 변환으로 실제 렌더링 크기 파악
fetchPriority 제한첫 화면 이미지에만 high, 나머지는 lazy
품질 기본값 통일quality=85로 일관된 품질
CDN 파라미터 활용URL만 조합하면 실시간 리사이징

다음 편에서는 1~3편의 최적화가 실제 Lighthouse 점수에 어떤 영향을 미쳤는지 측정 결과를 비교한다.


포케코리아 웹 성능 최적화 시리즈

참고 자료