황인성

GitHub

Next.js에서 이미지 로딩 경험 개선하기

Tags
MDX·Next.js·React
Published

thumbnail
Photo by @jameswiseman on Unsplash

Next.js의 이미지 최적화 기능은 훌륭합니다. 전용 <Image /> 컴포넌트를 사용하여 간단하게 사용할 수 있으며, 필요한 기능이 거의 전부 마련되어 있습니다. 하지만 HTML의 기본 img 태그와 동작 상의 차이가 많아, 잘 모르고 사용하면 그 기능을 온전히 활용하기 힘들 수 있습니다.

이 포스트에서는 제가 블로그를 개발하면서 얻은 노하우를 풀어보려고 합니다.

다양한 사이즈에 대응하기

<Image />를 가장 간단하게 사용하면, 다음과 같이 사용할 수 있습니다.

<Image src={src} alt="My Image" width={1920} height={1080} />

이는 HTML에 다음과 같이 렌더링됩니다:

<img
  alt="thumbnail"
  loading="lazy"
  width="1920"
  height="1280"
  decoding="async"
  data-nimg="1"
  srcset="
    /_next/image?url=%2F_next%2Fstatic%2Fmedia%source.26d59dbb.png&amp;w=1920&amp;q=75 1x
    /_next/image?url=%2F_next%2Fstatic%2Fmedia%source.26d59dbb.png&amp;w=1920&amp;q=75 2x
  "
  src="
    https://insd.dev/_next/image?url=%2F_next%2Fstatic%2Fmedia%source.26d59dbb.png&amp;w=3840&amp;q=100
  "
/>
  • /_next/image라는 특수 Function의 프록시를 거쳐 이미지를 변환합니다. 로드에 꽤 오랜 시간을 소요하지만, Vercel이나 OpenNext 등의 배포 방법을 선택한다면 대부분 CDN에 결과가 캐싱되므로 이를 활용합니다.
    • url=은 이미지의 실제 경로입니다. URL-Safe하게 인코딩되어 삽입됩니다.
    • w=는 이미지의 너비입니다. next.config.tsimages.deviceSizes에 지정된 크기만 사용할 수 있으므로, 이미지의 width 및 height를 사용하여 가장 가까운 크기를 할당합니다.
    • q=는 변환할 이미지의 압축 퀄리티입니다. 낮을수록 용량이 작아지지만 품질이 떨어지며, 기본값은 75입니다.

기본 옵션은 충분히 좋지만, 다음의 문제점을 가지고 있습니다.

  • srcset이 이미지의 너비를 기준으로 만들어지므로, 스타일링된 크기에 비해 과도하게 큰 이미지를 할당받을 수 있습니다.
  • width가 한정되므로 상황에 따라 이미지가 실제에 비해 미묘하게 품질이 떨어질 수 있습니다.

이미지를 의도대로 정확하게 표시하면서 네트워크 비용을 절약하고 싶다면 next.config.ts에서 이미지 크기 세트를 자주 사용되는 너비로 선별하고 sizes 옵션을 함께 활용하는 것이 좋습니다.

예를 들어, 이 블로그에서는 640px, 720px, 960px의 게시물 너비만을 사용하며 썸네일 이미지는 200px를 사용합니다. 이 때 next.config.ts를 다음과 같이 설정할 수 있습니다.

file iconnext.config.ts
const config: NextConfig = {
  images: {
    formats: ['image/avif', 'image/webp'],
    qualities: [75, 100],
    deviceSizes: [640, 720, 960, 1280, 1440, 1920],
    imageSizes: [32, 48, 64, 96, 128, 256, 384, 200, 400],
  },
  ...
}
  • qualities100을 추가하였습니다. 블로그에 스크린샷이 많이 사용되므로, 해당 값이 낮으면 크게 깨져 보이기 때문입니다.
  • deviceSizes는 게시물 너비에 더불어, Retina 2x까지 대응 가능하도록 해당 너비의 2배수 값까지 삽입하였습니다.
  • imageSizes는 Next.js 기본값과 더불어 썸네일 이미지의 너비인 200px와 그 2배수 값을 삽입하였습니다.

