FRONTEND/React

[ React ] 더 나은 사용자 경험을 위한 스켈레톤 UI

JUNGY00N 2024. 3. 14. 18:37

개발 중인 여름방학 프로젝트의 사용자 경험을 증가시키기 위해 스켈레톤 UI 추가해볼 것이다. 

 

현재 상황

먼저, 현재 나의 상황이다. 네트워크를 느리게 설정하여 확인해보겠다. 

그림을 보여줘야 하는 부분에서, api를 호출 중일 때 로딩 스피너가 보이고, (임의로 둠)

사진이 뜰 때 흰색 그림이 보인 후 그림이 다 보여지는 경우 있음 

스켈레톤 UI란?

실제 콘텐츠가 들어갈 자리를 잠시 대신하게 되는 빈 껍데기이다.

출처 : https://ui.toast.com/weekly-pick/ko_20201110

 

더 나은 UX를 위한 React에서 스켈레톤 컴포넌트 만들기

스켈레톤 컴포넌트가 무엇인지 알고 있는가? 스켈레톤 컴포넌트는 데이터를 가져오는 동안 콘텐츠를 표시하는 컴포넌트이다. 사용자는 콘텐츠를 기다리다가 쉽게 지치고 지루함을 느끼므로 단

ui.toast.com

 

서버에서 데이터를 가져오고 있다는 것을 알려주는 사용자 친화적인 UI이다.

만약 스켈레톤 UI가 없다면, 사용자는 빈 화면만 보게 되어 좋지 않은 사용자 경험을 겪게 된다. 

이를 통해, 사용자의 대기 시간을 덜 지루하게 만들어준다. 

 

스켈레톤 UI를 만들 때, 가장 중요한 것은 실제 UI와 최대한 비슷해야한다는 것이다. 

실제 UI와 너무 큰 차이가 나게되면, 사용자가 이를 또 다른 독립적인 컴포넌트라고 인식할 수 있다. 

 

 

스켈레톤 UI 추가하기

홈페이지에 스켈레톤 UI 추가 

기존에 보이는 이미지와 같은 사이즈, border값을 적용하였다.

애니메이션 같은 경우에는, tailwind css를 사용하고 있었기 때문에, 간단히 추가할 수 있었다.

const HomeDiaryItemSkeleton = () => {
  return (
    <div className="h-[120px] w-[120px] animate-pulse rounded-md bg-gray-300"></div>
  );
};

export default HomeDiaryItemSkeleton;

위 컴포넌트를 isLoading 중일 때 불러오면 되는데, 내 화면에서 4개 정도면 가로 페이지를 모두 커버하기 때문에, map함수로 4개를 넣어주었다. 

          isLoading ? (
            <div className="flex gap-2">
              {[1, 2, 3, 4].map((_, idx) => (
                <HomeDiaryItemSkeleton key={"home my skeletion" + idx} />
              ))}
            </div>
          )

그러면 위와 같이, 이미지가 불러와지기 전 회색 스켈레톤 UI가 보이는 것을 확인할 수 있다! 

 

피드페이지에 스켈레톤 UI 추가 

피드페이지에도 스켈레톤 UI를 추가해보겠다. 

 

피드페이지의 각 아이템같은 경우에는, 홈페이지의 아이템처럼 크기가 일정하지 않다.

현재 idx에 따라서 높이와 너비를 다르게 설정해두어서, 스켈레톤 UI도 해당 높이와 너비를 적용해주어야 한다. 

 

또한, 이 페이지는 현재 무한스크롤로 구현되어 있다.

isLoading & isFetcingNextPage 

useInfiniteQuery로부터 현재 로딩중인지 속성을 꺼내와야 한다.

근데 처음에는 isLoading값만 가져와서 사용했다. 

하지만, isLoading은 처음 데이터를 가져올 때만 true값이고, 그 이후에는 계속 false였다. 

