리액트 18 동시성 렌더링

느린 렌더링 때문에 중요한 반응까지 늦어지지 않게 만드는 방식

리액트 18 동시성 렌더링

이전 버전의 React(17 이하)에서 렌더링은 ‘동기적’이고 ‘중단 불가능한’ 작업이었다. 한 번 렌더링이 시작되면, 그 작업이 끝날 때까지 메인 스레드는 블로킹된다. 이는 복잡한 UI를 계산하는 동안 사용자가 버튼을 클릭하거나 타이핑을 해도 애플리케이션이 즉각 반응하지 못하는 멈춤 현상의 주원인이었다.

React 18은 이러한 문제를 해결하기 위해 동시성 렌더링을 도입했다. 핵심은 렌더링 자체를 빠르게 하는 것이 아니라, 렌더링을 중단하고 중요한 작업을 먼저 처리할 수 있는 ‘반응성’을 확보하는 데 있다.

처음에는 동시성 렌더링이라는 이름 때문에 React가 렌더링을 병렬로 더 빠르게 처리해주는 기능처럼 느껴졌다. 하지만 실제로 중요했던 것은 속도보다 우선순위였다. 모든 렌더링을 빠르게 끝내는 것이 아니라, 사용자가 방금 한 행동이 먼저 화면에 반영되도록 느린 렌더링을 뒤로 미루는 방식에 가깝다.

매 상태 변경마다 지나치게 무거운 렌더링 작업이 실행되는 게 아니라면 크게 사용할 일은 없을 것이다. 다만 그런 상황에 대한 해결 방안이 생겼다는 것이 중요한 부분이다.

React 18은 상태 업데이트를 우선순위에 따라 두 가지로 분류한다.

  • Urgent updates (긴급 업데이트): 사용자의 직관적인 행동과 관련된 업데이트 (예: 타이핑, 클릭, 드래그). 즉각적인 반응이 없으면 사용자는 앱이 고장 났다고 느낀다.

  • Transition updates (전환 업데이트): 화면의 전환이나 데이터 시각화처럼 즉각적이지 않아도 되는 업데이트 (예: 검색 결과 목록 렌더링, 탭 전환).

image.png

대표적인 예로 검색 UI를 들 수 있다. 검색창에 글자를 입력하는 행위는 Urgent하게 처리되어야 하고, 입력값에 따라 아래에 추천 검색어나 검색 결과를 띄워주는 행위는 Transition으로 처리할 수 있다. 추천 검색어 렌더링 때문에 타이핑이 끊기는 경험은 치명적인 UX 저하를 야기하기 때문이다.

React 18은 무거운 작업을 Transition으로 분류하여, 긴급한 상호작용이 발생하면 진행 중이던 렌더링 작업을 일시 중단하거나 폐기하고 급한 작업부터 처리할 수 있게 한다. 이를 다루기 위해 주로 startTransition / useTransition 계열과 useDeferredValue를 사용한다.

  • startTransition

  • useTransition

  • useDeferredValue

startTransition & useTransition

startTransition은 특정 상태 업데이트를 Transition(낮은 우선순위)으로 마킹하는 API다. 함수형 컴포넌트에서는 주로 isPending 상태값과 함께 useTransition 훅을 사용한다.

import { useState, useTransition } from 'react';

const TabContainer = () => {
    const [tab, setTab] = useState('about');
    const [isPending, startTransition] = useTransition();

    const selectTab = (nextTab) => {
        // 탭 변경 상태 업데이트를 낮은 우선순위로 감싼다.
        startTransition(() => {
            setTab(nextTab);
        });
    }

    return (
        <div>
            {/* isPending을 통해 전환이 진행 중이라는 상태를 보여줄 수 있다 */}
            {isPending && <Spinner />} 
            <TabContent tab={tab} />
        </div>
    );
}

이 코드가 적용되면, 사용자가 탭을 전환하는 도중 다시 다른 탭을 클릭할 경우 React는 이전 탭의 렌더링을 중단하고 최신 요청을 처리할 수 있다. 결과적으로 사용자의 클릭이나 입력 같은 긴급한 상호작용이 무거운 렌더링에 함께 묶여 늦어지는 일을 줄일 수 있다.

useDeferredValue

useTransition이 상태 업데이트를 감싸는 방식이라면, useDeferredValue는 값 자체의 반영을 지연시킨다. 주로 props로 전달받은 데이터나, 직접 상태 업데이트를 감쌀 수 없는 값을 기반으로 무거운 렌더링이 발생할 때 유용하다.

import { useState, useDeferredValue } from 'react';

const SearchPage = () => {
     const [query, setQuery] = useState('');
     // query는 즉시 업데이트되지만, deferredQuery는 여유가 있을 때 따라온다.
     const deferredQuery = useDeferredValue(query);

     return (
        <>
           <input value={query} onChange={(e) => setQuery(e.target.value)} />
           {/* 무거운 리스트 컴포넌트에는 지연된 값을 전달한다 */}
           <HeavyResultList query={deferredQuery} />
        </>
     );
}

Debounce/Throttle과의 차이

useDeferredValue는 흔히 Debounce(일정 시간 대기)와 비교되지만, 동작 원리가 다르다.

  • Debounce: 무조건 정해진 시간(예: 300ms)을 기다린다. 고사양 기기에서도 불필요한 딜레이가 발생할 수 있다.

  • useDeferredValue: 렌더링 우선순위를 낮춘다. 메인 스레드가 여유로우면 비교적 빠르게 따라오고, 부하가 크면 중요한 입력 반응을 막지 않도록 렌더링을 뒤로 미룬다.

useDeferredValue는 API 호출 횟수를 줄이는 도구가 아니다. 네트워크 요청 자체를 줄이고 싶다면 Debounce, 캐싱, 요청 중복 제거 같은 별도의 전략이 필요하다. useDeferredValue는 요청 빈도를 제어한다기보다, 이미 바뀐 값이 무거운 UI 렌더링에 반영되는 우선순위를 낮추는 도구에 가깝다.

올바른 Pending 처리

useDeferredValue를 사용할 때 화면에 표시되는 데이터가 최신 값인지 확인하려면, 원본 값과 지연된 값을 비교할 수 있다.

// 두 값이 다르다면, 현재 화면은 이전 값을 기준으로 렌더링되고 있다.
const isStale = query !== deferredQuery;

return (
    <div style={{ opacity: isStale ? 0.5 : 1 }}>
        <HeavyResultList query={deferredQuery} />
    </div>
);

주의사항

동시성 렌더링은 느린 작업을 없애주는 기능이 아니다. 느린 렌더링은 여전히 느리다. 다만 그 느린 렌더링이 사용자의 중요한 입력 반응까지 막지 않도록 우선순위를 조정해준다.

특히 useDeferredValue를 Debounce처럼 이해하면 안 된다. useDeferredValue로 지연된 값이 useEffect 의존성 배열에 들어간다고 해서 API 호출 빈도가 안정적으로 줄어드는 것은 아니다. 네트워크 요청과 같이 비용이 발생하는 작업에는 여전히 Debounce를 병행하거나, React Query와 같은 데이터 페칭 라이브러리의 캐싱 전략을 함께 사용하는 것이 안전하다.

결국 useTransitionuseDeferredValue는 느린 작업을 없애는 도구가 아니라, 느린 작업 때문에 중요한 반응까지 같이 늦어지지 않도록 분리하는 도구에 가깝다.