useDeferredValue, useTransition 톺아보기

keemsebeen·2025년 6월 8일
post-thumbnail

이전에 업데이트 큐에 대해 이야기하면서 Lanes 모델을 언급드렸는데요. 리액트 18에서는 Lanes 모델의 도입과 함께 다양한 동시성 관련 훅들이 등장했습니다. 그중에서도 useDeferredValueuseTransition렌더링 우선순위를 세분화하고, 보다 부드러운 사용자 경험을 제공하는 데 핵심적인 역할을 합니다.

오늘은 이 두 훅에 대해 이야기해보겠습니다.

리액트의 동시성 모드와 Lanes 모델

리액트 18 이전에는 모든 렌더링이 동기적으로 처리되었습니다. 렌더링 작업이 시작되면 완료될 때까지 중간에 멈추거나 우선순위를 조정할 수가 없었고, 결과적으로 무거운 작업이 있는 경우 사용자 입력이나 애니메이션이 끊기거나 느려지는 문제가 있었습니다.

이러한 문제를 해결하기 위해 리액트 18에서는 동시성 모드가 도입되었고 그 핵심에는 Lanes모델이 있습니다.

ExpirationTime에서 Lanes 모델로의 전환

리액트 16과 17에서는 ExpirationTime이라는 개념을 사용하여 업데이트 우선순위를 관리했습니다.

export const NoWork = 0;        // 아무 작업도 없음
export const Never = 1;         // 거의 처리되지 않아도 되는 매우 낮은 우선순위
export const Idle = 2;          // 유휴 상태에서 처리
let ContinuousHydration = 3;    // 서버사이드 렌더링 hydration 관련
export const Sync = MAX_SIGNED_31_BIT_INT;   // 즉시 처리해야 하는 동기 작업
export const Batched = Sync - 1;             // 배치로 처리할 작업

하지만 이 모델에는 몇가지 한계가 존재합니다.

  1. 단일 만료 시간 파이버 노드에는 오직 하나의 만료 시간만 설정할 수 있기 때문에, 서로 다른 우선순위의 업데이트를 동시에 처리하기 어렵습니다.

