Concurrent rendering

hong·2026년 4월 9일

javascript

목록 보기
8/12

Concurrent rendering

리액트가 렌더링 작업을 한 번에 끝까지 하지않고 중간에 끊었다 하고, 더 급한 작업을 먼저 처리할 수 있게 만든 렌더링 방식.

Input 입력과 동시에 리스트 필터링이 이루어지는 작업이 있다고 한다면, 입력 같은 급한 작업은 먼저 반영하고 리스트 필터링같은 무거운 작업은 나중에 이어서 처리하는 거임

(근데 ! 입력을 먼저 처리하고. 무거운 처리를 뒤로 미루는 일반적인 아이디어를 모두 concurrent rendering이라고 칭하진않음. 리액트 문맥에서 쓰는 말임)

첫인상만 보면 (일단 concurrent 뜻부터가 ‘동시에 발생하는’ 이니까) 오 리액트가 CPU 코어 여러 개 써서 동시에 렌더하나보다! 라고 이해할수도 있지만, 그건 아니고 브라우저 메인 스레드에서 동작하지만 리액트가 작업을 쪼개서 스케쥴링하는 것에 가깝다.

청소를 해야하는데 초인종이 울린다면 사람 두 명이 각각 청소를 하고 현관문을 열어주는 것이 아닌, 우선 현관문을 열어주어 용건을 확인한 뒤 청소를 이어서 하는 느낌인 것.

Concurrent rendering은 React 18부터 도입된 렌더링 방식과 관련된 개념인데,

startTransition
useTransition
useDeferredValue
Suspense

같은 기능들이 이 모델과 연결되어 동작한다. 조금 더 자세히 보자면 :

startTransition

이 상태 변경은 급하지 않으니, 더 중요한거 먼저 처리해도 돼, 라고 알려주는 기능.

예를 들어 검색을 한다고 하면, 검색창에 타이핑하는 건 급하고 결과를 그리는 건 덜 급하므로 입력값 반영을 먼저 바로바로 해줘, 라고 말하는 것이다.

useTransition과 거의 비슷하지만, isPending은 안준다. 대신 컴포넌트 밖에서도 쓸수있음.

예를 들면 :

import { startTransition } from 'react';

function navigate(setPage: (page: string) => void) {
startTransition(() => {
setPage('/about');
});
}

이런 식으로, Hook이 아니라 그냥 함수라서 import 해서 유틸 함수 같은 곳에서도 쓸 수 있다.

이 때 제한이 하나 있는데, startTransition이든 useTransition에서 꺼낸 startTransition이든, 그 state의 setter에 접근 가능해야 감쌀 수 있다.

예를 들면 :

import { startTransition } from 'react';

export function updateFilter(setFilter: (v: string) => void, value: string) {
startTransition(() => {
setFilter(value);
});
}

이 경우 컴포넌트 밖이라도 setFilter를 전달 받았으므로 OK

startTransition(() => {
  setFilter('abc');
});

하지만 위와 같은 경우는 X. setFilter자체를 모르기때문이다.

주의할 점은, startTransition(() => { ... }) 안에서 동기적으로 일어난 state update만 transition으로 표시됨.

중간에 await나 setTimeout을 지나서 나중에 하는 업데이트는 다시 한 번 startTransition으로 감싸야한다.

이게 무슨 말이냐면, ‘콜백이 실행되는 그 순간’에 일어난 state update만 transition으로 체크하기 때문에 안에서 바로 setState를 하면 transition이 잡히지만, 나중에 실행되는 setState는 자동으로 안잡힌다는 뜻이다.

예를 들어 아래와 같은 경우는 transition 잘 잡히지만 :

startTransition(() => {
  setTab('posts'); //  바로 실행됨
  setOpen(true);   // 이것도 바로 실행됨
});

아래는 다르다 :

startTransition(() => {
  setTimeout(() => {
    setTab('posts'); // 자동으로 transition 아님
  }, 1000);
});

await도 비슷하다.

startTransition(async () => {
  const data = await fetchSomething();
  setList(data);
});

비동기적으로 일어나는 것에 대해서도 transition을 붙이고 싶다면

startTransition(async () => {
  const data = await fetchSomething();

  startTransition(() => {
    setList(data); // transition으로 표시
  });
});

위와같이 한번 감싸야 안전하다.
이렇게 하지 않으면 transition이 안붙어서 urgent/normal 업데이트처럼 처리되기떄문이다.

useTransition

startTransition의 컴포넌트 버전 + pending 상태 관리라고 보면 됨

[isPending, startTransition]을 주는 Hook.

탭 전환, 무거운 필터링, 라우트 전환, 서버액션/폼 제출 뒤 UI 갱신할 때 좋음
왜냐면… transition 진행 중일 때 버튼을 비활성화하거나 로딩 표시를 해줄수있기 때문에 좋다.

그러면 이게 startTransition이랑 차이가 머지?

startTransition만 쓰면 덜 급한 업데이트라는 것을 알려주기만 하는건데 useTransition 쓰면 로딩중임요~ 라고 UI에 표시까지 가능한 것이다

그러니까, startTransition이 망치 하나라면, useTransition은 ‘지금 작업 중’ 표지판까지 붙은 공구세트인거다.

