[React] 이미지 로딩, 렌더 사이 깜박임 현상 해결하기

기본적인 skeleton을 구현한다면, 이미지를 불러오는 동안 (로딩 되는 동안) 스켈레톤 UI가 잘 작동 하지만, 이미지를 불러오고 나서, 렌더되는 사이에 오른쪽에서 왼쪽으로 버벅거리며 렌더되는 현상이 있습니다.

Image의 src를 Control할 수 있게 state로 변경하여, 이를 개선해보고자 합니다.

import { useEffect, useState } from 'react';
import styles from "./index.module.css";

function ImageComponent(props) {
  const [imageBlob, setImageBlob] = useState(null);
  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    const fetchImage = async () => {
      try {
        setIsLoading(true);
        const response = await fetch(props.src);

        if (!response.ok) {
          throw new Error('Response Error');
        }

        const blob = await response.blob();

        const imageObjectUrl = URL.createObjectURL(blob);

        setImageBlob(imageObjectUrl);
        setIsLoading(false);
      } catch (error) {
        setIsLoading(false);
        console.error("Image loading failed", error);
      }
    };

    fetchImage();

    return () => {
      if (imageBlob) {
        URL.revokeObjectURL(imageBlob);
      }
    };
  }, [props.src]);

  return (
     <div
      className={`${styles.container} ${isLoading ? styles.skeleton : ""}`}
      style={{ width: props.style?.width, height: props.style?.height }}
    >
      {!isLoading && (
        <img src={imageBlob} alt="Loaded content" />
      )}
    </div>
  );
}

export default ImageComponent;
  1. isLoading을 따로 세팅하여, 모든 이미지 Blob이 생성되기 이전까진 Loading을 보여지게 만듭니다.

img가 완전히 로드되기 이전까지 스켈레톤 UI를 보여지게 합니다.

조금 더 개선해볼 수 있지 않을까?

고화질 이미지를 받는다면, Stream 형식으로 받습니다. 따라서 각 청크가 로드될 때마다, 이미지가 오른쪽에서 부터 차곡차곡 쌓이게 되는 것인데요.

‘초기에 받아온 청크를 바탕으로, 전체 이미지를 보여줄 수 있다면 사용성을 개선할 수 있지 않을까?’ 라는 생각에서 시작하였습니다.

async function roadRowImgByChunk(response: Response, { lowImgCallback, doneCallback }: RoadRowImgByChunkPropObj) {
  const reader = response.body?.getReader();

  const chunks: Uint8Array[] = [];

  if (reader) {
    while (true) {
      const { done, value } = await reader.read();

      if (done) {
        const highImgBlob = new Blob(chunks);
        const expandedURL = URL.createObjectURL(highImgBlob);
        doneCallback(expandedURL);
        break;
      }

      chunks.push(value);
        // 로직을 작성할 수 있습니다.
    }
  }
}

ReadableStream을 사용하여 각 청크에 대한 로직을 작성할 수 있습니다.

‘초기에 받아온 청크로 나머지 이미지 영역을 채우고, Blur 효과를 준다면, 초기에 받아온 색상 값의 정보를 보여줄 수 있지 않을까?’ 라는 생각으로 다음과 같이 구현할 수 있었습니다.

async function roadRowImgByChunk(response: Response, { lowImgCallback, doneCallback }: RoadRowImgByChunkPropObj) {
  const reader = response.body?.getReader();
  const contentLength = parseInt(response.headers.get("Content-Length") || "0", 10);

  const chunks: Uint8Array[] = [];
  let receivedLength = 0;
  let isPaint = false;

  if (reader) {
    while (true) {
      const { done, value } = await reader.read();

      if (done) {
        const highImgBlob = new Blob(chunks);
        const expandedURL = URL.createObjectURL(highImgBlob);
        doneCallback(expandedURL);
        break;
      }

      chunks.push(value);
      receivedLength += value.length;

      const ratio = receivedLength / contentLength;

      if (ratio > 0.01 && !isPaint) {
        const replicationFactor = Math.ceil(1 / ratio);

        // 기존 청크를 복제하여 새로운 청크 배열 생성
        const newChunks: Uint8Array[] = Array.from({ length: replicationFactor }, () => chunks).flat();
        console.log(newChunks);

        const rowImgBlob = new Blob(newChunks);
        const expandedURL = URL.createObjectURL(rowImgBlob);
        lowImgCallback(expandedURL);
        isPaint = true;
      }
    }
  }
}

결과는 다음과 같습니다.

response 응답을 기다릴 때 까지는 Skeleton UI를 보여주고, Stream을 읽어들이는 과정에서 Chunk 일부를 바탕으로 나머지 이미지를 채워 회색 영역으로 만들었습니다.

기대한 바로는 초기 청크 값이 나머지 영역을 채워 반복된 이미지 패턴이 만들어질 것을 예상했는데, 그게 아니라 단순히 회색으로 덮혀버리는 현상이 발생합니다.

결론. 사용성 개선이 되진 않은 것 같다.

초기 청크를 가지고 이미지를 채우는 것이 괜찮은 방법이라 생각하였는데, 실제로 사용해보니 차라리 Loading컴포넌트를 보여주는 형식으로 보여주는게 좋을 것이란 결론을 내렸습니다.

아니면 저화질 이미지를 함께 제공하여 Progressive Image로 렌더링 하는 것이 훨씬 효과적으로 생각됩니다.

그럼에도 이번 챌린지를 통해 ReadableStream과 Chunk그리고 Blob에 대한 이해도가 높아진 것 같아 만족 스럽습니다.

추가적으로.

제가 사용하던 파일 확장자는 JPG입니다. 그리고 이번에 알게된 키워드는 Progressive jpeg와 baseline jpegs 입니다.

  • Baseline jpg를 사용하면 다음과 같이 렌더링 됩니다.

  • Progressive jpg를 사용하면 다음과 같습니다.

파일 확장명을 일반적인 jpg가 아닌 Progressive jpg를 사용한다면 더욱 사용성 있는 서비스를 개발할 수 있겠네요.