보이지 않는 행의 렌더링 생략을 통한 리스트 렌더링 최적화

Droomii·2024년 2월 20일
0
post-thumbnail

1. 문제 발단

위와 같이 생긴 테이블에, 페이지네이션 없이 3천 개가 넘는 행을 출력해야 하는 상황이 있었습니다.
당연히 3천개나 되는 행을 깡으로 렌더링하면 시간이 오래 걸리는 이슈가 생깁니다.

3000여개의 행을 아무런 요령 없이 출력하니까 4.6초나 걸립니다. 처참하군요...

2. 화면에 보이는 행만 렌더링하기

현재 렌더링되는 3천개의 행 중, 보이는 행은 10개도 채 되지 않습니다. 즉 현재 방식으로는, 나머지 2990개의 행은 아직 보이지도 않으면서, 자신을 보여줄 준비를 미리 하고 있는 셈입니다. 하지만 사용자가 밑에까지 스크롤하지 않아서 끝내 보이지 않는다면? 렌더링을 허투루 하게 된 것이죠.

이 설레발 치는 행(row)님들에게 헛된 희망을 버리게 할 필요가 있었습니다. 가시권에 들어올 때만 렌더링이 되게 할 수는 없을지 고민하다가, 무한 스크롤에 활용했던 IntersectionObserver가 생각이 났습니다.

2.1 IntersectionObserver 활용하기

Intsersection Observer API는 특정 요소가 부모 요소와 교차하는지 관찰하는 API입니다.
이를 활용하면 특정 행이 부모 요소와 교차하고 있는지 확인할 수 있을 것입니다.

const SomeRow = (props: Props) => {
  const {
    name,
    ...
  } = props;
  ...
  const [show, setShow] = useState(false);
  const divRef = useRef<HTMLDivElement>(null);

  // 렌더 최적화 - 화면에서 벗어나면 내용 렌더 안하도록
  useEffect(() => {
    const div = divRef.current;
    if (!div) return;
    
    // Intersection Observer 생성
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          setShow(entry.isIntersecting);
        });
      },
      { threshold: 0 }
    );

    observer.observe(divRef.current);

    return () => {
      // 클린업
      observer.unobserve(div);
    };
  }, []);

  return (
    <div className={cx('row')} ref={divRef}>
      {show && (
        <>
          ...각종 렌더링
        </>
      )}
    </div>
  )
}
  • 행이 mount될 때, useEffect를 통해 IntersectionObserver를 생성하고, 알맞은 옵션을 부여합니다.
  • threshold를 0으로 지정함으로써, 가시권 밖에서 최초로 들어올 때, 가시권에서 완전히 사라졌을 때 이벤트가 발동됩니다.
  • entry.isIntersecting 속성은 타깃 요소와 루트 요소가 교차된 상태인지를 나타내는 boolean입니다. 즉 isIntersectingtrue이면, 현재 화면에서 보이는 상태입니다. 이 속성을 show 상태로 지정해주고, 요소의 내용 렌더링 여부를 이 show 상태로 분기처리 합니다.
  • 언마운트시 unobserve를 통해 클린업 해주는 것을 잊지 말자!

최적화 이전에는 4.6초라는 긴 시간이 걸렸지만, IntersectionObserver를 활용해 화면에 보이는 행만 정상적으로 렌더링 하고, 가려져 있는 행은 껍데기만 남기고 렌더링을 하지 않음으로써 0.1초로 단축시킬 수 있었습니다. (무려 97.8% 개선! 와!!)

2.2 한계점

이 방식의 문제점은, 화면에 보이지 않는 '껍데기' 요소를 여전히 렌더링 하고 있다는 점입니다.

껍데기 요소마저 렌더링하지 않는다면 각 행의 위치가 틀어지므로, 이를 일단 유지하기 위해서 위 방식을 취했습니다.
더 나아가, 클릭 후 화면에 반영되기까지 0.1초가 걸린다면 예민한 사용자에겐 충분히 체감될 시간이기도 합니다.


2.3 껍데기도 없애보자

각 행의 높이가 일정하다면, 아래와 같은 방식으로도 개선이 가능할 것 같습니다.

  1. 모든 행의 positionabsolute로 지정하기
  2. 순서에 따라 인라인으로 top을 할당해주기
  3. IntersectionObserver를 활용하는 것이 아닌, 리스트를 감싸는 요소의 높이 + 스크롤 위치를 활용하여 보이지 않는 행의 렌더링 생략하기

오버엔지니어링이 아닐까 싶었지만, 일단 시도해 보았습니다.

먼저, 리스트 컴포넌트에, 보여줄 행만 렌더링하기 위한 코드를 추가해줍니다.

const ROW_HEIGHT = 56;

