뒤로가기
웹 이미지 최적화 완전 가이드

January 17, 2022

frontendperformance

상품 페이지의 로드 타임이 6초였다. 6초. 모바일에서는 더 심했다. PM이 "페이지가 너무 느려요"라고 할 때 나는 "렌더링 최적화를 해봐야겠네요"라고 대답했다. React.memo를 넣고, useMemo를 추가하고, 번들 사이즈를 분석했다. 효과가 미미했다. 원인이 거기가 아니었으니까.

Lighthouse를 돌렸다. Performance 47점. 빨간색. 진단 섹션을 열어보니 "Properly size images"와 "Serve images in next-gen formats"가 둘 다 빨간 경고를 내고 있었다. 네트워크 탭을 열었다. 페이지 총 전송량 21MB. 그중 이미지가 18MB. 전체 페이지 무게의 87%가 이미지였다.

상품 리스트에 이미지 30개가 있었고, 전부 원본 크기(3000x2000px) JPEG로 내려보내고 있었다. 모바일 화면에 300px로 보이는 이미지를 3000px로 다운로드하고 있었던 거다. 10배 큰 이미지를 받아서 브라우저가 축소해서 보여주고 있었다.

JavaScript 번들을 아무리 깎아봐야, 이미지 18MB 앞에서는 의미가 없었다. 결국 이미지 최적화 하나로 로드 타임을 6초에서 1.8초로 줄였다. 4초 이상을 이미지에서만 벌었다. 이건 그 과정의 기록이다.

1단계: 포맷을 바꾸다#

가장 먼저 한 건 이미지 포맷 변환이었다. JPEG를 WebP로 바꾸는 것만으로도 용량이 25~35% 줄어든다. AVIF로 바꾸면 WebP보다 20% 더 줄어든다.

처음에 AVIF로 전부 바꾸려고 했다. 용량이 가장 작으니까. 그런데 문제가 터졌다. Safari에서 AVIF가 깨졌다.

정확히는 Safari 16.3 이하에서 AVIF를 지원하지 않았다. 16.4부터 지원인데, 우리 유저의 약 12%가 16.3 이하였다. 그 유저들에게는 이미지가 아예 안 보였다. QA에서 잡혔는데, 개발 환경이 최신 Chrome이다 보니 배포 전까지 몰랐다.

<picture> 태그로 해결했다. 브라우저가 지원하는 포맷을 자동으로 선택하게 만드는 거다.

html
<picture>
  <source srcset="/product.avif" type="image/avif" />
  <source srcset="/product.webp" type="image/webp" />
  <img src="/product.jpg" alt="상품 이미지" />
</picture>

AVIF를 지원하면 AVIF를, 아니면 WebP를, 둘 다 안 되면 JPEG를 쓴다. 점진적 향상(progressive enhancement)의 교과서적인 패턴이다.

이것만으로 이미지 용량이 18MB에서 약 8MB로 줄었다. 아직 충분하지 않았다.

2단계: 크기를 줄이다#

3000x2000px 원본을 그대로 보내는 게 두 번째 문제였다. 모바일에서는 400px, 태블릿에서는 800px, 데스크탑에서는 1200px이면 충분한데 전부 3000px을 받고 있었다.

srcset으로 여러 크기를 제공했다.

html
<img
  srcset="
    /product-400w.webp 400w,
    /product-800w.webp 800w,
    /product-1200w.webp 1200w
  "
  sizes="
    (max-width: 640px) 100vw,
    (max-width: 1024px) 50vw,
    33vw
  "
  src="/product-800w.webp"
  alt="상품 이미지"
/>

sizes가 중요하다. 브라우저에 "이 이미지가 뷰포트에서 얼마나 차지하는지" 힌트를 주는 거다. 모바일에서 100vw(화면 전체), 태블릿에서 50vw, 데스크탑에서 33vw라고 알려주면 브라우저가 적절한 크기를 선택한다.

이걸 수동으로 관리하면 미칠 것 같아서, 빌드 스크립트로 자동화했다.

ts
import sharp from 'sharp';
import { glob } from 'glob';
import path from 'path';

async function optimizeImages() {
  const images = await glob('public/images/**/*.{jpg,jpeg,png}');
  const widths = [400, 800, 1200];

  for (const imagePath of images) {
    const ext = path.extname(imagePath);

    for (const w of widths) {
      await sharp(imagePath)
        .resize(w)
        .webp({ quality: 80 })
        .toFile(imagePath.replace(ext, `-${w}w.webp`));

      await sharp(imagePath)
        .resize(w)
        .avif({ quality: 65 })
        .toFile(imagePath.replace(ext, `-${w}w.avif`));
    }
  }
}

optimizeImages();

이 스크립트를 CI/CD에 넣었다. 원본 이미지를 커밋하면 빌드 시 자동으로 3가지 크기 x 2가지 포맷(WebP, AVIF)의 변환본이 생성된다.

포맷 변환 + 리사이징으로 이미지 용량이 8MB에서 2MB 아래로 줄었다.

3단계: lazy loading과 그 함정#

이미지 30개를 페이지 로드 시 전부 다운로드할 필요가 없다. 화면에 보이는 것만 먼저 로드하고, 스크롤하면서 나머지를 로드하면 된다.

html
<img src="/product.webp" alt="상품" loading="lazy" />

loading="lazy" 하나 추가하면 끝이다. 뷰포트에 가까워지면 로드를 시작한다.

