간단하게 만들어보는 리스트 가상화

jh_leitmotif·2023년 1월 27일
3
post-thumbnail

요즘 우리 회사의 화두는 기존 프로그램의 안정성과 속도를 개선하는 것이다.

덕택에(?) 폭풍전야같은 이 시간에, 무엇을 해볼까 하다가

주변에서 많이 볼 수 있는 성능 개선 방안인 리스트 가상화 방법을 별도의 라이브러리 없이 직접 구현해봤다.

왜 굳이 라이브러리를 쓰지 않았냐면, 도움 안 받고 하면 멋있으니까(?)

보통은 react-window, react-virtualized 이 두 라이브러리를 사용해서 구현한다.

이 문서에서는 Vanilla JS와 React로 구현한, 2가지 방법을 소개한다.

글이 너무 길다. 그냥 맨 밑에 있는 레포 링크를 따라 들어가 코드만 봐도 된다.

📛 주의 📛 :: 이 문서는 구현 내용을 공유하는 데에 목적이 있으며 정답이 아님을 명시합니다. 또한 잘못된 정보가 있을 수 있습니다.


리스트 가상화가 뭐에요?


첫번째 gif는 약 5천장에 달하는 이미지를 한 번에 내려받고 있다.
그 덕택에 서버가 일감이 많다며 503 코드로 강성 파업을 해버리고 말았다.

반면 두번째 gif는 스크롤시 해당 영역이 보일 때 이미지를 로드하고 있다.

한 줄 요약하면.

눈에 보이는 아이템만 실제로 그려주자.

는 것이 리스트 가상화다. 멋드러지게 개발 용어를 조금 섞어보자.

사용자가 바라보는 실제 뷰포트에 보이는 요소만 렌더링하자.

실제 성능을 비교해보면 아래와 같다.

전자가 한 번에 받아온, 후자가 가상화를 적용한 것의 성능표로 요약 탭의 '렌더링' 시간을 확인하면 된다.

실제로도 전자의 경우 페이지 진입시 버벅임이 느껴지고, 후자는 전혀 그런 기미가 없었다.


구현 방법을 생각해보자.

구현한 두 가지 방법은 아래와 같다.

  1. element의 위치가 화면 내부에 있는지 가늠하는 것
  2. Intersection Observer로 교차를 감지하는 것

https://jsonplaceholder.typicode.com/photos
Mockdata를 사용한 api 주소. 이 사이트가 이래저래 테스트에 좋은 것 같다.

🎯 Element의 위치가 Viewport에 있는가?

이미지 인용 : https://web.dev/i18n/ko/virtualize-long-lists-react-window/

해당 element가 사용자가 보는 화면에 있으면 된다.

이 때 활용할 수 있는 것은 clientWidth, clientHeight다.

대충 생각해보면, 다음과 같다.

1. element의 top과 left가 0보다 크다.
2. element의 right가 clientWidth(내부창 너비)보다 작다. (x축)
3. element의 bottom이 clientHeight(내부창 높이)보다 작다. (y축)

 >> 2와 3을 보충설명하면, element의 현재 위치가 내부 너비 / 내부 높이 안 쪽에 있다는 뜻.

https://stackoverflow.com/questions/123999/how-can-i-tell-if-a-dom-element-is-visible-in-the-current-viewport/7557433#7557433

관련된 코드는 스택오버플로우의 게시글에서 긁어왔다.

STEP 1. 데이터를 받아오자.

const table = document.querySelector(".table");

let data;
let elList;

...(중략)...

data = response.data;

