React 19 동시성 UI 패턴: useTransition · useDeferredValue · useOptimistic으로 입력이 버벅이지 않는 4가지 코드 패턴
React 18이 나왔을 때 동시성(Concurrency)이라는 개념을 처음 접했던 기억이 있어요. 솔직히 "그래서 내 코드가 뭐가 달라지는 건데?"라는 생각이 먼저 들었거든요. opt-in 방식에 설명도 추상적이라 한동안 그냥 넘겼는데, React 19에서 새로운 API들이 정비되고 실제로 프로젝트에 적용해보니 "아, 이게 이런 문제를 푸는 거구나"라는 감이 비로소 잡혔어요.
이 글은 예시 코드가 TypeScript 기준이고, React 기초(useState, 컴포넌트 구조)를 아는 분이라면 누구든 읽을 수 있게 썼습니다. useTransition이나 useDeferredValue를 언제 써야 하는지 선택 기준이 헷갈렸던 분들께 특히 도움이 될 거라고 생각해요.
TL;DR: 이 글에서 다루는 4개 패턴(
useTransition,useDeferredValue,useOptimistic,useTransition+<Suspense>)을 적용하면 검색창 지연, 페이지 전환 깜빡임, 서버 응답 대기 없는 즉각 피드백 문제를 해결할 수 있어요. React 19 동시성 패턴의 핵심은 긴급한 업데이트와 그렇지 않은 업데이트를 분리해 스케줄링함으로써, 무거운 연산 중에도 UI가 항상 반응성을 유지하도록 만드는 것입니다.
핵심 개념
동기적 렌더링의 문제: UI가 왜 멈추는가
기존 React의 렌더링은 동기적이고 블로킹(blocking)입니다. 상태가 바뀌면 React는 그 결과를 계산하는 동안 다른 모든 작업을 멈춰요. 수천 개 아이템이 담긴 리스트를 필터링하는 상황을 생각해보면, 사용자가 검색창에 타이핑하는 동안 React는 필터링 결과를 계산하느라 입력 자체가 버벅이는 경험을 만들어냅니다.
동시성 패턴이 해결하려는 문제가 바로 이거예요. 동시성이 활성화되면 React는 렌더링 작업을 중단(interrupt), 일시 정지(pause), 재개(resume) 할 수 있습니다. 사용자가 키를 누르는 순간 진행 중이던 리스트 렌더링을 잠깐 멈추고, 입력창 업데이트를 먼저 처리한 다음, 여유가 생기면 리스트를 다시 렌더링하는 방식이죠.
여기서 한 가지 짚어두고 싶은 게 있어요. 동시성 기능 자체는 React 18에서 createRoot를 사용하는 시점부터 이미 opt-in으로 도입됐습니다. React 19는 이를 확장하고 useOptimistic, Actions 같은 새 API를 추가·정비한 버전으로 이해하시는 게 더 정확해요. "React 18은 동시성이 없다"는 오해를 자주 보는데, createRoot 기반 앱이라면 이미 동시성 렌더러 위에서 돌고 있는 거거든요.
동시성(Concurrency) 이란 React가 여러 렌더링 작업의 우선순위를 판단해 중요도 순으로 처리하는 능력입니다. 실제로 여러 작업이 물리적으로 동시에 실행되는 게 아니라, 높은 우선순위 작업이 낮은 우선순위 작업을 선점(preempt)하는 구조예요. JS는 단일 스레드라 렌더링 자체를 멈출 수는 없고, React의 스케줄러가 렌더링을 작은 청크로 쪼개 브라우저에 실행권을 양보하는 방식으로 이를 구현합니다.
두 가지 업데이트: 긴급 vs 비긴급
동시성 패턴을 이해하는 가장 빠른 방법이 이 구분이에요.
| 구분 | 예시 | 특징 |
|---|---|---|
| 긴급(Urgent) 업데이트 | 키 입력, 클릭, 터치 | 즉각 반영되지 않으면 앱이 망가진 것처럼 느껴짐 |
| 비긴급(Non-urgent) 업데이트 | 검색 결과 렌더링, 페이지 전환, 차트 업데이트 | 약간의 지연이 있어도 자연스럽게 수용됨 |
React 19의 동시성 API는 이 두 가지를 명시적으로 분리하는 도구들입니다.
실전 적용
본격적인 코드를 보기 전에, 이 섹션에서 다루는 API를 한눈에 정리해두면 흐름 잡기가 편해요.
| API | 무엇을 제어하는가 | 대표 사용처 |
|---|---|---|
useTransition / startTransition |
상태 업데이트의 우선순위 | 검색 필터링, 페이지 전환 |
useDeferredValue |
값의 소비 시점 | 자동완성, 미리보기 렌더링 |
useOptimistic |
서버 응답 전 낙관적 UI | 좋아요 버튼, 폼 제출 |
<Suspense> |
비동기 컴포넌트 선언적 처리 | 데이터 페칭, 코드 스플리팅 |
useTransitionvsuseDeferredValue선택 기준: 상태를 직접 업데이트하는 코드를 건드릴 수 있다면useTransition, 외부 컴포넌트나 props로 내려오는 값처럼 업데이트 시점을 제어하기 어려운 경우라면useDeferredValue를 선택하시는 게 깔끔합니다.
예시 1: 검색창이 버벅이지 않게 — useTransition
실무에서 자주 맞닥뜨리는 상황이에요. 검색창에 타이핑할 때마다 수천 개 아이템을 필터링해야 하는데, 입력 자체가 느려지는 문제죠. 저도 처음엔 debounce로 해결하려 했는데, 동시성 패턴을 쓰면 훨씬 자연스럽게 풀립니다.
import { useState, useTransition } from 'react';
// filterHeavyData: items 배열을 query로 동기 필터링하는 무거운 함수
function SearchPage() {
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(filterHeavyData(e.target.value)); // 비긴급: 무거운 필터링은 양보
});
}
return (
<>
<input value={query} onChange={handleChange} placeholder="검색어를 입력하세요" />
{isPending ? <Spinner /> : <ResultList items={results} />}
</>
);
}| 코드 | 역할 |
|---|---|
setQuery(e.target.value) |
긴급 업데이트 — 입력창은 항상 즉각 반응 |
startTransition(() => ...) |
비긴급 표시 — React에게 "이건 나중에 처리해도 돼"라고 알림 |
isPending |
트랜지션이 진행 중인지 여부 — 스피너 등 로딩 UI에 활용 |
debounce와 달리 트랜지션은 인위적인 딜레이 없이, 브라우저가 여유로울 때 자연스럽게 처리돼요. 타이핑이 빠를수록 React는 이전 필터링 계산을 버리고 최신 입력으로 다시 계산합니다.
다만 현실적인 한계도 있어요. filterHeavyData가 순수한 동기 함수라면, JS 스레드 자체가 블로킹되는 문제는 이 방식으로도 완전히 해결되지 않습니다. CPU-bound 작업이 극단적으로 무거운 경우엔 Web Worker도 함께 고려해볼 만해요.
그리고 React 19에서 눈에 띄는 변화 하나: startTransition이 이제 async 함수를 직접 받을 수 있습니다. React 18에서는 트랜지션 콜백 안에서 await를 쓸 수 없었는데, React 19에서는 가능해졌어요. 폼 제출처럼 서버 요청을 트랜지션으로 감싸야 할 때 코드가 훨씬 깔끔해집니다.
예시 2: 페이지 전환 중 현재 콘텐츠 유지 — useTransition + <Suspense>
검색창 다음으로 자주 마주치는 패턴이 페이지네이션이에요. 다음 버튼을 눌렀을 때 기존 테이블이 사라지고 스켈레톤이 깜빡이다가 새 데이터가 나타나는 UX, 생각보다 불편하게 느껴지거든요. 이걸 동시성 패턴으로 자연스럽게 만들 수 있어요.
function DataTable() {
const [page, setPage] = useState(1);
const [isPending, startTransition] = useTransition();
function goToNextPage() {
startTransition(() => {
setPage(p => p + 1);
});
}
return (
<Suspense fallback={<TableSkeleton />}>
{/* 새 페이지 로딩 중에는 현재 콘텐츠를 흐리게 보여줌 */}
<div style={{ opacity: isPending ? 0.5 : 1, transition: 'opacity 0.2s' }}>
{/* TableContent는 내부적으로 use(fetchPage(page))를 호출해 Suspense를 트리거함 */}
<TableContent page={page} />
</div>
<button onClick={goToNextPage} disabled={isPending}>
{isPending ? '로딩 중...' : '다음 페이지'}
</button>
</Suspense>
);
}startTransition 없이 setPage를 호출하면 React는 <TableContent>가 준비될 때까지 <Suspense>의 fallback(스켈레톤)으로 전환합니다. startTransition을 사용하면 현재 콘텐츠를 유지한 채 새 데이터를 백그라운드에서 준비하고, 준비가 완료되면 한번에 전환돼요. 스켈레톤이 깜빡이는 대신 테이블이 살짝 투명해지는 자연스러운 로딩 표현이 가능해집니다.
예시 3: 서버 응답 기다리지 않고 즉시 피드백 — useOptimistic
좋아요 버튼처럼 "누르면 즉시 반응해야 하는데, 서버에도 저장해야 하는" 기능에서 useOptimistic이 진가를 발휘해요. 기존에는 로컬 상태, 로딩 플래그, try/catch 롤백 로직을 모두 직접 작성해야 했는데, useOptimistic은 그 보일러플레이트를 상당히 줄여줍니다.
function LikeButton({ postId, initialLikes }: { postId: string; initialLikes: number }) {
const [optimisticLikes, addOptimisticLike] = useOptimistic(
initialLikes,
(currentState, increment: number) => currentState + increment
);
async function handleLike() {
addOptimisticLike(1); // 즉시 +1 반영 (낙관적 업데이트)
await likePost(postId); // 백그라운드에서 서버 요청
}
return (
<form action={handleLike}>
<button type="submit">♥ {optimisticLikes}</button>
</form>
);
}| 동작 | 결과 |
|---|---|
| 버튼 클릭 | 숫자 즉시 +1 (네트워크 대기 없음) |
| 서버 요청 성공 | 부모에서 내려온 initialLikes가 업데이트되면서 값 확정 |
| 서버 요청 실패 | initialLikes prop이 변경되지 않으면 원래 값으로 자동 복원 |
롤백 동작에 대해 한 마디 덧붙이면, "실패하면 자동 롤백"이라고 단순하게 이해하기보다 조금 더 정확하게 알아두시는 게 좋아요. 낙관적 상태는 handleLike 액션이 완료될 때까지 유지되고, 완료 후에는 initialLikes prop 값으로 돌아갑니다. 서버 요청이 실패해도 부모 컴포넌트가 새로운 initialLikes를 내려보냈다면 그 값으로 확정돼요. 롤백이 제대로 작동하려면 에러 발생 시 부모 상태를 업데이트하지 않는 구조여야 합니다.
예시 4: 빠른 타이핑에 자동완성이 끌려다니지 않게 — useDeferredValue
상위 컴포넌트에서 prop으로 값을 받아서 쓰는 경우, 직접 startTransition을 감쌀 수가 없어요. 이럴 때 useDeferredValue가 딱 맞는 도구입니다. 값의 소비 시점 자체를 늦추는 방식이거든요.
import { memo, useDeferredValue } from 'react';
// memo 없이는 deferredValue가 바뀌지 않아도 부모 리렌더링 시 SuggestionList도 같이 리렌더링됩니다
const SuggestionList = memo(function SuggestionList({ query }: { query: string }) {
return <ul>{/* query를 기반으로 제안 목록 렌더링 */}</ul>;
});
function AutoComplete({ value }: { value: string }) {
const deferredValue = useDeferredValue(value);
const isStale = value !== deferredValue; // 지연 중인지 여부
return (
<div style={{ opacity: isStale ? 0.6 : 1 }}>
<SuggestionList query={deferredValue} />
</div>
);
}value가 빠르게 바뀌어도 SuggestionList는 이전 결과를 유지하다가 React에 여유가 생기면 업데이트됩니다. 타이핑이 빠른 사용자일수록 중간 입력들이 스킵되고 최종 입력으로 한 번만 렌더링되는 효과가 나요.
여기서 memo 래핑이 빠지면 안 된다는 점을 꼭 짚고 싶어요. deferredValue가 아직 업데이트되지 않았어도 부모 컴포넌트가 리렌더링되면 SuggestionList도 같이 리렌더링되거든요. memo가 있어야 deferredValue가 실제로 바뀐 경우에만 SuggestionList가 다시 그려지고, 지연 효과가 살아납니다. useDeferredValue는 memo와 세트로 써야 제대로 동작한다고 기억해두시면 좋을 것 같아요.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| UI 반응성 유지 | 무거운 렌더링 도중에도 입력·클릭이 즉각 반응 |
| 선언적 비동기 처리 | 로딩·에러·낙관적 상태를 명시적인 플래그 없이 처리 가능 |
| 자동 메모이제이션 | React Compiler 도입 시 useMemo·useCallback 수동 작성 불필요 |
| 점진적 도입 가능 | 기존 코드에 startTransition 몇 줄 추가만으로 적용 가능 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 학습 곡선 | useTransition과 useDeferredValue 선택 기준이 초반엔 헷갈림 |
상태를 직접 제어할 수 있는지 여부로 판단 기준 명확화 |
| Suspense 남용 위험 | 너무 세분화된 Suspense 경계는 폭포식 로딩(waterfall) 유발 가능 | 사용자에게 의미 있는 UI 단위로 경계 설계 |
| 객체 참조 문제 | useDeferredValue에 매 렌더마다 새 참조가 생성되는 값을 넘기면 지연 효과 없음 |
원시값 또는 useMemo 안정화 값을 전달 |
| React Compiler 별도 설정 | React 19 업그레이드만으로는 자동 메모이제이션 미활성화 | babel-plugin-react-compiler 빌드 설정 추가 |
| 디버깅 복잡성 | 인터럽터블 렌더링으로 렌더 순서 추적이 어려워질 수 있음 | React DevTools 5.x의 Concurrent Mode 탭 활용 |
| Server/Client 경계 설계 | Server Actions + 클라이언트 동시성 조합 시 경계 오설계로 UX 저하 가능 | Server/Client 책임 분리를 설계 단계에서 명확히 구분 |
Waterfall(폭포식 로딩): Suspense 경계가 중첩될 때, 상위 경계가 해결되고 나서야 하위 경계의 데이터 요청이 시작되는 현상입니다. 병렬로 처리될 수 있는 요청들이 순차 실행되어 전체 로딩 시간이 늘어나요. 데이터 요청을 컴포넌트 트리 상단으로 올리거나, Promise를 미리 시작(kick-off)하는 방식으로 완화할 수 있습니다.
실무에서 가장 흔한 실수
-
startTransition안에 긴급 업데이트를 넣기: 입력창의value상태처럼 즉각 반응해야 하는 것을 트랜지션 안에 넣으면, 타이핑이 지연되는 것처럼 느껴집니다. 트랜지션은 무거운 결과 처리에만 적용하고, 입력 상태는 바깥에 두는 게 기본 원칙이에요. -
useDeferredValue에 객체·배열을 직접 전달하기: 매 렌더마다 새 참조가 생성되는 값을 넘기면 지연이 전혀 효과가 없습니다. 원시값(string, number)이나useMemo로 안정화된 값을 전달하는 게 중요해요. -
useDeferredValue하위 컴포넌트에memo빠트리기:memo없이는deferredValue가 바뀌지 않아도 부모 리렌더링에 의해 하위 컴포넌트가 같이 리렌더링됩니다.useDeferredValue는memo와 반드시 함께 사용해야 제대로 동작해요.
마치며
React 19 동시성 패턴은 "무거운 일을 하면서도 사용자에게는 항상 반응하는 UI"를 만들기 위한 체계적인 도구 모음입니다. 한꺼번에 다 익힐 필요는 없고, 버벅임이 느껴지는 구체적인 상황 하나를 골라 적용해보시면 감이 빠르게 잡힐 거예요.
지금 바로 시작해볼 수 있는 3단계:
-
기존 프로젝트에서
useTransition한 곳만 적용해볼 수 있어요. 검색창이나 필터 기능처럼 입력과 결과 렌더링이 함께 있는 컴포넌트를 찾아startTransition(() => setResults(...))패턴으로 감싸고, 타이핑 반응성이 달라지는지 직접 느껴보시면 좋을 것 같아요. -
React DevTools 5.x의 Profiler에서 Concurrent Mode 탭을 열어보시는 것도 권장해요. 트랜지션 경계, Suspense 경계, 우선순위 레인이 시각화되어 어떤 업데이트가 어떤 우선순위로 처리되는지 직관적으로 파악할 수 있거든요. 코드를 바꾸기 전에 현재 병목이 어디 있는지 먼저 파악하는 데도 도움이 됩니다.
-
React Compiler 도입 여부도 검토해볼 만합니다.
pnpm add -D babel-plugin-react-compiler eslint-plugin-react-compiler후eslint-plugin-react-compiler로 기존 코드베이스를 스캔하면 컴파일러가 처리 가능한 비율부터 가늠해볼 수 있어요. Meta 사례에서는 수동 메모이제이션 코드 2,300줄을 제거하고 오히려 성능이 개선됐다고 하니, 규모 있는 프로젝트라면 충분히 검토할 가치가 있습니다. 다만 이건 빌드 설정 변경이 필요한 작업이라, 먼저 1, 2단계로 반응성 개선을 경험한 뒤에 여유롭게 접근하시는 걸 권합니다.
참고 자료
꼭 읽어볼 것:
- React v19 공식 릴리즈 노트 | react.dev
- useTransition 공식 레퍼런스 | react.dev
- useDeferredValue 공식 레퍼런스 | react.dev
더 읽을거리:
- React Compiler v1.0 공식 발표 | react.dev
- Suspense 공식 레퍼런스 | react.dev
- useOptimistic 공식 레퍼런스 | react.dev
- React 19 Concurrency Deep Dive: useTransition | DEV Community
- React 19 useDeferredValue Deep Dive | DEV Community
- React 19 useOptimistic Deep Dive | DEV Community
- From Freeze to Flow: React 2025 Concurrent Rendering | Medium
- Smooth Async Transitions in React 19 | AppSignal Blog
- Meta의 React Compiler 1.0 프로덕션 출시 | InfoQ