그리고 게시물 내부와 썸네일에 각각 sizes 옵션을 사용합니다.

// 게시물 내부. ToC의 표시 여부에 따른 반응형 너비 셋까지 함께 고려하였음.
<Image
  src={thumbnail}
  alt="썸네일 이미지"
  width={1920}
  height={1080}
  sizes="(min-width:962px) 720px, (min-width:768px) calc(100vw - 15rem), 100vw"
/>

// 썸네일 이미지. 사이즈는 모든 환경에서 항상 200px이다.
<Image src={thumbnail} alt="썸네일 이미지" width={1920} height={1080} sizes="200px" />

sizes 옵션을 사용하는 즉시, Next.js의 srcSet 생성값이 다음과 같이 변경됩니다.

/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fthumbnail.03hg3m62xy-9q.png&amp;w=32&amp;q=75 32w,
/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fthumbnail.03hg3m62xy-9q.png&amp;w=48&amp;q=75 48w,
/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fthumbnail.03hg3m62xy-9q.png&amp;w=64&amp;q=75 64w,
/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fthumbnail.03hg3m62xy-9q.png&amp;w=96&amp;q=75 96w,
/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fthumbnail.03hg3m62xy-9q.png&amp;w=128&amp;q=75 128w,
/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fthumbnail.03hg3m62xy-9q.png&amp;w=200&amp;q=75 200w,
/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fthumbnail.03hg3m62xy-9q.png&amp;w=256&amp;q=75 256w,
/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fthumbnail.03hg3m62xy-9q.png&amp;w=384&amp;q=75 384w,
/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fthumbnail.03hg3m62xy-9q.png&amp;w=400&amp;q=75 400w,
/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fthumbnail.03hg3m62xy-9q.png&amp;w=640&amp;q=100 640w,
/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fthumbnail.03hg3m62xy-9q.png&amp;w=720&amp;q=100 720w,
/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fthumbnail.03hg3m62xy-9q.png&amp;w=960&amp;q=100 960w,
/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fthumbnail.03hg3m62xy-9q.png&amp;w=1280&amp;q=100 1280w,
/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fthumbnail.03hg3m62xy-9q.png&amp;w=1440&amp;q=100 1440w,
/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fthumbnail.03hg3m62xy-9q.png&amp;w=1920&amp;q=100 1920w
  • 1x2x만 삽입되었던 것이 deviceSizesimageSizes에 지정하였던 모든 값이 하드코딩되도록 변경됩니다.
  • 브라우저는 sizes를 통해 자동으로 가장 적절한 이미지 너비를 선택하여 렌더링됩니다.
  • 오작동을 방지하기 위해서인지, sizes가 충분히 작은 값일 때만 imageSizes 값이 하드코딩됩니다.

loading="lazy"를 사용하면, sizes="auto"가 사용 가능해집니다. 이는 sizes를 직접 작성하는 부담을 덜어주지만, 저는 다음과 같은 이유로 이를 추천하지 않습니다.

  • loading="eager"가 꼭 필요할 때 이를 사용할 수 없어집니다.
  • Safari 27.0 이전 버전은 sizes="auto"를 지원하지 않습니다. 글 작성 시점에서 27.0은 개발자 베타 단계입니다.

관련 문서

로딩 인디케이터 표시하기

이 블로그에서 사용되는 이미지 로딩 인디케이터

웹 브라우저에서 이미지는 꽤나 늦게 표시되는 요소이지만, 기본 동작만으로는 이미지가 로드되고 있는 것인지 제대로 판별하기 어렵습니다. 전부 로드되지 않은 이미지는 alt가 표시기되거나, 빈 공간이거나, 로드된 부분만 잘려서 표시되기 때문입니다. UX를 개선하기 위해, 저는 이미지에 로딩 인디케이터와 트랜지션을 적용하려고 하였습니다. 일반적으로는 아래와 같이 작성합니다.

