React|React Hook 톱아보기

useTransition (Async Support) vs useDeferredValue: 동시성 렌더링의 완성

10
useTransition (Async Support) vs useDeferredValue: 동시성 렌더링의 완성

크리스가 만든 검색 기능에는 치명적인 문제가 있다.

사용자가 검색어(query)를 입력할 때마다 10,000개의 아이템을 필터링해서 리스트를 다시 그린다.

typescript
// ❌ 렉 걸리는 검색창
function SearchApp() {
  const [query, setQuery] = useState('');
  
  const handleChange = (e) => {
    // 입력값 업데이트 (긴급)
    setQuery(e.target.value); 
    // 리스트 필터링 로직이 실행되면서 메인 스레드를 점유함 -> 입력창이 버벅거림
  };

  return (
    <div>
      <input value={query} onChange={handleChange} />
      <HeavyList query={query} />
    </div>
  );
}

타자를 칠 때마다 화면이 뚝뚝 끊긴다. 크리스는 "디바운스(Debounce)를 써야 하나?"라고 생각했지만, 디바운스는 입력이 끝난 후에 반응하므로 검색 경험이 굼뜨게 느껴진다.

리액트 18부터 도입된 동시성(Concurrency) 훅들은 이 문제를 근본적으로 해결한다. 바로 "중요한 업데이트는 즉시 처리하고, 무거운 업데이트는 나중에(Interruptible) 처리하는 것"이다.

오늘은 이 마법을 부리는 두 가지 도구, useTransitionuseDeferredValue를 비교하고, React 19에서 추가된 비동기 지원 기능까지 알아본다.

1. useTransition: "급한 것 먼저 하세요"

useTransition은 상태 업데이트를 "긴급하지 않음(Non-urgent)"으로 표시하는 훅이다.

