1차 개발 당시, 스터디 카드 리스트를 무한 스크롤 방식으로 먼저 구현했다. 하지만 실제 배로 후, 초기 진입 속도나 리스트 로딩 속도가 느리다고 체감했다.
그래서 useMemo
, useCallback
을 부모 컴포넌트인 StudyList와 자식 컴포넌트인 StudyCard에 골고루 적용해봤다.
그런데.. 메모제이션이 전혀 적용되지 않았다.
무한스크롤이 발생하거나, 필터링 조건이 바뀌거나, 새로운 스터디가 등록될 때마다 자식 컴포넌트까지 전부 재렌더링되면서, useMemo
로 감싼 로직(today
, 모집 마감 여부
등)도 매번 다시 실행되었다.
실제 카드 수를 500개 이상으로 늘려주고, Chrome Devtools Profiler로 렌더링 속도를 측정했다.
항목 | 무한스크롤 | 가상화 |
---|---|---|
방식 | 스크롤 시 새로운 데이터 추가 로딩 | 화면에 보이는 것만 DOM에 렌더링 |
DOM 수 | 계속 누적(계속 누적 (10 → 20 → … 1000) | 항상 일정 (뷰포트 기준) |
렌더링 비용 | 데이터 많을수록 ↑ | 데이터 수와 무관 |
가상화를 위해 react-window을 사용했다.
TypeScript 사용 시 타입 설치도 필요하다:
npm install react-window
npm install @types/react-window
<Grid
columnCount={columnCount}
columnWidth={cardWidth + 18}
height={700} // 뷰포트 높이(px), 조정 가능
rowCount={Math.ceil(studies.length / columnCount)}
rowHeight={cardHeight + 24}
width={gridWidth}
onItemsRendered={({ visibleRowStopIndex }) => {
const lastVisibleIndex = (visibleRowStopIndex + 1) * columnCount;
if (
hasMore &&
!loading &&
lastVisibleIndex >= studies.length - columnCount
) {
fetchStudies();
}
}}
>
{({ columnIndex, rowIndex, style }) => {
const idx = rowIndex * columnCount + columnIndex;
if (idx >= studies.length) return null;
return (
<div
style={{
...style,
left: (style.left as number) + 12 / 2, // 양 옆 gap/2씩
top: (style.top as number) + 12 / 2,
width: cardWidth,
height: cardHeight,
}}
>
<StudyCard key={studies[idx].id} study={studies[idx]} />
</div>
);
}}
</Grid>
window.innerWidth
를 기준으로 카드 가로 폭, 열 수 등을 계산해서 반영했다.같은 조건에서 다시 Profiler 측정하기
렌더링 성능이 약 2배 이상 개선되었고
전체 카드 수와 무관하게 일정한 성능 유지 가능해짐
가상화 적용하기 쉬웠는데 렌더링 시간이 바로 감소돼서 좋았다.
실제 사용자 경험을 고려하면 성능과 UX 모두 잡을 수 있는 조합이라고 느꼈다. 👍