Intro

혹시 이미지를 렌더링하는 과정에서 이미지가 뚝뚝 끊기며 렌더링되어 불편했던 경험이 있나요?

오늘은 제가 겪은 이미지 경험을 개선했던 과정을 공유하려고 합니다.

문제 상황

정적 호스팅이 아닌 경우 일반적으로 이미지 경로는 서버로부터 전달받게 됩니다. 그리고 서버로부터 응답이 올 때 까지 스켈레톤과 같은 UI를 제공하여 사용자 경혐 향상을 도모합니다.

하지만 문제는 브라우저가 이미지를 다운로드 받는 시간이 별도로 발생한다는 것입니다. 그 결과, 브라우저가 이미지를 다운로드 하고 스트리밍 하는 과정이 그대로 드러납니다.

개선 전

일반적으로 왼쪽 상단부터 우측 하단으로 스트리밍 되는 것을 확인할 수 있습니다.

서버가 응답 > 이미지 경로 삽입 > 브라우저가 이미지 로드 시작

개선 (1)

이와 같은 경험을 개선하고자 플레이스홀더를 사용할 수 있습니다. 브라우저는 이미지 객체의 src 프로퍼티에 경로가 삽입되는 순간 이미지를 다운로드하기 시작합니다. 이를 활용하여 플레이스홀더로 스켈레톤을 노출해 보겠습니다.

type ImageComponentProps = {
  loadingFallback: ReactNode;
} & ComponentPropsWithoutRef<'img'>;
 
const ImageComponent = (props: ImageComponentProps) => {
  const { loadingFallback, ...restProps } = props;
  const [loaded, setLoaded] = useState(false);
 
  const handleImageLoaded = () => {
    setStatus(true);
  };
 
  if (!loaded) {
    const img = new Image();
 
    img.src = props.src;
    img.onload = handleImageLoaded;
 
    return loadingFallback;
  }
 
  return <img {...restProps} />;
};

이미지가 로드되지 않은 경우 loadingFallback을 렌더링하고 이미지 객체를 만들어 경로를 삽입함으로서 이미지 다운로드를 요청합니다.

이미지가 다운로드 된 것은 onload 이벤트핸들러를 통해 감지할 수 있습니다. loaded 상태를 변경하기 적절한 타이밍입니다.

잘 동작하는 것을 확인할 수 있습니다. 개선 1

개선 (2)

지금까진 이미지가 성공적으로 로드되는 경우만 다루었습니다. 이미지 로드가 실패하는 경우도 대응해 보겠습니다.

로딩 상태를 조금 더 세분화 해주면 큰 변경 없이 대응 가능합니다.

type ImageComponentProps = {
  loadingFallback: ReactNode;
  errorFallback: ReactNode;
} & ComponentPropsWithoutRef<'img'>;
 
const ImageComponent = (props: ImageComponentProps) => {
  const { loadingFallback, errorFallback, ...restProps } = props;
  const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
 
  const handleImageLoaded = () => {
    setStatus('success');
  };
 
  const handleImageFailed = () => {
    setStatus('error');
  };
 
  if (status === 'error') {
    return errorFallback;
  }
 
  if (status === 'loading') {
    const img = new Image();
 
    img.src = props.src
    img.onload = handleImageLoaded;
    img.onerror = handleImageFailed;
 
    return loadingFallback;
  }
 
  return <img {...restProps} />;
};

잘 동작하는 것을 확인할 수 있습니다. 이미지 요청 실패

다시 문제..

지금까지 이미지가 로딩중인 경우, 실패한 경우를 대응해 보았습니다. 두 경우 모두 ImageComponent 내부에서 이뤄졌는데요. 이는 해당 이미지의 status가 ImageComponent 내부에 종속되는 단점이 있습니다. 이 단점은 언제 다가올까요?

UX를 판단하는 기준에 따라 다르겠지만, 이미지가 모두 로드되었을 때 관련 정보들을 함께 노출하고 싶을 수 있습니다. 또한, 이미지 로드가 실패한 경우 관련 정보들도 함께 전달하고 싶지 않을 수도 있습니다.