export default function LoadingImage({ src, alt, width, height }) {
  const [loaded, setLoaded] = useState(true)
  const ref = useRef<HTMLImageElement>(null)

  useLayoutEffect(() => {
    if (ref.current?.complete) setLoaded(false);
  }, [src])

  return (
    <div>
      {!loaded && <Indicator />}
      <Image
        ref={ref}
        className={cn("transition-opacity", loaded && "opacity-0")}
        onLoad={() => setLoaded(true)} />
      />
    </div>
  )
}

useLayoutEffect를 활용해 인디케이터를 클라이언트에서만 활성화되도록 하여, SSR 결과만을 사용할 수 있는 비-자바스크립트 환경(웹 크롤러 등)에서도 정상적으로 표시될 수 있도록 합니다.

하지만 이는 커스터마이징이 자유롭지 않습니다. LoadingImage 컴포넌트만 사용해서는 내부의 div<Image>에 모든 prop을 문제없이 전달하기는 현실적으로 쉽지 않습니다.

또, 이미 로컬에서 캐시되어 사실상 즉시 표시될 수 있는 이미지를 구분하기 힘듭니다. Next.js <Image>는 별도 선언이 없으면 내부의 <img>loading="lazy"를 전달하는데, 이 옵션은 이미지의 위치를 기준으로 로딩 여부를 결정하기 위해 이미지 로딩 자체를 한 박자 늦추도록 합니다.

이는 이미 캐시된 이미지임에도 페이지가 전환된 후 이미지가 다른 요소에 비해 한 박자 늦게 표시되도록 하며, useLayoutEffect에서 completetrue를 반환할 확률이 극도로 낮아지도록 합니다. 트랜지션이 적용된 경우 거의 모든 페이지 전환에서 트랜지션이 전부 출력됩니다.

따라서 저는 이미지 컴포넌트를 다음과 같이 작성했습니다.

file iconimage.tsx
'use client';

import { ComponentProps, ReactElement, SyntheticEvent, useEffect, useMemo, useRef } from 'react';
import { useState } from 'react';
import { ImageOffIcon } from 'lucide-react';
import { getImageProps, ImageProps } from 'next/image';
import { Slot } from 'radix-ui';
import { cn } from '@/lib/utils/cn';
import Loader from '@/components/loader';
import { resolveCurrentSrc } from '@insd47/current-src';
import { useDelayedUnmount } from '@/lib/hooks/mount';

export default function ImageFrame({ children, className, ...props }: Props) {
  if (!children) throw new Error('ImageFrame must have <Image /> as a child');

  const src = children.props.src;
  const ref = useRef<HTMLImageElement | null>(null);

  const seen = useMemo(() => {
    if (typeof window === 'undefined') return false;
    const { props } = getImageProps(children.props);

    const src = props.srcSet
      ? (resolveCurrentSrc(props.srcSet, props.sizes) ?? props.src)
      : props.src;

    const key = new URL(src, window.location.href).href;
    return sessionStorage.getItem(`ImageFrame:${key}`) === 'true';
  }, [children.props]);

  const [status, setStatus] = useState<Status>(hydrated && !seen ? 'loading' : 'ready');
  const indicator = useDelayedUnmount(status === 'loading', 300);

  useEffect(() => {
    if (!hydrated) hydrated = true;
    if (!ref.current?.complete) setStatus('loading');
  }, []);

  function onLoad({ currentTarget }: SyntheticEvent<HTMLImageElement>) {
    setStatus('ready');
    const key = new URL(currentTarget.currentSrc, window.location.href).href;
    sessionStorage.setItem(`ImageFrame:${key}`, 'true');
  }

  return (
    <div
      {...props}
      className={cn(
        'relative bg-foreground/2 overflow-hidden transition-colors duration-300',
        status === 'ready' && 'bg-background bg-noise',
        className,
      )}
    >
      {indicator && (
        <Loader
          className={cn(
            'absolute -z-1 left-1/2 top-1/2 size-6 -translate-x-1/2 -translate-y-1/2',
            'text-xl text-muted-foreground',
          )}
        />
      )}
      {src && status !== 'error' && (
        <ImageSlot
          ref={ref}
          className={cn(
            'size-full object-cover shrink-0 transition-opacity duration-300',
            status !== 'ready' && 'opacity-0',
          )}
          onLoad={onLoad}
          onError={() => setStatus('error')}
          loading={seen ? 'eager' : 'lazy'}
          decoding={seen ? 'sync' : 'async'}
          suppressHydrationWarning
        >
          {children}
        </ImageSlot>
      )}
      {(!src || status === 'error') && (
        <ImageOffIcon
          className={cn(
            'absolute left-1/2 top-1/2 size-6 -translate-x-1/2 -translate-y-1/2',
            'text-muted-foreground',
          )}
        />
      )}
    </div>
  );
}

