📜 서론
저번 포스팅에서 많은 양의 컨텐츠를 로드할 경우 어떤 이슈가 생긴다고 했었다. 해당 페이지에서 데이터를 로드한 뒤, 다른 페이지로 이동했다가 다시 돌아올 때 딜레이가 발생하는 문제다. 이번 포스팅에서는 이 이슈를 어떻게 해결했는지 설명해보고자 한다.
💬 이슈 설명
무한 스크롤을 통해 리뷰 카드 수십 개를 로드해 렌더링 후, 다른 페이지로 갔다가 다시 해당 페이지로 돌아오면 위와 같이 딜레이가 심하게 걸린다. 해당 현상은 로드 된 리뷰 카드 개수에 비례해 더 심해지는 것을 확인할 수 있었다.
🔍 원인 분석 및 해결 과정
일단 리뷰 카드 개수에 따라 현상이 심해지는 것으로 보아, 서버에서 데이터를 받아오는 것과 상관 없이 페이지 내 DOM 트리가 무거워져서 생기는 문제인 것으로 보인다. 리스트 가상화 기법을 적용하면 해결될 것으로 보이는데, 그 전에 다른 방법들을 적용해도 유의미한 최적화가 이루어지는지 테스트해보았다.
⚠️ 테스트 조건 ⚠️
모든 작성글 79개 캐싱 후 데이터 로딩 일절 없게 만든 뒤 페이지 -> 피드 페이지로 이동하는 과정을 프로파일링했음
성능 측정은 구글 크롬 확장 프로그램인 React Developer Tools의 Profiler 기능을 사용했다.
React Developer Tools - Chrome 웹 스토어
Adds React debugging tools to the Chrome Developer Tools. Created from revision c7c68ef842 on 10/15/2024.
chromewebstore.google.com
📌 1. 기본(개선 이전)
화면 너비와 상관 없이 전체 페이지가 렌더링 되는데에 346.5ms가 소요됨
📌 2. 리뷰 카드 이미지에 lazy loading 적용
전체 페이지가 렌더링 되는데에 333ms가 소요됨
큰 변화 없음
📌 3. 리뷰 카드 이미지에 decoding="async"
옵션 적용
오히려 더 높아지는걸 확인함
📌 4. ReviewCard
컴포넌트에 memo
적용
큰 변화 없음
🧰 CardList
컴포넌트에 리스트 가상화 기법 적용
📌 리스트 가상화 기법이란?
Windowing이라고도 불리는 리스트 가상화(List Virtualization) 기법은 대량의 리스트 데이터를 효율적으로 렌더링하는 방법이다. 화면(뷰포트)에 보이는 요소만 렌더링하고, 보이지 않는 요소는 필요할 때 동적으로 추가하여 성능을 최적화한다. 지금 이 이슈를 해결하기에 가장 적합한 방법이다. 그리고 이 기법을 구현하기 위해 사용할 라이브러리가 바로 Tanstack Virtual이다.
TanStack Virtual
Virtualize only the visible content for massive scrollable DOM nodes at 60FPS in TS/JS, React, Vue, Solid, Svelte, Lit & Angular while retaining 100% control over markup and styles.
tanstack.com
📌 반응형 그리드 + 리스트 가상화 기법 = ?
하지만 Tanstack Virtual을 적용하면서 기존의 UI 형태를 유지하려는 과정에서 상당한 난관에 봉착했다. 문제는 다음과 같았다.
- TanStack Virtual 사용 시 리스트의 각 요소가 렌더링될 범위(높이)를 미리 지정해줘야 한다. 이 영역을 Virtualized Window(가상화 윈도우)라 한다.
- 각 리뷰 카드마다 가상화 윈도우를 적용할 경우, 기존에 CSS로 만들어진 반응형 그리드와 동일한 기능을 하는 UI를 유지하기가 쉽지 않아진다.
- 무한 스크롤을 어떻게 적용할지도 문제다.
그러던 중 "오늘의 집" 사이트에서도 리스트 가상화 기법을 사용한다는 정보를 얻게 되었고, 사이트를 살펴 보던 중 다음과 같은 걸 발견했다.
호진방 블로그
안녕하세요, 방호진의 기술 블로그입니다.
www.banghojin.site
바로 리스트 내의 단위를 한 행으로 처리하고 있었던 것. 그렇다. 그리드도 결국 여러 행의 집합이니, 이렇게 하면 반응형 그리드 구현 문제를 쉽게 해결할 수 있다.(왜 이런 생각을 못했을까)
그럼 이제 해야할 것은 다음과 같다.
- 가상화 리스트의 각 가상화 윈도우 내에 반응형 그리드를 적용한다.
- 각 행에 뷰포트 너비에 따라 할당할 카드의 개수를 다르게 적용한다.
- 간단한 계산식을 통해 리뷰 카드들이 각 행에 순서대로, 개수에 맞게 배치되도록 한다.
코드는 다음과 같다.
https://gist.github.com/Baejw0111/36bdbd7c766259a8036a44a6745e7f59
리뷰 리스트 페이지 에 리스트 가상화 기법 적용하기
리뷰 리스트 페이지 에 리스트 가상화 기법 적용하기. GitHub Gist: instantly share code, notes, and snippets.
gist.github.com
import { useRef } from "react";
import ReviewCard from "@/widgets/ReviewCard";
import { useIntersectionObserver } from "@/shared/hooks";
import { useWindowVirtualizer } from "@tanstack/react-virtual";
import { VirtualItem } from "@tanstack/react-virtual";
import { useTailwindBreakpoint } from "@/shared/hooks";
import UserCard from "@/widgets/UserCard";
import { ReviewInfo, UserInfo } from "@/shared/types/interface";
/**
* @description 카드 목록을 반환하는 컴포넌트
* @param infoList 카드 정보 배열
* @param callback 무한 스크롤 콜백 함수
* @param cardType 카드 타입(리뷰 카드 또는 유저 프로필 카드)
* @returns 카드 목록
*/
export default function CardList({
infoList,
callback,
cardType,
}: {
infoList: ReviewInfo[] | UserInfo[];
callback: () => void;
cardType: "review" | "userProfile";
}) {
const breakpoint = useTailwindBreakpoint(); // 현재 화면 너비
const listRef = useRef<HTMLDivElement>(null);
const gridColumnCount = {
xs: 1,
sm: 2,
md: 2,
lg: 3,
xl: 3,
"2xl": 4,
}; // 현재 화면 너비에 따른 그리드 칼럼 수
const totalWindowCount = Math.ceil(
infoList.length / gridColumnCount[breakpoint]
); // 가상화 윈도우의 총 개수
// 화면에 표시되는 리뷰 카드 그리드의 각 행을 가상화 윈도우로 관리
const virtualizer = useWindowVirtualizer({
// 가상화 윈도우의 총 개수는 (리뷰 개수 / 현재 화면 너비에 따른 그리드 칼럼 수)가 된다.
count: totalWindowCount,
estimateSize: cardType === "review" ? () => 240 : () => 192, // 각 행의 예상 높이
gap: 24, // 행 간의 간격
overscan: 2, // 미리 렌더링할 행 개수
});
const infiniteScrollRef = useIntersectionObserver<VirtualItem>({
callback,
dependency: virtualizer.getVirtualItems(),
});
return (
<>
{infoList.length > 0 ? (
<div ref={listRef}>
<div
className="relative w-full"
style={{
height: `${virtualizer.getTotalSize()}px`,
}}
>
{virtualizer.getVirtualItems().map(
(
item // 가상화 윈도우 렌더링
) => (
<div
key={item.index}
className="absolute top-0 left-0 w-full"
data-index={item.index}
style={{
// 각 가상화 윈도우들을 제대로 배치하는 설정
transform: `translateY(${item.start}px)`,
}}
>
<div
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-6"
key={item.index}
ref={
// 마지막 페이지의 뒤에서 두번째 윈도우가 뷰포트에 표시될 시 무한 스크롤 콜백 함수 트리거
item.index === totalWindowCount - 2
? infiniteScrollRef
: null
}
>
{Array.from({ length: gridColumnCount[breakpoint] }).map(
// 각 가상화 윈도우 내의 리뷰 카드들을 렌더링. 화면 너비에 다라 개수가 달라짐
(_, index) => {
// 각 카드의 Index
const cardIndex =
item.index * gridColumnCount[breakpoint] + index;
// 각 행에 카드들을 순서대로 배치
if (cardIndex < infoList.length) {
// 카드 리스트의 카드 타입에 따라 리뷰 카드 또는 유저 프로필 카드 반환
return cardType === "review" ? (
<ReviewCard
key={cardIndex}
reviewInfo={infoList[cardIndex] as ReviewInfo}
/>
) : (
<UserCard
userInfo={infoList[cardIndex] as UserInfo}
key={cardIndex}
/>
);
}
}
)}
</div>
</div>
)
)}
</div>
</div>
) : (
<div className="flex justify-center items-center h-full">
<p className="text-muted-foreground">
{cardType === "review"
? "리뷰가 없습니다."
: "해당하는 유저가 없습니다."}
</p>
</div>
)}
</>
);
}
CardList
컴포넌트가 두 종류의 카드들(리뷰 카드, 사용자 프로필 카드)을 렌더링할 수 있도록 만들어서 코드가 좀 길다.
다음은 화면의 너비(열의 개수)에 따라 페이지 렌더링 시간을 비교한 것이다.
📌 4열
전체 페이지가 렌더링 되는데에 125.2ms가 소요됨
346.5ms -> 125.2ms
📌 3열
전체 페이지가 렌더링 되는데에 84.6ms가 소요됨
346.5ms -> 84.6ms
📌 2열
전체 페이지가 렌더링 되는데에 61.2ms가 소요됨
346.5ms -> 61.2ms
📌 1열
전체 페이지가 렌더링 되는데에 30.5ms가 소요됨
346.5ms -> 30.5ms
💡 결론
뷰포트 내에 표시되는 리뷰 카드 UI의 개수가 적을수록 렌더링 시간이 빨라지기에 열 개수와 렌더링 시간이 정비례하는 걸 확인할 수 있다. 이렇게 리스트 가상화 기법을 사용해서 최소 2.7배, 최대 11배 이상의 성능 개선을 이뤄낼 수 있었다.
'프로젝트 > Re|view' 카테고리의 다른 글
[Re|view] 11. React Router loader + Tanstack Query로 네트워크 폭포수 방지하기 (0) | 2025.02.27 |
---|---|
[Re|view] 10. 클라우드 서비스 연동 및 Github Actions로 CI/CD 구축하기 (0) | 2025.02.11 |
[Re|view] 8. 무한 스크롤 적용기 (0) | 2025.01.30 |
[Re|view] 7. FSD 구조 적용 실패기 (0) | 2025.01.28 |
[Re|view] 6. 카카오 로그인 적용기 - Redux Toolkit으로 사용자 정보 관리하기 & 로그아웃 (1) | 2024.09.04 |