게다가 useTransition은 startTransition과 달리 단순 함수가 아니라 Hook이라 규칙이 있다.
함수 컴포넌트 안, custom Hook 안에서만, 그리고 top-level에서만 써야한다.

*잠깐 여기서 top-level이라 함은?
함수의 가장 바깥쪽 레벨을 말하는 겁니당.

예를 들면 아래와 같이 :

function MyComponent() {
  const [isPending, startTransition] = useTransition(); // top-level

  function handleClick() {
    console.log('click');
  }

  return <button onClick={handleClick}>버튼</button>;
}

그렇담 실전에서 적용하려면 기준을 어떻게 세워야할까?

라이브러리에서 transition걸고싶으면 startTransition, 직접 컴포넌트 안에서 로딩 문구나 버튼 disabled 걸고싶으면 useTransition 쓰면 됨

useDeferredValue

값 하나를 deferred value로 만들어서, 그 값을 사용하는 무거운 렌더가 늦게 따라오게 하는 것이다.

이게 앞의 두개랑 먼 차이지?

startTransition/useTransition은 업데이트 자체의 우선순위를 낮추는 것이고 useDeferred는 값 하나를 늦게 따라오게 해서 그 값을 쓰는 무거운 UI만 늦추는 것.

예를 들면 검색할 때 query는 입력할 때 즉시 바뀌고, deferredQuery는 조금 늦게 따라오게 할 수 있다.
그 결과 무거운 결과 리스트는 deferredQuery 기준으로 다시 그리게 해서 버벅임을 줄일 수 있다.
이를 코드로 보면 이렇다 :

import { useMemo, useState, useDeferredValue } from 'react';

function SearchPage() {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);

  const filteredList = useMemo(() => {
    console.log('filter 실행:', deferredQuery);

    return ['apple', 'banana', 'grape', 'orange'].filter((item) =>
      item.toLowerCase().includes(deferredQuery.toLowerCase())
    );
  }, [deferredQuery]);

  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="검색어 입력"
      />

      <p>즉시 반영값: {query}</p>
      <p>조금 늦게 따라오는 값: {deferredQuery}</p>

      <ul>
        {filteredList.map((item) => (
          <li key={item}>{item}</li>
        ))}
      </ul>
    </div>
  );
}

export default SearchPage;

State setter를 직접 쥐고 있지 않아도 쓸 수 있는 경우가 많다는 것이 장점인데,
예를 들어 어떤 prop이 부모에서 내려오고 있을때 그 prop 기반 렌더가 무겁다고 하자.
그럼 그 prop을 받아서 useDeferredValue로 쓰면된다.

import { useDeferredValue, useMemo, useState } from 'react';

const ITEMS = Array.from({ length: 20000 }, (_, i) => `item-${i}`);

function Parent() {
  const [query, setQuery] = useState('');

  return (
    <div>
      <h1>검색</h1>

      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="검색어 입력"
      />

      <p>부모의 즉시 값: {query}</p>

      <HeavyList query={query} />
    </div>
  );
}

function HeavyList({ query }: { query: string }) {
  const deferredQuery = useDeferredValue(query);

  const filteredItems = useMemo(() => {
    return ITEMS.filter((item) =>
      item.toLowerCase().includes(deferredQuery.toLowerCase())
    );
  }, [deferredQuery]);

  const isStale = query !== deferredQuery;

  return (
    <div>
      <p>자식이 받은 원본 prop: {query}</p>
      <p>자식이 늦게 따라가는 값: {deferredQuery}</p>

      {isStale && <p>리스트 갱신 중...</p>}

      <ul>
        {filteredItems.slice(0, 20).map((item) => (
          <li key={item}>{item}</li>
        ))}
      </ul>
    </div>
  );
}

export default Parent;

위의 코드에서 보면 알 수 있듯이, 자식은 setQuery를 전혀 모르는데도 받은 query prop를 useDeferredValue로 다룰 수 있다는 이점이 있다.

이 패턴에서 자주 나오는 것이 stale UI다.

  • Stale UI란? : 최신 값으로 아직 안바뀐, 약간 이전 상태의 UI를 잠깐 보여주는걸 말한다. 즉 한박자 늦게 따라오는 상태!

Suspense

이건 성격이 좀 다른게 우선순위 조절 도구라기보단 아직 준비 안된 컴포넌트를 어떻게 보여줄지 정하는 경계에 가깝다.
아직 로딩 중이면, 그동안 fallback 보여주는걸 할 수 있는 것.

Suspense boundary는 selective hydration에 참여하는 단위가 된다
참고 : https://velog.io/@hongihongi60/streaming-SSR-selective-hydration

간단 정리

카페 주문받는다고 할 때
startTransition : 포장은 덜 급하니까 뒤로 미루자
useTransition : 포장은 뒤로 미뤘고, 지금 포장 진행 여부 표시하자
useDeferredValue : 손님 주문 내용은 바로 받아적되 가게 전광판에 보여주는 주문 목록은 한박자 늦게 업데이트하자
Suspense : 케이크 준비 전엔 준비 중 펫말 걸자

profile
프론트엔드 개발을 하고 있습니다 ⌨️

0개의 댓글