Published on

React에서 큰 리스트를 효율적으로 렌더링하는 방법

Authors
  • avatar
    Name
    GW

개요

React 애플리케이션을 개발하다 보면 다음과 같은 요구사항을 자주 만나게 됩니다.

  • 상품 목록 10,000개 보여주기
  • 게시글 목록 무한 스크롤
  • 채팅 메시지 수천 개 표시
  • 관리자 페이지의 대용량 테이블

처음에는 단순히 map()으로 렌더링하면 될 것처럼 보입니다.

const ProductList = ({ products }) => {
  return (
    <>
      {products.map((product) => (
        <ProductItem key={product.id} product={product} />
      ))}
    </>
  );
};

하지만 데이터가 수천 개로 늘어나면 이야기가 달라집니다.

스크롤이 버벅거리거나 화면이 늦게 그려지고, 심한 경우 브라우저가 멈춘 것처럼 느껴질 수도 있습니다.

그렇다면 정말 React의 성능이 좋지 않은 걸까요?

사실 대부분의 경우 문제는 React 자체가 아니라 한 번에 너무 많은 컴포넌트를 렌더링하는 방식에 있습니다.

이번 글에서는 다음 내용을 순서대로 살펴보겠습니다.

  • 큰 리스트가 왜 느려지는지
  • React는 리스트를 어떻게 렌더링하는지
  • Virtualization이 무엇인지
  • react-window를 이용해 어떻게 해결하는지
  • 언제 사용하는 것이 좋고 언제 사용하지 않는 것이 좋은지

큰 리스트는 왜 느려질까?

다음과 같은 코드는 React를 처음 배울 때 가장 많이 작성하는 코드입니다.

const ProductList = ({ products }) => {
  return (
    <>
      {products.map((product) => (
        <ProductItem key={product.id} product={product} />
      ))}
    </>
  );
};

100개의 데이터라면 큰 문제가 없습니다.

하지만 데이터가 10,000개라면 어떻게 될까요?

React는 ProductItem 컴포넌트를 10,000개 모두 생성합니다.

products (10,000개)
↓
ProductItem 10,000개 생성
↓
Virtual DOM 생성
↓
DOM 업데이트

여기서 많은 사람들이 오해하는 부분이 있습니다.

Virtual DOM이 있으니까 성능 문제가 없지 않을까?

그렇지 않습니다.

Virtual DOM은 불필요한 DOM 업데이트를 줄여주는 기술이지, 컴포넌트를 생성하는 비용까지 없애주는 것은 아닙니다.

React는 먼저

  • 컴포넌트를 실행하고
  • Virtual DOM을 만들고
  • 이전 Virtual DOM과 비교한 뒤
  • 필요한 DOM만 업데이트합니다.

즉, DOM 업데이트가 줄어들더라도 10,000개의 컴포넌트를 계산하는 비용은 여전히 존재합니다.

React는 실제로 무엇을 할까?

예를 들어 상품 데이터가 10,000개 있지만, 현재 화면에는 20개 정도만 보인다고 가정해 보겠습니다.

상품 목록을 다음과 같이 구현했다면 어떻게 될까요?

const ProductList = ({ products }) => {
  return (
    <>
      {products.map((product) => (
        <ProductItem key={product.id} product={product} />
      ))}
    </>
  );
};

React는 "화면에는 20개만 보이니까 20개만 렌더링해야지" 라고 생각하지 않습니다.

React 입장에서는 "배열에 10,000개가 있으니 10,000개 모두 렌더링해야 하는구나" 라고 판단합니다.

즉, 현재 화면에 보이지 않는 9,980개의 컴포넌트까지 모두 생성하고, 이를 Virtual DOM에 저장합니다.

사용자가 실제로 보는 개수
20개
───────────────────
React가 렌더링하는 개수
10,000개

화면에 보이지 않는 상품도 렌더링 대상에 포함된다는 점이 문제의 시작입니다.

렌더링보다 더 큰 문제는 DOM입니다

많은 컴포넌트를 렌더링하는 것도 비용이지만, 실제 DOM 노드가 많아지면 브라우저에도 부담이 발생합니다.

예를 들어 <li> 요소가 10,000개 생성되면 브라우저는 이 요소들을 화면에 표시하기 위해 아래의 여러 작업을 수행해야 합니다.

  • 레이아웃(Layout): 각 요소의 크기와 위치 계산
  • 페인팅(Paint): 요소의 색상, 텍스트 등 그리기
  • 스크롤 처리: 스크롤에 따라 화면에 표시할 영역 관리

