앞의 포스팅에서 언급했던 것처럼 무한스크롤 관련 이슈를 해결하고 나니 성능 이슈가 부각되었다. 원문과 번역문을 두고 사용자가 편집할 수 있는 서비스인데 아이템이 500개 정도만 되어도 키보드 입력 이벤트가 발생하면 사용자가 키보드를 누르고 화면에 해당 글자가 뜨는 시간 사이에 지연이 체감되는 정도였다.
list virtualization 또는 windowing 기법을 적용해서 뷰포트에 보이는 아이템만 DOM 노드에 가지게 하면 성능 면에서 큰 개선을 할 수 있을 거라고 생각했다. react-virtualized가 대표적인 windowing 라이브러리로 지금은 legacy가 된 이전 버전 react 공식 문서의 성능 최적화 파트에서도 추천하는 라이브러리를 먼저 고려했다.
프로젝트의 요구 사항 중 목록 가상화를 적용하는 데 있어서 까다로운 부분이 두 가지가 있었다. 먼저 첫 번째는 dynamic height로, 문장의 길이가 가변적이기 때문에 아이템의 높이(height)가 전부 달랐다. 따라서 dynamic height를 지원하지 않는 라이브러리는 사용할 수 없었다. react-virtualized의 경량화 버전으로 같은 메인테이너가 만든 react-window 라이브러리가 있는데, react-window는 미리 계산된 크기의 아이템만 사용할 수 있어서 우리 프로젝트처럼 가변적인 크기를 가진 아이템에는 사용하기 어려웠다.
react-virtualized에서는 CellMeasurer를 사용해서 rowHeight에 동적인 height를 주입할 수 있었다. CellMeasurer는 사용자에게 보이지 않는 방식으로 일시적으로 렌더링하여 셀의 내용을 자동으로 측정하는 고차 구성 요소로, 고정 너비를 지정하여 동적인 height를 측정한다.
const rowRenderer = ({ index, key, parent, style }: ListRowProps) => {
const item = items[index];
return (
<CellMeasurer cache={cache} parent={parent} key={key} columnIndex={0} rowIndex={index}>
{({ measure }) => <Item key={key} rowIndex={index} item={item} style={style} measure={measure} />}
</CellMeasurer>
);
};
measure 함수를 원문 셀과 번역문 셀 컴포넌트에 전달해서 원문과 번역문 텍스트가 생성된 이후 요소의 height를 다시 측정하면 정상적으로 렌더링이 됐다. 하지만 편집 화면에서 사이드 패널이 열리고 닫힘에 따라 추가적으로 Item의 높이가 달라지는데 이 경우에도 다시 measure 함수가 작동하게 해주어야 했다.
이 외에도 두 번째 이슈는 scrollTo 함수였다. 이전 포스팅에서 한 번 언급한 적이 있던 것처럼 어느 페이지든지 시작 페이지가 될 수 있었기 때문에 특정 아이템으로 이동하는 함수가 꼭 필요했다. 하지만 react-virtualized에서 지원하는 scrollTo 함수가 별도로 있지 않았고, 라이브러리를 도입하자 기존에 자체적으로 작성했던 scrollTo 함수가 동작하지 않았다.
라이브러리를 프로젝트의 요구사항에 맞게 커스텀해서 사용하는 방안도 있겠지만, 프로젝트의 요구사항에 맞는 다른 라이브러리가 있는지 추가 검색을 시작했다. 그리고 tanstack의 react-virtual을 찾았다! tanstack의 virtual은 dynamic height도 지원하고, pixel offset으로 이동하는 scrollToOffset과 특정 index로 이동하는 scrollToIndex 함수를 지원했다. dynamic height의 경우 react-virtualized처럼 measure가 필요한 시점마다 measure 함수를 호출하는 방식이 아니어서 사이드패널의 on/off 여부와 상관없이 dynamic height가 정상적으로 작동했다. 전반적으로 react-virtualized에 비해서 보일러 플레이트 코드가 적었고, 인터페이스 사용 방식이 더 직관적이라는 생각이 들었다.
react-virtual에서 까다로웠던 부분은 사용자가 스크롤을 위아래로 이동하면서 DOM 요소가 사라지고 생성됨에 따라 렌더링하는 아이템 배열 데이터와 react-query의 캐시 데이터를 런타임의 DOM tree와 일치시키기가 어렵다는 점이었다. 하지만 목록 가상화 기법을 사용하면 뷰포트에 보여지는 아이템만 DOM tree에 있기 때문에 아이템 개수가 수십만 개 이상이더라도 성능 이슈가 생기지 않을 것이기 때문에 이전처럼 사용자의 스크롤 이벤트에 따라서 이전 페이지, 다음 페이지를 가져오는 방식이 아니라 처음 페이지 진입시 전체 데이터를 한 번에 다 가져와서 렌더링하는 방식으로 API를 변경했다.
구체적인 구현은 아래 Youtube 클립의 도움을 많이 받았고, 이 클립에 나온 내용대로 구현하는 코드는 별도의 포스팅으로 작성했다.
약 2,000개의 아이템을 가지고 있을 때 키보드 입력(keydown) 이벤트 후 성능을 측정해 보았다. 그 결과 기존 useInfiniteQuery와 Intersection Observer API를 사용해 페이지 단위로 데이터를 가져올 때는 1142 ms 이 걸렸지만, useQuery를 사용해 한 번에 전체 데이터를 다 가져와서 react-virtual로 렌더링하자 20 ms 로 단축되었다. 무려 98%의 개선을 이뤄낸 것이다!
성능 개선 외에도 이번 task를 통해 라이브러리 선정에 대한 나름의 기준이 생긴 것이 소정의 성과였다.
1. 라이브러리의 기본 원리 이해
: 이 라이브러리를 도입해 얻고자 하는 것이 무엇인지, 해당 라이브러리가 무엇을 하고자 하는 라이브러리인지
2. 라이브러리 공식 문서
: 공식 문서의 설명과 예제가 잘 작성되어 있는지 (개인 레포인 react-virtualized와 tanstack의 문서는 동일선 상에 두고 비교하기 어려운 수준이었다.)
3. 프로젝트와의 호환성
: react-virtualized는 react + vite 환경에 곧바로 적용되지 않아서 라이브러리 레포의 이슈를 뒤져서 config 파일을 수정해야 했다.
4. 프로젝트 내에서 꼭 필요한 요구 사항 지원
: dynamic height와 scrollTo 함수와 같이 까다로운 부분을 라이브러리를 도입함으로써 수월하게 진행할 수 있는지
📚 참고 포스팅
'돌멩이 하나 > 셀프 크리틱' 카테고리의 다른 글
1-2년 차에 공부해야 할 키워드 (2) | 2025.01.01 |
---|---|
불필요한 useEffect 없애기 (5) | 2024.06.02 |
무한 스크롤과의 사투 (1) (1) | 2024.03.31 |
id는 숫자여야 할까, 문자열이어야 할까? (0) | 2023.12.03 |
다크 모드 - 화면 깜빡임(FOUC) 이슈 해결 및 시스템 설정과 연동 (0) | 2023.06.14 |