여기서 함정이 있었다. 히어로 이미지까지 lazy loading을 걸어버린 거다.

페이지 최상단의 큰 배너 이미지에 loading="lazy"를 넣었는데, 이게 LCP(Largest Contentful Paint)를 악화시켰다. web.dev의 LCP 최적화 가이드에서 명확하게 경고하고 있다. "LCP 이미지를 lazy-load하지 마라. 항상 불필요한 리소스 로드 지연을 초래한다." lazy loading은 뷰포트 진입 후에 로드를 시작하기 때문에, 이미 화면에 보이는 이미지에 적용하면 로드가 늦어진다.

수정은 간단했다. 첫 화면에 보이는 이미지(히어로 배너, 첫 번째 줄의 상품 카드 3~4개)에는 lazy loading을 제거하고, 나머지에만 적용했다.

tsx
// next/image를 쓴다면 priority prop으로 해결
<Image
  src="/hero-banner.webp"
  alt="히어로 배너"
  fill
  priority  // lazy loading 대신 preload
  sizes="100vw"
/>

priority를 넣으면 <link rel="preload">가 자동으로 추가된다. 브라우저가 HTML을 파싱하면서 이미지를 미리 다운로드하기 시작한다. LCP가 확 줄어들었다.

추가로 fetchpriority 속성도 있다. next/image를 안 쓸 때 유용하다.

html
<img fetchpriority="high" src="/hero.webp" alt="히어로" />

4단계: 이미지 CDN이 모든 걸 바꿨다#

여기까지 하고 나서도, 이미지 관리가 고통스러웠다. 새 상품이 추가될 때마다 원본을 여러 크기로 변환하고, WebP와 AVIF를 생성하고, CDN에 올리는 과정이 번거로웠다. 상품팀에서 이미지를 올리면 개발팀이 수동으로 최적화하는 흐름이었다.

웹 퍼포먼스에 대해 깊이 다루는 한 엔지니어의 책에서 이미지 CDN을 강력히 추천하는 걸 읽었다. 이미지 CDN은 URL 파라미터로 크기, 포맷, 품질을 제어한다. 원본 하나만 올리면 CDN이 알아서 변환해준다.

html
<!-- 이미지 CDN 사용 예시 -->
<img
  src="https://cdn.example.com/product.jpg?=800&=webp&=80"
  alt="상품"
/>

Cloudinary, imgix, Cloudflare Images 같은 서비스를 검토했고, 결국 도입했다. 원본 이미지 하나만 업로드하면 CDN이 요청 브라우저에 따라 AVIF나 WebP를 자동 선택하고, 디바이스에 맞는 크기를 생성해준다. 빌드 스크립트가 필요 없어졌다. 상품팀이 원본만 올리면 끝이었다.

다만 web.dev에서 지적하는 트레이드오프가 있다. 서드파티 도메인에서 이미지를 서빙하면 DNS 조회, TCP 연결 등 추가 커넥션 비용이 든다. 가능하면 이미지 CDN을 자기 도메인으로 프록시하는 게 좋다. images.example.com을 CDN으로 연결하는 식으로.

최종 결과: 숫자로 보는 차이#

전체 최적화 과정의 전후 비교다.

지표이전이후
이미지 총 용량18MB1.6MB
페이지 로드 시간6초1.8초
LCP5.2초1.1초
Lighthouse Performance4792

이미지 용량이 91% 줄었다. LCP가 4초 이상 개선됐다. Lighthouse 점수가 빨간색에서 초록색으로 바뀌었다.

정리하면 이렇게 했다.

  1. 포맷 변환: JPEG를 WebP + AVIF로. <picture> 태그로 폴백 처리.
  2. 반응형 이미지: srcsetsizes로 디바이스에 맞는 크기 제공.
  3. lazy loading: 뷰포트 밖 이미지에만 적용. 첫 화면 이미지는 절대 lazy loading하지 않는다.
  4. LCP 이미지 우선 로드: priority 또는 fetchpriority="high" 사용.
  5. 이미지 CDN 도입: 수동 변환 파이프라인을 없애고 자동화.

next/image를 쓰면 대부분 해결된다#

Next.js 프로젝트라면 next/image가 위의 대부분을 자동으로 해준다.

tsx
import Image from 'next/image';

<Image
  src="/products/shoe.jpg"
  alt="운동화"
  width={800}
  height={600}
  sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
/>

WebP/AVIF 자동 변환, 디바이스별 리사이징, lazy loading 기본 적용, CLS 방지까지 해준다. priority prop 하나로 LCP 이미지도 처리된다.

외부 이미지를 쓸 때는 도메인 등록이 필요하다.

js
// next.config.js
const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'cdn.example.com',
        pathname: '/images/**',
      },
    ],
  },
};

이미지가 먼저다#

성능 최적화를 할 때 대부분의 개발자(나 포함)가 JavaScript부터 건드린다. React.memo, useMemo, 코드 스플리팅, 번들 분석. 그런데 실제로 페이지 무게에서 가장 큰 비중을 차지하는 건 이미지다. HTTP Archive 데이터에 따르면 평균 웹 페이지에서 이미지는 전체 무게의 절반 이상을 차지한다.

JavaScript 번들을 10KB 줄이는 노력보다, 이미지 포맷을 바꾸는 게 10배 효과적일 수 있다. 성능 문제가 있다면, Lighthouse를 돌리고, 네트워크 탭을 열어서 이미지 비중부터 확인하는 게 맞다. 의외로 답이 거기에 있는 경우가 많다.