data.forEach((i,idx)=>{
 const imgDiv = document.createElement("div") 
 
 imgDiv.classList.add("tableEl");
 
 table.appendChild(imgDiv);
}
elList = document.querySelectorAll(".tableEl");

elList.forEach((elem,idx)=>{
	elem.elems_index = index;
})

addEventToEl(elList); // 각 element가 viewport에 보일 때 이미지 태그를 추가하는 콜백

data = 받아온 이미지 url의 배열
elList = 그려진 row들의 배열

받아온 데이터 갯수만큼 row를 그려주고 각 row에 순번을 매기는 코드다.

해당 element가 viewport에 등장했을 때 이 element의 순번에 맞는 이미지를 그려주어야 한다.

따라서 element의 순번 필드인 elems_index에 순서를 부여했다.

Element의 property에 elems_index라는 필드가 있다. 해당 요소가 몇 번째 요소인지 알려주기 위한 거 같은데.... 아무리 mdn을 뒤져봐도 나오는 문서가 없다.

https://stackoverflow.com/questions/57286010/why-index-of-foreach-is-always-0-in-array-returned-by-intersectionobserver
위의 게시물을 참고했다.

STEP 2. viewport에 보이면 이미지를 그려주자.

위의 링크로 올려둔 viewport 감지 코드를 활용해, scroll Event를 먹여주면 된다.

해당 동작을 addEventToEl 함수가 수행한다.

const addEventToEl = (elList) => {
  document.querySelector(".table").addEventListener("scroll", () => {
    elList.forEach((el) => {
      if (isElementInViewport(el)) {
        setTimeout(() => {
          const idx = el.elems_index;
          const imgTag = document.createElement("img");
          imgTag.style.width = "100%";
          imgTag.style.height = "100%";
          imgTag.style.objectFit = "cover";
          imgTag.src = data[idx].url;

          el.appendChild(imgTag);
        }, 500);
      }
    });
  });
};

스크롤되는 영역인 table에 스크롤 이벤트 리스너를 먹이고,

스크롤 될 때마다 row 배열을 순회하면서 viewport에 나타난 el에 이미지 태그를 붙여준다.

setTimeout은 넣어도 그만, 안넣어도 그만인데 일부러 로딩되는 느낌을 주기 위해서 넣었다. 실 서비스용도라면 스켈레톤 처리를 별도로 했을 것 같다.

img는 모두 로딩됬을 때 호출되는 onload event가 있다. 해당 이벤트로 스켈레톤 처리를 보여줄지, 보여주지 않을지 선택할 수 있다.

놀랍게도, 이게 끝이다.


❌ 아쉽지만, 성능의 문제가 있다.

이 방법의 문제는 'scroll event' 가 트리거가 된다는 점이다.

따라서, 별도로 초기 렌더링 처리를 해두지 않으면 스크롤하지 않는 이상 이미지가 뜰리가 없다.

그리고 스크롤 이벤트가 발생할 때마다 리스트를 순회하기 때문에 데이터의 크기에 비례해 로직 수행시간이 늘어날 테다.

또한 Reflow 이슈가 있다.

Reflow란 Element가 문서에서 어디에 있는지를 계산할 때 다시 렌더링되는 현상을 의미한다.

getBoundingClientRect는 element의 좌표값을 계산하므로 reflow(스타일 다시 계산)가 일어난다.

https://gist.github.com/paulirish/5d52fb081b3570c81e3a
위의 링크에서 어떤 element api가 reflow를 일으키는지 확인할 수 있다.

좀 더 개선할 수 있는 여지가 없을까 싶다.
보통 이 때 건드리게 되는 것이 Intersection Observer인 것 같다.


🎯 그냥 Element가 실제 화면이랑 교차됐는지만 보면 되잖아?

Intersection Observer는 해당 요소가 실제 화면에 보여지고 있는지 감시해주는 좋은 친구다.

현시점에서 IE 11을 포함한 대부분의 브라우저를 지원하고 있기 때문에 호환성도 뛰어나다.

기본적인 Observer 선언 방법은 다음과 같다.

const io = new IntersectionObserver((entries)=>{
	entries.forEach((entry)=>{
    	if (entry.isIntersecting){
        	// 화면에 보였을 때의 동작을 넣어준다.
        }
    })
}, {
	threshold:1,
})

첫 번째 인자로는 Observer가 바라보는 요소의 배열을 파라미터로 넘겨주는 콜백 함수를 넣는다.

두 번째 인자는 옵션에 해당하는 값인데, 코드에서 사용된 threshold는 해당 요소가 몇 %보이고 있을 때 intersect 되었는가? 를 정해주는 값이다.

위의 경우엔 해당 요소가 화면에 완전하게 보일 때 intersect됐다고 판단한다.

옵션은 이 외에도 많은데, 그건 아래 문서에서 확인 바란다.

https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver
Intersection Observer API 명세서

STEP 1. 데이터를 받아오자

해당 STEP은 앞서 서술한 viewport 관련 내용에 있는 것과 동일한데, 단 하나만 바뀐다.

elList = document.querySelectorAll(".tableEl");

elList.forEach((elem,index)=>{
	elem.elems_index=index;
    io.observe(elem);
}

아까는 addEventToEl이라는 함수를 통해 각 element에 이벤트를 먹여줬는데, 위 코드의 경우에는 각 element를 observer가 바라보도록 만든다.

STEP 2. 교차 콜백을 작성하자

위에 작성한 intersection observer 선언 코드에서 entry.isIntersecting일 때 수행될 동작을 작성한다.

if (entry.isIntersecting){
	setTimeout(()=>{
    	const idx = entry.target.elems_index;
        
        const imgTag = document.createElement("img");
        imgTag.src = data[idx].url;
        
        entry.target.appendChild(imgTag);
    },500);
    
    if (entry.target.elems_index === data.length -1){
    	io.disconnect();
    }
}

기본적인 골자는 위에 작성했던 것과 똑같지만 element의 위치를 계산하는 코드가 빠져있는 것을 볼 수 있다.

또한 요소의 순번이 맨 끝인 경우, observer가 감시하고 있는 element들로의 연결을 모두 끊는 코드가 포함되어 있다.

disconnect()가 꼭 수행되어야 하는가? 에 대해서는 공식적인 포스트가 없고, 어차피 element가 삭제될 때 같이 삭제되니 문제가 없다라는 포스트도 있다. 관련 링크는 아래에 남긴다.

https://stackoverflow.com/questions/51106261/should-mutationobservers-be-removed-disconnected-when-the-attached-dom-node-is-r/51106262#51106262

https://stackoverflow.com/questions/62638631/is-calling-intersectionobserver-unobserve-strictly-required

어디선가 Reflow가 일어나고 있는 것 같지만, 적어도 observer로 인해서 일어나는 건 아닌 것으로 보인다.


React의 경우

Intersection Observer의 React 버전 코드는 구글에 검색해보면 수만가지(?)가 있으니 코드로 작성하지는 않는다.

React 스타일로 우아하게(??) 데이터를 렌더링한다.

각 row에 ref를 달아준다. Vanilla JS에서의 예시로 비유하면, 각 row를 observe하는 것과 같다.

인자로 넘겨주는 onIntersect 콜백이

if (entry.isIntersecting)

시에 호출되는 콜백으로, 교차되었는지 여부에 대한 상태를 true로 돌려준다.

React 버전코드에서 앞서 서술된 코드들과 다른 것은 isFirst와 isView가 있다.

isFirst는 처음 페이지에 진입했을 때 그 화면 내에 있는지에 대한 여부로

1~10번째 row들은 어차피 사용자에게 처음 보여지기 때문에 굳이 Observer가 바라볼 필요가 없으므로 처리한 부분이다.

그리고 isView는 앞서 서술한 onload event 를 이용한 트릭이다.

이미지가 다운로드 되기 전에는 로딩 스피너를 겹쳐서 보여주고, 완료되면 컴포넌트를 해제한다.

intersect observer 스크립트를 살펴보면 교차되자마자 disconnect를 호출하는 것을 볼 수 있는데, 이는 각각의 row가 Intersection Observer를 가지고 있기 때문에 조치한 부분이다.

Vanilla JS처럼 Table의 Element를 순회하면서 각 row마다 observe를 달아줘도 되기는 하지만, 그러면 React를 쓰는 이유가 없으므로 위와 같이 구현했다.

그런데 ref가 너무 많다?

데이터가 많아질 수록 ref도 많아진다.

ref는 메모리를 점유하는 객체이므로 데이터 양과 비례해 점유되는 메모리도 많아지지 않을까... 라는 생각에 고민을 좀 해봤는데.

이건 결국 페이지네이션으로 완화해야 되는 문제겠구나 싶다.

실제로 무신사 스토어 웹 페이지의 랭킹 페이지를 보면 한 페이지에 90개씩 페이징이 들어가있고, 리스트 가상화도 적용되어 있다.

https://www.musinsa.com/ranking/best?new_product_yn=Y


끝!

끝으로, 내가 구현한 내용물이 저장되어 있는 github repo를 공유한다.

Vanilla JS
https://github.com/KimJeongHyun/vanilla_list_virtualization
React
https://github.com/KimJeongHyun/image-virtualization-intersect

profile
Define the undefined.

0개의 댓글