React의 렌더링 퍼포먼스 개선기 (부제: 수백개의 아이템을 가진 리스트를 개선하기)

MinuKang
Weverse Company Tech Blog
12 min readApr 6, 2021

--

안녕하세요, 미누캉입니다. 근 2년만에 프론트 엔드 글을 쓰게되었는데요, 그 사이에 위버스 컴퍼니 웹개발팀으로 이직을 하게 되었습니다 😇

저희는 글로벌 팬 커뮤니티 위버스의 웹을 만들고 있습니다. 굉장히 많은 아티스트와 팬분들을 연결하는 커뮤니티를 만들다보니 많은 트래픽과 데이터를 경험하게 됩니다.

제보 동영상과 함께 시작된 개선 작업 (현장님 땡큐)

위 동영상의 내용을 요약하자면, 수 백개의 댓글을 로드 한 후 다른 페이지로 이동을 하게되면, 화면이 수 십초간 멈추게 됩니다. 이는 피드, 댓글 등을 끊임없이 볼 수 있는 서비스에서 굉장히 악영향을 미치는 경험이라서, 빠르게 원인 파악을 착수하게 되었습니다.

1. 아이템 컴포넌트에 담겨있는 기능이 굉장히 많다.

해당 컴포넌트에는 번역/신고/좋아요와 같은 수 많은 기능들이 상위 컨테이너에서 관리되는게 아닌 각각의 컴포넌트에 주입이 되어있었고, 그래서 화면상에서 언마운트 되었을 때 지연이 될 수 있었습니다. 그래서 이를 최소화 하는 작업을 진행하니 약간의 개선 효과가 있었습니다. 하지만,

2. 그러기엔 보여지는 갯수가 너무 많다.

더 많은 아이템을 불러오게 되면 똑같이 지연됩니다. 그래서 근본적으로 ‘출력되는게 너무 많다’ 라고 판단하여, 현재 화면에만 보여지는 부분만 출력을 해보자! 라고 생각을 하게 되었습니다.

영역에 있는 부분만 보여지게 하고, 나머지는 사라지게 해보자! (출처 : https://bvaughn.github.io/forward-js-2017)

이를 구현한 라이브러리로 react-window , react-virtualized 가 있습니다. 둘 다 페이스북의 Brian Vaughn님이 만들어주신 컴포넌트입니다. 이 중에서 react-virtualized 를 다음과 이유로 선택하였습니다.

  • react-windowreact-virtualized와 비교하여 굉장히 가볍고, 빠른 속도를 자랑합니다. (2kb와 33.kb의 차이!, 물론 react-virtualized 에서 필요한 기능만 번들링하게끔 할 수 있다)
  • 하지만 react-window 는 미리 계산된 크기의 아이템만 사용할 수 있습니다. 위버스의 피드와 댓글을 사용자가 작성한 내용, 첨부한 미디어에 따라 가변적인 크기를 지닙니다.
  • 결국 자동으로 아이템의 크기와 위치를 계산시켜주는 CellMeasure 기능을 지원하는 react-virtualized 를 선택하게 되었습니다.

3. 하지만 사용하는 방식이 비직관적이야

예전에도 이 라이브러리를 사용하기 위해 들여다본적이 있었는데요, 굉장히 비직관적인 사용방식 때문에 허를 내둘렀던 경험이 있었습니다.

react-virtualized 로 ‘간단한’ 가변적 아이템을 가진 인피니티 스크롤을 만든 예제. 한 눈에 봐서는 각 각의 역할을 이해하기 힘들다.
  • react-virtualized 는 오로지 rowCount라는 상태로만 변화를 감지 할 수 있습니다. 그러기에 직접 배열 데이터를 관리해서 인덱스를 기준으로 아이템을 출력해야합니다.
  • CellMeasureCache 기능을 이용하여 CellMeasure 로 자동으로 계산된 아이템의 크기를 캐시 할 수 있습니다. 문제는 아이템의 크기가 변경되거나, 아이템이 배열 중간에 삽입되거나, 수정되거나, 삭제될 경우에 직접이 캐시를 컨트롤 해줘야 합니다.

그냥 업데이트 될 때 마다 캐시 날려버리고 새로 만들고 레이아웃 업데이트하면 만사 땡이긴 한데, 그만큼 또 레이아웃 계산을 모든 아이템에 실행하게 되어 레이아웃이 흐트러지고 느려집니다 (경험담)

비직관적인 사용방식, 직접 다뤄야하는 배열 관리, 수동적인 캐시 컨트롤을 개선하기 위해 이를 간편하게 사용할 수 있는 컴포넌트로 만들기로 했습니다. 다음과 같은 목표로 개발에 들어갔습니다.

  • 사용하는 입장에서 인덱스/배열 조작을 신경쓰지 않게끔 children로 아이템들을 넘겨주고, 해당 키의 순서로 변이를 감지하자
  • 아이템의 가변적인 크기 변경을 감지하여 캐시를 업데이트하자
  • 일반적인 리액트 컴포넌트의 경험을 하게끔 하자! (중요)

아이템 감지 및 업데이트

https://github.com/minuukang/react-easy-virtualized/blob/main/src/hooks/useCache.ts

아이템의 변경을 감지하기 위해서 children의 키들을 직렬화하여 useEffect의 디펜더시에 사용합니다. (이유는 렌더링 될 때 마다 해당 배열은 레퍼런스는 항상 변하기 때문)

그리고 변경되기 전에 이전값(beforeUpdateRenderKeyRef)에다가 저장하여, 현재 키 배열과 비교하여 삭제되었을 경우 캐시를 삭제해주고, 위치가 변경되었으면 해당 위치로 캐시값을 재조정합니다.

아이템 크기 변경 감지

아이템의 크기 변경을 감지하기 위해 Element의 박스모델 변이를 감지하는 ResizeObserver를 사용했습니다. 사용하다보니 문제가 있었는데요, 해당 옵저버는 크기가 변경되었을 때 뿐만 아니라, 해당 노드가 삽입되거나, 삭제되었을 때도 변이로 감지하여 사이드 이펙트가 있었습니다.

또한 옵저버를 새로 만들 경우(ex) Hook dependencies가 변경될 경우), 이를 끊었다가 다시 새로 만들어 원래 감지했던 Element들을 다시 관찰해야 했습니다. 이를 쉽게 처리하기 위해 커스텀 훅으로 만들어 사용했습니다.

