코드노트

React의 useTransition 가이드 정리, 동작 흐름 확인해보기 본문

Code note/리액트

React의 useTransition 가이드 정리, 동작 흐름 확인해보기

코드노트 2025. 4. 20. 19:27

useTransition이란?

useTranstion은 React18에서 도입된 Concurrent Rendering 기능의 일부로, UI 반응성과 성능을 개선하기 위해
"덜 중요한 작업을 늦춰서 처리" 하는 훅이다.

 

사용자가 빠르게 반응을 체감해야하는 작업이 있다면 가벼운 작업과 무거운 작업을 분리 할 수 있으며, UI의 지연(Lag)을 줄이는데 효과가 있다.

 

- 가벼운 작업 예시 : 버튼 클릭, 입력 필드 타이핑 등

- 무거운 작업 예시 : 목록 필터링, 차트 업데이트 등


언제 사용할까?

  • 대량 데이터 필터링, 렌더링, 정렬
  • 애니메이션, 차트, 목록 등 복잡한 UI 업데이트
  • 사용자의 입력 반응은 빠르게 결과는 나중에 처리하고 싶을 때

사용하지 않아도 되는 경우

  • 상태 업데이트가 가벼운 경우
  • 비동기 fetch 작업 위주로 구성될 때
  • 전체 앱이 단순한 상태 흐름을 가질 때

사용법은 간단하다.

const [isPending, startTransition] = useTransition();

startTransition(() => {
  // 우선순위가 낮은 상태 업데이트
  setSomeState(data);
});
  • isPending : 현재 transiton중인 상태를 나타낸다. ( true / false )
  • startTransition : 작업을 시작하는 함수

예제 실시간 검색 필터링

import { useState, useTransition } from 'react';

const largeList = [...Array(10000)].map((_, i) => ({
  id: i,
  label: `Item ${i}`,
}));

function SearchInput() {
  const [input, setInput] = useState('');
  const [filteredList, setFilteredList] = useState([]);
  const [isPending, startTransition] = useTransition();

  const handleChange = (e) => {
    const keyword = e.target.value;
    setInput(keyword);

    startTransition(() => {
      const filtered = largeList.filter(item =>
        item.label.toLowerCase().includes(keyword.toLowerCase())
      );
      setFilteredList(filtered);
    });
  };

  return (
    <div>
      <input value={input} onChange={handleChange} />
      {isPending && <p>검색 중...</p>}
      <ul>
        {filteredList.map(item => <li key={item.id}>{item.label}</li>)}
      </ul>
    </div>
  );
}
  • 검색어가 입력될 때마다  handleChange가 실행되지만 setInput으로 input상태를 업데이트 한다.
  • startTansition을 통해서 입력 반영 이후에 필터링은 나중에 처리되도록 진행한다.
  • 이때 isPending 값을 통해서 검색중이라는 UI를 유저에게 보여지게 할 수 있다.
  • React Scheduler직접 우선순위를 조절하고 렌더링 시점까지 관리된다.

 

만약 debounce를 사용해도 되는거 아니야? 라고 할 수 있지만

import { useState, useMemo, useEffect } from 'react';
import debounce from 'lodash.debounce';

function SearchDebounced({ data }) {
  const [input, setInput] = useState('');
  const [filtered, setFiltered] = useState([]);

  const debouncedSearch = useMemo(() =>
    debounce((keyword) => {
      const result = data.filter(item =>
        item.label.toLowerCase().includes(keyword.toLowerCase())
      );
      setFiltered(result);
    }, 300), [data]
  );

  useEffect(() => {
    debouncedSearch(input);
  }, [input, debouncedSearch]);

  return (
    <>
      <input value={input} onChange={(e) => setInput(e.target.value)} />
      <ul>
        {filtered.map((item) => <li key={item.id}>{item.label}</li>)}
      </ul>
    </>
  );
}

- 이렇게 debounce를 사용하게 되면 300ms동안 입력이 멈추면 그때 filter로직이 실행되게 된다.

- 필터 함수 호출 횟수가 줄어들기 때문에 성능에는 좋지만 입력 즉시 반응하지 않기 때문에 UX에서는 느린걸 느낄 수 있다.

- 그 외에도 로딩 상태 추가하기 위해서는 상태하나를 더 추가해야하는 번거로움이 필요하다.


그럼 debounce말고 무조건적인 useTransition이 좋은걸까?

 

그건 또 아니다.

