Fluent React 8 - Concurrent
2024/03/20
n°55
category : React
☼
2024/03/20
n°55
category : React
챕터 7의 리액트 동시성Concurrency은 이전 글에서 이어진다. 이번 장에서 우선순위 업데이트 즉 '렌더링 스케줄링’이 리액트 내부에서 어떻게 이루어지며, 이를 활용하는 훅을 알아본다. 또한 이전 글에서 살펴본 '렌더 레인’과 같은 개념들을 조금 더 깊이 있게 알아본다. 긴 글이니 천천히 반복해서 읽어보자! (이탤릭체는 본문의 인용입니다)
앞서 살펴본 스택 조정자의 문제점과 동일하다. 즉 동기식 렌더링은 메인 스레드를 차단하는 것이 문제다. 컴포넌트의 업데이트가 빈번하고, 구성이 복잡한 앱에서 사용자 경험 저하로 이어진다. (사용자의 input 입력이 비싸고 불필요한 다른 렌더링 때문에 차단되는 예시를 떠올려보자.)
동시 렌더링 Concurrent Rendering은 렌더링 차단 문제를 해결한다. 파이버 조정자와 같은 문제의식을 공유하며, 마찬가지로 리액트 내부에서 렌더링을 효율적으로 개선한다.
…동시(Concurrent) 렌더링으로, React는 업데이트의 중요성과 긴급성에 따라 업데이트를 우선 순위를 지정할 수 있으며, 덜 중요한 것들에 의해 중요한 업데이트가 차단되지 않도록 합니다. 이를 통해 React는 무거운 부하 하에서도 반응적인 UI를 유지할 수 있으며, 이는 더 나은 사용자 경험으로 이어집니다.
…동시 렌더링으로, CPU에 부하가 많은 렌더링 작업은 사용자 인터랙션과 애니메이션과 같은 더 중요한 렌더링 작업에 뒷자리를 할당받을 수 있습니다. 더욱이, React는 타임 슬라이스를 할 수 있습니다. 즉, 렌더링 과정을 더 작은 청크로 나누고 점진적으로 처리할 수 있다. 이를 통해 React는 여러 프레임에 걸쳐 작업을 수행할 수 있으며, *작업이 중단되어야 하는 경우 중단할 수 있습니다.
요약하자면 다음과 같다
모두동기적 렌더링의 문제를 해결하는 방법으로 보인다. 또한 파이버 조정자가 하는 일과도 일맥상통해 보인다.
파이버 조정자Fiber Reconciler는 렌더링 과정을 Fiber라고 하는 더 작고 관리하기 쉬운 작업 단위로 나누어 렌더링 작업을 일시 중지, 재개 및 우선 순위 지정할 수 있으며, 그 중요성에 따라 업데이트를 연기하거나 스케줄링할 수 있다.
파이버는 조정 단계에서 파이버 조정자가 작업을 처리하기 위한 작은 단위라고 하였다. 앞선 챕터의 내용을 복습하면 다음과 같다.
파이버와 파이버 조정에 대해 알아본 이전 챕터에서 아직 설명되지 않은 부분이 있다. “어떻게” 작업을 일시 중지, 우선순위 지정, 연기, 스케쥴링-하는지에 대해선 completeWork에서 만들어진 DOM이 폐기될 수 있다-는 내용으로 암시된 내용이 전부였다. 이번 챕터에서 동시성으로 어떻게 중요도에 따라 렌더링의 스케쥴이 결정되는지 더 깊이있게 살펴본다.
아래는 메시지 인풋과 메시지를 렌더링하는 채팅 컴포넌트다. 이 예시는 React의 동시 렌더링 훅을 활용하여 인터랙션과 잦은 업데이트를 효과적으로 처리하는 간단한 예시를 보여준다.
const MessageInput = ({ onSubmit }) => {
const [message, setMessage] = useState("");
const handleSubmit = (e) => {
e.preventDefault();
onSubmit(message);
setMessage("");
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
<button type="submit">Send</button>
</form>
);
};
const MessageList = ({ messages }) => (
<ul>
{messages.map((message, index) => (
<li key={index}>{message}</li>
))}
</ul>
);
const ChatApp = () => {
const [messages, setMessages] = useState([]);
const [isOtherUserTyping, setIsOtherUserTyping] = useState(false)
useEffect(() => {
// 웹소켓 서버와 연결하여 새로 들어오는 메시지를 구독
const socket = new WebSocket("wss://your-websocket-server.com");
socket.onmessage = (event) => {
setMessages((prevMessages) => [...prevMessages, event.data.messages]);
setIsOtherUserTyping(event.data.otherUserTyping)
};
// 동시성 렌더링 적용 시
startTransition(() => {
setMessages((prevMessages) => [...prevMessages, event.data]);
});
return () => {
socket.close();
};
}, []);
const sendMessage = (message) => {
// Send the message to the server
};
return (
<div>
<MessageList messages={messages} />
// 상태 유저가 입력 중인지 나타내는 typing 인디케이터
{isOtherUserTyping && <p>user is typing..</p>}
<MessageInput onSubmit={sendMessage} />
</div>
);
};
useTransition훅의 startTransition을 사용하여 MessageList의 업데이트를 더 낮은 우선 순위로 스케줄링하여 MessageInput의 UI를 차단하지 않고 렌더링한다. 이를 통해 사용자 입력은 중단되지 않으며, 사용자 인터랙션(input 입력)보다는 덜 중요한 새로 받은 메시지는 우선 순위에서 낮게 렌더링된다. 결과적으로 무거운 부하에 효율적으로 작동할 수 있게 되었다.
파이버 조정자Fiber Reconciler는 스케줄러와 여러 효율적인 API에 의존하여 이 기능을 가능하게 한다. 이러한 API를 통해 React는 유휴 기간 동안 작업을 수행하고 가장 적절한 시기에 업데이트를 스케줄링할 수 있다.
동시성 렌더링의 핵심인 스케줄러, 작업의 우선 순위 수준, 업데이트를 연기하는 메커니즘을 더 깊이 살펴보자.
파이버 조정자Fiber reconcilier와 독립적으로 시간 관련 유틸리티를 제공하는 독립형 패키지
리액트 아키텍처의 핵심에는 렌더링의 스케줄을 관리하는 스케줄러가 있다. 이 스케줄러는 조정자reconcilier 내에서 사용된다. 스케줄러와 조정자는 렌더 레인을 사용하여 작업의 긴급함에 따라 우선 순위를 매기고, 조직화한다.
리액트에서 스케줄러의 주요 역할은 메인 스레드를 제어하는 것이다. 메인 스레드의 원활한 실행을 보장하기 위해 주로 자바스크립트의 마이크로태스크 큐를 사용한다.
조금 더 자세히 이해하기 위해 작성 시점의 리액트 소스 코드 일부를 살펴보자.
/*
* 이 함수는 루트가 업데이트를 받을 때마다 호출된다.
* 이 함수는 두 가지 작업을 수행한다.
* 1) 루트가 루트 스케줄에 포함되어 있는지 확인하고,
* 2) 루트 스케줄을 처리할 대기 중인 마이크로태스크가 있는지 확인
* 실제 스케줄링 로직의 대부분은
* `scheduleTaskForRootDuringMicrotask`가 실행될 때까지 발생하지 않는다.
*/
export function ensureRootIsScheduled(root: FiberRoot): void {
// 스케줄에 루트를 추가
if (root === lastScheduledRoot || root.next !== null) {
// 이미 스케줄된 루트root는 빠른 경로로 처리됨.
} else {
if (lastScheduledRoot === null) {
firstScheduledRoot = lastScheduledRoot = root;
} else {
lastScheduledRoot.next = root;
lastScheduledRoot = root;
}
}
/*
* 루트가 업데이트를 받을 때마다 다음 스케줄 처리까지 이 값을 true로 설정
* 만약 이 값이 false라면, 스케줄을 확인하지 않고 flushSync를 빠르게 종료할 수 있다
*/
mightHavePendingSyncWork = true;
/*
* 현재 이벤트의 끝에서, 각 루트를 통해
* 올바른 우선순위에서 각각에 대한 작업이 스케줄되어 있는지 확인
*/
if (__DEV__ && ReactCurrentActQueue.current !== null) {
// 이 내부는 'act' 스코프 내부이다
if (!didScheduleMicrotask_act) {
didScheduleMicrotask_act = true;
scheduleImmediateTask(processRootScheduleInMicrotask);
}
} else {
if (!didScheduleMicrotask) {
didScheduleMicrotask = true;
scheduleImmediateTask(processRootScheduleInMicrotask);
}
}
if (!enableDeferRootSchedulingToMicrotask) {
/*
* 이 플래그가 disabled되어 있는 동안,
* 마이크로태스크를 기다리는 대신 렌더 작업을 즉시 스케줄한다.
* TODO: 우리가 계획한 추가 기능들을 해제하기 위해
* enableDeferRootSchedulingToMicrotask를 가능한 빨리 적용해야한다.
*/
scheduleTaskForRootDuringMicrotask(root, now());
}
if (
__DEV__ &&
ReactCurrentActQueue.isBatchingLegacy &&
root.tag === LegacyRoot
) {
// Special `act` case: Record whenever a legacy update is scheduled.
ReactCurrentActQueue.didScheduleLegacyUpdate = true;
}
}
React 루트는 root: FiberRoot로 표현되며, 업데이트를 받으면 이 함수가 호출되어 두 가지 핵심 작업을 수행한다.
note: React 루트는 커밋 단계에서 업데이트를 수행하기 위해 최종으로 “스왑”(업데이트)된 트리이다. (앞선 글에서 실제 화면에 업데이트 되기 전에 미리 구성된 트리)
ensureRootIsScheduled이 호출되면,
1. 해당 루트가 루트 스케줄에 포함되어 있는지 확인한다.
2. 이 루트 스케줄을 전용으로 처리하는 대기 중인 마이크로태스크가 있는 것을 보장한다.
마이크로태스크는 자바스크립트의 이벤트 루프와 연관된다. 자바스크립트의 이벤트 루프를 복습해보자.
마이크로태스크
이벤트 루프
태스크 큐(매크로 태스크 큐)
마이크로태스크 큐
실행
루트 스케줄은 마이크로 태스크 큐로 관리되어 렌더링이나 이벤트 처리와 같은 다른 작업에 앞서 빠르고 순서대로 처리되도록 보장된다.
특징 및 사용법
마이크로태스크는 태스크 큐의 다른 작업보다 우선순위가 높으므로 다음 매크로 태스크로 이동하기 전에 실행된다. 마이크로태스크가 계속해서 마이크로태스크 큐에 추가되는 경우, 태스크 큐가 처리되지 않을 수 있다. 이를 '스타베이션(starvation)'이라고 한다.
리액트와 ensureRootIsScheduled 함수 안에서 마이크로태스크는 루트 스케줄 처리가 우선순위가 낮은 다른 작업보다 높은 우선순위로 즉시 처리되도록 보장하는 데 사용된다. 이는 리액트 내에서 부드러운 UI 업데이트와 효율적인 작업 관리를 유지하는 데 도움이 된다.