입력 지연, 스크롤 끊김, 스피너 깜빡임은 대부분 렌더링 우선순위가 적절히 관리되지 않을 때 발생합니다. React 18의 동시성 렌더링은 업데이트를 급한 것과 덜 급한 것으로 구분해 UX를 개선합니다. 이 글은 startTransition, useTransition, useDeferredValue, Suspense, flushSync를 활용해 실무에서 렌더링 우선순위를 제어하는 방법을 정리합니다.
1. 왜 우선순위가 중요한가
사용자 입력 등 즉각 반응이 필요한 업데이트와, 리스트 필터링처럼 무거운 계산은 동일한 타이밍에 처리되면 입력이 밀리고 UI가 버벅입니다. 우선순위를 나눠 처리하면 입력은 즉시 반응하고, 무거운 작업은 틈날 때 렌더링되어 전체 체감 성능이 좋아집니다.
2. React 18 동시성 렌더링 개념 요약
React는 업데이트를 여러 우선순위로 분류해 스케줄링합니다. 급한 업데이트는 바로 렌더링하고, 덜 급한 업데이트는 중단 가능하게 처리합니다. 개발자는 몇 가지 API로 의도를 표시할 수 있습니다. 대표적으로 startTransition은 낮은 우선순위로 처리하라는 힌트를 주고, useDeferredValue는 특정 값의 반영을 미룹니다. Suspense는 로딩 경계에서 어느 화면을 유지할지를 결정하는 데 관여합니다.
3. startTransition/useTransition으로 비급한 업데이트로 내리기
검색어 입력은 즉시 반응해야 하지만, 대용량 리스트 필터링은 비급하게 처리해도 됩니다. startTransition을 사용해 결과 계산/렌더링을 낮은 우선순위로 내리면 입력 지연을 크게 줄일 수 있습니다.
import { useMemo, useState, useTransition } from "react";
function heavyFilter(items, query) {
// 의도적으로 무거운 연산 예시
const q = query.toLowerCase();
return items.filter((it) => it.name.toLowerCase().includes(q));
}
export default function SearchList({ items }) {
const [query, setQuery] = useState("");
const [result, setResult] = useState(items);
const [isPending, startTransition] = useTransition();
const onChange = (e) => {
const next = e.target.value;
setQuery(next); // 급한 업데이트: 입력 필드 반응
startTransition(() => {
// 비급한 업데이트: 무거운 필터링 및 리스트 렌더링
const filtered = heavyFilter(items, next);
setResult(filtered);
});
};
return (
<div>
<input value={query} onChange={onChange} placeholder="검색" />
{isPending && <span style={{ marginLeft: 8 }}>업데이트 중...</span>}
<ul>
{result.map((it) => (
<li key={it.id}>{it.name}</li>
))}
</ul>
</div>
);
}
핵심은 사용자가 느끼는 즉시성(setQuery)은 유지하고, 무거운 업데이트(setResult)는 startTransition으로 감싸 낮은 우선순위로 넘기는 것입니다. isPending으로 진행 상태를 가볍게 표시할 수 있습니다.
4. useDeferredValue로 자식 트리 비용 뒤로 미루기
부모의 상태는 즉시 업데이트하되, 해당 값을 소비하는 무거운 자식 렌더링은 약간 늦게 반영하고 싶을 때 useDeferredValue를 사용합니다. 입력 반응성과 결과 렌더링을 자연스럽게 분리합니다.
import { useDeferredValue, useMemo, useState } from "react";
function Results({ query, items }) {
// 무거운 계산을 메모이제이션
const list = useMemo(() => {
const q = query.toLowerCase();
return items.filter((it) => it.name.toLowerCase().includes(q));
}, [items, query]);
return (
<ul>{list.map((it) => <li key={it.id}>{it.name}</li>)}</ul>
);
}
export default function DeferredSearch({ items }) {
const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query);
const isStale = deferredQuery !== query; // UI에 진행 상태 표시 가능
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
{isStale && <span style={{ marginLeft: 8 }}>렌더링 대기 중...</span>}
<Results query={deferredQuery} items={items} />
</div>
);
}
useDeferredValue는 특정 값의 변경 반영을 늦추는 것이지 계산 자체를 백그라운드 스레드로 옮기는 것은 아닙니다. 무거운 계산은 useMemo 등과 함께 사용하세요.
5. Suspense와 우선순위: 깜빡임 줄이기
Suspense 경계를 적절히 배치하면 로딩 중 깜빡임을 줄일 수 있습니다. 특히 startTransition과 함께 쓰면 비급한 업데이트 동안 이전 화면을 유지하고, 준비되면 한 번에 전환합니다.
import { Suspense, lazy, useState, useTransition } from "react";
const HeavyChart = lazy(() => import("./HeavyChart"));
export default function Dashboard() {
const [filter, setFilter] = useState("week");
const [isPending, startTransition] = useTransition();
const onChange = (next) => {
startTransition(() => {
setFilter(next); // 차트가 lazy 로딩/데이터 대기로 suspend될 수 있음
});
};
return (
<div>
<select value={filter} onChange={(e) => onChange(e.target.value)}>
<option value="day">일간</option>
<option value="week">주간</option>
<option value="month">월간</option>
</select>
{isPending && <span style={{ marginLeft: 8 }}>전환 중...</span>}
<Suspense fallback={<div>차트 불러오는 중...</div>}>
<HeavyChart filter={filter} />
</Suspense>
</div>
);
}
전환을 startTransition으로 표시하면 React는 가능한 한 기존 UI를 유지하고 준비가 되면 한 번에 교체합니다. 급한 업데이트였다면 fallback이 즉시 나타나 깜빡임이 늘어납니다.
6. 정말 급한 경우: flushSync로 동기 플러시
스크롤 위치 측정, 포커스 이동, 즉시 반영이 정말 필요한 케이스는 flushSync로 동기 커밋을 강제할 수 있습니다. 남용은 성능을 해칠 수 있으니 꼭 필요한 곳에만 사용합니다.
import { flushSync } from "react-dom";
import { useEffect, useRef, useState } from "react";
export default function Chat() {
const [messages, setMessages] = useState([]);
const endRef = useRef(null);
const addMessage = (msg) => {
flushSync(() => {
setMessages((prev) => [...prev, msg]); // 동기 커밋 보장
});
endRef.current?.scrollIntoView({ behavior: "smooth" }); // 최신 DOM 기준 스크롤
};
useEffect(() => {
// 예시: 서버 이벤트로 수신
const id = setInterval(() => addMessage(Date.now().toString()), 2000);
return () => clearInterval(id);
}, []);
return (
<div style={{ height: 200, overflow: "auto" }}>
{messages.map((m) => (
<div key={m}>{m}</div>
))}
<div ref={endRef} />
</div>
);
}
flushSync는 배치를 깨고 즉시 커밋하므로 자주 호출하면 메인 스레드가 바빠집니다. 레이아웃 계산 직전처럼 꼭 필요한 순간에만 쓰세요.
7. 이벤트와 우선순위 설계 팁
입력 onChange, 클릭, 포인터 이동 등 사용자 상호작용은 보통 높은 우선순위입니다. 해당 핸들러 안에서는 즉시 반응이 필요한 상태만 직접 setState하고, 나머지는 startTransition으로 감싸 비급화하세요. 애니메이션 프레임에 맞춰 업데이트해야 하는 UI는 requestAnimationFrame 안에서 상태 변경을 최소화하고, 가능한 DOM API를 직접 사용하는 것이 부드럽습니다.
8. 실무 패턴: 대용량 필터링, 자동완성, 대시보드
대용량 리스트 필터링은 입력 setState는 즉시, 결과 계산은 startTransition으로 처리합니다. 자동완성의 네트워크 요청은 디바운스와 startTransition을 조합해 타이핑 응답성을 유지합니다. 대시보드 위젯 전환은 Suspense 경계를 위젯 단위로 나누고, 전환 트리거를 startTransition으로 감싸 깜빡임을 줄입니다. 자식 트리가 무거운 경우에는 부모에서 useDeferredValue로 전달값을 늦춰 페인트를 안정화시킵니다.
9. 무엇이 우선순위를 바꾸지 않는가
useMemo, useCallback, React.memo는 재계산과 재렌더 비용을 줄여주지만 우선순위를 바꾸지 않습니다. 성능 최적화와 우선순위 제어는 보완 관계입니다. 또한 useLayoutEffect는 커밋 타이밍을 제어하지 렌더 우선순위를 바꾸지 않습니다.
10. 디버깅과 측정
React DevTools Profiler로 커밋 시간과 렌더 횟수를 측정하세요. isPending이나 deferredValue 비교로 전환 구간을 시각적으로 표시하면 체감 UX를 검증하기 쉽습니다. Performance 패널에서 입력 처리와 렌더 타이밍을 함께 확인해 메인 스레드가 막히는 지점을 찾으세요.
11. 체크리스트
입력 지연이 보이면 먼저 비즈니스 로직과 렌더링을 분리하고, 무거운 UI 업데이트를 startTransition으로 내리세요. 자식 렌더 비용이 큰 경우 useDeferredValue로 전달값을 늦추세요. 로딩 깜빡임이 거슬리면 적절한 Suspense 경계와 전환을 도입하세요. 절대 필요한 순간에만 flushSync로 동기 커밋을 강제하세요. 최적화 도구(useMemo, memo)로 불필요한 재렌더를 줄이되, 우선순위 제어와 혼동하지 마세요.
정리하면, 우선순위 제어는 “무엇을 지금 하고 무엇을 나중에 할지”를 명확히 선언하는 일입니다. 위 API들을 상황에 맞게 조합하면 입력은 즉시, 무거운 업데이트는 부드럽게 반영되는 UI를 구현할 수 있습니다.
'React' 카테고리의 다른 글
| React에서 멀티 윈도우 브라우징 데이터 동기화하기 (0) | 2026.06.25 |
|---|---|
| React 앱에서 CSS Grid를 활용한 반응형 레이아웃 구현 (0) | 2026.06.25 |
| React 앱에 GraphQL Federation 아키텍처 적용하기 (0) | 2026.06.24 |
| React에서 REST API 에러 처리 및 재시도 로직 구현하기 (0) | 2026.06.24 |
| React 앱에서 서버 로그 데이터를 실시간 모니터링하기 (0) | 2026.06.23 |