본문 바로가기

React

React 18의 Concurrent Features 활용 가이드

React 18의 Concurrent Features는 UI 응답성을 지키면서 무거운 작업을 부드럽게 처리하도록 돕습니다. 핵심은 작업의 우선순위를 나눠 급한 업데이트(타이핑, 클릭)와 무거운 업데이트(필터링, 렌더 트리 큰 변경)를 동시에 조율해 끊김 없는 사용자 경험을 제공하는 것입니다.

1. Concurrent Rendering의 목표

Concurrent Rendering은 멀티스레드가 아닙니다. React가 렌더링 작업을 쪼개고, 급한 작업을 먼저 처리하며, 불필요해진 작업을 중단할 수 있게 합니다. 따라서 입력 지연, 스크롤 끊김, 대량 리스트 필터링 시 프리즈 같은 문제를 줄일 수 있습니다.

2. Automatic Batching: 비동기에서도 렌더 한 번

React 18은 이벤트 핸들러뿐 아니라 setTimeout, fetch.then 같은 비동기 컨텍스트에서도 상태 업데이트를 자동으로 배치합니다. 불필요한 재렌더를 줄여줍니다.

import { useState } from "react";

function AutoBatchExample() {
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);

  const save = async () => {
    // 동기: 한 번에 배치
    setCount((c) => c + 1);
    setFlag((f) => !f);

    await new Promise((r) => setTimeout(r, 300));

    // 비동기: React 18부터 여기도 배치되어 렌더 1회
    setCount((c) => c + 1);
    setFlag((f) => !f);
  };

  return (
    <div>
      <div>count: {count} / flag: {String(flag)}</div>
      <button onClick={save}>Save</button>
    </div>
  );
}

주의: 배치를 끄고 싶다면 flushSync를 사용할 수 있으나, 정말 필요한 경우에만 사용합니다.

3. startTransition / useTransition: 무거운 업데이트를 낮은 우선순위로

사용자 입력은 즉시 반영하고, 비싼 계산/렌더는 뒤로 미루고 싶을 때 사용합니다. isPending으로 로딩 UI도 쉽게 연결할 수 있습니다.

import { useMemo, useState, useTransition } from "react";

const heavyFilter = (items, query) => {
  // CPU를 일부러 쓰는 비싼 작업 흉내
  const q = query.toLowerCase();
  let res = items.filter((x) => x.toLowerCase().includes(q));
  for (let i = 0; i < 2000000; i++) {}
  return res;
};

function TransitionSearch({ items }) {
  const [input, setInput] = useState("");
  const [list, setList] = useState(items);
  const [isPending, startTransition] = useTransition();

  const onChange = (e) => {
    const value = e.target.value;
    setInput(value); // 긴급 업데이트: 입력은 즉시 반영

    startTransition(() => {
      // 낮은 우선순위: 무거운 계산은 천천히
      const next = heavyFilter(items, value);
      setList(next);
    });
  };

  return (
    <div>
      <input value={input} onChange={onChange} placeholder="검색" />
      {isPending ? <p>필터링 중...</p> : null}
      <ul>{list.map((x) => (<li key={x}>{x}</li>))}</ul>
    </div>
  );
}

실무 팁: Transition 내부에서 네트워크 요청을 시작해도 됩니다. 단, 이전 요청은 자동 취소되지 않으므로 AbortController 등으로 직접 취소해주면 더 좋습니다.

4. useDeferredValue: 부모 값은 즉시, 자식은 여유 있게

부모의 입력 상태는 즉시 반영하되, 그 값을 소비하는 자식에서만 느슨하게 적용하고 싶을 때 유용합니다. 리스트, 차트 렌더링에 적합합니다.

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

function DeferredSearch({ items }) {
  const [query, setQuery] = useState("");
  const deferredQuery = useDeferredValue(query); // 이전 값이 잠시 유지될 수 있음

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

  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      {deferredQuery !== query ? <p>업데이트 중...</p> : null}
      <ResultList data={results} />
    </div>
  );
}

function heavyCompute(items, q) {
  const qq = q.toLowerCase();
  let res = items.filter((x) => x.toLowerCase().includes(qq));
  for (let i = 0; i < 1500000; i++) {}
  return res;
}