DOM 노드가 많아질수록 이러한 작업에 필요한 비용도 증가할 수 있습니다.

결국 아래와 같은 과정이 반복됩니다.

React
↓
10,000개 컴포넌트 생성
↓
10,000개의 DOM 생성
↓
브라우저 Layout
↓
Paint
↓
스크롤 처리

그래서 단순히 React만 느려지는 것이 아니라 브라우저 자체도 함께 느려질 수 있습니다.

그렇다면 필요한 것만 렌더링하면 되지 않을까?

여기서 자연스럽게 이런 생각이 듭니다.

화면에 보이는 것만 렌더링하면 되는 것 아닌가?

맞습니다.

실제로 대부분의 경우 사용자는 10,000개의 컴포넌트 중 20~30개만 보고 있습니다.

그렇다면 굳이 보이지 않는 9,970개의 컴포넌트까지 모두 렌더링할 필요는 없습니다.

이 아이디어에서 나온 것이 바로 Virtualization(가상화) 입니다.

Virtualization이란 무엇일까?

Virtualization은 현재 화면에 보이는 요소만 렌더링하고, 화면 밖의 요소는 렌더링하지 않는 기법입니다.

예를 들어 리스트가 1~10,000개 있다고 해보겠습니다.

현재 사용자가 보는 위치는 501~505번째 컴포넌트입니다.

501
502
503
504
505

일반적인 렌더링이라면 1~10,000개의 컴포넌트를 모두 렌더링합니다.

하지만 Virtualization은 501~505번째 컴포넌트만 렌더링합니다.

501
502
503
504
505
↓
현재 보이는 요소만 렌더링

사용자가 스크롤하면 기존 컴포넌트를 제거하고, 새로운 컴포넌트를 재사용하면서 다음 항목을 보여줍니다.

즉, 스크롤할 때마다 현재 보이는 컴포넌트를 재사용하면서 새로운 데이터만 교체합니다.

현재 보이는 컴포넌트
↓
재사용
↓
새로운 데이터만 교체

그래서 데이터가 1,000개든, 10,000개든, 100,000개든 상관없이 실제로 렌더링되는 컴포넌트 수는 거의 일정하게 유지됩니다.

일반 목록과 가상화된 목록의 스크롤 방식 비교

일반 목록과 가상화된 목록의 스크롤 차이. 출처: web.dev

왜 React는 이런 기능을 기본으로 제공하지 않을까?

화면에 보이는 것만 렌더링하는 것이 더 좋은데, React는 왜 기본적으로 모든 리스트를 렌더링할까?

그 이유는 React는 UI 라이브러리이지, 리스트의 크기나 스크롤 방식까지 알 수는 없기 때문입니다.

어떤 리스트는

  • 데이터가 10개일 수도 있고
  • 스크롤이 없을 수도 있으며
  • 모든 요소를 한 번에 렌더링해야 하는 경우도 있습니다.

즉, React는 "배열을 그대로 화면에 렌더링한다."라는 역할만 담당합니다.

반대로 "수천 개의 데이터를 어떻게 효율적으로 보여줄 것인가?"는 애플리케이션이 해결해야 할 문제입니다.

그래서 React 생태계에서는 이러한 문제를 해결하기 위해 아래와 같은 Virtualization 라이브러리를 사용합니다.

다음으로는 가장 많이 사용하는 react-window를 이용해 큰 리스트를 효율적으로 렌더링하는 방법을 살펴보겠습니다.

react-window로 큰 리스트 최적화하기

Virtualization을 직접 구현할 수도 있지만, 대부분은 검증된 라이브러리를 사용하는 것이 좋습니다.

대표적으로 많이 사용하는 라이브러리가 react-window입니다.

간단한 예제를 살펴보겠습니다.

import { FixedSizeList } from 'react-window';

const Row = ({ index, style }) => {
  return <div style={style}>상품 {index + 1}</div>;
};

const ProductList = () => {
  return (
    <FixedSizeList width={400} height={500} itemCount={10000} itemSize={50}>
      {Row}
    </FixedSizeList>
  );
};

겉보기에는 10,000개의 데이터를 모두 렌더링하는 것처럼 보이지만, 실제로는 그렇지 않습니다.

react-window는 현재 화면에 보이는 아이템과 스크롤을 위해 필요한 일부 아이템만 렌더링합니다.

예를 들어 현재 화면에 10개의 아이템이 보인다면 실제로는 다음과 비슷한 범위만 렌더링합니다.

