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 모두 잡을 수 있는 조합이라고 느꼈다. 👍