← 전체 글로 돌아가기

웹 개발

리스트 렌더링이 느릴 때 확인할 것

큰 리스트를 표시하면 브라우저가 느려지는데, 원인은 렌더링, 네트워크, 또는 이벤트 핸들러일 수 있습니다.

화면에 1000개 항목을 렌더링하는데 죽어라 느리다. 스크롤도 끊긴다. 뭐가 문제일까.

먼저 병목 찾기

Chrome DevTools Performance 탭을 켜고 기록한다. 느린 부분은 어디인가?

  • 렌더링이 느린가 (황색 그래프)
  • 스크립트 실행이 느린가 (빨강)
  • 페인팅이 느린가 (초록)

Performance 탭의 Flame Chart를 보면 각 작업이 얼마나 걸렸는지 나온다.

// Performance 측정
console.time('render-list');
// 리스트 렌더링 코드
console.timeEnd('render-list');

렌더링 최적화

1000개 항목을 DOM에 다 추가하면 브라우저가 이들을 모두 계산한다. 대신 화면에 보이는 것만 렌더링한다.

// 나쁜 예
<ul>
  {items.map(item => <li key={item.id}>{item.name}</li>)}
</ul>

// 낫다: 가상 스크롤 라이브러리 사용
import { FixedSizeList } from 'react-window';

<FixedSizeList
  height={600}
  itemCount={items.length}
  itemSize={35}
  width="100%"
>
  {({ index, style }) => (
    <div style={style}>{items[index].name}</div>
  )}
</FixedSizeList>

가상 스크롤은 화면에 보이는 항목만 DOM에 유지한다. 스크롤할 때 항목을 교체한다.

이벤트 핸들러 최적화

각 리스트 항목에 이벤트 핸들러가 있고, 그 안에서 상태를 바꾸면 모든 항목이 리렌더링될 수 있다.

// 나쁜 예: 부모에서 상태를 관리하면 모든 자식이 리렌더링된다
function List() {
  const [selected, setSelected] = useState(null);

  return (
    <ul>
      {items.map(item => (
        <li key={item.id} onClick={() => setSelected(item.id)}>
          {item.name}
        </li>
      ))}
    </ul>
  );
}

// 낫다: 각 항목에서 자신의 상태만 관리
function ListItem({ item }) {
  const [isHovered, setIsHovered] = useState(false);

  return (
    <li
      onMouseEnter={() => setIsHovered(true)}
      onMouseLeave={() => setIsHovered(false)}
    >
      {item.name}
    </li>
  );
}

데이터 요청 최적화

리스트 전체를 한 번에 가져오면 초기 로딩이 느리다.

// 나쁜 예: 10000개 항목을 다 가져온다
const [items, setItems] = useState([]);

useEffect(() => {
  fetch('/api/items')
    .then(res => res.json())
    .then(data => setItems(data));
}, []);

// 낫다: 페이지네이션 또는 무한 스크롤
const [items, setItems] = useState([]);
const [page, setPage] = useState(1);

const loadMore = () => {
  fetch(`/api/items?page=${page}&limit=50`)
    .then(res => res.json())
    .then(data => setItems(prev => [...prev, ...data]))
    .then(() => setPage(page + 1));
};

메모리 누수 확인

리스트를 많이 스크롤하다 보면 메모리가 계속 증가할 수 있다. DevTools Memory 탭에서 힙 스냅샷을 찍고 비교한다.

메모리가 줄어들지 않는다면 이벤트 리스너, 타이머, 또는 외부 구독이 정리되지 않은 것이다.

// 나쁜 예: useEffect에서 정리하지 않는다
useEffect(() => {
  const handleScroll = () => { /* ... */ };
  window.addEventListener('scroll', handleScroll);
  // 정리 코드가 없다!
}, []);

// 낫다: 정리 함수로 리스너 제거
useEffect(() => {
  const handleScroll = () => { /* ... */ };
  window.addEventListener('scroll', handleScroll);

  return () => {
    window.removeEventListener('scroll', handleScroll);
  };
}, []);

최종 확인: 작은 리스트부터

100개 항목으로 테스트하고 부드러운가? 1000개로 올려보자. 어느 지점부터 느린가?

마지막으로, 가상 스크롤을 도입하기 전에 먼저 불필요한 리렌더링을 없애는 게 낫다. 최적화는 먼저 측정하고 진단한 후에 한다.