가상 스크롤링(Virtual Scrolling) 을 활용한 렌더링 성능 최적화

Ji-Heon Park·2024년 7월 31일
2

TmaxRG

목록 보기
5/10

문제

현재 진행 중인 프로젝트에서 무한 스크롤로 인해 데이터가 계속해서 쌓이면서 스크롤이 버벅이거나 클릭 후 화면의 변경이 지나치게 오래 걸리는 문제가 발생했습니다. 시간이 지날수록 데이터가 메모리에 계속 쌓여 브라우저 성능이 점차 악화되었습니다.

특히, 리스트 아이템을 클릭하여 메인 콘텐츠 화면을 업데이트하는 단순한 이벤트 처리에도 3초 이상 소요되는 현상이 발생해 사용자 경험이 크게 저하되었습니다.

원인

The width of a bar indicates how long it took to render the component (and its children) when they last rendered. If the component did not re-render as part of this commit, the time represents a previous render. The wider a component is, the longer it took to render.
The color of a bar indicates how long the component (and its children) took to render in the selected commit. Yellow components took more time, blue components took less time, and gray components did not render at all during this commit.
Introducing the React Profiler

리액트 프로파일러 확장프로그램을 사용해서 진단하였을 때, 카드리스트 컴포넌트를 렌더링 하는데 많은 시간을 쓰고 있습니다:

카드리스트 컴포넌트의 기존 코드는 리스트 데이터를 단순하게 map으로 모두 렌더링 하고 있었습니다.

<Container>
  {cardList.map((card, index) => (
    <Card
    	key={card.cardId}
  		className={
    		selectedCardId === card.cardId ? 'selected' : ''
  		}
        onClick={() => {
          onSelectCurrentCard(card.cardId);
        }}
		data-testid="app-card"
	/>
  )}
</Container>

하지만 데이터의 갯수가 점점 쌓이며 DOM에 너무 많은 행을 렌더링하여 앱 성능이 저하되고 있었습니다.

해결

몇개의 데이터를 받아도 사실 화면에 보여지는 아이템의 갯수는 리스트의 크기만큼으로 한정되어있습니다. 그렇기에 가상 스크롤링 + 메모이제이션을 사용하여 최적화를 진행했습니다.

가상 스크롤링?

가상 스크롤링(Virtual Scrolling)은 대량의 데이터를 효율적으로 렌더링하기 위한 기술입니다. 모든 데이터를 한 번에 렌더링하지 않고, 화면에 보이는 데이터만 렌더링하여 성능을 최적화합니다.

구현

react-window라이브러리를 사용하여 가상스크롤링을 쉽게 적용할 수 있습니다.

npm install react-window

FixedSizeList컴포넌트로 리스트를 감싸고 내부에서 스크롤에 해당하는 아이템을 렌더링할 수 있도록 해줍니다.

FixedSizeList의 height에는 화면에 보여줄 리스트의 높이와 아이템 하나의 높이 (itemSize)를 props로 넣으면 스크롤에 따라 보여질 아이템의 인덱스를 반환합니다.

간격을 주기위해서는 아이템의 높이값 + 간격값을 itemSize에 넣고, 렌더링 되는 아이템에 높이를 지정해줘야 합니다.

추가로 불필요한 리렌더링을 방지하기위해 메모이제이션을 적용하였는데 이때 react-window에서 제공하는 areEqual 비교함수를 넣을 수 있습니다.

import { FixedSizeList, ListChildComponentProps, areEqual } from 'react-window';

const CARD_HEIGHT = 54;

export default function CardList() {
  // ...

  const renderMemonizedCardItem = memo(
    ({ index, style }: ListChildComponentProps<any>) => {
      const card = cardList[index];

      return (
        <Card
          key={card.cardId}
          style={{
            ...style,
            height: `${CARD_HEIGHT}px`,
          }}
          className={selectedCardId === card.cardId ? 'selected' : ''}
          onClick={() => {
            onSelectCurrentCard(card.cardId);
          }}
          data-testid="app-card"
        />
      );
    },
    areEqual
  );

  return (
    <Container>
        <FixedSizeList
          height={644}
          itemCount={cardList.length}
          itemSize={CARD_HEIGHT + 6}
          width={316}
          itemKey={(index) => cardList[index].cardId}
        >
          {renderMemonizedCardItem}
        </FixedSizeList>
    </Container>
  );
};

가상 스크롤링을 적용하여 처음과 달리 모든 아이템이 노출되지 않고, 스크롤에 따라 필요한 갯수만 렌더링되는 것을 확인할 수 있습니다.

성능 비교

눈으로 보기에도, 확연하게 속도가 빠른 모습을 보이지만 정확히 수치화해보았습니다.

Profiler

가장 먼저 리액트에서 제공하는 프로파일러 컴포넌트를 사용했습니다. 프로파일러를 사용하여 렌더링 시간 측정이 가능합니다.

import { Profiler, ProfilerProps as ReactProfilerProps } from 'react';

interface ProfilerProps extends Omit<ReactProfilerProps, 'onRender'> {
  onRender?: React.ProfilerOnRenderCallback;
}

export default function ProfilerTableLogWrapper({
  children,
  ...others
}: ProfilerProps) {
  return (
    <Profiler
      onRender={(
        id,
        phase,
        actualDuration,
        baseDuration,
        startTime,
        commitTime
      ) => {
        console.table({
          id,
          phase,
          actualDuration,
          baseDuration,
          startTime,
          commitTime,
        });
      }}
      {...others}
    >
      {children}
    </Profiler>
  );
}

위와 같이 렌더링 지표를 테이블로 콘솔에 찍도록 커스텀 후 측정하였을 때, 카드리스트의 리렌더링 시간이 1210.89ms에서 1.8ms로 99.85% 단축되었습니다. (actualDuration)

console.time

다음은 실제 클릭으로 이루어져야하는 이벤트의 속도를 측정하였습니다. console.time/console.timeEnd를 사용해서 타이머를 실행/종료할 수 있습니다.

프로젝트는 iFrame기반 MFA구조로 이루어져 있는데 리스트의 아이템을 클릭할때 발생하는 이벤트의 순서를 간단하게 도식화하면 아래와 같습니다.

위 과정 중 클릭이벤트 ~ iFrame과 통신이 이루어지기 직전까지의 이벤트가 처리되는 시간을 측정하였을때, 3506.14ms에서 690.65ms로 80.3% 단축된 것을 확인하였습니다.

profile
Frontend Developer | 기록되지 않은 것은 기억되지 않는다

0개의 댓글

관련 채용 정보