이미지는 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 규칙을 문서화하면 팀 전체 품질이 올라갑니다.
'React' 카테고리의 다른 글
| React 앱에 GraphQL 캐싱 전략 도입하기 (0) | 2026.05.20 |
|---|---|
| React에서 WebGL 직접 구현하여 인터랙티브 그래픽 만들기 (0) | 2026.05.20 |
| React 앱에서 브라우저 저장소(Local Storage와 Session Storage) 활용하기 (0) | 2026.05.18 |
| React에서 JSON Schema 기반 폼 생성하기 (0) | 2026.05.18 |
| React 앱에서 Toast/Notification 시스템 구축하기 (0) | 2026.05.16 |