React 18부터 동시성 모드가 도입되었다. 리액트에서의 동시성 모드는 한 번에 둘 이상의 작업이 동시에 진행되는 것을 말한다. 더 자세히 얘기하자면, 작업에 우선순위를 정하고 우선순위에 따라 작업을 번갈아 빠르게 수행하여 마치 동시에 진행되는 것처럼 보이게 하는 기능이다.
동시성 모드를 통해 리액트는 무거운 렌더링 작업에 개입할 수 있게 되었다. 기존에는 렌더링이 한 번 시작되면 도중에 멈출수 없었지만, 동시성이 도입되면서 렌더링 과정을 중단하고 재개할 수 있게 되었다. 이러한 기능으로 리액트는 불필요한 렌더링을 방지하고 사용자 경험 개선에도 영향을 주었다.
동시성 모드는 기존의 동기식 렌더링 대신 비동기적으로 렌더링을 수행한다. 여러 작업에 우선순위를 정하고 우선순위가 높은대로 작업을 진행한다. 만약 높은 우선순위의 작업이 들어오면 진행 중이던 낮은 우선순위의 렌더링 작업을 중단하고 높은 우선순위의 작업을 진행하는 것이다.
import ReactDOM from 'react-dom';
import App from 'App';
const container = document.getElementById('app');
const root = ReactDOM.createRoot(container);
root.render(<App />);
React 18 이후에는 ReactDOM.createRoot로 생성된 루트를 통해 App을 렌더링한다.
동시성 모드가 도입되면서 리액트에서 새롭게 생긴 기능들이 있다. 각 기능들을 하나씩 살펴보자.
UI는 상태가 바뀌면 그에 맞춰 리렌더링된다. 만약 상태가 여러번 바뀔 경우 UI도 여러번 업데이트 된다면 불필요한 리렌더링이 발생하는 것이다. 리액트는 이러한 불필요한 리렌더링을 줄이기 위해 Batching이라는 기능을 사용하고 있었다. 하지만 동시성 모드가 도입되면서 Batching의 기능을 향상시킨 Automatic Batching이 나타났다.
// React 18 이전
function handleClick() {
setCount(c => c + 1);
setFlag(f => !f);
// 리렌더링 (Bathcing)
}
setTimeout(() => {
setCount(c => c + 1); // 리렌더링
setFlag(f => !f); // 리렌더링
// Batching 없음
}, 1000);
Batching은 상태가 여러번 바뀌어도 렌더링을 한 번만 하는 것을 의미한다. 위 코드를 보면, handleClick 함수 내의 상태가 바뀔 때마다 리렌더링이 일어나는 것이 아니라, 모든 상태 값이 바뀐 후에 일괄적으로 리렌더링을 진행한다. 다만, React 18 이전에는 Batching이 리액트의 이벤트 핸들러 내에서만 동작하고 Promise, setTimeout, Native Event Handlers에서는 동작하지 않았다.
function handleClick() {
setCount(c => c + 1);
setFlag(f => !f);
// 리렌더링 (Bathcing)
}
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
// Automatic Batching
}, 1000);
동시성 모드가 도입되면서 리액트는 Promise, setTimeout, Native Event Handlers에서도 Batching이 가능해졌다. 이를 Automatic Batching이라고 한다.
import { flushSync } from 'react-dom';
function handleClick() {
flushSync(() => {
setCounter(c => c + 1);
});
// 리렌더링 (Automatic Batching 무시)
flushSync(() => {
setFlag(f => !f);
});
// 리렌더링 (Automatic Batching 무시)
}
하지만 이러한 Automatic Batching을 원하지 않을 수도 있다. 그럴 때는 ReactDom.flushSync() 메서드를 사용하면 Automatic Batching을 무시하고 즉시 리렌더링 된다.
프론트엔드 개발자라면 데이터가 로딩되는 동안 spinner나 skeleton을 활용해서 사용자에게 로딩 중인 화면을 보여준 경험이 있을 것이다.
const App = () => {
return (
<InterviewResultPage />
)
}
const InterviewResultPage = () => {
const { data, isLoading } = useGetResult(interviewId!);
// 데이터 로딩 중
if (isLoading) {
return <Loading />;
}
return (
<ResultComponent />
)
}
위 코드는 데이터 조회가 아직 로딩 중이라면 Loading을 렌더링하고 로딩이 끝나면 관련된 컴포넌트들과 함께 ResultComponent를 렌더링한다. 이처럼 Suspense를 사용하지 않는 경우에는 명령형으로 분기 처리를 하며 화면을 렌더링 해야한다.
const App = () => {
return (
<Suspense fallback={<Loading />}>
<InterviewResultPage />
</Suspense>
)
}
const InterviewResultPage = () => {
const { data, isLoading } = useGet=Result(interviewId!);
return (
<ResultComponent />
)
}
Suspense는 비동기 데이터 호출, fallback UI 렌더링, 완성된 UI 렌더링 등 기존의 렌더링을 여러 작업으로 나누어 번갈아 가며 진행된다. 비동기 데이터 호출로 로딩이 진행되는 동안 fallback UI를 화면에 띄우고, 로딩이 완료되면 완성된 UI를 보여준다. 이를 통해 마치 비동기 데이터 호출과 렌더링 작업이 동시에 이루어지는 것처럼 보인다.
Suspense를 통해 사용자에게 자연스러운 UI를 보여줌으로써 사용자 경험을 향상하지만, 오히려 Suspense가 사용자 경험을 해치는 경우도 있다. 만약 비동기 데이터 호출이 빠르게 이루어지는 경우에는 Suspense의 fallback UI가 아주 짧게 보여지기 때문에 화면이 깜빡거리는 것처럼 보여진다. 이러한 현상을 해결하기 위해 리액트는 Transition API를 제공하고 Suspense와 함께 사용하길 권장한다.
Transition은 긴급한 업데이트와 긴급하지 않은 업데이트를 나누기 위해 나온 개념이다. 입력, 클릭, 누르기 등과 같은 직접적인 상호작용을 반영하는 것을 긴급한 업데이트, UI를 한 화면에서 다른 화면으로 전환하는 것을 긴급하지 않은 업데이트(Transition 업데이트)로 나누었다. 즉, Transition은 작업의 우선순위를 낮추어 UI 업데이트를 의도적으로 지연한다. 만약 Transition 처리한 작업보다 더 높은 우선순위의 작업이 들어오면 해당 작업을 진행한 후 Transiton된 작업을 진행한다.
import { startTransition } from 'react';
function TabContainer() {
const [tab, setTab] = useState('about');
function selectTab(nextTab) {
startTransition(() => {
setTab(nextTab);
});
}
// ...
}
startTransition으로 감싸여진 작업은 우선순위가 낮은 것으로 진행되고, 클릭과 같은 긴급한 업데이트를 진행하는 중에는 작업이 중단된다. 위 코드는 탭 전환을 예시로 나타낸 코드로, 만약 사용자가 탭을 계속 클릭한다면 아직 완료되지 않은 렌더링(Transition 작업)은 버리고 최신 업데이트만 렌더링한다.
import {useState, useTransition} from 'react';
import {updateQuantity} from './api';
function CheckoutForm() {
const [isPending, startTransition] = useTransition();
const [quantity, setQuantity] = useState(1);
function onSubmit(newQuantity) {
startTransition(async function () {
const savedQuantity = await updateQuantity(newQuantity);
startTransition(() => {
setQuantity(savedQuantity);
});
});
}
// ...
}
useTransition은 startTransition과 동일한 기능을 수행하는 훅으로, isPending과 startTransition을 반환한다. isPending은 대기 중인 Transition이 있는지 나타내고, startTransition은 위에서 말했듯 상태 업데이트를 지연시킨다. 이때, startTransition 내에서 호출되는 함수를 Actions이라고 부른다.
startTransition이 처음 호출되면 isPending은 true 상태로 변하며 모든 Transition 업데이트가 완료될 때까지 true 상태를 유지한다. Transition 업데이트가 모두 반영되면 isPending은 false가 되며 UI가 업데이트된다.
import { useState, useDeferredValue } from 'react';
function SearchPage() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
// ...
}
useDeferredValue도 useTransition처럼 우선순위를 낮춰 작업 처리를 지연시킨다. 다만 한 가지 다른 점이 있다면 useTransition은 특정 함수의 우선순위를 낮추지만 useDeferredValue는 특정 값의 우선순위를 낮춘다.
useDeferrredValue는 검색과 같이 새로운 내용(검색 결과)이 로딩되는 동안 이전 내용(이전 검색 결과)을 표시하거나, 이전 내용이 오래됨을 표시하거나, UI 일부의 리렌더링을 지연할 때 사용한다.
📚 React v18.0
📚 Concurrent Mode
📚 React 18 Concurrent Rendering
📚 React 18 Automatic Batching: 렌더링 성능 향상의 무기
📚 React 18 Concurrent 로 UX 개선하기
📚 useTransition
📚 useDeferredValue