현재 화면
501
502
503
504
505
506
507
508
509
510
↓
실제 렌더링
498 ~ 513

사용자가 스크롤을 내리면 화면에 보이는 데이터가 바뀝니다.

예를 들어 화면이 다음과 같이 변경되었다고 해보겠습니다.

기존 화면
501
502
503
504
505
↓
스크롤
↓
새로운 화면
502
503
504
505
506

이때 react-window는 새로운 DOM을 계속 생성하는 것이 아니라, 기존 DOM을 최대한 재사용하면서 내용과 위치만 변경합니다.

스크롤
↓
기존 DOM 재사용
↓
표시할 데이터 변경
↓
위치(style) 업데이트

예를 들어 기존에 <div>501</div>을 표시하던 DOM은 새로운 DOM을 만드는 대신 <div>502</div>를 표시하도록 내용이 변경될 수 있습니다.

이처럼 화면에 필요한 개수만 유지하면서 기존 DOM을 재사용하기 때문에, 데이터가 10,000개이든 100,000개이든 브라우저가 관리하는 DOM 개수는 거의 일정하게 유지됩니다.

여기서 중요한 점은 React가 자동으로 DOM을 재사용해 주는 것이 아니라는 것입니다.

현재 화면에 어떤 아이템을 렌더링할지, 어떤 DOM을 재사용할지는 react-window가 관리합니다.

React는 react-window가 전달해 준 결과를 화면에 렌더링하는 역할을 담당합니다.

왜 이렇게 빨라질까?

많은 사람들이 "Virtualization은 React의 렌더링을 빠르게 만드는 기술" 이라고 생각합니다.

하지만 조금 더 정확하게 말하면

React와 브라우저가 해야 하는 일을 모두 줄여주는 기술입니다.

예를 들어 일반적인 방식이라면 아래와 같은 과정이 반복됩니다.

일반 렌더링

10,000개 데이터
↓
10,000개 컴포넌트 생성
↓
10,000개의 Virtual DOM 생성
↓
10,000개의 DOM 생성
↓
브라우저가 10,000개의 DOM에 대해 Layout과 Paint 수행

반면 Virtualization을 적용하면 아래와 같은 과정이 반복됩니다.

Virtualization

10,000개 데이터
↓
현재 화면에 필요한 20개 컴포넌트 생성
↓
20개의 Virtual DOM 생성
↓
20개의 DOM 생성
↓
브라우저가 20개의 DOM에 대해 Layout과 Paint 수행

React가 계산해야 하는 컴포넌트 수도 줄어들고, 브라우저가 관리해야 하는 DOM 수도 줄어듭니다.

그래서 초기 렌더링과 스크롤 성능이 모두 크게 개선됩니다.

Virtualization은 React의 렌더링 자체를 없애는 것이 아니라, 렌더링해야 하는 컴포넌트와 DOM의 개수를 크게 줄여 전체 렌더링 비용을 낮추는 방식입니다.

Virtualization은 언제 적용해야 할까?

모든 리스트에 Virtualization을 적용할 필요는 없습니다.

예를 들어 상품이 20개 정도라면 일반적인 map()만으로도 충분하며, Virtualization을 적용해도 얻는 성능 이점은 크지 않을 수 있습니다.

반면 수천 개 이상의 데이터를 한 번에 렌더링해야 한다면 Virtualization을 고려할 수 있습니다.

실무에서는 대표적으로 다음과 같은 화면에서 활용할 수 있습니다.

  • 쇼핑몰: 대량의 상품 목록
  • 관리자 페이지: 회원 목록, 주문 내역 등 대용량 테이블
  • 채팅 서비스: 수천 개의 메시지가 쌓이는 대화 목록
  • 로그 및 모니터링 시스템: 지속적으로 데이터가 추가되는 목록

다만 데이터가 많다고 무조건 Virtualization이 필요한 것은 아닙니다.

페이지네이션을 통해 한 번에 20~30개씩만 표시하거나, 일반적인 렌더링으로도 충분히 부드럽게 동작한다면 굳이 적용할 필요가 없습니다.

반대로 무한 스크롤은 데이터를 조금씩 불러오더라도, 스크롤할수록 화면에 렌더링되는 항목이 계속 늘어날 수 있습니다.

이런 경우에는 Virtualization을 함께 적용하여 데이터는 필요할 때 불러오고, 화면에는 현재 필요한 항목만 렌더링할 수 있습니다.

Pagination / Infinite Scroll
→ 데이터를 언제, 얼마나 가져올지 결정

Virtualization
→ 가져온 데이터 중 화면에 필요한 항목만 렌더링

