본문 바로가기

React

React에서 이미지 최적화를 위한 Responsive Image 구현하기

이미지는 LCP와 데이터 사용량에 직접적인 영향을 주기 때문에, 반응형 이미지(Responsive Images) 전략은 성능 최적화의 핵심입니다. React에서 srcset, sizes, picture를 올바르게 활용하고, 지연 로딩과 최신 포맷(WebP/AVIF)을 적용하는 실무 코드를 소개합니다.

1. 핵심 개념 요약

- srcset: 다양한 너비의 이미지를 제공하고 브라우저가 뷰포트/픽셀 비율에 따라 최적의 리소스를 선택하도록 합니다.

- sizes: 현재 레이아웃에서 이미지가 차지할 예상 너비를 알려 브라우저의 선택을 정확하게 만듭니다.

- picture/source: 포맷/아트 디렉션(화면 크기별 다른 크롭)을 제어합니다.

- width/height 속성: 고정 값 제공으로 CLS를 방지합니다. style로는 width:100%, height:auto를 주어 반응형으로 표시합니다.

- 포맷 전략: AVIF > WebP > JPEG/PNG 순으로 폴백합니다.

2. 브라우저 기본 문법 빠르게 훑기

/* 기본 HTML 개념 (React 아닌 순수 예시) */
<picture>
  <source type='image/avif' srcset='hero-640.avif 640w, hero-1280.avif 1280w' sizes='(max-width: 768px) 100vw, 768px' />
  <source type='image/webp' srcset='hero-640.webp 640w, hero-1280.webp 1280w' sizes='(max-width: 768px) 100vw, 768px' />
  <img src='hero-1280.jpg' srcset='hero-640.jpg 640w, hero-1280.jpg 1280w' sizes='(max-width: 768px) 100vw, 768px' alt='히어로 이미지' width='1280' height='720' loading='lazy' decoding='async' />
</picture>

React에서도 위 개념은 동일하지만, JSX 속성 이름은 srcSet처럼 카멜 케이스를 사용합니다.

3. 실무용 ResponsiveImage 컴포넌트

이미지 CDN(Cloudinary/Imgix/Akamai/Vercel 등)이 있는 경우 쿼리 파라미터로 너비/포맷 변환을 요청하는 방식이 관리가 쉽습니다.

import React from 'react';

// CDN에 w(너비), fm(포맷) 파라미터를 붙여 URL을 생성합니다.
const buildUrl = (base, { w, fm }) => {
  if (!w && !fm) return base;
  const hasQuery = base.includes('?');
  const qp = [];
  if (w) qp.push(`w=${w}`);
  if (fm) qp.push(`fm=${fm}`);
  return `${base}${hasQuery ? '&' : '?'}${qp.join('&')}`;
};

export function ResponsiveImage({
  srcBase,              // 원본 이미지 URL (CDN 권장)
  alt,
  widths = [320, 480, 768, 1024, 1440],
  sizes = '(max-width: 768px) 100vw, 768px',
  formats = ['avif', 'webp', 'jpeg'],
  aspectRatio = 16 / 9, // 가로/세로 비 (예: 16/9)
  priority = false,     // true면 LCP 히어로 용도
  className,
  style
}) {
  const largestW = Math.max(...widths);
  const defaultW = widths[Math.floor(widths.length / 2)] || largestW;
  const toSrcSet = (fm) => widths.map((w) => `${buildUrl(srcBase, { w, fm })} ${w}w`).join(', ');

  const hasAvif = formats.includes('avif');
  const hasWebp = formats.includes('webp');
  const fallbackFm = formats.find((f) => ['jpeg', 'jpg', 'png'].includes(f)) || undefined;

  return (
    <picture>
      {hasAvif && (
        <source type='image/avif' srcSet={toSrcSet('avif')} sizes={sizes} />
      )}
      {hasWebp && (
        <source type='image/webp' srcSet={toSrcSet('webp')} sizes={sizes} />
      )}
      <img
        src={buildUrl(srcBase, { w: defaultW, fm: fallbackFm })}
        srcSet={toSrcSet(fallbackFm)}
        sizes={sizes}
        alt={alt}
        loading={priority ? 'eager' : 'lazy'}
        decoding='async'
        fetchPriority={priority ? 'high' : 'auto'}
        width={largestW}
        height={Math.round(largestW / aspectRatio)}
        className={className}
        style={{ aspectRatio, width: '100%', height: 'auto', ...style }}
      />
    </picture>
  );
}

/* 사용 예시 */
export default function Hero() {
  return (
    <section>
      <ResponsiveImage
        srcBase='https://cdn.example.com/photos/landing/hero'
        alt='성능 최적화된 히어로 배너'
        widths={[480, 768, 1024, 1440, 1920]}
        sizes='(max-width: 768px) 100vw, 1200px'
        formats={['avif', 'webp', 'jpeg']}
        aspectRatio={16 / 9}
        priority
        className='hero-image'
      />
    </section>
  );
}

포인트:

- width/height 속성으로 CLS를 방지합니다. style에 aspect-ratio를 더해 레이아웃 안정성을 높입니다.

- 히어로 이미지는 priority=true로 fetchPriority='high', loading='eager'를 부여합니다.

