지금까지, 대량의 스크롤이 필요한 리스트를 최적화한 경험이 2번 있었다. 한 번은 prismic에서 미리보기 이미지 리스트를 최적화한 것이었고, 다른 한 번은 xnb.js에서 대량의 텍스트 미리보기 컴포넌트를 최적화한 것이었다. 두 프로젝트의 골자는 보이는 것만 렌더링하는 것이었으나, 그 구현체는 사뭇 달랐다.
렌더링 성능 최적화에 있다. 브라우저에서 대량의 dom 요소가 렌더링될 때, 각 요소들의 위치를 결정하는 레이아웃 과정을 거친다. 레이아웃 과정은 렌더링되는 엘리먼트가 많을수록, 렌더링할 엘리먼트의 depth가 클수록 소요 시간이 증가한다.
따라서 어떠한 컴포넌트의 렌더링 시간이 길어진다면 엘리먼트가 많아서 그럴 가능성을 의심해야 하며, 렌더링할 엘리먼트의 수를 줄이면 레이아웃 속도를 향상시킬 수 있다. 이 중, 보이지 않는 엘리먼트는 굳이 렌더 트리에 없어도 되지 않는가? 라는 아이디어에서 출발한 것이 보이는 요소 렌더링 기법인 것이다.
Prismic에서는 windowing 기법을 사용하여 보이는 요소만 렌더링했다. windowing 기법이란 컴포넌트의 스크롤 위치에 따라, 보이는 요소의 범위를 계산하여, 실제 보이는 요소만 dom 트리에 추가하고, 나머지 요소는 렌더링 자체를 하지 않아버리는 기법을 의미한다.
리액트에서 windowing 기법을 구현하는 라이브러리로는 react-window, react-virtualized가 있으나, Prismic의 미리보기 컴포넌트는 이중 폴더 구조 형태의 리스트로 구성되어 있기에, 좀더 높은 자유도를 위해 직접 구현하였다.
가상 리스트 기법은 다음의 원리로 구현된다.
2번은 현재 스크롤에서 어느 부분이 보여야 할지를 계산하는 방법으로, 가상 리스트를 이해하는 데 가장 중요하니, 부연설명을 해보겠다.
우선, 위의 사진은 리스트의 기본 구조이다. 위에서부터 0번, 1번 순으로 이어지며, 0번과 리스트 컨테이너 사이의 간격을 startPadding
, 각 리스트 높이를 elemHeight
, 리스트 사이의 간격을 elemGap
이라고 하겠다.
여기서 0번 엘리먼트의 시작 y값은 startPadding
이며, 종료 y값은 startPadding+elemHeight
이다. 1번 엘리먼트의 시작 y값은 startPadding+elemHeight+elemGap
이고, 종료 y값은 startPadding+elemHeight+elemHap+elemHeight
이다. 이 규칙을 적용하면, i번 엘리먼트의 시작 y값과 종료 y값을 구할 수 있다.
startPadding + (elemHeight+elemGap)*i
startPadding + (elemHeight+elemGap)*i + elemHeight
이제, 저 리스트에 가상의 가로선을 그어보자. 이 가로선이 보이는 엘리먼트의 기준이 되는 선이다. 가로선이 엘리먼트의 시작점에 있을 때, 엘리먼트 중간에 있을 때, 엘리먼트 종료점에 있을 때,엘리먼트와 엘리먼트 사이 공간에 있을 때로 나눠서 생각해보자.
먼저, 가로선 아래에 있는 엘리먼트가 보인다고 가정해보자. 이 가로선은 스크롤의 시작 가로선이다.
사진의 각 경우를 살펴보자.
스크롤 시작 가로선의 경우, 끝점을 기준으로 보이는 엘리먼트의 인덱스가 나뉘어진다.
다음으로, 가로선 위에 있는 엘리먼트가 보인다고 가정해보자. 이 가로선은 스크롤의 종료 가로선이다.
사진의 각 경우를 살펴보자.
스크롤 종료 가로선의 경우, 시작점을 기준으로 보이는 엘리먼트의 인덱스가 나뉘어진다.
이를 기반으로, 스크롤 시작 지점과 종료 지점에 보이는 엘리먼트의 인덱스 범위를 시각화해 보았다. 왼쪽 숫자가 스크롤 시작 지점 인덱스 범위, 오른쪽 숫자가 스크롤 종료 지점 인덱스 범위다.
잘 보면, (0번을 제외하고) 각 인덱스 범위의 길이는 elemHeight + elemGap
으로 동일하고, 몫 연산을 이용하면 스크롤 시작 지점/종료 지점의 y값을 기준으로 보이기 시작하는/보이기 끝나는 엘리먼트의 인덱스 번호를 알 수 있다.
Math.floor( (scrollStartY - (startGap - elemGap)) / (elemHeight + elemGap) )
Math.floor( (scrollEndY - startGap) / (elemHeight + elemGap) )
Prismic 프로젝트는 대량의 이미지를 분류하기 위해 만들어졌다. 이렇기 때문에, 최소 50개 이상, 최대 3000개의 이미지가 미리보기 컴포넌트에 보여지게 된다.
또한, 유틸리티 웹 어플리케이션이라는 앱의 특성상 싱글 페이지 어플리케이션 구현에 용이한 React를 사용하였다.
이러한 Prismic의 특성으로 인해, 미리보기 리스트의 성능을 향상시키기 위해 windowing 기법을 사용하는 것이 효과적이라고 생각했다. 그 이유는 다음과 같다.
xnb.js에서는 보이지 않는 요소의 css display 속성을 hidden 처리하는 기법을 사용하여 보이는 요소만 렌더링했다. intersection observer api를 이용해, 엘리먼트 영역이 보이면 display 속성을 block으로 처리하고, 보이지 않으면 display 속성을 hidden 처리하여서 렌더 트리에서 해당 엘리먼트를 제거하는 방식으로 구현했다.
이 기법은 다음의 원리로 구현된다.
xnb.js의 미리보기 컴포넌트는 최대 1만 줄의 텍스트를 렌더링해야 하지만, 청크 단위로 구분되면 최대 100개의 청크만 렌더링하게 된다. 이 점으로 미루어 보아, 보이지 않는 요소 hidden 처리 대신 가상 리스트로 얻을 수 있는 이점이 크게 있지 않을 것이라고 생각했다.
또한, xnb.js 1.3 업데이트는 빠르게 대중에게 퍼블리싱해야 할 필요가 있었다. 이미 2만 여 명의 대중에게 배포된 서비스를 업데이트하는 것이기 떄문에, 업데이트 속도가 늦으면 대중이 불편해할 것이기 때문이라고 판단했다. 그렇기 때문에 빠르게 이해하고 적용할 수 있는 보이지 않는 요소 hidden처리 기법을 사용했다.
windowing 기법과 보이지 않는 요소 hidden 처리는 보이는 부분만 렌더 트리에 존재한다는 공통점이 있으나, 세부적으로 다음의 차이를 보인다.