본문 바로가기

개발

Intersection Observer와 Skeleton UI로 UX 개선하기

업무 중 로딩 속도가 느린 페이지를 개선한 경험을 소개한다. Intersection Observer와 Skeleton UI를 활용했다.

문제 현상

우선 문제가 되었던 컴포넌트는 다음과 같이 하위 리스트를 가지고 있는 형태였다.

 

const Item = ({ data }) => {
  return (
    <ListItem>
      <ListTitle title={data.title} />
      <SubList>
        {data.subList.map((subData) => (
          <SubListItem data={subData} />
        ))}
      </SubList>
    </ListItem>
  );
}

 

페이지는 API 서버로부터 데이터를 가져와서 적게는 20개, 많게는 100개의 위 Item을 랜더링하는데, Item마다 SubList가 5~10개 정도 존재했다. 데이터의 갯수 자체는 그렇게 많지 않았지만 리스트가 중첩된 구조로 인해 랜더링에 시간이 꽤 소요됐는데, 컨텐츠가 모두 보이기까지 Item 50개일 때 기준으로 평균 1초 이상 걸렸다.

 

API 응답 속도와 데이터 양에도 매번 차이가 있어서 과학적인(?) 측정은 따로 하지 않았지만, 사용자가 종종 '느리다'는 느낌을 받을 정도였으니 개선이 필요한 것은 분명했다.

 

해결 방법

SubList가 느린 랜더링의 원인이었으므로 모든 ItemSubList가 아니라 현재 화면에 표시되고 있는 ItemSubList만 랜더링되도록 수정했다.

Intersection Observer

소개

Intersection Observer는 간단히 설명하자면 어떤 DOM 요소가 viewport 혹은 다른 부모 요소와 교차하고 있는지(=대개의 경우 visible한지) 파악할 수 있도록 해주는 Web API다. 이미지 레이지 로딩, 무한 스크롤 등을 구현할 때 사용된다.

 

기존에 Scroll Event를 기반으로 Element.getBoundingClientRect() 등을 반복적으로 호출해서 구현하던 것을 더 적은 비용으로 구현할 수 있도록 해준다.

 

다시 예시로 돌아와서, Item이 현재 화면에 표시되고 있는지 여부를 판단하기 위해 react-intersection-observer를 사용했다. 이름에서 알 수 있듯이 Intersection Observer를 리액트에서 사용하기 편리하도록 만든 라이브러리다.

적용

import { useInView } from "react-intersection-observer";

const Item = ({ data }) => {
  const { ref, inView } = useInView();

  return (
    <ListItem ref={ref}>
      <ListTitle title={data.title} />
      {inView && (
          <SubList>
          {data.subList.map((subData) => (
            <SubListItem data={subData} />
          ))}
        </SubList>
      )}
    </ListItem>
  );
}

 

위와 같이 ref를 관찰할 DOM 요소에 prop으로 넣어주면, ListItem이 화면에 표시될 때 inViewtrue가 되어 SubList가 랜더링된다.

 

반대로 ListItem이 화면에서 벗어나면 inViewfalse로 바뀌어 SubList가 unmount된다. 나의 경우 랜더링되었던 SubList를 굳이 unmount 시킬 이유가 없어서 다음과 같이 코드를 수정했다.

 

또 순서가 빠른 Item들의 경우 화면에 보이는 상태로 컴포넌트가 mount될 것이기 때문에 shouldRenderSubList의 초기값이 true가 되도록 수정했다.

 

const Item = ({ data }) => {
  const { ref, inView } = useInView();
  const [shouldRenderSubList, setShouldRenderSubList] = useState(data.index < 3);

  useEffect(() => {
    if(shouldRenderSubList) {
      return;
    }

    if(inView) {
      setShouldRenderSubList(true);
    }
  }, [shouldRenderSubList, inView]);

  return (
    <ListItem ref={ref}>
      <ListTitle title={data.title} />
      {shouldRenderSubList && (
          <SubList>
          {data.subList.map((subData) => (
            <SubListItem data={subData} />
          ))}
        </SubList>
      )}
    </ListItem>
  );
}

 

이렇게 하니 한 번 랜더링된 SubList가 더 이상 unmount되지 않았다.

 

이제 남은 문제는 ListItem이 화면에 들어오고 SubList가 랜더링되기까지의 짧은 순간에 UI jumping이 발생한다는 것이었다.

 

물론 useInView 훅의 옵션을 활용해서 ListItem이 viewport에 들어오기 '몇 픽셀 전'에 랜더링되도록 할 수 있고, 예시에서는 생략했지만 실제로는 그렇게 구현을 했다. 다만 사용자가 스크롤을 빠르게 내리는 상황에서는 여전히 UI jumping이 발생하므로 추가적인 조치가 필요했다.

Skeleton UI

소개

그래서 SubList에 Skeleton UI를 적용하기로 했다. Skeleton UI는 랜더링될 컴포넌트의 대략적인 형태(=뼈대)를 우선 보여주고, 이후에 실제 컨텐츠를 보여주는 것을 말한다. 많은 React UI 라이브러리에서 컴포넌트로 제공하며 당연히 순수 CSS로도 구현이 가능하다.

 

 

적용

// 생략

{shouldRenderSubList ? (
  <SubList>
    {data.subList.map((subData) => (
      <SubListItem data={subData} />
    ))}
  </SubList>
) : (
  <Skeleton />
)}

shouldRenderSubListtrue라면 SubList를, false라면 미리 정의된 Skeleton을 랜더링하도록 수정했다.

 

Skeleton이 적당한 높이를 차지하고 있어서 UI jumping이 발생하지 않고, 실제 컨텐츠와 유사한 형태를 보여주기 때문에 전환이 자연스럽게 느껴지는 효과가 있었다.

결과

이전에 Item이 50개일 때 평균 1초 이상 걸리던 것이 Item이 100개일 때에도 최대 500ms가 넘지 않는 수준으로 개선되었다. 사용자들로부터도 '빨라졌다'는 긍정적인 반응을 얻어서 매우 보람있는 경험이었다.