그래서, 다음 페이지를 가져오고 있음을 뜻하는 isFetchingNextPage 속성을 꺼내와 사용한다. 

  const {
    data: diaryData,
    fetchNextPage,
    hasNextPage,
    isLoading,
    isFetchingNextPage,
  } = useInfiniteQuery(["getPublicDiary"], fetchPublicData, {
    getNextPageParam: (lastPage, pages) => {
      if (lastPage && lastPage?.length > 0) return pages.length + 1;
      else return undefined;
    },
  });

 

그리고, 스켈레톤 UI 호출하는 코드를 2번 써주어야 한다. 

isLoading이 ture인 경우 (= 첫 데이터의 로딩) 와,

isFetchingNextPage가 true인 경우 (= 첫 페이지 이후가 로딩) 에 스켈레톤 UI를 호출해야 한다. 

    <div className="grid grid-flow-dense grid-cols-3 grid-rows-3">
      {isLoading
        ? Array.from({ length: 12 }).map((_, idx) => (
            <FeedItem idx={idx} key={"feedItem-skeleton" + idx} />
          ))
        : diaryItems.map(({ diary, totalCount }, idx) => (
            <FeedItem
              image={diary.imageUrl}
              idx={idx}
              like={totalCount}
              _id={diary.id}
              key={"feedItem" + diary.id}
            />
          ))}
      <div ref={ref} />
      {isFetchingNextPage &&
        Array.from({ length: 12 }).map((_, idx) => (
          <FeedItem idx={idx} key={"feedItem-skeleton" + idx} />
        ))}
    </div>

 

그리고, 기존의 feedItem의 너비와 높이를 그대로 이어가기 위해, 컴포넌트를 그대로 사용하면서, props 값의 변화를 주었다. 

interface FeedItemProps {
  image?: string;
  like?: number;
  idx: number;
  _id?: string;
}

 

props 중 idx만 필수로 지정해주고, 나머지는 없어도되는 값으로 지정해준다.

그리고 나서 아래와 같이, like와 image 값이 유효할 때와 아닐때로 나누어서 리턴한다.

  • like와 image 값이 유효하면, 실제 데이터를 불러오는 컴포넌트
  • 유효하지 않으면, loading 컴포넌트로서 불러오는 컴포넌트

여기에서 getGridRatio()와 getSize()는 피드페이지에서 너비와 높이를 다르게 설정하기 위한 함수이다.

더보기
  const getGridRatio = () => {
    switch (idx % 6) {
      case 0:
      case 3:
      case 5:
        return "col-span-1 row-span-1";
      case 1:
        return "col-span-2 row-span-1";

      case 2:
      case 4:
        return "col-span-1 row-span-2";
      default:
        return "col-span-1 row-span-1";
    }
  };

  const getSize = () => {
    return `${idx % 6 === 1 ? "w-[66vw] custom-breakpoint:w-[300px]" : "w-[33vw] custom-breakpoint:w-[150px]"} ${idx % 6 === 2 || idx % 6 === 4 ? "h-[66vw] custom-breakpoint:h-[300px]" : "h-[33vw] custom-breakpoint:h-[150px]"} `;
  };
like != undefined && image ? (
    <div
      className={`${getGridRatio()} relative cursor-pointer ${getSize()} `}
      onClick={linkToDetalPage}
    >
      <img
        src={"https://" + image}
        className={`${getSize()} object-cover`}
        loading="lazy"
      />
      <div
        className={`absolute top-0 bg-black bg-opacity-10 ${getSize()}`}
      ></div>
      <StarCountInImage like={like} />
    </div>
  ) : (
    <div className={`${getGridRatio()} ${getSize()} `}>
      <div
        className={`animate-pulse bg-gray-200 ${getSize()} border-solid border-gray-100 custom-breakpoint:border-[1px]`}
      ></div>
    </div>
  );

 

피드페이지 스켈레톤 UI 추가 결과

기존의 로딩 스피너보다, 스켈레톤 UI를 추가하니 조금 더 사용자가 기다릴 때 지루하지 않은 것 같다.