선택 기준: 값의 생성(계산) 자체를 늦추고 싶으면 startTransition, 이미 있는 값을 "소비"하는 쪽만 늦추고 싶으면 useDeferredValue가 간단합니다.

5. Suspense: 코드 스플리팅과 데이터 로딩 UX 개선

Concurrent 렌더링과 결합된 Suspense는 로딩 상태를 깔끔하게 구성합니다. 우선 코드 스플리팅부터 적용해 UX를 부드럽게 만듭니다.

import { Suspense, lazy } from "react";

const Settings = lazy(() => import("./Settings"));

export default function Page() {
  return (
    <Suspense fallback={<SettingsSkeleton />}>
      <Settings />
    </Suspense>
  );
}

데이터 패칭은 React 자체 fetch-Suspense가 아직 권장 기본은 아닙니다. 대신 React Query, SWR 등 라이브러리의 suspense 옵션을 활용하면 안정적으로 사용할 수 있습니다.

// React Query 예시
import { Suspense } from "react";
import { useQuery } from "@tanstack/react-query";

function UserPanel({ id }) {
  const { data } = useQuery({
    queryKey: ["user", id],
    queryFn: () => fetch(`/api/users/${id}`).then((r) => r.json()),
    suspense: true,
  });
  return <div>{data.name}</div>;
}

export default function Screen({ id }) {
  return (
    <Suspense fallback={<p>유저 불러오는 중...</p>}>
      <UserPanel id={id} />
    </Suspense>
  );
}

팁: Suspense 경계를 작게 나눠 화면 일부만 스켈레톤 표시하면 전체 페이지가 깜빡이지 않습니다.

6. Hydration과 useId: SSR에서 안정적인 id

Concurrent Hydration 환경에서 id 불일치를 피하려면 useId를 사용합니다. 클라이언트/서버 모두 안정적인 id를 생성합니다.

import { useId } from "react";

function LabeledInput() {
  const id = useId();
  return (
    <div>
      <label htmlFor={id}>이메일</label>
      <input id={id} type="email" />
    </div>
  );
}

7. 마이그레이션 체크리스트

1) createRoot로 전환해야 Concurrent 기능이 활성화됩니다.

// Before
// ReactDOM.render(<App />, document.getElementById("root"));

// After
import { createRoot } from "react-dom/client";
const root = createRoot(document.getElementById("root"));
root.render(<App />);

2) StrictMode에서 개발 중 일부 훅/이펙트가 두 번 실행됩니다. 부작용 코드를 정리하고, 외부 요청은 idempotent하게 처리합니다.

3) 외부 상태 관리 라이브러리는 useSyncExternalStore로 Concurrent 호환성을 갖췄는지 확인합니다.

8. 흔한 함정과 실무 팁

– Transition은 네트워크/계산을 자동 취소하지 않습니다. 이전 작업 취소가 필요하면 AbortController나 취소 토큰을 사용합니다.

– Transition 안에서 입력 값(컨트롤드 컴포넌트)을 설정하지 마십시오. 입력 지연이 생깁니다. 입력은 긴급 업데이트로, 무거운 파생 작업만 Transition으로 감쌉니다.

– useDeferredValue는 일시적으로 이전 값을 유지합니다. 이로 인해 표시 내용이 입력보다 늦게 따라오는 것이 정상입니다. 이 차이를 UI로 알려주려면 스피너나 "업데이트 중" 텍스트를 함께 표시합니다.

– CPU가 매우 무거운 작업은 Web Worker로 분리하거나 작업을 청크로 쪼개 requestIdleCallback 등으로 나누어 실행하는 것도 고려합니다.

– 성능 측정은 React DevTools Profiler로 실제로 렌더 횟수와 시간, isPending 구간을 확인하며 진행합니다.

9. 요약

– 자동 배치로 렌더 횟수를 줄입니다.

– startTransition/useTransition으로 무거운 업데이트를 낮은 우선순위로 보내 UX를 부드럽게 합니다.

– useDeferredValue로 부모-자식 간 업데이트 속도를 분리합니다.

– Suspense 경계를 작게 나눠 코드 스플리팅과 데이터 로딩 UX를 개선합니다.

– createRoot 전환, useId 적용, 외부 상태 호환성 점검까지 마치면 실서비스에 안전하게 도입 가능합니다.