블로그 목록

useTransition과 useDeferredValue — Concurrent 렌더링이 푸는 문제

2026-06-257 min read
ReactConcurrentuseTransitionuseDeferredValue성능

useTransition과 useDeferredValue는 무거운 작업을 빠르게 만들지 않는다. 대신 입력이 끊기지 않게 반응성을 지킨다. 그 원리를 React 렌더링 모델부터 짚는다.

1. 문제 — 렌더링은 "시작하면 못 멈췄다"

React 17까지 렌더링은 동기적이고 중단 불가능했다. state가 바뀌면 화면을 다시 그리는데, 이 작업이 메인 스레드를 독점하고 중간에 멈출 수 없었다.

function Search() {
  const [query, setQuery] = useState("");
  const results = hugeList.filter((item) => item.includes(query)); // 무거운 렌더

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <List items={results} />
    </>
  );
}

타이핑을 하면 매 키마다 ① 리렌더 시작 → ② 1만 개 필터링 + <List> 렌더(예: 100ms) → ③ 그동안 메인 스레드가 막혀 다음 키 입력이 씹힌다. 급한 일(타이핑 반영)과 안 급한 일(목록 갱신)이 같은 우선순위로 묶여 있는 게 원인이다.

2. 해결 — 우선순위 + 중단 가능한 렌더링

타이핑 중 — 급한 입력은 즉시, 무거운 렌더는 미루고 중단 가능⌨⌨⌨⌨타이핑 멈춤입력 반영(급함)즉시목록 렌더(낮은 우선순위)중단중단중단완료입력은 매 키마다 즉시 반영 · 무거운 목록 렌더는 다음 키가 오면 버려지고 재시작→ 타이핑이 멈춘 뒤에야 한 번만 완성된다 (작업이 빨라지는 게 아니라 반응성을 지킴)

Concurrent 렌더링은 두 가지를 가능하게 한다.

  • 우선순위 구분 — 어떤 업데이트는 "급함(urgent)", 어떤 건 "안 급함(transition)"으로 표시
  • 중단 가능(interruptible) — 낮은 우선순위 렌더 도중 급한 업데이트(새 키)가 오면, 하던 렌더를 버리고 급한 것부터 처리한 뒤 다시 시작

즉 무거운 목록 렌더를 잠깐 멈추고 입력에 양보할 수 있다. useTransition과 useDeferredValue가 이걸 쓰는 두 API다.

3. useTransition — "이 업데이트는 안 급해"

startTransition으로 상태 업데이트를 감싸면 그 업데이트가 낮은 우선순위가 된다.

function Search() {
  const [query, setQuery] = useState("");
  const [list, setList] = useState(hugeList);
  const [isPending, startTransition] = useTransition();

  const onChange = (e) => {
    setQuery(e.target.value); // ① 급함 — 즉시 반영 (입력창이 안 끊김)

    startTransition(() => {   // ② 안 급함 — 낮은 우선순위
      setList(hugeList.filter((item) => item.includes(e.target.value)));
    });
  };

  return (
    <>
      <input value={query} onChange={onChange} />
      {isPending && <Spinner />}
      <List items={list} />
    </>
  );
}
  • setQuery(급함)는 즉시 처리 → 입력창은 항상 부드럽다
  • setList(transition)는 무거운 렌더 도중 새 키가 오면 버려지고 다시 시작
  • isPending으로 "갱신 중" UI를 보여줄 수 있다

핵심 조건 — startTransition 안에 넣을 수 있는 건 내가 setState를 직접 호출하는 경우다.

4. useDeferredValue — 값의 반영을 한 박자 늦춤

setState를 내가 제어할 수 없을 때(예: 값이 props로 내려옴) 쓴다. 값의 지연된 복사본을 만든다.

function Search({ query }) {        // query는 부모가 내려줌 (제어 불가)
  const deferredQuery = useDeferredValue(query);

  const results = useMemo(
    () => hugeList.filter((item) => item.includes(deferredQuery)),
    [deferredQuery],
  );

  return <List items={results} />;
}

동작:

  1. query가 바뀌면 먼저 옛 deferredQuery로 빠르게 렌더(급한 부분만 반영)
  2. 그다음 낮은 우선순위로 새 값을 반영한 재렌더를 예약
  3. 그 사이 또 바뀌면 진행 중 렌더를 버리고 최신 값으로 다시

→ 입력창은 query(최신)로 즉시 반응하고, 무거운 목록만 한 박자 늦게 따라온다.

5. 둘의 차이 — 언제 무엇을?

useTransitionuseDeferredValue
다루는 대상상태 업데이트(setState)값(value)
쓰는 상황setState를 내가 호출할 때값이 props로 오는 등 제어 못 할 때
부가 기능isPending 제공없음 (value !== deferred로 직접 비교 가능)

같은 문제를 "업데이트를 미루느냐 / 값을 미루느냐"라는 두 각도에서 푼다.

6. 핵심 오해 — "빨라지는 게 아니다"

면접에서 중요한 포인트다.

  • Concurrent 기능은 무거운 작업을 더 빠르게 만들지 않는다. 1만 개 필터링은 여전히 100ms 걸린다.
  • 대신 그 작업을 중단 가능하게, 낮은 우선순위로 돌려 급한 입력이 끼어들 틈을 준다. 즉 총 작업량이 아니라 체감 반응성(responsiveness) 을 개선한다.

또 하나 — useDeferredValue가 효과를 보려면 무거운 자식이 memo로 감싸져 있어야 한다. 그래야 지연된 값이 안 바뀐 렌더에서 자식이 재렌더되지 않고 넘어간다.

const List = memo(function List({ items }) {
  /* 무거운 렌더 */
});

7. 정리

React 17까지는 모든 렌더가 같은 우선순위라 무거운 렌더가 입력 반응성을 잡아먹었다. Concurrent 렌더링은 업데이트에 우선순위를 주고 낮은 우선순위 렌더를 중단 가능하게 만든다. useTransition은 내가 호출하는 setState 를, useDeferredValue는 제어 못 하는 값 을 낮은 우선순위로 미뤄, 무거운 작업 중에도 타이핑이 끊기지 않게 한다. 작업을 빠르게 하는 게 아니라 반응성을 지키는 도구다.


리렌더 자체를 줄이는 메모이제이션과는 결이 다르다. 상태 갱신의 정확성 문제는 클로저와 stale closure, 리렌더 최소화는 불변성과 구조적 공유 글에서 다룬다.