type Status = 'loading' | 'ready' | 'error';
const ImageSlot = Slot.createSlot<HTMLImageElement, Omit<ImageProps, 'src' | 'alt'>>('ImageFrame');
let hydrated = false;

interface Props extends Omit<ComponentProps<'div'>, 'children'> {
  children?: ReactElement<ImageProps> | null;
}

사용처에서는 이렇게 작성합니다.

<ImageFrame className="size-32.5 md:w-50 object-cover">
  <Image src={thumbnail} alt={title} sizes="200px" quality={75} />
</ImageFrame>
  • Radix Slot을 활용하여, 자식 컴포넌트로 <Image>를 그대로 받아, 확장성이 뛰어납니다.
    • 부모 요소에서 자식 요소의 props를 그대로 사용할 수 있습니다.
    • 동시에 부모 div의 props를 확장하여, 사용처에서 부모 div<Image>의 props을 전부 덮어쓸 수 있습니다.
  • 페이지 전환 시 캐시된 이미지가 즉시 표시될 확률을 높입니다.
    • sessionStorage를 활용하여 한 번 본 이미지를 기록하고, 렌더링 전 동기적으로 이를 확인합니다.
    • 기록된 이미지는 loading="eager"decoding="sync"를 적용하여 이미지 로드 시점을 앞당깁니다.
    • img.currentSrc 및 전용 Parser 패키지를 활용하여 정확한 srcSet 후보를 키로 삼습니다.
    • 동기적 파싱의 한계로, sizes="auto"를 지원하지 않습니다.
  • SSR을 보다 깔끔하게 분기합니다. 전용 hydrated 변수를 사용하여, 서버를 거치지 않는 렌더는 state 초기값 자체에서 loading을 설정할 수 있도록 하였습니다.

Next.js 공식 문서상, 즉시 표시되어야 하는 LCP(Largest Contentful Paint) 요소에만 loading="eager"를 수동으로 붙여 주는 것이 정석입니다. 하지만 마크다운 컨텐츠 배포를 주력으로 하는 저로써는 이를 적절하게 할당하기 현실적으로 어려웠습니다. 이 때 sessionStorage는 적절한 타협점이 되었습니다.

sessionStorage인가요?

브라우저에서 이미지가 캐싱되었는지 확인할 수 있는 전용 API가 없습니다. fetch(url, { cache: 'only-if-cached' })performance.getEntriesByName 등 다양한 방법을 시도해보았지만, 저는 sessionStorage가 가장 적절한 타협점으로 결론지었습니다.

렌더링 전 동기적으로 정확한 srcSet 후보를 뽑기 위하여, @insd47/current-src 패키지를 만들었습니다. 다음과 같이 설치하실 수 있습니다.

pnpm install @insd47/current-src

관련 문서

MDN - HTMLImageElement loading property