예를 들어, 옷을 구매하는 사용자가 보고 있는 상품 아래에 다른 상품을 노출해야 하는 경우를 상상해볼 수 있습니다. 옷은 디자인의 영향을 많이 받는 제품이기에 제품 이미지 없이 가격이나 이름만 노출된 품목은 사용자에게 선택받을 확률이 매우 낮습니다. 이와 같은 상황에서 이미지 로드가 실패한 상품은 렌더링 하지 않음으로서 매끄러운 상품 탐색 경험을 제공할 수 있습니다.

위와 같은 경우를 기존의 ImageComponent를 활용하여 대응해 보겠습니다. 먼저 ImageComponent를 조금 변경하겠습니다.

const ImageComponent = (props: ImageComponentProps) => {
  ... 
  
  if (status === 'loading') {
    const img = new Image();
 
    img.src = props.src
    img.onload = compose(handleImageLoaded, props.onLoad);
    img.onerror = compose(handleImageFailed, props.onError);
 
    return loadingFallback;
  }
 
  return <img {...restProps} />;
};
 

외부에서 주입되는 load, error 이벤트 핸들러를 연동하였습니다. 이제 다음과 같이 ProductItem을 구현할 수 있습니다.

const ProductItem = ({product}) => {
  const { name, price } = product
  
  const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
 
  const handleImageLoaded = () => {
    setStatus('success');
  };
 
  const handleImageFailed = () => {
    setStatus('error');
  };
 
  if (status === 'error') {
    return null;
  }
 
  if (status === 'loading') {
    return <Skeleton />
  }
 
  
 return 
  <div>
   	<ImageComponent src='thumbnail' onLoad={handleImageLoaded} onError={handleImageFailed}/> 
    <Name>{name}</Name>
    <Price>{price}</Price>
  </div>
}

status를 다루는 부분이 상당히 중복됩니다. 이 부분은 훅으로 추출하여 여차저차 할 수 있다고 하겠습니다.

만들고 보니 익숙한 모습이 보이지 않으신가요? 내부적으로 status를 다루는 형태가 api status를 다루는 형태와 비슷합니다.

우리는 이와 같은 상태에서 관심사를 분리하고 선언적으로 status를 다룰 수 있는 api를 알고 있습니다. 바로 suspense입니다.

확장

suspense는 throw된 promise를 catch하여 이행될 경우 리렌더링 시킵니다. 우리는 promise가 이행될 타이밍을 알고 있습니다. 바로 이미지가 로드된 경우입니다.

기존의 ImageComponent를 변경해 보겠습니다.

type SuspenseImgProps = ComponentPropsWithoutRef<'img'>
 
const SuspenseImg = (props: SuspenseImgProps) => {
  const { src, ...restProps } = props;
  
  useSuspense(() => {
    const { promise, resolve, reject } = Promise.withResolvers();
 
    const img = new Image();
 
    img.onload = resolve;
    img.onerror = reject;
    img.src = src!;
 
    return promise;
  });
 
  return <img {...props} />;
}

useSuspense의 동작 방식이 궁금하신 분들은 참고해 주세요!

이전과 달리 이미지를 로드하는 promise를 throw합니다. 그리고 해당 promise는 이미지가 로드된 경우 resolve됩니다.

한번 활용해 볼까요?

최종 개선

이전에 작성한 ProductItem을 개선해 보겠습니다.

기존에 작성된 status 관리 로직을 모두 제거하고 suspenseErrorBoundary를 적용합니다.

const ProductItem = ({product}) => {
  const { name, price } = product
  
 return 
  <ErrorBoundary fallback={null}>
    <Suspense fallback={<Skeleton />}>   
     <div>
        <SuspenseImg src='thumbnail' /> 
        <Name>{name}</Name>
        <Price>{price}</Price>
     </div>
    </Suspense>
  </ErrorBoundary>
}
 

훨씬 직관적이지 않나요?

이제 이미지 status의 여파 범위를 선언적으로 조작할 수 있습니다. 기존처럼 이미지만 fallback을 보여주고 싶은 경우 이미지만 감싸주면 되는 것이죠.

최종적인 개선 결과물은 다음과 같습니다. 최종 개선

마무리

프로젝트에서 이미지를 다루며 경험했던 불편 사항을 개선하는 과정을 공유해 보았습니다. 조금이라도 도움이 되었으면 좋겠습니다. 😄

결론 : 불편했던 이미지 경험은 사실 UX와 DX 모두였다.

참고