10,000개 아이템 리스트, 3초에서 0.3초로
가상 스크롤링과 메모이제이션으로 대규모 리스트 렌더링 성능을 10배 개선한 사례
가상 스크롤링과 메모이제이션으로 대규모 리스트 렌더링 성능을 10배 개선한 사례
어드민 대시보드에서 주문 목록을 보여주는 화면이 있었다. 처음엔 문제없었는데, 데이터가 쌓이면서 목록이 10,000건을 넘어가기 시작했다. 페이지에 들어가면 브라우저가 몇 초간 멈추고, 스크롤도 버벅거렸다.
“그냥 페이지네이션 넣으면 되지 않나요?”라는 의견도 있었다. 근데 운영팀에서는 전체 목록을 한눈에 보면서 검색하고 필터링하는 게 중요하다고 했다. 스크롤하면서 빠르게 훑어보는 워크플로우가 있었던 것.
React DevTools Profiler로 확인해보니, 초기 렌더링에 10,000개 아이템 전부를 DOM에 그리고 있었다. 각 아이템이 꽤 복잡한 컴포넌트라서 렌더링 시간이 3초가 넘었다.
스크롤 성능도 문제였다. 스크롤할 때마다 전체 리스트가 리렌더링되면서 프레임 드랍이 심했다. 측정해보니 15fps 정도. 60fps가 정상이니 심각한 수준이었다.
메모리 사용량도 높았다. 10,000개 DOM 노드가 메모리에 상주하니 당연한 결과.
핵심은 화면에 보이는 것만 렌더링하는 것이다. 10,000개 중 화면에 보이는 건 20~30개 정도. 나머지는 그릴 필요가 없다.
react-window 라이브러리를 사용했다. 직접 구현할 수도 있지만, 엣지 케이스 처리가 까다로워서 검증된 라이브러리를 선택했다.
import { FixedSizeList as List } from 'react-window';
function OrderList({ orders }: { orders: Order[] }) {
return (
<List
height={600}
itemCount={orders.length}
itemSize={80}
width="100%"
>
{({ index, style }) => (
<div style={style}>
<OrderItem order={orders[index]} />
</div>
)}
</List>
);
}
스크롤 위치에 따라 보이는 아이템만 렌더링한다. 스크롤하면 위아래로 약간의 버퍼를 두고 아이템을 재활용한다.
문제는 아이템마다 높이가 다를 수 있다는 것이었다. 주문 메모가 길면 아이템이 늘어났다. VariableSizeList로 전환했다.
import { VariableSizeList as List } from 'react-window';
function OrderList({ orders }: { orders: Order[] }) {
const getItemSize = (index: number) => {
const order = orders[index];
// 메모 길이에 따라 높이 계산
const baseHeight = 80;
const memoLines = Math.ceil(order.memo.length / 50);
return baseHeight + memoLines * 20;
};
return (
<List
height={600}
itemCount={orders.length}
itemSize={getItemSize}
width="100%"
>
{({ index, style }) => (
<div style={style}>
<OrderItem order={orders[index]} />
</div>
)}
</List>
);
}
가상 스크롤링만으로 초기 렌더링은 해결됐는데, 필터링할 때 여전히 느렸다. 필터 조건이 바뀔 때마다 전체 리스트가 리렌더링되고 있었다.
React.memo로 아이템 컴포넌트를 감싸고, 필터링 로직도 useMemo로 캐싱했다.
const OrderItem = React.memo(function OrderItem({ order }: { order: Order }) {
return (
<div className="order-item">
<span>{order.id}</span>
<span>{order.customerName}</span>
<span>{order.status}</span>
</div>
);
});
function OrderList({ orders, filter }: Props) {
const filteredOrders = useMemo(() => {
return orders.filter(order => {
if (filter.status && order.status !== filter.status) return false;
if (filter.search && !order.customerName.includes(filter.search)) return false;
return true;
});
}, [orders, filter.status, filter.search]);
return <VirtualizedList orders={filteredOrders} />;
}
검색창에 타이핑할 때마다 필터링이 돌면 비효율적이다. 디바운싱을 적용해서 타이핑이 멈춘 후에 필터링하도록 했다.
const [searchInput, setSearchInput] = useState('');
const debouncedSearch = useDebouncedValue(searchInput, 300);
// debouncedSearch가 바뀔 때만 필터링 실행
확장 프로그램과 캐시 영향을 빼려고 시크릿 창에서 다섯 번 측정한 값이다.
| 지표 | 개선 전 | 개선 후 |
|---|---|---|
| 초기 렌더링 | 3.2초 | 0.3초 |
| 스크롤 FPS | 15fps | 60fps |
| 메모리 사용량 | 180MB | 85MB |
| 필터링 응답 | 1.5초 | 0.1초 |
초기 렌더링이 10배 빨라졌다. 체감상으로는 “느리다”가 “즉시”로 바뀐 느낌. 운영팀에서도 “이제 쓸만하다”는 피드백이 왔다.
스크롤도 부드러워졌다. 15fps에서 60fps로 개선되면서 버벅거림이 완전히 사라졌다.
무한 스크롤 vs 가상 스크롤: 처음엔 무한 스크롤을 고려했다. 스크롤하면 데이터를 더 불러오는 방식. 근데 이 경우엔 전체 데이터가 이미 있고, 검색/필터링을 클라이언트에서 해야 해서 가상 스크롤이 맞았다.
접근성: 가상 스크롤은 접근성 이슈가 있을 수 있다. 스크린 리더가 전체 목록을 인식하지 못할 수 있다. aria-rowcount와 aria-rowindex를 추가해서 전체 아이템 수와 현재 위치를 알려줬다.
키보드 네비게이션: 화살표 키로 목록을 이동할 수 있어야 했다. 포커스가 보이는 영역 밖으로 나가면 자동으로 스크롤되도록 처리했다.