외부 어플리케이션에서 사용하기

앞서 살펴보았듯 Next.js의 이미지 최적화의 원리는 src를 /_next/image 라우트를 거쳐 로드하도록 하는 것입니다. 만약 동일한 CDN 서버에 의존하는 로컬 어플리케이션(ex: 사이트를 관리하는 CMS 소프트웨어)가 있다면, 어떤 언어로 작성되어 있든 Next.js 서버의 이미지 최적화 기능을 동일하게 누릴 수 있습니다.

예를 들어, React SPA를 프론트엔드로 두는 Tauri 앱에서 다음과 같이 Next <Image />를 흉내내는 컴포넌트를 작성할 수 있습니다.

file iconimage.tsx
import { type ComponentProps, type SyntheticEvent } from 'react';
import { resources } from '@/lib/features/aws';

/**
 * console(비 Next 환경)용 `next/image` Mock입니다.
 * main의 Image Optimizer를 빌려 쓰기 위해 옵티마이저 URL과 srcSet을 직접 만듭니다.
 */
export default function NextImage({
  src,
  alt,
  width,
  height,
  quality = 75,
  sizes,
  ...props
}: Props) {
  const srcSet = width ? buildSrcSet(src, width, quality, sizes) : undefined;

  function ref(e: HTMLImageElement | null) {
    if (typeof props.ref === 'function') props.ref(e);
    else if (props.ref) props.ref.current = e;

    if (e?.complete) onLoad({ currentTarget: e } as SyntheticEvent<HTMLImageElement>);
  }

  function onLoad(event: SyntheticEvent<HTMLImageElement>) {
    void event.currentTarget.decode().finally(() => props.onLoad?.(event));
  }

  return (
    <img
      {...props}
      ref={ref}
      alt={alt}
      sizes={sizes}
      width={width}
      height={height}
      onLoad={onLoad}
      srcSet={srcSet?.map((e) => e.join(' ')).join(', ')}
      src={srcSet?.[srcSet.length - 1][0] ?? src}
    />
  );
}

function buildSrcSet(src: string, width: number, quality: number, sizes?: string) {
  if (sizes) {
    return allSizes.map((size) => [buildImageUrl(src, size, quality), `${size}w`] as const);
  }

  return [width, width * 2]
    .map((w) => allSizes.find((s) => s >= w) ?? 3840)
    .map((s, i) => [buildImageUrl(src, s, quality), `${i + 1}x`] as const);
}

function buildImageUrl(src: string, size: number, quality: number) {
  const { stage } = resources.App;
  const optimizer = stage === 'production' ? resources.Router.url : 'http://localhost:3000';
  const resolved = src.startsWith('/storage/') ? `${resources.Router.url}${src}` : src;

  return `${optimizer}/_next/image?url=${encodeURIComponent(resolved)}&w=${size}&q=${quality}`;
}

const deviceSizes = [640, 750, 828, 1080, 1200, 1920, 2048, 3840];
const imageSizes = [16, 32, 48, 64, 96, 128, 256, 384];
const allSizes = [...imageSizes, ...deviceSizes];

interface Props extends Omit<ComponentProps<'img'>, 'src' | 'width' | 'height' | 'srcSet'> {
  src: string;
  width?: number;
  height?: number;
  quality?: number;
}
  • Next.js의 srcSet 생성 로직을 그대로 흉내냈습니다.
  • Ref Function을 활용하여 onLoad가 무조건 실행될 수 있도록 보장하였습니다. 이는 위의 ImageFrame을 제대로 사용하기 위한 조건입니다.

단순히 React 컴포넌트 뿐만 아니라, Jetpack Compose나 Flutter 등 다른 프레임워크에도 동일한 Mock을 작성하여 사용할 수도 있습니다. 스토리지(S3 등)에는 원본 파일을 올리고, 모든 사용처에서 Next.js의 이미지 최적화 함수에 기대어 이미지를 렌더링할 수 있는 것입니다.