- 나머지 이미지는 기본 lazy 로딩으로 네트워크 효율을 높입니다.

4. CDN이 없거나 정적 파일만 있는 경우

여러 크기의 이미지를 직접 내보내고 import하여 srcset을 구성합니다.

// 예: Vite/CRA에서 public 또는 import 자산 사용
import img640 from '/images/card-640.jpg';
import img1280 from '/images/card-1280.jpg';
import webp640 from '/images/card-640.webp';
import webp1280 from '/images/card-1280.webp';

export function CardImage() {
  return (
    <picture>
      <source
        type='image/webp'
        srcSet={`${webp640} 640w, ${webp1280} 1280w`}
        sizes='(max-width: 600px) 100vw, 300px'
      />
      <img
        src={img1280}
        srcSet={`${img640} 640w, ${img1280} 1280w`}
        sizes='(max-width: 600px) 100vw, 300px'
        alt='카드 썸네일'
        width={1280}
        height={720}
        loading='lazy'
        decoding='async'
        style={{ width: '100%', height: 'auto', aspectRatio: 16 / 9 }}
      />
    </picture>
  );
}

5. sizes 정확히 쓰기 (가장 흔한 실수)

브라우저는 sizes를 기준으로 srcset에서 어떤 너비를 고를지 결정합니다. 레이아웃에 맞춰 현실적인 값을 넣어야 과다운로드를 막습니다.

- 풀폭 이미지: sizes='100vw'

- 콘텐츠 폭이 720px로 제한: sizes='(max-width: 768px) 100vw, 720px'

- 3열 카드 그리드(데스크톱): sizes='(max-width: 768px) 100vw, 33vw'

디자인 시스템에 따라 브레이크포인트별 실제 렌더 폭을 정확히 반영하세요.

6. 히어로(LCP) 최적화: preload + fetchpriority

가장 큰 이미지(보통 히어로)는 사전 로드로 LCP를 더 줄일 수 있습니다. React 앱에서는 index.html 또는 Helmet을 활용합니다.

/* index.html 예시 (CDN 사용 시) */
<link
  rel='preload'
  as='image'
  href='https://cdn.example.com/photos/landing/hero?w=1200&fm=avif'
  imagesrcset='https://cdn.example.com/photos/landing/hero?w=768&fm=avif 768w, https://cdn.example.com/photos/landing/hero?w=1200&fm=avif 1200w, https://cdn.example.com/photos/landing/hero?w=1920&fm=avif 1920w'
  imagesizes='(max-width: 768px) 100vw, 1200px'
  fetchpriority='high'
  type='image/avif'
/>

주의: preload는 실제로 사용할 이미지와 동일한 srcset/sizes 조합을 제공해야 중복 네트워크를 피할 수 있습니다.

7. 아트 디렉션: 화면별 다른 크롭

모바일과 데스크톱에서 전혀 다른 크롭/비율이 필요하면 media 속성을 가진 source를 사용합니다.

export function ArtDirectedHero() {
  return (
    <picture>
      <source
        media='(max-width: 768px)'
        type='image/webp'
        srcSet='https://cdn.example.com/hero-mobile?w=768&fm=webp 768w, https://cdn.example.com/hero-mobile?w=1080&fm=webp 1080w'
        sizes='100vw'
      />
      <source
        media='(min-width: 769px)'
        type='image/webp'
        srcSet='https://cdn.example.com/hero-desktop?w=1200&fm=webp 1200w, https://cdn.example.com/hero-desktop?w=1920&fm=webp 1920w'
        sizes='1200px'
      />
      <img
        src='https://cdn.example.com/hero-desktop?w=1200'
        alt='화면 크기에 따라 다른 크롭'
        width={1200}
        height={675}
        loading='eager'
        decoding='async'
        style={{ width: '100%', height: 'auto', aspectRatio: 16 / 9 }}
      />
    </picture>
  );
}

8. 접근성/SEO 체크리스트

- alt는 콘텐츠 목적을 설명합니다. 장식용은 빈 문자열 alt=''을 사용합니다.

- width/height 명시로 CLS를 방지합니다.

- 중요한 이미지는 fetchpriority='high', 나머지는 loading='lazy'를 사용합니다.

- 이미지 파일명/캡션에 키워드를 적절히 반영하면 검색 가시성에 도움이 됩니다.

9. 성능 검증과 디버깅

- Lighthouse(Performance/LCP/CLS), Chrome DevTools Network 패널에서 실제로 선택된 리소스 크기를 확인하세요.

- Sizes 변경 전/후 전송 바이트, LCP 변화를 비교합니다.

- 느린 3G 시뮬레이션에서 초기 페인트 지연, 이미지 선택 로직이 적절한지 검토합니다.

10. 자주 하는 실수

- sizes를 100vw로 고정해 데스크톱에서 과도한 큰 이미지를 받는 경우

- width/height 누락으로 레이아웃 시프트 발생

- WebP/AVIF만 제공하고 JPEG/PNG 폴백을 빼서 구형 브라우저에서 이미지 미표시

- 히어로에 lazy를 써서 LCP가 느려지는 문제

위 가이드를 토대로 컴포넌트를 표준화하고, 디자인 시스템의 브레이크포인트별 sizes 규칙을 문서화하면 팀 전체 품질이 올라갑니다.