황인성

GitHub

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

Tags
MDX·Next.js·React
Published

thumbnail
Photo by @jameswiseman on Unsplash

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

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

다양한 사이즈에 대응하기

아무 옵션 없이, <Image /> 컴포넌트에 src, width, height만 제공한다면 1x2x 크기에 대응합니다. 이는 이미지가 정확히 지정된 크기 그대로 사용될 때는 정확합니다. 하지만 다양한 비율이 고정된 크기의 작은 썸네일에 사용될 수도 있고, 게시물 너비에 꽉 맞게 표시되어야 할 떄도 있습니다. 이러한 상황에서 원본 이미지의 크기를 그대로 불러오는 것은 비효율적입니다.

<Image /> 컴포넌트는 sizes 옵션을 제공합니다. 현재 화면의 너비에 대응하여 적절한 사이즈의 이미지를 표시하기 위한 HTML 표준 문법입니다. 직접 작성하실 수도 있지만, Next.js에서는 다음과 같이 정말 간단하게 사용하실 수 있습니다.

<Image src={banner} alt="banner image" sizes="auto" />

next/image에서 auto 옵션을 사용하면 next.config.ts에 지정한 이미지 너비 목록을 srcSet에 전부 하드코딩하여, 현재 표시되고 있는 이미지의 크기를 기반으로 가장 적절한 이미지를 표시하도록 할 수 있습니다. 본래 auto 옵션을 사용하려면 loading="lazy"가 설정되어 있어야 하나, Next.js <Image /> 컴포넌트의 loading의 기본값은 eager가 아닌 lazy이므로 생략하고 사용하여도 됩니다.

관련 문서

Next.js에서 import문으로 가져온 이미지 데이터는 내부적으로 widthheight가 포함되어 있으므로, <Image /> 컴포넌트에서 따로 선언해주지 않아도 됩니다.

로딩 인디케이터 표시하기

Next.js 어플리케이션을 Vercel이나 OpenNext를 사용하여 배포하면 대부분의 에셋이 CDN에 캐싱되어 빠른 로딩 속도를 보장합니다. 하지만 이미지 사이즈가 최적화 후에도 여전히 크거나, 네트워크 로딩 속도가 느리면 '깜빡' 하고 표시될 수 있습니다. 이미지 컴포넌트의 몇 가지 prop을 활용하면, 로딩 인디케이터를 표시하여 이미지 로딩 경험을 개선할 수 있습니다. 아래는 이 블로그에서 실제로 사용되고 있는 이미지 컴포넌트입니다.

file iconimage.tsx
'use client';

import { useLayoutEffect, useRef, useState } from 'react';
import type { ComponentProps, ReactElement } from 'react';
import { ImageOffIcon } from 'lucide-react';
import type { ImageProps } from 'next/image';
import { Slot } from 'radix-ui';
import { cn } from '@/lib/utils/cn';
import { useDelayedUnmount } from '@/lib/hooks/mount';
import Loader from '@/components/loader';

export default function ImageFrame({ children, className, ...props }: Props) {
  const src = children?.props.src;
  const ref = useRef<HTMLImageElement | null>(null);
  const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('ready');
  const indicator = useDelayedUnmount(status === 'loading', 300);

  if (!src && status !== 'error') {
    setStatus('error');
  } else if (src && status === 'error') {
    setStatus('ready');
  }

  useLayoutEffect(() => {
    if (!ref.current || !src) return;
    const { complete, naturalWidth } = ref.current;
    setStatus(complete ? (naturalWidth > 0 ? 'ready' : 'error') : 'loading');
  }, [src]);

  return (
    <div {...props} className={cn('relative bg-foreground/2 overflow-hidden', className)}>
      {children && src && status !== 'error' && (
        <ImageSlot
          ref={ref}
          className={cn(
            'size-full object-cover shrink-0 opacity-0 transition-opacity duration-300',
            status === 'ready' && 'opacity-100',
          )}
          onLoad={() => setStatus('ready')}
          onError={() => setStatus('error')}
          sizes="auto, 100vw"
        >
          {children}
        </ImageSlot>
      )}
      {indicator && (
        <Loader
          className={cn(
            'absolute left-1/2 top-1/2 size-6 -translate-x-1/2 -translate-y-1/2',
            'text-xl text-muted-foreground transition-opacity duration-300 opacity-0',
            status === 'loading' && 'opacity-100',
          )}
        />
      )}
      {(!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',
          )}
        />
      )}44
    </div>
  );
}

