React 동시성 이해하기: 입력은 즉시 반응하고 무거운 렌더링은 뒤로 미루는 법
빠른 답
- 현재 기준으로는
Concurrent Mode라는 전역 모드를 켜는 방식보다,createRoot기반의 동시 렌더링과transitionAPI를 필요한 업데이트에 적용하는 방식으로 이해하는 편이 정확합니다. - 입력창 값처럼 즉시 반영되어야 하는 상태는
transition으로 감싸지 않고, 그 입력에 따라 바뀌는 무거운 결과 영역만 낮은 우선순위로 미룹니다. - 상태 업데이트를 직접 호출하는 위치라면
useTransition또는startTransition을, 이미 전달받은 값의 화면 반영만 늦추고 싶다면useDeferredValue를 고려합니다. - 동시성 API는 느린 계산을 빠르게 만드는 도구가 아니라, 사용자가 먼저 체감하는 렌더링이 막히지 않도록 우선순위를 나누는 도구입니다.
목차
시간 흐름으로 이해하기
검색창에 글자를 입력하고 그 결과로 큰 목록이 다시 렌더링되는 상황을 기준으로 보면 React 동시성은 다음 흐름으로 동작합니다.
여기서 중요한 구분은 입력 상태와 결과 상태를 같은 속도의 일로 보지 않는 것입니다. 사용자는 키를 눌렀을 때 글자가 바로 찍히는지를 먼저 체감하고, 결과 목록은 조금 늦게 따라와도 흐름을 이해할 수 있습니다.
흐름으로 보기
이 순서로 보면 동시성 API를 어디에 둘지 판단하기 쉽습니다. 먼저 입력값을 누가 소유하는지 정하고, 그 입력에서 만들어지는 검색 기준이나 필터 기준을 분리합니다. 이후 렌더링 비용이 큰 리스트, 차트, 상세 패널을 입력 컴포넌트와 떨어뜨려 두면 React가 우선순위를 나눌 여지가 생깁니다.
Concurrent Mode라는 표현이 헷갈리는 이유
예전 글에서 자주 보이는 Concurrent Mode는 “앱 전체에 별도 모드를 켠다”는 인상을 줍니다. 하지만 현재 React 문서 기준으로는 그런 전역 스위치보다 동시 렌더링 기능을 API 단위로 사용하는 설명이 더 잘 맞습니다.
React 18에서는 createRoot를 통해 새 루트 API와 동시 렌더러 기반 기능을 사용할 수 있게 되었고, ReactDOM.render는 deprecated 되었습니다. React 19에서는 ReactDOM.render와 ReactDOM.hydrate가 제거되어 각각 createRoot, hydrateRoot로 옮겨야 합니다. 2026년 4월 기준 React 공식 문서는 React 19.2를 최신 문서 기준으로 제공합니다.
용어는 다음처럼 나누어 두면 혼란이 줄어듭니다.
Concurrent Mode: 과거 실험적 설명이나 오래된 글에서 자주 쓰이던 표현입니다.- 동시 렌더링: React가 렌더링 작업을 중단, 재시작, 우선순위 조정할 수 있는 내부 능력입니다.
- Transition: 특정 상태 업데이트를 덜 급한 렌더링으로 표시하는 API 수준의 표현입니다.
- Deferred value: 이미 받은 값의 화면 반영을 늦게 따라가도록 만드는 Hook 수준의 표현입니다.
설정 기준 잡기
클라이언트에서 React 앱을 새로 렌더링하는 진입점은 react-dom/client의 createRoot를 사용합니다. 서버에서 만든 HTML을 이어받아 hydration 하는 앱이라면 hydrateRoot를 사용해야 합니다.
{
"dependencies": {
"react": "^19.2.1",
"react-dom": "^19.2.1"
}
}
클라이언트 전용 앱의 기본 진입점은 다음처럼 잡을 수 있습니다.
import { createElement } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App.js";
const container = document.getElementById("root");
const root = createRoot(container);
root.render(createElement(App));
서버 렌더링 결과가 이미 DOM에 들어와 있다면 createRoot로 다시 그리는 대신 hydrateRoot를 사용합니다.
import { createElement } from "react";
import { hydrateRoot } from "react-dom/client";
import { App } from "./App.js";
const container = document.getElementById("root");
hydrateRoot(
container,
createElement(App, {
initialQuery: window.__INITIAL_QUERY__ ?? ""
})
);
이 차이는 동시성 자체보다도 앱 시작 방식에 영향을 줍니다. hydrateRoot가 필요한 곳에서 createRoot를 쓰면 서버에서 만든 DOM을 이어 쓰지 못하고 클라이언트에서 다시 만들 수 있습니다.
데이터 흐름과 상태 소유권
입력 지연은 입력값과 무거운 결과 렌더링이 하나의 상태 변화에 묶일 때 자주 드러납니다. 사용자가 글자 하나를 입력할 때 입력창, 필터 조건, 긴 목록, 빈 상태 메시지, 페이지네이션, 차트가 모두 같은 우선순위로 바뀐다면 작은 이벤트가 큰 렌더링으로 커집니다.
먼저 원본 상태와 파생 값을 나누어 보는 편이 좋습니다. 검색창에 보이는 text는 사용자가 직접 바꾸는 원본 상태입니다. 검색 결과의 기준이 되는 query는 text에서 파생되지만, 결과 렌더링을 늦추기 위해 별도의 갱신 타이밍을 둘 수 있습니다.
import { useMemo, useState, useTransition } from "react";
export function SearchPage({ items }) {
const [text, setText] = useState("");
const [query, setQuery] = useState("");
const [isPending, startTransition] = useTransition();
function handleTextChange(event) {
const nextText = event.target.value;
setText(nextText);
startTransition(() => {
setQuery(nextText);
});
}
const results = useMemo(() => {
return filterItems(items, query);
}, [items, query]);
return (
<SearchView
text={text}
results={results}
isPending={isPending}
onTextChange={handleTextChange}
/>
);
}
function filterItems(items, query) {
const normalized = query.trim().toLowerCase();
if (normalized.length === 0) {
return items.slice(0, 50);
}
return items.filter((item) =>
item.title.toLowerCase().includes(normalized)
);
}
이 코드에서 setText는 즉시 실행됩니다. controlled input의 값은 사용자의 입력과 직접 연결되어 있기 때문입니다. 반면 setQuery는 결과 목록 렌더링을 유발하므로 transition 안에서 낮은 우선순위로 표시됩니다.
다만 startTransition이 filterItems 같은 CPU 작업 자체를 없애지는 않습니다. 목록이 매우 크거나 계산이 비싸다면 리스트 가상화, 서버 검색, 캐싱, Web Worker, 페이지네이션 같은 별도 최적화가 함께 필요할 수 있습니다.
useTransition과 startTransition을 쓰는 위치
useTransition은 컴포넌트 안에서 전환 상태를 시작하고, 그 작업이 진행 중인지 UI에 표시해야 할 때 사용하기 좋습니다. 탭 전환, 검색 결과 렌더링, 상세 패널 교체처럼 “선택 반응은 먼저, 무거운 화면은 나중”이라는 흐름이 있는 곳에 어울립니다.
import { useState, useTransition } from "react";
export function ProductTabs({ tabs }) {
const [selectedTab, setSelectedTab] = useState(tabs[0].id);
const [renderedTab, setRenderedTab] = useState(tabs[0].id);
const [isPending, startTransition] = useTransition();
function selectTab(nextTab) {
setSelectedTab(nextTab);
startTransition(() => {
setRenderedTab(nextTab);
});
}
return (
<ProductTabsView
tabs={tabs}
selectedTab={selectedTab}
renderedTab={renderedTab}
isPending={isPending}
onSelectTab={selectTab}
/>
);
}
여기서 selectedTab은 사용자가 누른 탭을 즉시 보여 주는 상태이고, renderedTab은 실제 무거운 패널을 렌더링하는 기준입니다. 사용자가 탭을 빠르게 바꾸면 React는 이전 패널 렌더링을 끝까지 밀어붙이기보다 최신 선택을 기준으로 다시 렌더링할 수 있습니다.
컴포넌트 바깥의 라우터 유틸리티나 외부 이벤트 핸들러처럼 Hook을 쓰기 어려운 위치에서는 startTransition 함수를 직접 사용할 수 있습니다. 대신 이 방식은 isPending 값을 제공하지 않습니다.
useDeferredValue가 어울리는 경우
useDeferredValue는 내가 상태 업데이트를 직접 감쌀 수 없을 때 유용합니다. 예를 들어 부모가 이미 query를 내려주고 있고, 자식 컴포넌트는 그 값으로 무거운 결과 목록만 렌더링한다고 해 보겠습니다. 자식은 부모의 setQuery 위치를 모르므로 startTransition을 적용하기 어렵습니다.
이때 자식에서 지연된 값을 만들 수 있습니다.
import { useDeferredValue, useMemo } from "react";
export function SearchResultPanel({ query, items }) {
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
const results = useMemo(() => {
return filterItems(items, deferredQuery);
}, [items, deferredQuery]);
return (
<ResultPanelView
query={deferredQuery}
results={results}
isStale={isStale}
/>
);
}
useDeferredValue는 debounce처럼 고정된 시간을 기다리는 API가 아닙니다. React는 먼저 최신 입력을 반영한 렌더링을 처리하고, 지연된 값을 사용한 렌더링을 백그라운드에서 다시 시도합니다. 공식 문서에서도 useDeferredValue 자체가 네트워크 요청 수를 줄이지는 않는다고 설명합니다.
따라서 서버 검색 요청을 줄이고 싶다면 debounce, 요청 취소, 캐시 전략은 데이터 계층에서 따로 다루는 편이 좋습니다. useDeferredValue는 요청 타이밍보다 “받은 값이 화면에 언제 반영되는가”에 더 가까운 도구입니다.
흔한 안티패턴
첫 번째 안티패턴은 입력값 자체를 transition으로 감싸는 것입니다. React 공식 문서도 transition 업데이트를 text input 제어에 사용할 수 없다고 안내합니다.
function handleTextChange(event) {
const nextText = event.target.value;
startTransition(() => {
setText(nextText);
});
}
이 코드는 검색 결과가 아니라 입력창 값 자체를 낮은 우선순위로 미룹니다. 보통은 입력 상태인 setText를 즉시 호출하고, 결과 기준값인 setQuery나 무거운 화면 전환만 transition으로 감싸는 편이 흐름을 설명하기 쉽습니다.
두 번째 안티패턴은 transition 안에서 무거운 동기 계산을 직접 실행하는 것입니다. startTransition에 넘긴 함수는 나중에 예약되어 실행되는 함수가 아니라 즉시 호출됩니다.
function handleTextChange(event) {
const nextText = event.target.value;
setText(nextText);
startTransition(() => {
const nextResults = verySlowFilter(items, nextText);
setResults(nextResults);
});
}
이 코드에서 setResults는 transition으로 표시되지만, verySlowFilter 계산은 이벤트 핸들러 안에서 바로 실행됩니다. 계산 자체가 병목이라면 결과 배열을 상태로 저장하기보다 검색 기준값만 상태로 두고, 렌더링 비용과 계산 비용을 따로 줄이는 방법을 검토해야 합니다.
세 번째 안티패턴은 파생 상태를 중복 저장하는 것입니다. items와 query로 항상 계산할 수 있는 results를 별도 상태로 저장하면 원본과 파생 값의 경계가 흐려집니다. 이 경우 오래된 결과가 남거나, 입력값과 결과 목록이 어긋나는 버그가 생기기 쉽습니다.
현재 기준의 마이그레이션 포인트
React 동시성을 오래된 “모드” 설명으로만 이해하면 마이그레이션 지점이 흐려질 수 있습니다. 현재 기준에서는 루트 API, transition API, deferred value, Suspense가 각각 어떤 역할을 하는지 나누어 보는 편이 좋습니다.
- React 17 이하 스타일:
ReactDOM.render중심으로 앱을 시작합니다. - React 18 기준:
ReactDOM.render는 deprecated 되었고,createRoot를 사용하면 새 동시 렌더러 기반 기능을 사용할 수 있습니다. - React 19 기준:
ReactDOM.render와ReactDOM.hydrate는 제거되었고, 각각createRoot,hydrateRoot로 옮겨야 합니다. - SSR 앱: 서버에서 만든 DOM을 이어받아야 하므로
hydrateRoot를 사용합니다. - 테스트 코드: React 19에서는
react-test-renderer가 deprecated 되었으므로 구현 세부보다 사용자 관점의 테스트로 옮기는 흐름을 함께 검토할 만합니다.
공식 문서는 다음 링크에서 확인할 수 있습니다.
- React Versions
- React 18 Upgrade Guide
- React 19 Upgrade Guide
- startTransition API Reference
- useTransition API Reference
- useDeferredValue API Reference
- createRoot API Reference
React 동시성은 상태 관리 기법을 대체하지 않습니다. 오히려 상태 소유권이 분명할수록 효과가 잘 드러납니다. 입력 상태는 입력을 소유한 곳에서 즉시 갱신하고, 그 입력으로 인해 바뀌는 무거운 화면만 낮은 우선순위로 보내는 구조가 동시성 API의 의도를 코드에 잘 남깁니다.
원문 참고
https://www.maeil-mail.kr/question/81
댓글
댓글 쓰기