동시성(Concurrent) 기능 개요

React 18 동시성 렌더링의 개념, createRoot로 활성화하는 방법, startTransition·useDeferredValue·Suspense의 역할과 사용 원칙을 설명합니다.

· 6 min read · PALDYN Team

지난 글에서 코드 스플리팅을 살펴봤다. 이번에는 React 18의 가장 큰 변화인 동시성(Concurrent) 기능을 다룬다. 이 기능들은 앱의 응답성을 크게 향상시키지만, 올바르게 이해하지 않으면 예상치 못한 동작을 유발할 수 있다.

동시성 렌더링이란?

React 17까지는 렌더링이 동기적이었다. 한번 렌더링이 시작되면 완료될 때까지 메인 스레드를 점유했다. 무거운 컴포넌트를 렌더링하는 동안 사용자 입력이 들어와도 처리가 지연됐다(jank).

React 18의 동시성 모드에서는 렌더링을 중단(interrupt)하고 재개(resume)할 수 있다. 더 중요한(긴급한) 작업이 생기면 현재 렌더링을 잠깐 멈추고 그것을 먼저 처리한다.

동기 vs 동시성 렌더링

동시성 활성화: createRoot

React 18에서 동시성 기능을 사용하려면 createRoot로 앱을 마운트해야 한다:

// React 17 (legacy)
import ReactDOM from 'react-dom';
ReactDOM.render(<App />, document.getElementById('root'));

// React 18 (concurrent)
import { createRoot } from 'react-dom/client';
const root = createRoot(document.getElementById('root')!);
root.render(<App />);

createRoot를 쓰지 않으면 useTransition을 임포트해도 동시성 스케줄링이 적용되지 않는다.

우선순위: 긴급 vs 전환

동시성의 핵심은 렌더링에 우선순위를 부여하는 것이다.

종류예시특성
긴급(Urgent)타이핑, 클릭, 스크롤즉각 반응 필요 — 지연 시 UX 파탄
전환(Transition)탭 전환, 검색 결과, 페이지 이동약간의 지연 허용

startTransition으로 감싼 업데이트는 “전환”으로 표시되어, 더 긴급한 업데이트가 있으면 뒤로 밀린다.

startTransition

startTransition과 isPending

import { startTransition } from 'react';

function handleSearch(query: string) {
  // 즉각적인 업데이트 (긴급)
  setInputValue(query);

  // 후순위 업데이트 (전환)
  startTransition(() => {
    setSearchResults(computeResults(query));
  });
}

setInputValue는 즉시 반영돼 input이 끊김 없이 동작하고, setSearchResults는 결과 목록 렌더링이 오래 걸려도 input을 블로킹하지 않는다.

useTransition 훅

컴포넌트 안에서 isPending 상태와 함께 사용하려면 useTransition을 쓴다:

import { useTransition } from 'react';

function SearchBox() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<Item[]>([]);
  const [isPending, startTransition] = useTransition();

  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    setQuery(e.target.value); // 긴급

    startTransition(() => {
      setResults(search(e.target.value)); // 전환
    });
  }

  return (
    <div>
      <input value={query} onChange={handleChange} />
      {isPending && <span>검색 중...</span>}
      <ResultList results={results} />
    </div>
  );
}

isPending은 전환이 진행 중일 때 true가 되어 로딩 인디케이터를 표시할 수 있다.

useDeferredValue

useTransition이 “업데이트를 전환으로 시작하는 것”이라면, useDeferredValue는 “이미 존재하는 값의 업데이트를 지연”하는 것이다.

import { useDeferredValue, memo } from 'react';

const SlowList = memo(({ items }: { items: Item[] }) => {
  // 인위적으로 느린 렌더링
  return <ul>{items.map((item) => <li key={item.id}>{item.name}</li>)}</ul>;
});

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

  const items = useMemo(() => search(deferredQuery), [deferredQuery]);

  const isStale = query !== deferredQuery;

  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <div style={{ opacity: isStale ? 0.7 : 1 }}>
        <SlowList items={items} />
      </div>
    </div>
  );
}

deferredQueryquery보다 약간 뒤처진 값이다. SlowListdeferredQuery 기준으로 렌더링되므로, 타이핑 중에도 input이 즉각 반응한다.

동시성 기능 선택 가이드

내 컴포넌트에서 setState를 직접 제어할 수 있다면
  → useTransition + startTransition

외부에서 prop으로 받은 값의 렌더링을 지연하고 싶다면
  → useDeferredValue

비동기 로딩 상태를 선언적으로 처리하고 싶다면
  → Suspense

주의사항

동시성 모드에서는 컴포넌트 함수가 여러 번 호출될 수 있다. React는 렌더링을 준비하다가 더 중요한 업데이트로 인해 이전 렌더를 버리고 다시 시작할 수 있기 때문이다.

// ⚠ side effect를 렌더 함수 본체에서 실행하면 안 됨
function Component() {
  logToServer(); // 동시성 모드에서 여러 번 호출될 수 있음!
  return <div />;
}

// ✓ useEffect 안에서만
function Component() {
  useEffect(() => {
    logToServer(); // 마운트 시 한 번만
  }, []);
  return <div />;
}

이것이 React Strict Mode에서 렌더 함수를 두 번 호출하는 이유다 — 동시성 모드에서 발생할 수 있는 문제를 미리 드러낸다.


지난 글: React.lazy와 코드 스플리팅

다음 글: useTransition으로 UI 응답성 개선


읽어주셔서 감사합니다. 😊