const ImageSlot = Slot.createSlot<HTMLImageElement, Omit<ImageProps, 'src' | 'alt'>>('ImageFrame');

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

사용처에서는 다음과 같이 사용합니다:

// 게시물 카드 옆 썸네일
<ImageFrame className="size-32.5 border-r md:w-50 object-cover overflow-hidden shrink-0">
  <Image src={thumbnail} alt={title} quality={75} />
</ImageFrame>
  • 이미지가 이미 캐시되어 있는 경우 onLoad가 호출되지 않기 떄문에, element.complete를 사용한 로드 여부를 확인하는 것이 사실상 필수입니다.
  • ImageFrame에서 src, width 등 이미지 컴포넌트의 Prop을 받는 대신, Radix Slot을 사용하여 자식 요소로 Next.js 이미지 컴포넌트를 그대로 받도록 하여 버전 업그레이드의 영향을 최소화하였습니다.
  • 로딩 상태의 기본값을 ready로 놓고 useLayoutEffect를 사용하여 loading으로 전환하도록 하여 JavaScript가 비활성화되어도 이미지를 볼 수 있도록 처리합니다.
  • <img>는 자식을 가질 수 없기 때문에 로딩 인디케이터를 표시하려면 <div> 사용이 필수인데, 이러한 구조에서는 DOM 위계가 확실히 보이기 때문에 확장성이 매우 좋습니다.

ImageFrame은 Client Component로서 자식의 props를 활용하기 떄문에 만약 자식 Image가 Server Component로서 렌더링된다면 충돌이 발생합니다. 이 점을 유의하여 사용하여야 합니다.

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

Next.js의 이미지 최적화의 원리는 src를 /_next/image 라우트를 거쳐 로드하도록 하는 것입니다. 해당 route는 원본 이미지를 변환 및 압축하고, 지정된 사이즈로 다운스케일링하여 반환합니다. 변환 과정은 꽤 오래 걸리지만, 대부분의 Next.js 배포 환경은 이를 CDN에 캐싱할 것을 염두에 두기 때문에 결과적으로는 최적화의 역할을 수행합니다.

만약 동일한 CDN 서버에 의존하는 로컬 어플리케이션(ex: 사이트를 관리하는 CMS 소프트웨어)가 있다면, 어떤 언어로 작성되어 있든 Next.js 서버의 이미지 최적화 기능을 동일하게 누릴 수 있습니다. 예를 들어, React SPA를 프론트엔드로 두는 Tauri 앱에서:

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

/**
 * 비 Next 환경용 `next/image` Mock입니다.
 * main의 Image Optimizer를 빌려 쓰기 위해 옵티마이저 URL과 srcSet을 직접 만듭니다.
 */
export default function NextImage({
  src,
  alt,
  width,
  height,
  quality = 75,
  sizes,
  onLoad,
  ...props
}: NextImageProps) {
  const { widths, kind } = getWidths(width, sizes);
  const srcSet = widths
    .map((w, i) => `${loader(src, w, quality)} ${kind === 'w' ? `${w}w` : `${i + 1}x`}`)
    .join(', ');

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

  return (
    <img
      alt={alt}
      src={loader(src, widths[widths.length - 1], quality)}
      srcSet={srcSet}
      sizes={sizes}
      width={width}
      height={height}
      onLoad={handleLoad}
      {...props}
    />
  );
}

interface NextImageProps extends Omit<
  ComponentProps<'img'>,
  'src' | 'width' | 'height' | 'srcSet'
> {
  src: string;
  width?: number;
  height?: number;
  quality?: number;
}

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

function loader(src: string, width: 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=${width}&q=${quality}`;
}

function getWidths(width: number | undefined, sizes: string | undefined) {
  if (sizes || typeof width !== 'number') {
    return { widths: deviceSizes, kind: 'w' as const };
  }

  const widths = [...new Set([width, width * 2].map((w) => allSizes.find((s) => s >= w) ?? 3840))];
  return { widths, kind: 'x' as const };
}

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