우리의 상황에서:

  • 긴급: input 창에 글자가 찍히는 것 (사용자 눈에 바로 보여야 함).
  • 덜 긴급: 아래쪽 리스트가 필터링 되는 것 (0.1초 늦게 떠도 상관없음).
  • 크리스는 리스트 업데이트를 startTransition으로 감싸기로 했다.

    typescript
    // ✅ useTransition 적용
    import { useState, useTransition } from 'react';
    
    function SearchApp() {
      const [query, setQuery] = useState('');
      const [listQuery, setListQuery] = useState('');
      const [isPending, startTransition] = useTransition();
    
      const handleChange = (e) => {
        const value = e.target.value;
        
        // 1. 긴급 업데이트: 입력창은 즉시 반응
        setQuery(value);
    
        // 2. 전환 업데이트: 리스트 필터링은 뒷전으로 미룸
        startTransition(() => {
          setListQuery(value);
        });
      };
    
      return (
        <div>
          <input value={query} onChange={handleChange} />
          
          {/* isPending으로 로딩 상태 표시 가능 */}
          {isPending && <span className="spinner">필터링 중...</span>}
          
          {/* 리스트는 listQuery를 바라본다 */}
          <HeavyList query={listQuery} />
        </div>
      );
    }

    이제 사용자가 타자를 빠르게 치면, 리액트는 setQuery만 부지런히 처리하고 startTransition 내부의 작업은 건너뛴다. 타자가 멈추면 그제야 최신 값으로 리스트를 한 번만 그린다. 입력창이 절대 버벅거리지 않는다.

    React 19의 진화: 비동기 지원 (Async Transitions)

    React 19부터는 startTransition 안에 비동기 함수(Async/Await)를 넣을 수 있게 되었다!

    이것은 앞서 배운 useActionState의 기본 원리이기도 하다.

    typescript
    // React 19: 비동기 작업도 Transition으로 처리
    const handleClick = () => {
      startTransition(async () => {
        // API 호출이 끝날 때까지 자동으로 isPending이 true가 된다.
        await updateProfile(name);
      });
    };

    이제 별도의 isLoading state를 만들지 않아도, 비동기 작업 중 UI를 제어할 수 있다.

    2. useDeferredValue: "이 값은 나중에 쓸게요"

    useDeferredValue는 useTransition과 목적은 같지만 사용법이 다르다.

    useTransition은 상태를 업데이트하는 함수(Setter)를 감싸는 반면, useDeferredValue는 이미 바뀐 값(Value) 자체를 감싼다.

    주로 Props로 전달받은 값을 지연시키고 싶을 때 유용하다.

    typescript
    // ✅ useDeferredValue 적용
    import { useState, useDeferredValue } from 'react';
    
    function SearchApp() {
      const [query, setQuery] = useState('');
      
      // query는 즉시 바뀌지만, deferredQuery는 리액트가 여유 있을 때 바뀐다.
      const deferredQuery = useDeferredValue(query);
    
      return (
        <div>
          {/* 입력창은 원본 query를 사용하여 즉각 반응 */}
          <input value={query} onChange={e => setQuery(e.target.value)} />
          
          {/* 리스트는 지연된 값을 사용 */}
          {/* deferredQuery가 업데이트되기 전까지는 이전 리스트를 계속 보여줌 */}
          <HeavyList query={deferredQuery} />
        </div>
      );
    }

    코드가 훨씬 간단하다. startTransition을 쓸 수 없는 상황(예: 라이브러리가 주는 값을 그대로 쓸 때)에서 매우 유용하다.

    3. 무엇을 써야 할까? (Comparison)

    두 훅 모두 "렌더링 우선순위 조절"이라는 같은 목표를 가진다.

    특징useTransitionuseDeferredValue
    제어 대상State 업데이트 함수 (setState) (props, 변수)
    사용 위치상태를 변경하는 곳 (이벤트 핸들러)값을 사용하는 곳 (자식 컴포넌트, Props)
    장점isPending 상태를 제공하여 로딩 UI 구현 가능코드가 간결함, useEffect 의존성 배열에 넣기 좋음
    비유"이 작업은 천천히 처리해" (명령)"이 값은 덜 신선해도 돼" (데이터)

    크리스의 선택 가이드:

  • 내가 직접 setState를 호출할 수 있다? -> useTransition (로딩 표시도 되니까 좋음)
  • 부모로부터 Props를 받았는데 이게 무거운 렌더링을 유발한다? -> useDeferredValue
  • 4. 디바운스(Debounce)와는 무엇이 다른가?

  • Debounce: setTimeout을 사용해 작업을 아예 지연시킨다. 0.5초 동안은 아무 일도 안 일어난다. (낭비되는 시간 존재)
  • Concurrency: 작업을 시작하되, 더 급한 일(키입력)이 생기면 중단(Interrupt)하고 양보한다. 낭비되는 시간 없이 CPU를 풀가동하여 사용자 반응성을 최대로 높인다.
  • 따라서 렌더링 최적화에는 동시성 훅이 디바운스보다 훨씬 사용자 친화적(UX)이다. (단, API 요청을 줄이는 게 목적이라면 여전히 디바운스가 필요하다.)

    핵심 정리

  • 문제: 무거운 렌더링 작업이 메인 스레드를 막으면, 입력창 같은 필수 UI가 버벅거린다.
  • useTransition: 상태 업데이트의 우선순위를 낮춘다. isPending을 통해 진행 상태를 알 수 있으며, React 19부터는 비동기 함수도 지원한다.
  • useDeferredValue: 값의 업데이트를 지연시킨다. Props로 받은 데이터를 렌더링에 사용할 때 유용하다.
  • 결론: 디바운스 대신 이들을 사용하면, 멈춤 없는 부드러운 UI 경험을 제공할 수 있다.
  • 이제 렌더링 성능까지 잡았다.

    내장 훅 마스터리의 마지막 단계는 "외부 세계와의 동기화"다.

    useEffect로 window.resize 이벤트를 구독하거나, Redux/Zustand 같은 외부 스토어를 만들 때 발생하는 티어링(Tearing, 화면 찢어짐) 문제를 아는가?

    "useSyncExternalStore & useId: 라이브러리 개발자와 SSR을 위한 필수 훅" 편에서 계속된다.


    🔗 참고 링크

  • React Docs - useTransition
  • React Docs - useDeferredValue
  • Patterns for concurrency
  • 댓글 (0)

    0/1000
    댓글을 불러오는 중...