Skip to content

반응형 이미지를 CSS-first로 처리하기

모바일과 PC에서 다른 이미지를 보여줄 때, JavaScript 대신 브라우저 네이티브 방식을 사용하면 레이아웃 깜빡임 없이 올바른 이미지를 즉시 렌더링할 수 있습니다.

1. 모바일/PC 이미지 분기에 JS를 사용하지 않습니다.

좋지 않은 예시

tsx
// useIsMobile 결과에 따라 이미지 src를 분기
import Image from 'next/image';
import { useIsMobile } from '@/hooks/device';

export default function Hero() {
  const isMobile = useIsMobile();

  return (
    <section>
      {isMobile !== null && (
        <Image
          src={`${CDN_PATH}/hero_${isMobile ? 'mo' : 'pc'}.png`}
          alt='hero'
          width={isMobile ? 328 : 960}
          height={isMobile ? 200 : 400}
          unoptimized
          quality={100}
        />
      )}
    </section>
  );
}

문제

useIsMobile()은 클라이언트 사이드에서만 동작하므로 SSR 시점에는 null을 반환합니다. 그 결과 서버에서는 이미지가 렌더링되지 않고, hydration이 완료된 뒤에야 이미지가 나타나면서 CLS(Cumulative Layout Shift)가 발생합니다. CLS 점수 0.1 이하가 "Good" 기준인데, JS 기반 이미지 분기는 이 기준을 넘기기 어렵습니다.

또한 JS 번들이 로드되고 실행되어야 비로소 이미지 요청이 시작되기 때문에, 브라우저가 HTML 파싱 단계에서 미리 이미지를 가져올 수 있는 기회를 잃습니다. HTML 기반 프리로드와 비교하면 이미지 표시가 1초 이상 늦어질 수 있습니다.

unoptimizedquality={100}을 함께 사용하면 Next.js의 이미지 최적화 파이프라인도 완전히 우회되어, 원본 용량 그대로 사용자에게 전달됩니다.

좋은 예시

tsx
// <picture> + <source media>로 브라우저가 직접 분기
import { getImageProps } from 'next/image';

export default function Hero() {
  const { props: pcProps } = getImageProps({
    src: `${CDN_PATH}/hero_pc.png`,
    width: 960,
    height: 400,
    alt: 'hero',
  });

  const { props: moProps } = getImageProps({
    src: `${CDN_PATH}/hero_mo.png`,
    width: 328,
    height: 200,
    alt: 'hero',
  });

  return (
    <section>
      <picture>
        <source
          media='(min-width: 1024px)'
          srcSet={pcProps.srcSet}
          width={960}
          height={400}
        />
        <img
          src={moProps.src}
          srcSet={moProps.srcSet}
          alt='hero'
          width={328}
          height={200}
          loading='lazy'
        />
      </picture>
    </section>
  );
}

<picture><source media>는 CSS 미디어쿼리와 동일한 방식으로 동작하므로, JS 실행 없이 HTML 파싱 시점에 브라우저가 올바른 이미지를 선택합니다. 조건에 맞지 않는 이미지는 아예 요청하지 않기 때문에 대역폭 낭비도 없습니다. SSR에서도 마크업이 그대로 내려가기 때문에 CLS가 발생하지 않으며, getImageProps를 통해 Next.js의 이미지 최적화(리사이즈, 포맷 변환, srcSet 생성)도 그대로 유지됩니다. Next.js 공식 문서에서도 art direction(모바일/PC 이미지 분기) 시 이 패턴을 권장합니다.

2. 미최적화 이미지는 getImageProps로 최적화합니다.

좋지 않은 예시

tsx
export default function Banner() {
  return (
    <>
      {/* 이미 CDN에서 최적화된 WebP 이미지 */}
      <picture>
        <source
          media='(min-width: 1024px)'
          srcSet={`${CDN_PATH}/banner_pc.webp`}
          width={546}
          height={260}
        />
        <img
          src={`${CDN_PATH}/banner_mo.webp`}
          alt='banner'
          width={328}
          height={200}
          loading='lazy'
        />
      </picture>

      {/* 최적화되지 않은 PNG 이미지도 동일하게 네이티브 picture만 사용 */}
      <picture>
        <source
          media='(min-width: 1024px)'
          srcSet={`${CDN_PATH}/feature_pc.png`}
          width={581}
          height={509}
        />
        <img
          src={`${CDN_PATH}/feature_mo.png`}
          alt='feature'
          width={321}
          height={455}
          loading='lazy'
        />
      </picture>
    </>
  );
}