https://github.com/minuukang/react-easy-virtualized/blob/main/src/helpers/useResizeObserver.ts

변경이 감지될때 첫 관찰이거나 엘리먼트의 루트 노드를 확인하여 Document 객체가 아닐 경우 삭제되었다고 판단하여 실행하지 않도록 했습니다. 또한 Element를 관찰할때마다 Set 에다가 넣고, Dependencies가 변경되었을 때 해당 Set에 있는 Element들을 다시 관찰하도록 했습니다.

https://github.com/minuukang/react-easy-virtualized/blob/main/src/hooks/useLayout.ts

이렇게 만들어진 useResizeObserver로 엘리먼트의 크기 변화를 감지하여, 상위 엘리먼트의 dataset에 선언된 키(virtualizedKey)를 가져와서 캐시를 업데이트 시켜줍니다.

IE에서는 ResizeObserver를 지원하지 않기 때문에 필요하다면 폴리필을 사용해야합니다.

효율적인 렌더링을 위해 여분 출력하기

순조롭게 개발하다가 사용성에서 문제를 발견했는데요, 스크롤 영역 밖으로 아이템이 사라지면 완전히 삭제시킨 후, 다시 나타날때 출력을 합니다. 그래서 내부 state가 초기화되죠. 이 state가 아이템의 크기와 관련이 있을 경우 다음과 같은 현상이 일어납니다.

굉장히 긴 댓글을 남겨주신 유저의 댓글을 더보기를 누른 후, 스크롤을 내렸다가 다시 진입해보니?

내용이 긴 댓글의 경우 일정 영역까지만 보이게 하고 “더보기” 버튼을 통해 자세히 볼 수 있도록 지원하는데, 이를 컴포넌트의 내부 상태로 관리되고 있습니다. 하지만 react-virtualized 를 적용한 리스트에서 사라졌다가 다시 나타날 때 “더보기” 상태는 초기화되지만 캐시된 크기가 “더보기”를 눌렀을 때의 크기를 가지게 됩니다.

이를 해결하기 위해 처음에는 사이즈가 변화된 아이템이 다시 화면상에 나타날때 캐시를 초기화하고 스크롤을 재조정하는 식으로 생각했습니다. 하지만 브라우저가 부드러운 스크롤을 지원할 경우 스크롤 재조정이 제대로 되지 않고 화면 깜빡이는 현상이 생깁니다.

