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 적용, 외부 상태 호환성 점검까지 마치면 실서비스에 안전하게 도입 가능합니다.
'React' 카테고리의 다른 글
| React에서 Virtualized List 구현하기 (0) | 2026.04.13 |
|---|---|
| React Hooks로 커스텀 훅 설계하기 (1) | 2026.04.13 |
| React와 TypeScript를 함께 사용할 때의 베스트 프랙티스 (0) | 2026.04.10 |
| React에서 Context API로 전역 상태 관리하기2 (0) | 2026.04.10 |
| React에서 Context API로 전역 상태 관리하기 (0) | 2026.04.10 |