문제

이미 WebP로 최적화된 이미지와 미최적화 PNG 원본을 동일한 방식으로 다루면, PNG 이미지는 원본 용량 그대로 사용자에게 전달됩니다. 예를 들어, PNG 원본이 488kB인 이미지를 getImageProps로 처리하면 약 83kB까지 줄일 수 있지만, 네이티브 <picture>만 사용하면 이 최적화 기회를 놓치게 됩니다. 반대로 이미 WebP로 최적화된 이미지에 getImageProps를 적용하면 용량 절감 없이 Next.js 이미지 서버를 거치는 지연만 추가됩니다.

좋은 예시

tsx
import { getImageProps } from 'next/image';

export default function Banner() {
  // 미최적화 PNG — getImageProps로 최적화 파이프라인 활용
  const { props: featurePcProps } = getImageProps({
    src: `${CDN_PATH}/feature_pc.png`,
    width: 581,
    height: 509,
    alt: 'feature',
  });

  const { props: featureMoProps } = getImageProps({
    src: `${CDN_PATH}/feature_mo.png`,
    width: 321,
    height: 455,
    alt: 'feature',
  });

  return (
    <>
      {/* 이미 최적화된 WebP — 네이티브 picture로 직접 서빙 */}
      <picture>
        <source
          media='(min-width: 1024px)'
          srcSet={`${CDN_PATH}/banner_pc.webp`}
          width={546}
          height={260}
        />
        <img
          src={`${CDN_PATH}/banner_mo.webp`}
          alt='banner'
          width={328}
          height={200}
          loading='lazy'
        />
      </picture>

      {/* 미최적화 PNG — getImageProps로 최적화 */}
      <picture>
        <source
          media='(min-width: 1024px)'
          srcSet={featurePcProps.srcSet}
          width={581}
          height={509}
        />
        <img
          src={featureMoProps.src}
          srcSet={featureMoProps.srcSet}
          alt='feature'
          width={321}
          height={455}
          loading='lazy'
        />
      </picture>
    </>
  );
}

이미 최적화된 이미지(WebP 등)는 CDN에서 직접 서빙하고, 미최적화 이미지(PNG 원본 등)에만 getImageProps를 적용합니다. 이렇게 구분하면 불필요한 이미지 서버 경유 없이, 최적화가 필요한 이미지에서만 용량을 대폭 절감할 수 있습니다.

주의사항

  • 해상도만 다른 경우에는 <picture>가 과도합니다. 동일 이미지의 해상도만 다른 경우(resolution switching)에는 <img srcset sizes>가 더 적합합니다. <picture>는 모바일/PC에서 완전히 다른 이미지를 보여주는 art direction 용도로 사용합니다.
  • <picture> 내부 <img>에는 next/image의 blur placeholder, lazy loading 제어 등 편의 기능이 자동 적용되지 않습니다. 필요하다면 loading, decoding 등의 속성을 직접 지정해야 합니다.
  • 이미지 버전 관리 복잡성이 증가합니다. 모바일/PC 각각의 이미지를 별도로 준비하고 관리해야 하므로, 디자인 변경 시 양쪽 모두 업데이트해야 합니다.

자동화

  • @next/next/no-img-element: Next.js ESLint 규칙이 <img> 직접 사용을 경고하지만, <picture> 내부의 <img>는 의도적으로 예외 처리되어 경고가 발생하지 않습니다.
  • Lighthouse / PageSpeed Insights: CLS 점수를 측정하여 JS 기반 이미지 분기의 레이아웃 시프트 문제를 정량적으로 확인할 수 있습니다. CLS 0.1 이하를 목표로 합니다.