만약 외부 api를 줄여야하는 경우가 있다면?

-> 검색시마다 api요청을 진행하게 된다면 debounce를 사용해서 api호출 횟수를 줄이는게 바람직 하다.

 

다시 말하지만 useTransition은 단순하게 지연을 시키는 것이 아니라 React 내부에서 상태 업데이트의 우선순위를 낮춰서 UI가 끊기지 않게 해주는 스케줄링 기술이다.

 

그렇다면 코드레벨을 통해서 useTransition의 동작 흐름을 살펴 보자!

 

const [isPending, startTransition] = useTransition();

- useTransition의 리턴하는 구조이다.

 

// 내부 구조 유추 형태 (단순화)
function useTransition() {
  const [isPending, setPending] = useState(false);

  function startTransition(callback) {
    setPending(true);
    
    // 실제 업데이트 예약
    unstable_runWithPriority(LowPriority, () => {
      callback();
      setPending(false);
    });
  }

  return [isPending, startTransition];
}

- 내부적으로 실행되는 코드를 단순하게 살펴보면 React는 내부적으로 state의 queue 상태를 가진 객체를 만든다.

- 실제로는 react-reconciler, scheduler, react-dom 패키지가 함께 작동한다.

 

// scheduler 구현 일부를 단순화한 예
unstable_runWithPriority(LowPriority, () => {
  // → 이 안에 있는 state 업데이트는 낮은 우선순위로 처리됨
  setFilteredList(filteredList);
});

- startTransition으로 감싸면 React는 그 안의 state update를 "low priority update"로 등록한다.

- 이렇게 진행되면 즉시 렌더링 되는게 아니라 긴급(urgent)한 작업들이 먼저 끝나기를 기다리게 된다.

- 여기서 핵심은 Scheduler가 이 업데이트를 바로 처리하지 않는 다는 것이다. React 18을 보면 새롭게 도입된 Concurrent Mode의 핵심 기능 중 하나이다.


React의 작업 우선 순위

// 내부 우선순위 enum
export const ImmediatePriority = 1;
export const UserBlockingPriority = 2;
export const NormalPriority = 3;
export const LowPriority = 4;
export const IdlePriority = 5;

- 보통 setState는 Normal ~ UserBlocking 수준으로 바로 처리되지만, startTransition()안에 있는 작업은 LowPriority로 들어간다.

function flushWork(hasTimeRemaining, currentTime) {
  // 시간이 남을 때만 low priority 작업 실행
  if (hasTimeRemaining || currentTime > deadline) {
    // do work
  } else {
    // 중단하고 urgent 작업 대기
  }
}

그럼 React 렌더링 중간에 지금 여유가 있는지 확인하고 Idle time이나 frame time이 안남았을때까지 기다렸다가 처리한다.

 

 

동작 순서

[사용자 입력]
  ↓
(setInput)  --> High Priority → 즉시 렌더링
  ↓
(startTransition 안의 setFilteredList) → Low Priority → 나중에 렌더링

debounce, useTransition 차이점 정리

항목 debounce useTransition
실행 타이밍 delay 이후 1번 실행 상태는 즉시 반영, 렌더링은 나중에
목적 함수 호출/요청 빈도 줄이기 렌더링 병목 분산 및 반응성 개선
사용 범위 네트워크 요청 최적화, 단순 함수 제어 React 렌더링 우선순위 스케줄링
UX에 미치는 영향 입력에 약간의 지연 있음 입력은 즉시 반영됨
내부 메커니즘 setTimeout, clearTimeout 기반 React Scheduler + Fiber 기반

 

 

이제 UI적으로 왜 유리한지 이해 되는것 같다.

  • 입력 값 변화는 즉시 화면에 반영된다!
  • 그러나 filteredList는 나중에 반영되기 때문에 렌더링 비용이 분산된다!
  • 렌더링 프레임을 블로킹하지 않고 부드러운 사용자 경험을 제공할 수 있다!

 

마무리 해보자면

  • useTransition은 React 내부의 Scheduler와 Fiber구조를 활용해 렌더링 우선순위를 조절하는 비동기 렌더링 기술이다.
  • startTransition 내부의 업데이트는 낮은 우선순위로 처리되어, 사용자 입력에 대한 UI응답을 빠르게 하고, 무거운 렌더링은 나중에 처리한다.
  • debounce와 달리, 호출 타이밍 제어가 아니라 렌더링 제어에 특화된 방식이다.