웹 개발을 하다 보면 이런 경험이 있을 것이다. 사용자가 버튼을 클릭했는데 화면이 멈춘 것처럼 보이거나, 입력 필드에 타이핑을 하는데 글자가 뚝뚝 끊어지며 나타나는 상황 말이다. 이런 문제들은 보통 React의 동기적 렌더링 때문에 발생한다. 하지만 React 18에서 정식으로 도입된 Concurrent Mode는 이제 React는 마치 멀티태스킹을 하는 운영체제처럼 여러 작업을 동시에 처리하면서도 사용자 경험을 최우선으로 고려한다. (실제 OS 에서 사용하는 여러 용어가 나온다.)
(👨🏻🏫 : "보통 뚝뚝 끊기면, 렉 걸린다고 생각할 수도 있습니다만, 요즘같이 훌륭한 네트워크 상태와, 하드웨어 기반에서 돌아가는 앱이나 웹사이트라면, 뭔가를 놓치고 있나 한 번씩 의심해봐야 해요!")
Concurrent Mode는 React가 렌더링 작업을 중단하고 재개할 수 있게 해주는 기능이다.
(👨🏻🏫 : "이는 OS에서 사용하는 Concurrency(동시성) 와 Parallelism(병렬성)을 비교하는 사진입니다! 프론트엔드 개발자는 비동기, 배치 처리라는 용어를 자주 사용하기에, 동시성이란 단어를 들으면 무의식적으로 병렬적으로 돌아가는구나라고 오해를 하는 경우가 있습니다. 그러나, 병렬적(Parallelism)이 아니라, 동시성(Concurrency)이라는 사실을 잊으면 안 됩니다! 일을 여러 단위로 나누는 것과, 여러 코어를 활용해 각각 실행하는 것은 다른 거예요!")
이 글에서는 Concurrent Mode가 무엇인지, 어떻게 작동하는지, 그리고 왜 모든 React 개발자가 알아야 하는지에 대해 자세히 알아본다.
Concurrent Mode는 React가 렌더링 작업을 동시에(concurrent) 처리할 수 있게 해주는 새로운 렌더링 모드다. 여기서 '동시에'라는 말이 중요한데, 실제로는 동시가 아니라, 여러 작업을 시분할(time-slicing) 방식으로 처리한다. 기존 React는 동기적(synchronous)으로 렌더링을 수행했다. 즉, 한 번 렌더링이 시작되면 완료될 때까지 다른 작업을 할 수 없었다. 이는 다음과 같은 문제를 야기했다:
(👨🏻🏫 : "React 18 이전의 동기적 렌더링이라는 것은 렌더링 프로세스 자체가 동기적이라는 의미입니다. 즉, 한 번 렌더링이 시작되면 완료될 때까지 중단할 수 없었다는 뜻이죠. 하지만 이것이 비동기 요청 처리와 직접적인 관련이 있는 것은 아닙니다.")
비동기 요청이 완료되어 상태가 업데이트될 때의 동작이 동기적이거나, 비동기적으로 처리되는지는, 이벤트 핸들러의 내부인지, 외부인지에 따라 달랐다:
*// React 17 이전 - 이벤트 핸들러 내부*
function handleClick() {
setUser(data.user); *// 배치처리 됨*
setPosts(data.posts); *// 배치처리 됨*
setLoading(false); *// 배치처리 됨*
*// 모든 상태 업데이트가 한 번에 렌더링됨*
}
기존의 React 17에서도, 이벤트 핸들러 내부에서는 자동으로 배치(batching)되어 한 번만 렌더링된다.
*// React 17 이전 - 이벤트 핸들러 외부 (Promise, setTimeout 등)*
useEffect(() => {
fetchData().then(data => {
setUser(data.user); *// 즉시 렌더링 1,*
setPosts(data.posts); *// 끝나고, 렌더링 2*
setLoading(false); *// 끝나고, 렌더링 3*
});
}, []);
이벤트 핸들러 외부에서는 각 상태 업데이트마다 동기적으로 렌더링이 발생한다. 즉, Web API에 맡기는 것들은 전부 다 동기적이었다…! 왜 그런지 간략하게 설명하자면, React는 내부적으로 배치 플래그를 사용해 상태 업데이트를 관리하는데, 이를 Web API 에 위임할 때는 React 의 손을 떠나 배치 플래그를 사용하지 못 하기 때문이라고 한다.
실행 컨텍스트 | 배치 처리 여부 (React 17) | 예시 |
---|---|---|
onClick, onSubmit 등 React 이벤트 | ✅ 배치됨 | <button onClick={handler}> |
Promise.then() | ❌ 개별 렌더링 | fetch().then() |
setTimeout/setInterval | ❌ 개별 렌더링 | setTimeout(callback) |
addEventListener | ❌ 개별 렌더링 | element.addEventListener() |
useEffect 내부 | ❌ 개별 렌더링 | useEffect(() => {}) |
React 18에서는 Automatic Batching이 도입되어 이런 차이가 사라졌다:
*// React 18 - 모든 상황에서 배치됨*
useEffect(() => {
fetchData().then(data => {
setUser(data.user); *// 배치됨*
setPosts(data.posts); *// 배치됨*
setLoading(false); *// 배치됨*
});
}, []); *// 약 5ms 내의 모든 업데이트가 한 번에 렌더링됨*
실행 컨텍스트 | 배치 처리 여부 (React 18) | 예시 |
---|---|---|
onClick, onSubmit 등 React 이벤트 | ✅ 배치됨 | <button onClick={handler}> |
Promise.then() | ✅ 배치됨 | fetch().then() |
setTimeout/setInterval | ✅ 배치됨 | setTimeout(callback) |
addEventListener | ✅ 배치됨 | element.addEventListener() |
useEffect 내부 | ✅ 배치됨 | useEffect(() => {}) |
(👨🏻🏫 : "예전에는 컨텍스트에 따라 동기적으로 랜더링하거나 배치처리되어 랜더링했는데, 이제는 항상 묶어서 랜더링하는 것이죠!")
따라서 비동기 요청이 완료되어 상태가 업데이트될 때는 항상 동기적으로 렌더링이 실행되었지만, 그 빈도와 방식이 상황에 따라 달랐다는 것이 정확한 설명이다.
Concurrent Mode는 이런 문제들을 우선순위 기반 스케줄링과 중단 가능한 렌더링으로 해결한다:
*// React 18에서 Concurrent Mode 활성화*
import { createRoot } from 'react-dom/client';
const container = document.getElementById('root');
const root = createRoot(container);
root.render(<App />);
출처: React 공식 문서 - Concurrent Features
Concurrent Mode는 다음과 같은 핵심 기능들로 구성된다(이번엔 개념만 다루고 사용법은 다음에 다루겠다.)
우선은 두 렌더링 방식의 차이점을 명확히 이해하는 것이 중요하다.
동기 렌더링에서는 React가 한 번에 하나의 작업만 처리한다:
*// 동기 렌더링 예시 - 문제가 있는 코드*
function HeavyComponent() {
const [items, setItems] = useState([]);
*// 무거운 계산 작업*
const expensiveValue = useMemo(() => {
let result = 0;
for (let i = 0; i < 1000000; i++) {
result += Math.random();
}
return result;
}, []);
return (
<div>
{*/* 이 렌더링이 완료될 때까지 UI가 블로킹됨 */*}
{items.map(item => <ExpensiveItem key={item.id} data={item} />)}
<div>계산 결과: {expensiveValue}</div>
</div>
);
}
이 경우 계산이 완료될 때까지 전체 UI가 블로킹된다.
Concurrent Mode에서는 React가 작업을 작은 단위로 나누어 처리한다:
*// Concurrent Mode에서의 최적화된 처리*
function OptimizedComponent() {
const [isPending, startTransition] = useTransition();
const [heavyData, setHeavyData] = useState([]);
const [userInput, setUserInput] = useState('');
const handleHeavyUpdate = () => {
startTransition(() => {
*// 이 업데이트는 낮은 우선순위로 처리됨*
setHeavyData(generateHeavyData());
});
};
const handleInputChange = (e) => {
*// 사용자 입력은 높은 우선순위로 즉시 처리됨*
setUserInput(e.target.value);
};
return (
<div>
<input
value={userInput}
onChange={handleInputChange}
placeholder="타이핑해보세요 - 즉시 반응합니다!"
/>
<button onClick={handleHeavyUpdate}>
{isPending ? '처리 중...' : '무거운 작업 시작'}
</button>
<HeavyList data={heavyData} />
</div>
);
}
출처: React 공식 문서 - useTransition
특징 | 동기 렌더링 | Concurrent Mode |
---|---|---|
UI 반응성 | 렌더링 중 블로킹 | 항상 반응적 |
사용자 입력 | 지연 발생 | 즉시 반응 |
메모리 사용 | 낮음 | 약간 높음 |
복잡성 | 단순 | 복잡 |
성능 | 예측 가능 | 적응적 |
개발 경험 | 직관적 | 학습 곡선 존재 |
Concurrent Mode의 핵심은 작업 우선순위(Task Priority)와 스케줄링(Scheduling)이다. React는 모든 업데이트를 순서에 따라, 동일하게 처리하지 않고, 중요도에 따라 우선순위를 매긴다.
React는 내부적으로 다음과 같은 우선순위 레벨을 사용한다:
*// React 내부 우선순위 (개념적 표현입니다!)*
const Priority = {
IMMEDIATE: 1, *// 사용자 입력 (클릭, 타이핑이 최우선!)*
USER_BLOCKING: 2, *// 사용자가 기다리는 작업*
NORMAL: 3, *// 일반적인 업데이트*
LOW: 4, *// 백그라운드 작업*
IDLE: 5 *// 유휴 시간에 처리할 작업*
};
function PriorityExample() {
const [urgentCount, setUrgentCount] = useState(0);
const [normalCount, setNormalCount] = useState(0);
const [isPending, startTransition] = useTransition();
const handleUrgentUpdate = () => {
*// 높은 우선순위 - 즉시 처리됨*
setUrgentCount(prev => prev + 1);
};
const handleNormalUpdate = () => {
*// 낮은 우선순위 - 다른 작업이 있으면 지연됨*
startTransition(() => {
setNormalCount(prev => prev + 1);
});
};
return (
<div>
<h3>긴급 카운터: {urgentCount}</h3>
<h3>일반 카운터: {normalCount} {isPending && '(업데이트 중...)'}</h3>
<button onClick={handleUrgentUpdate}>
긴급 업데이트 (즉시 처리)
</button>
<button onClick={handleNormalUpdate}>
일반 업데이트 (지연 가능)
</button>
</div>
);
}
출처: React 공식 문서 - useTransition
React의 Scheduler는 다음과 같은 방식으로 작동한다:
requestIdleCallback
과 유사한 방식으로 브라우저와 협력*// useDeferredValue를 사용한 최적화*
function SearchResults({ query }) {
const deferredQuery = useDeferredValue(query);
const results = useMemo(() => {
*// 무거운 검색 로직*
return searchDatabase(deferredQuery);
}, [deferredQuery]);
return (
<div>
{*/* 사용자 입력은 즉시 반영 */*}
<input value={query} onChange={handleChange} />
{*/* 검색 결과는 지연되어 렌더링 */*}
<SearchResultsList results={results} />
{*/* 로딩 상태 표시 */*}
{query !== deferredQuery && <div>검색 중...</div>}
</div>
);
}
출처: React 공식 문서 - useDeferredValue
React가 우선순위를 결정하는 주요 요소들:
(👨🏻🏫 : "저는 이걸 '응급실 트리아지'에 비유한답니다. 생명이 위급한 환자를 먼저 보고, 덜 급한 환자는 나중에 보는 것처럼요!")
중단 가능한 렌더링은 Concurrent Mode의 가장 혁신적인 기능이다. 기존에는 렌더링이 시작되면 끝까지 완료해야 했지만, 이제는 중간에 멈추고 더 중요한 작업을 처리할 수 있다.
React는 렌더링 작업을 작은 시간 단위(slice)로 나누어 처리한다:
*// 개념적인 Time Slicing 구현*
function timeSlicingExample() {
const SLICE_TIME = 5; *// 5ms 단위로 작업 분할*
function renderWithTimeSlicing(workList) {
const startTime = performance.now();
while (workList.length > 0 &&
(performance.now() - startTime) < SLICE_TIME) {
const work = workList.shift();
processWork(work);
}
if (workList.length > 0) {
*// 남은 작업이 있으면 다음 프레임에서 계속*
requestIdleCallback(() => renderWithTimeSlicing(workList));
}
}
}
function LargeList({ items }) {
const [displayItems, setDisplayItems] = useState([]);
const [isPending, startTransition] = useTransition();
useEffect(() => {
*// 큰 리스트 렌더링을 낮은 우선순위로 처리*
startTransition(() => {
setDisplayItems(items);
});
}, [items]);
return (
<div>
{isPending && <div>리스트 업데이트 중...</div>}
<VirtualizedList items={displayItems} />
</div>
);
}
*// 가상화된 리스트 컴포넌트*
function VirtualizedList({ items }) {
const [visibleItems, setVisibleItems] = useState([]);
*// 스크롤 이벤트는 높은 우선순위로 처리*
const handleScroll = (e) => {
const { scrollTop, clientHeight } = e.target;
const startIndex = Math.floor(scrollTop / ITEM_HEIGHT);
const endIndex = startIndex + Math.ceil(clientHeight / ITEM_HEIGHT);
setVisibleItems(items.slice(startIndex, endIndex));
};
return (
<div onScroll={handleScroll} style={{ height: 400, overflow: 'auto' }}>
{visibleItems.map(item => (
<ListItem key={item.id} data={item} />
))}
</div>
);
}
Concurrent Mode는 React 사용자의 행동을 최우선적으로 고려하는 기술이다. 더 이상 무거운 렌더링 때문에 UI가 멈추거나 사용자가 하고자 하는 행동이 지연되는 일은 없을 것이다.
핵심을 다시 정리하면:
React 18을 사용하고 있다면, 이미 Concurrent Mode의 혜택을 받고 있을 것이다. 하지만 useTransition
, useDeferredValue
, Suspense
등의 API를 적극 활용할 줄 알거나, 문제점이 발생했을 때 해결하기 위해선 개념을 빠삭히 알아야 하고, 그래야지만 더욱 뛰어난 사용자 경험을 제공할 수 있다. 이를 잘 사용하는 방법에 대해서는 다음 글에서 한 번 다뤄보겠다!
(👨🏻🏫 : "보통 프론트엔드 개발자는 OS나 CS 지식과는 거리가 멀다고 오해할 수 있습니다. 단순히 화면(UI)을 구성하고, 그 과정에서 API 를 받아오는 정도만 생각한다면 말이죠! 저 또한 처음엔 그랬는데, React 생태계 뿐만 아니라, 실제 업무 상황에서 생기는 다양한 문제를 해결하기 위해선, 기본적인 CS나 OS 에서 사용하는 다양한 이론과 기법들이 적용된답니다...! 훌륭한 프론트엔드 개발자를 목표로 한다면 꼭 알아둬야 하겠죠?")
🙇🏻 글 내에 틀린 점, 오탈자, 비판, 공감 등 모두 적어주셔도 됩니다. 감사합니다..! 🙇🏻