React 18 useTransition으로 대량 리스트 렌더링 최적화

문제 상황

어드민 페이지에서 상품 검색 시 3000개 이상의 결과를 렌더링하면 입력창이 버벅이는 문제가 있었다. 사용자가 타이핑할 때마다 전체 리스트가 리렌더되면서 메인 스레드가 블로킹됐다.

기존 코드

function ProductSearch() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  const handleSearch = (e) => {
    const value = e.target.value;
    setQuery(value);
    setResults(filterProducts(value)); // 무거운 연산
  };

  return (
    <>
      <input value={query} onChange={handleSearch} />
      <ProductList items={results} /> {/* 3000+ 아이템 */}
    </>
  );
}

useTransition 적용

React 18의 useTransition을 사용해 검색 결과 업데이트를 낮은 우선순위로 처리했다.

import { useState, useTransition } from 'react';

function ProductSearch() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isPending, startTransition] = useTransition();

  const handleSearch = (e) => {
    const value = e.target.value;
    setQuery(value); // 즉시 반영
    
    startTransition(() => {
      setResults(filterProducts(value)); // 우선순위 낮춤
    });
  };

  return (
    <>
      <input value={query} onChange={handleSearch} />
      {isPending && <div>검색 중...</div>}
      <ProductList items={results} />
    </>
  );
}

결과

  • 입력 지연이 완전히 해소됐다
  • isPending 상태로 로딩 UI를 자연스럽게 제공할 수 있었다
  • debounce 없이도 충분히 부드러운 UX를 구현했다

주의사항

startTransition 내부의 setState는 동기적으로 실행되어야 한다. 비동기 작업은 외부에서 처리한 후 결과만 transition으로 감싸야 한다.

// ❌ 잘못된 사용
startTransition(async () => {
  const data = await fetchData();
  setResults(data);
});

// ✅ 올바른 사용
const data = await fetchData();
startTransition(() => {
  setResults(data);
});

useDeferredValue도 고려했지만, 검색 로직 제어가 필요해 useTransition을 선택했다. 상황에 따라 적절한 API를 선택하면 된다.