예를 들어, 사용자가 입력을 하면서 동시에 다크모드를 켰다고 가정해봅시다. 다크모드는 즉시 반영되어야 하지만, 검색어 입력에 따른 결과는 약간 늦어도 괜찮습니다. 그러나 이 두 업데이트가 같은 시점에 발생하면 동일한 ExpirationTime을 가지게 되어, 리액트는 이 둘을 구분하지 못하고 동일하게 처리합니다.

  1. 관련된 업데이트를 하나로 묶기 어려웠습니다. 우선순위 그룹화가 어렵다 보니, 불필요한 렌더링이 발생하거나 의도치 않은 상태 불일치가 생길 수 있습니다.
        function addItem(item) {
          setItems(prevItems => [...prevItems, item]); // 아이템 추가
          setTotalPrice(prevTotal => prevTotal + item.price); // 가격 업데이트
        }
        ```
        
  2. 여러 컴포넌트에 걸친 관련 업데이트들이 모두 함께 커밋되어야 하는 경우, 이를 보장하기 어려웠습니다.
  3. 서로 다른 우선순위의 업데이트를 적절히 일괄 처리하기가 어려웠습니다.(숫자 값으로 표현되기 떄문에 여러 단계의 세밀한 우선순위를 효과적으로 표현하기 어렵습니다.)
  1. 애니메이션, 드래그, 키 입력과 같은 상호작용은 높은 우선순위를 필요로 합니다.
  2. 데이터 페칭 이후의 렌더링은 중간 우선순위로 처리될 수 있습니다.
  3. 화면에 보이지 않는 콘텐츠 업데이트는 낮은 우선순위로 처리될 수 있습니다.
  • 구현 코드
    export function computeExpirationForFiber(
      currentTime: ExpirationTime,
      fiber: Fiber,
      suspenseConfig: null | SuspenseConfig,
    ): ExpirationTime {
      const mode = fiber.mode;
      if ((mode & BlockingMode) === NoMode) {
        return Sync;
      }
    
      const priorityLevel = getCurrentPriorityLevel();
      if ((mode & ConcurrentMode) === NoMode) {
        return priorityLevel === ImmediatePriority ? Sync : Batched;
      }
       // ..   
    }

Lanes 모델이란?

리액트의 Lanes 모델은 서로 다른 업데이트에 우선순위를 부여하기 위한 내부 메커니즘입니다. Lanes은 비트 필드로 표현되며, 각 비트는 특정 우선순위의 업데이트 집합을 나타냅니다. 비트 마스크 기반 시스템으로 각각의 우선순위를 서로 다른 비트 위치로 표현합니다.

Lanes는 흔히 고속도로의 차선에 비유됩니다. 이 비유가 적절한 이유는 다음과 같습니다.

  • 여러 차선이 병렬로 존재합니다.
  • 각 차선은 특정 우선순위의 업데이트를 담당합니다.
  • 빠른 차선(높은 우선순위)의 업데이트가 먼저 처리됩니다.

Lanes의 주요 우선순위

export const SyncLane: Lane =/*                        */ 0b0000000000000000000000000000001;
export const InputContinuousLane: Lane =/*             */ 0b0000000000000000000000000001000;
export const DefaultHydrationLane: Lane = /*            */ 0b0000000000000000000000000010000;
export const DefaultLane: Lane =/*                     */ 0b0000000000000000000000000100000;
export const TransitionLane1: Lane =/*                 */ 0b0000000000000000000000100000000;
export const IdleLane: Lane =/*                        */ 0b0100000000000000000000000000000;

각 Lane은 다음과 같은 우선순위를 가집니다.

  • SyncLane: 최고 우선순위로, 즉시 처리 되어야 하는 동기 업데이트에 사용됩니다.
  • InputContinuousLane: 입력 이벤트와 같은 연속적인 상호작용에 사용됩니다.
  • DefaultLane: 일반적인 상태 업데이트에 사용됩니다.
  • TransitionLane: 전환 작업(페이지 전환, 필터링 결과 등)에 사용되며, useDeferredValue , useTransition 에서 활용됩니다.
  • IdleLane: 우선순위가 가장 낮으며, 시스템이 한가할 때 처리해도 되는 작업에 적합합니다.

Lanes 모델은 리액트 내부의 업데이트 우선순위 처리를 훨씬 유연하게 만들어주며, useDeferredValueuseTransition 같은 동시성 훅들과 함께 강력한 사용자 경험 개선을 가능하게 합니다. 다음은 이 두 훅을 각각 살펴보며, 실제 렌더링 흐름에서 어떻게 동작하는지 예제를 통해 알아보겠습니다.

useDeferredValue

useDeferredValue는 값의 업데이트를 지연시켜 UI의 응답성을 유지하는 훅입니다.

const [value, setValue] = useState('');
const deferredValue = useDeferredValue(value)

실행 순서

  1. useDeferredValue는 값의 지연된 버전을 반환합니다.
  2. 리액트는 우선순위가 높은 업데이트를 먼저 처리하고, 그 후에 지연된 값을 업데이트 합니다. (원본 값이 변경되면 리액트는 먼저 다른 높은 우선순위 업데이트를 처리한 후, 여유 시간에 지연된 값을 업데이트합니다.)
  3. 새 값을 계산하는 동안 이전 값이 계속 표시되므로 UI가 응답적으로 유지됩니다.

어떤 문제를 해결하고자 등장했을까요?

리액트로 무거운 데이터를 사용해 화면을 조작하다보면 다음과 같은 상황을 마주할 수 있습니다.

  1. 사용자가 검색어를 입력하는데, 리스트가 너무 커서 타이핑이 버벅댄다.
  2. 필터를 바꾸면 화면이 멈칫한다.

이럴 때 useDeferredValue가 등장합니다. 개인적인 견해로는 자주 바뀌는 값으로 인해 무거운 렌더링이 자주 일어나는 문제를 해결하기 위해 이 훅을 만들지 않았을까 생각합니다. 무거운 렌더링은 나중으로 미루고, 우선 사용자 입력부터 처리하자는 사용자 중심의 UI 설계 철학 아닐까요?.

장점은 뭐가 있을까요?

  • 단순한 API 하나만으로 우선순위가 가능해집니다. (useTransition보다 더 간단한 API로 우선순위를 관리합니다.)
  • 컴포넌트 구조를 크게 변경하지 않고도 적용할 수 있습니다.
  • 브라우저가 한가할 때만 무거운 작업을 처리하니, 사용자가 실제로 체감하는 성능이 좋아집니다. (이는, 브라우저의 idle 시간을 활용하기 때문인데요. 궁금하면 더 찾아보세요.)
  • 더 중요한 업데이트가 오면 진행 중인 렌더링을 취소하고 새로운 작업을 시작할 수 있습니다.

단점은 없나요?

이런 useDeferredValue 도 도입시 고려해야 할 점이 존재합니다.

  • 사용자는 일시적으로 오래된 데이터를 보게 됩니다. 따라서 “왜 내 입력이 바로 반영이 안되지?”하는 의문이 들 수 있습니다. 어떻게 이 의문을 해소할 지 고민해봐야 합니다.
  • 원본 값과 지연된 값 사이의 일시적 불일치를 처리해야 합니다. 이 틈을 사용자에게 어떻게 알려주면 좋을 지 고민해봐야 합니다.
  • 두 개의 다른 값을 모두 추적해야 하니 코드의 복잡성이 증가할 수 있습니다.

언제 도입해보는게 좋은가요?

useDeferredValue는 만능 해결책이 아닙니다. 따라서 다음과 같은 상황에서 고려해보면 좋을 것 같습니다.

  • 사용자가 "왜 이렇게 느리지?"라고 느낄 만한 UI 응답성 문제가 있는 경우
  • 대규모 목록, 복잡한 차트, 고급 텍스트 편집기 같은 무거운 UI를 다루고 있는 경우
  • 외부 라이브러리에서 받아오는 값이나 props에 의존적인 무거운 렌더링이 있는 경우

debounce랑 뭐가 다를까요?

자주 비교되는 개념이 바로 debounce입니다. 둘 다 지연을 활용하는 기법이지만, 방식과 목적이 다릅니다.

  • debounce는 입력 이벤트를 일정 시간 동안 지연시켜 불필요한 호출을 막는 데 초점이 있습니다. 마치 엘리베이터 문이 닫히기 전에 누군가 또 탈지 기다리는 것처럼 일정시간 기다리는 시간이 존재합니다.
  • 반면, useDeferredValue입력은 즉시 반영하되, 그에 따른 무거운 렌더링 작업만 늦추는 방식입니다. UI 응답성을 지키는 게 핵심입니다.

useDeferredValue vs debounce 실제비교

useDeferredValue

단순히 텍스트를 표시하는 것이 무거운 작업이 아니기에 지연이 발생하지 않습니다. 따라서 100ms 동안 CPU를 점유(while (performance.now() - startTime < 100))하여 무거운 작업임을 나타내고 입력에 따라 500개의 결과를 렌더링하여 부하를 증가시켜봤습니다.

결과적으로 텍스트를 입력하면 입력 필드는 빠르게 업데이트되지만, 검색 결과는 deferredQuery를 사용하므로 지연되어 나타납니다. 이는 리액트가 더 중요한 UI 업데이트(입력 필드)에 우선순위를 두고, 덜 중요한 업데이트(결과 목록)는 나중에 처리하기 때문입니다.

debounce

디바운스는 자바스크립트의 타이머 기능을 활용하여 사용자 입력에 반응하는 방식을 제어합니다. 입력 속도와 관계없이 설정된 시간동안 실행 자체를 지연시키므로 그 기간 동안은 리렌더링이 전혀 발생하지 않습니다.

사용자가 빠르게 연속해서 입력할 경우 타이머가 지속적으로 초기화되어 최종 입력 이후에만 실제 업데이트가 진행됩니다. 이러한 특성 때문에 API 호출과 같은 서버 요청을 효과적으로 줄일 수 있으며, 불필요한 연산 자체를 원천적으로 차단하기 때문에 브라우저의 부담이 크게 감소합니다.

어느 부분에서 useDeferredValue 와 차이가 나는지 느껴지시나요?

useTransition

useTransition은 UI 업데이트의 우선순위를 관리하는 훅으로, 사용자 경험을 개선하기 위해 지금 당장 처리해야 하는 업데이트와 덜 중요한 업데이트를 구분하기 위해 사용됩니다.

예시

function SearchComponent() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isPending, startTransition] = useTransition();
  
  function handleChange(e) {
    setQuery(e.target.value);
    startTransition(() => {
      setResults(filterItems(allItems, e.target.value)); // 무거운 연산
    });
  }
  
  return (
    <div>
      <input value={query} onChange={handleChange} />
      {isPending ? <p>검색 중...</p> : null}
      <ul>
        {results.map(item => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </div>
  );
}

여기서 눈여겨볼 점은 사용자가 타이핑하는 동안 입력창은 지연 없이 즉시 반응하고, 무거운 검색 결과 계산은 startTransition 안에 넣어 우선순위를 낮춘다는 것입니다. 이로 인해 사용자는 타이핑이 막힌다는 느낌 없이 자연스럽게 입력할 수 있습니다.

작동 원리를 더 들여다보면

  1. useTransition[isPending, startTransition] 튜플을 반환합니다.
  2. startTransition 함수는 콜백으로 전달된 상태 업데이트에 낮은 우선순위(TransitionLane)를 부여합니다.
  3. isPending 값은 지연된 상태 업데이트가 완료될 때까지 true를 유지합니다.
  4. 리액트 내부적으로는 이 업데이트를 TransitionLane에 배치하여 다른 중요한 작업이 방해받지 않도록 합니다.

왜 등장했을까요?

대규모 상태 업데이트가 UI의 반응성을 저하시키는 문제를 해결하기 위해 만들어졌습니다. 다들 이런 경험 있지 않으신가요?

  1. 버튼을 클릭했는데 아무 반응이 없어 여러번 클릭하게 되는 상황
  2. 탭을 전환했는데 UI가 완전히 멈춰버리는 답답함
  3. 상태 변화 중 애니메이션이 부자연스럽게 끊기는 문제

이런 문제를 해결하기 위해 동시성 모드라는 새로운 패러다임이 등장하고, 핵심 아이디어인 렌더링 작업을 중단 가능하고 우선순위를 부여할 수 있게 만든 것 아닐까요? 저는 동시성 모드라는 것이 단순히 API가 추가됐다라기 보다는 리액트가 사용자 경험을 보는 철학적인 관점이 변화한 것은 아닐까 하는 생각도 들었습니다.

장점은 뭐가 있을까요?

  • 입력이나 클릭 같은 직접적인 상호작용은 즉시 반응하기 때문에, 사용자는 앱이 빠르게 동작한다고 느낍니다. 사실 무거운 작업은 여전히 시간이 걸리지만, 사용자는 그것을 알지 못하기에 덜 답답하게 느낄 겁니다.
  • isPending 상태를 통해 전환 과정에서의 피드백을 제공할 수 있어, 사용자는 무슨 일이 일어나고 있는지 알 수 있습니다. 불확실함이 줄어들면 사용자 만족도가 올라갑니다.
  • 사용자가 빠르게 여러 상태를 전환할 때, 중간 상태에 대한 무거운 렌더링을 건너뛰고 최종 상태만 처리할 수 있습니다.

단점은 없나요?

useTransition에도 주의할 점들이 있습니다.

  1. 우선순위를 고려한 상태 업데이트 모델을 이해해야 합니다.
  2. 동시에 여러 렌더 트리를 유지할 수 있으므로 메모리 사용량이 증가할 수 있습니다.
  3. 상태 업데이트가 지연될 수 있으므로, side effects가 예상치 못한 순서로 실행될 가능성이 있습니다.

기본적으로 리액트는 UI 업데이트를 위한 렌더 트리를 하나씩 처리합니다. 그런데 useTransition을 사용하면 다음과 같은 일이 벌어집니다.
1. 사용자가 타이핑을 함 → 즉시 반영 (높은 우선순위 → setQuery)
2. 동시에, 검색 결과를 준비 (낮은 우선순위 → startTransition 안의 setResults, 이 두 개의 상태 변화는 동시에 진행 중입니다.)
3. 따라서 리액트는 이를 위해 다음 두 개의 렌더 트리를 내부적으로 유지합니다.
현재 화면에 보여지고 있는 렌더 트리 → 사용자 입력을 빠르게 반영
Transition 중인 렌더 트리 → 아직 보여주지 않았지만 백그라운드에서 계산 중인 UI

useTransition과 useDeferredValue의 비교

두 훅 모두 내부적으로 React의 Lanes 모델을 활용하여 렌더링 우선순위를 관리하지만, 사용 방식과 목적에 차이가 있습니다.

특성useTransitionuseDeferredValue
주요 목적상태 업데이트의 우선순위 낮추기값의 지연된 버전 생성
제어 방식명시적 - 개발자가 어떤 업데이트를 지연시킬지 결정암시적 - React가 값 업데이트 시점 결정
API 구조함수 기반(startTransition)값 기반(반환 값)
상태 표시isPending 제공별도 상태 필요
적합한 사용 사례상태 업데이트 제어가 필요한 경우Props나 외부 값에 대응하는 경우
코드 복잡성약간 높음(상태 업데이트 래핑 필요)낮음(값만 전달)
Lanes 활용TransitionLane 직접 할당내부적으로 TransitionLane 사용
profile
프론트엔드 개발자 김세빈입니다. 👩🏻‍💻

0개의 댓글