const ListView = (props: Props) => {
  ...
  const listWrapRef = useRef<HTMLDivElement>(null);
  const [sliceStart, setSliceStart] = useState(0);
  const [sliceEnd, setSliceEnd] = useState(0);

  const setSliceRange = (start: number, end: number) => {
    setSliceStart(start);
    setSliceEnd(end);
  };

  useEffect(() => {
    const body = listWrapRef.current;
    if (!body) return;

    const height = body.clientHeight;

    setSliceRange(Math.floor(body.scrollTop / ROW_HEIGHT), Math.ceil((body.scrollTop + height) / ROW_HEIGHT));
    const scrollHandler = function (this: HTMLDivElement) {
      setSliceRange(Math.floor(this.scrollTop / ROW_HEIGHT), Math.ceil((this.scrollTop + height) / ROW_HEIGHT));
    };

    body.addEventListener('scroll', scrollHandler);

    return () => {
      body.removeEventListener('scroll', scrollHandler);
    };
  }, [list]);

  return (
    <div className={cx('recipient-list-wrap', className)}>
      ...
      {!!list.length && (
        // 리스트를 담고있는 스크롤 가능한 요소
        <div className={cx('recipient-list-body', 'modal-scroll')} ref={listWrapRef}>
          {/* 리스트를 감싸는 요소. (행의 높이 * 리스트 항목 개수)로 높이를 고정한다*/}
          <div className={cx('list-wrap')} style={{ height: list.length * ROW_HEIGHT }}>
            {/* 현재 보이는 행만 slice 하여 렌더링 한다. */}
            {list.slice(sliceStart, sliceEnd + 1).map((recipient, index) => {
              return (
                // index + sliceStart는 리스트 전체에서의 인덱스를 나타낸다.
                <Row
                  index={index + sliceStart}
                  ...
                />
              );
            })}
          </div>
        </div>
      )}
      ...
  );
};
  • 스크롤 이벤트 발동시 현재 스크롤 위치, 컨테이너의 높이를 기반으로 현재 화면에서 보여줘야 할 리스트의 범위를 구합니다.
  • 구한 범위를 갖고, 전달받은 배열을 slice 해줍니다.
  • 각 행에 부여할 절대 y위치를 계산하기 위해 각 행에 본래 index를 전달해줍니다.



const Row = (props: Props) => {
  const {
    ...
    index,
  } = props;

  return (
    // index * 행의 높이로, 행의 절대위치를 잡아준다.
    <div className={cx('row')} style={{ top: index * ROW_HEIGHT }}>
      ...
  )
}

추가로, 행 스타일에 position: absolute를 추가하고, topindex * ROW_HEIGHT를 부여해줌으로써 각 Row의 절대적인 위치를 지정해줍니다.

이제 딱 필요한 만큼만 렌더링 되는것을 확인할 수 있습니다.

2.4 최종 결과

중간 개선 결과인 88ms에서 3.3ms로,훨씬 더 줄일 수 있었습니다. (야호!)
최종적으로, 4.5초(4500ms)에서 3.3ms까지 획기적으로 줄임으로써, 사용자가 의식하지 못할 수준의 속도로 렌더링을 마칠 수 있게 됐습니다.

2.5 재사용을 위한 모듈화

추후 이런 상황이 또다시 발생할 것을 고려하여, 위와 같은 Lazy Loading 기능이 담긴 컴포넌트를 별도로 만들어 다른 곳에서도 사용할 수 있게 모듈화 했습니다.


interface RowRenderProps<T> {
  style: CSSProperties;
  data: T;
}

interface Props<T> extends Omit<ComponentPropsWithoutRef<'div'>, 'children'> {
  rowHeight: number;
  gap?: number;
  children: FC<RowRenderProps<T>>;
  dataList: T[];
  rowKey: keyof T;
}

const LazyList = <T,>(props: Props<T>) => {
  const { rowHeight, gap = 0, children, rowKey, dataList, ...htmlAttr } = props;

  const listWrapRef = useRef<HTMLDivElement>(null);
  const [sliceStart, setSliceStart] = useState(0);
  const [sliceEnd, setSliceEnd] = useState(0);
  const Row = useMemo(() => memo(children, (prevProps, nextProps) => deepEquals(prevProps, nextProps)), [children]);

  const setSliceRange = (start: number, end: number) => {
    setSliceStart(start);
    setSliceEnd(end);
  };

  useEffect(() => {
    const body = listWrapRef.current;
    if (!body) return;

    const height = body.clientHeight;
    setSliceRange(
      Math.floor(body.scrollTop / (rowHeight + gap)),
      Math.ceil((body.scrollTop + height) / (rowHeight + gap))
    );

    const scrollHandler = function (this: HTMLDivElement) {
      setSliceRange(
        Math.floor(this.scrollTop / (rowHeight + gap)),
        Math.ceil((this.scrollTop + height) / (rowHeight + gap))
      );
    };

    body.addEventListener('scroll', scrollHandler);

    return () => {
      body.removeEventListener('scroll', scrollHandler);
    };
  }, [dataList, gap, rowHeight]);

  return (
    <div {...htmlAttr} ref={listWrapRef}>
      <div style={{ height: dataList.length * rowHeight + (dataList.length - 1) * gap, position: 'relative' }}>
        {dataList.slice(sliceStart, sliceEnd + 1).map((data, index) => (
          <Row
            key={String(data[rowKey])}
            style={{ top: (index + sliceStart) * (rowHeight + gap), position: 'absolute' }}
            data={data}
          />
        ))}
      </div>
    </div>
  );
};
  • props로 행의 높이, 간격, 행의 렌더링 함수, 데이터 배열, map에서 key로 사용할 키값을 전달받습니다.
  • 내부에서 계산한 top 스타일 속성과 data를 렌더 함수에 전달합니다.

아래의 방식으로 컴포넌트를 사용하면 기존과 같이 동작합니다.

<LazyList
  rowKey={'id'}
  dataList={list}
  rowHeight={ROW_HEIGHT}
  >
  {({ data, style }) => (
    <div style={style}>
      ...data를 활용한 각종 렌더링
    </div>
  )}
</LazyList>
  • data 파라미터를 활용하여 원하는 형태의 행을 반환하는 함수를 children으로 전달합니다.
  • LazyList에서 계산한 style을 렌더단의 최상단 요소에 적용해줍니다.



참고문헌

Intersection Observer API - Web APIs | MDN

profile
What, How 이전에 Why를 고민하는 개발자입니다.

0개의 댓글

관련 채용 정보