결국 Virtualization은 리스트의 전체 데이터 개수만 보고 적용하기보다, 실제로 렌더링되는 항목 수와 DOM 노드 수, 스크롤 성능을 확인한 뒤 적용하는 것이 좋습니다.

직접 측정해 보기

지금까지는 Virtualization이 왜 성능을 개선하는지 원리를 살펴봤습니다.

그렇다면 실제로도 차이가 날까요?

동일한 조건에서 일반 렌더링과 react-window를 적용한 Virtualization을 각각 구현한 뒤, Chrome DevTools의 Performance Monitor를 이용해 DOM 노드 수를 비교해 보았습니다.

측정 환경은 다음과 같습니다.

  • 데이터 개수: 10,000개
  • 아이템 높이: 50px
  • 리스트 높이: 500px
  • 브라우저: Chrome
  • 실행 환경: 프로덕션 빌드
항목일반 렌더링Virtualization
DOM Nodes20,051개78개

일반 렌더링

일반 렌더링 DOM Nodes

일반 렌더링에서는 화면에 보이지 않는 아이템까지 모두 DOM으로 생성되기 때문에, 브라우저가 관리해야 하는 DOM 노드 수가 약 20,051개까지 증가했습니다.

Virtualization 적용

Virtualization DOM Nodes

같은 10,000개의 데이터를 사용했지만, react-window를 적용한 뒤에는 화면에 필요한 아이템만 렌더링하여 DOM 노드 수가 약 78개로 유지되었습니다.

즉, 데이터의 개수는 그대로지만 브라우저가 실제로 관리해야 하는 DOM의 개수는 약 99% 이상 감소한 것을 확인할 수 있습니다.

물론 DOM 노드 수가 성능을 결정하는 유일한 요소는 아닙니다.

하지만 브라우저는 DOM이 많아질수록 Layout, Paint 등 렌더링 과정에서 더 많은 작업을 수행해야 합니다.

따라서 큰 리스트에서는 DOM 개수를 줄이는 것만으로도 성능 개선에 큰 도움이 될 수 있습니다.

Virtualization에도 Trade-off가 있다

Virtualization이 항상 정답은 아닙니다.

대표적인 단점도 존재합니다.

1. 구현이 조금 더 복잡해진다.

단순한 map()과 달리

  • 아이템 높이
  • 스크롤 위치
  • Overscan

등을 고려해야 합니다.

2. 모든 UI에 적합하지 않다.

아이템의 높이가 계속 바뀌거나, 복잡한 애니메이션이 필요한 경우에는 구현 난이도가 올라갈 수 있습니다.

3. 검색 엔진이 모든 항목을 볼 수 있는 것은 아니다.

Virtualization은 화면에 보이는 요소만 렌더링합니다.

따라서 검색 엔진이 모든 리스트를 수집해야 하는 페이지라면 신중하게 적용해야 합니다.

성능은 좋아지지만 구현 복잡도도 함께 증가합니다.

그래서 데이터가 많지 않은데도 무조건 Virtualization을 적용하는 것은 오히려 과한 최적화가 될 수 있습니다.

정리

큰 리스트를 렌더링할 때 성능이 떨어지는 이유는 React가 느려서가 아니라, 한 번에 너무 많은 컴포넌트와 DOM을 생성하기 때문입니다.

Virtualization은 현재 화면에 필요한 요소만 렌더링하여 React와 브라우저가 처리해야 하는 작업을 크게 줄여줍니다.

핵심을 정리하면 다음과 같습니다.

  • 큰 리스트에서는 컴포넌트 생성 비용과 DOM 개수가 성능에 큰 영향을 줍니다.
  • Virtualization은 현재 화면에 필요한 요소만 렌더링합니다.
  • react-window 를 활용하면 화면에 필요한 항목만 유지하여 DOM 노드 수를 줄일 수 있습니다.
  • 데이터가 적은 경우에는 오히려 일반적인 map()이 더 단순하고 유지보수하기 좋습니다.
  • Pagination, Infinite Scroll, Virtualization은 서로 대체 관계가 아니라 상황에 따라 함께 사용할 수 있습니다.

큰 리스트를 다룰 때는 무조건 최적화 기법을 적용하기보다 먼저 현재 병목이 무엇인지를 확인하는 것이 중요합니다.

그리고 정말 많은 데이터를 렌더링해야 하는 상황이라면 Virtualization은 가장 효과적인 성능 최적화 방법 중 하나가 될 수 있습니다.

참고 자료