그래서 다른 방법을 고민하기 위해 문서들을 뒤져보다가 다음 옵션을 발견했습니다.

The overscanRowCount property renders additional rows in the direction the user is scrolling to reduce the chance of a user scrolling faster than virtualized content can be rendered. (See here for a visual example of why this property exists).

This property has performance implications though: the higher the value, the more work react-virtualized needs to do in reaction to each scroll event. (See here for a visual example of how windowing works.)

https://github.com/bvaughn/react-virtualized/blob/master/docs/overscanUsage.md

요약하자면 overscanRowCount 속성을 통해 여분의 아이템을 얼마나 출력할지 결정할 수 있고, 이를 통해 렌더링 되는 속도보다 더 빠르게 스크롤하는 사용자를 대비해서 더 자연스럽게 해줍니다.

그래서 이 옵션을 여유롭게 설정하여 자연스러운 스크롤 UX를 가질 수 있도록 했고, 여분 영역에서 벗어난 아이템은 스크롤이 멈추었을 때 일정 시간 후에 자동으로 업데이트 되도록 이 문제를 해결 했습니다.

사파리에서 react-infinite-scroller 패키지와의 충돌

이렇게 만들어진 컴포넌트를 위버스 댓글 리스트뿐만 아니라 피드, 미디어, 알림 등 인피니트 스크롤이 되는 영역에도 적용하여, 문제 없이 잘 동작하는 것을 확인했습니다. 하지만 유독 사파리 브라우저에서 스크롤이 끊어지고 굉장히 느린것을 확인했습니다. 원인을 확인해보니 기존에 사용하던 react-infinite-scroller 에서 마우스휠 이벤트를 특정한 조건에서 취소하는 부분이 있는데, 해당 부분이 react-virtualized 와 충돌하는 것을 확인했습니다.

이를 위해 기존에 사용하던 react-infinite-scroller 를 걷어내고, react-virtualized 에서 지원하는 InfiniteLoader 컴포넌트를 사용하기로 했습니다. 빠르게 코드에 적용해보니 다행히 사파리에서 끊어지고 느려지는 현상은 사라졌습니다.

react-virtualized의 InfiniteScroller 사용하는 방식

InfiniteLoader는 children로 onRowRendered, registerChild 콜백을 받아서 List 컴포넌트에 넘겨주는데, 기존에 만들어뒀던 컴포넌트 코드에서도 해당 onRowRendered와 ref를 사용하기 때문에 겹치는 핸들러들을 합쳐주는 작업을 하였습니다.

https://github.com/minuukang/react-easy-virtualized/blob/main/src/helpers/functionHelper.ts

간단하게 콜백 인수들을 받아와서 한번에 실행시켜주는 헬퍼 함수입니다. 콜백들의 같은 타입 추정을 위해서 가장 하위 타입 서브셋인 never를 사용해 인자의 타입을 추정합니다.

https://github.com/minuukang/react-easy-virtualized/blob/main/src/hooks/useRender.tsx

이후 List 컴포넌트를 감싸서 출력할 수 있는 렌더 함수를 만들어, 인피니트 스크롤 옵션이 있을 경우 mergeFunc 헬퍼로 InfiniteLoader 콜백 옵션들과 기존 코드의 콜백들을 합쳐줍니다. 인피니트 스크롤 옵션이 없을 경우 List의 props를 넘겨주는 콜백을 실행하게 됩니다.

위 렌더 함수는 이와 같이 사용됩니다.

또한 로딩 컴포넌트를 출력하기 위해 아이템의 끝(reverse 옵션이 있을 경우 시작)의 캐시 또한 사용하게되는데, 아이템이 업데이트될 때 마다 로딩 컴포넌트가 사용하고 있는 캐시도 삭제하는 작업을 진행했습니다.

https://github.com/minuukang/react-easy-virtualized/blob/main/src/hooks/useCache.ts#L43

결과물 및 인터페이스

무야호~

이제 언제든 필요한 리스트에다가 감싸서 사용할 수 있게 되었습니다.

자세한 구현 내용과 소스는 https://github.com/minuukang/react-easy-virtualized 에서 확인할 수 있습니다!

저희 위버스컴퍼니에서는 위와 같은 문제를 해결하고자 하는 분들을 찾고 있습니다! 개인적으로 연락주시거나 원티드(https://www.wanted.co.kr/wd/16935)를 통해 지원하실 수 있습니다!

--

--