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 업데이트와 효율적인 작업 관리를 유지하는 데 도움이 된다.
리액트 내부의 코드를 간략히 살펴보자. 이 함수가 하는 일은 이와 같다.
1. 함수는 먼저 루트를 스케줄에 추가한다.
2. 함수는 플래그 mightHavePendingSyncWork를 true로 설정한다.
3. 함수는 루트 스케줄을 처리하기 위해 마이크로태스크가 스케줄되었는지 확인한다. (‘act’ 내부)
4. enableDeferRootSchedulingToMicrotask 플래그를 확인하는 조건 블록이다.
5. 함수에는 React의 act 유틸리티 내에서 레거시 업데이트를 처리하는 조건이 포함된다.
이 함수를 통해 React에서 스케줄러의 역할을 이해할 수 있다. 작업을 렌더 레인에 기반하여 예약하는 것이다.
코드로 스케줄러의 동작을 모델링하면 다음과 같다:
if (nextLane === Sync) {
queueMicrotask(processNextLane);
} else {
Scheduler.scheduleCallback(callback, processNextLane);
}
따라서 스케줄러는 이름 그대로 함수를 해당 함수의 레인에 따라 실행할 시스템이다. 이제 레인에 대해 자세히 알아보자.
이전 글에서 렌더레인이 조정의 '렌더 단계’의 두 함수, beginWork, completeWork에서 마지막 인자로 사용되는 것을 확인했다. ('renderLanes는 업데이트가 처리되고 있는 "레인"을 나타내는 비트마스크).
비트마스크: 비트마스크(Bitmask)는 데이터의 비트(bit) 연산을 이용하여 정보를 표현하고 조작하는 기법. 각 비트의 위치가 하나의 플래그(또는 스위치)로 작용하여, 여러 조건을 한 번에 저장하고 관리할 수 있게 한다.
이제 렌더 레인의 세부 사항과 작동 방식, 비트마스크로의 내부 표현에 대해 자세히 살펴보자.
렌더 레인은 React가 렌더링 프로세스 중에 처리해야 할 업데이트를 조직화하고 우선 순위를 정하는 가벼운 추상화이다.
ex) setState를 호출하면 해당 업데이트가 레인에 넣어진다. 업데이트의 컨텍스트에 따라 다른 우선 순위를 이해할 수 있다.
각 레인은 특정 우선 순위 수준에 해당하며, 높은 우선 순위의 레인이 낮은 우선 순위의 레인보다 먼저 처리된다. React의 레인 종류는 다음과 같다.
렌더 레인이 렌더-커밋 단계 중 우선순위를 결정하는 과정:
1. 업데이트 수집 (렌더 단계)
2. 레인 처리 (렌더 단계)
3. 커밋 단계
4. 반복
우선 순위를 결정한다는 곧 우선순위에 맞는 '렌더 레인’에 배치한다로 이해할 수 있다. 우선순위의 결정은 렌더 단계에서 일어나며, 이 단계에서 렌더링의 스케줄러가 함께 작동한다. 렌더 단계에서 조정자가 beginWork를 진행하며 파이버 트리를 따라 업데이트 여부를 확인하고, 적절한 우선순위 lane에 따라 completeWork에서 새로운 트리를 구성하는 것으로 이해할 수 있다. 추가로 앞서 살펴본 스타베이션(계속 작업이 추가되어 대기 중인 작업이 계속 중단됨)을 막기 위해 이 과정을 매 렌더링마다 반복된다는 사실 또한 기억 할 만하다.
이제 렌더 레인이 할당되는 과정을 확대해서 살펴보자. 이 과정은 업데이트가 트리거될 때 다음 순서대로 수행하면서 우선순위를 결정하고 올바른 레인에 할당한다.
컨텍스트 결정 -> 컨텍스트 기반의 우선순위 추정 -> 우선순위 재지정 확인 -> 업데이트를 레인에 할당
1.업데이트 컨텍스트 결정
2. 컨텍스트에 따른 우선순위 추정
3. 우선순위 재지정 확인
4. 업데이트를 올바른 레인에 할당
이 프로세스 전반에 걸쳐 React는 내부 휴리스틱 및 업데이트가 발생하는 컨텍스트에 의존하여 우선순위에 대한 정보를 파악한다. 이러한 동적 우선순위 및 레인 할당은 React가 반응성과 성능을 균형 있게 유지할 수 있도록 하여 개발자의 수동 개입 없이도 응용 프로그램이 효율적으로 작동하도록 한다.
이어서 React가 각 레인에서 업데이트를 처리하는 방법을 살펴보자
업데이트가 각 레인에 할당되면 React는 그들을 우선순위 순서대로 처리한다. 앞서 살핀 ChatApp 컴포넌트 예시에서 React는 다음과 같은 순서로 업데이트를 처리할 것이다.
1. ImmediatePriority
2. UserBlockingPriority
3. NormalPriority
우선순위 순서로 업데이트를 처리하여 가장 중요한 사용자 입력의 렌더링의 반응성을 유지할 수 있도록 보장한다.
모든 업데이트를 각각의 레인에서 처리한 후에 React는 커밋 단계로 진입한다. 커밋 단계에서는 DOM에 변경 사항을 적용하고 효과를 실행하며 기타 완료 작업을 수행한다.
채팅 앱의 예시에서는 이 단계에서 1) 메시지 입력 값 업데이트, 2) 타이핑 인디케이터의 표시/숨김, 3) 새 메시지를 메시지 목록에 추가하는 작업이 순서대로 실행된다. 그런 다음 React는 다음 렌더 사이클에서 업데이트 수집, 레인 처리, 변경 사항 커밋 등의 프로세스를 반복한다.
리액트 내부의 실제 메커니즘을 완전히 이해하는 것은 매우 복잡하다. 이 이상은 필요 이상으로 어렵기 때문에, 인용을 참고하고 이어서 동시성 관련 훅에 대해 더 알아보자.
note 더욱 깊게 알아보기
.…두 레인이 함께 처리되어야 하는지 결정하는 Entanglement와 이미 처리된 업데이트 위에 업데이트가 다시 기반화되어야 하는지 결정하는 Rebasing과 같은 개념이 있습니다. 예를 들어, 전환이 완료되기 전에 동기화 업데이트에 의해 전환 작업이 중단된 경우에는 두 작업을 함께 실행해야 합니다. 또한 flushing effect에 대해서도 알아야합니다. 예를 들어, 동기 업데이트가 있는 경우에는 React가 효과를 업데이트 전/후에 플러시하여 동기 업데이트 간 상태의 일관된 순서를 보장할 수 있습니다. ...최종적으로 이런 작업을 대신해주는 것이 React가 존재하는 이유이며, React가 우리가 계속해서 응용 프로그램에 집중하는 동안 업데이트 문제, 그들의 우선 순위 및 순서를 근본적으로 처리하는 방식으로 백그라운드에서 추가하는 실제 가치입니다.
useTransition은 컴포넌트 내에서 상태 업데이트의 우선 순위를 관리하고 UI가 고우선 순위 업데이트로 인해 반응하지 않는 것을 방지하기 위한 훅이다. 새로운 데이터를 로드하거나 페이지 간 이동과 같이 시각적으로 방해가 되는 업데이트를 처리할 때 유용하다
useTransition 훅은 transition을 생성하고, 여기에 특정한 우선 순위를 할당하는 것으로 작동한다. 업데이트가 startTransition으로 래핑되면 transition이 된다. React는 이를 기반으로 할당된 우선 순위에 따라 업데이트를 예약하고 렌더링한다.
useTransition 훅을 사용하는 과정은 다음과 같다:
TransitionLanes은 SyncLane보다 우선 순위가 낮기 때문에 다른 고우선 순위 업데이트가 먼저 업데이트 되도록하여 부드러운 사용자 경험을 유지할 수 있다. startTransition 함수로 래핑된 모든 상태 업데이트는 TransitionLanes 레인에 들어가게 된다.
useTransition은 훅이므로 함수 컴포넌트 내에서만 사용할 수 있다. 두 개의 요소를 포함하는 배열을 반환한다.
startTransition
업데이트를 지연하거나 우선 순위를 낮게 할 때 사용할 수 있는 함수이다. 이 함수 내부에는 상태의 업데이트가 들어간다. 이 함수에 들어간 업데이트는 전환transition이 된다.
isPending
startTransition 내부의 전환transition이 진행 중인지 여부를 나타내는 boolean 값이다.
추가로 리액트는 useTransition의 반환값이 아닌 startTransition API도 제공한다. 이 API 일반 함수로 사용할 수 있다. isPending을 사용할 수 없다. useTransition과 같은 훅을 사용할 수 없는 코드 위치에서 React에 낮은 우선 순위 업데이트를 처리할 경우에 사용할 수 있다. (jsx 내부 혹은 util 함수 내부와 같은 경우에 사용할 수 있다.)
import React, { useState, useTransition } from "react";
function App() {
const [count, setCount] = useState(0);
const [isPending, startTransition] = useTransition();
const handleClick = () => {
doSomethingImportant();
startTransition(() => {
setCount(count + 1);
});
};
return (
<div>
<p>Count: {count}</p>
<button onClick={handleClick}>Increment</button>
{isPending && <p>Loading...</p>}
</div>
);
}
export default App;
이 예제에서는 useTransition을 사용하여 카운터를 증가시키는 상태 업데이트의 우선 순위를 관리한다. startTransition 함수 내에서 setCount 업데이트를 래핑함으로써 이 업데이트를 지연시킬 수 있도록 하여 다른 고우선 순위 업데이트(doSomethingImportant)가 동시에 발생할 때, cout의 업데이트에 UI가 반응하지 않게 할 수 있다.
useTransition은 페이지 간 이동할 때도 유용하다. 페이지 이동과 관련된 업데이트의 우선 순위를 관리하여 복잡한 페이지 전환을 처리할 때도 사용자 경험을 부드럽고 반응적으로 유지할 수 있다. 예시는 SPA에서 페이지 전환을 예시이다. 그러나 SPA가 아니더라도, URL경로 이동 없이 탭 선택으로 컴포넌트가 변경되는 컴포넌트에 적용할 수 있다.
import React, { useState, useTransition } from "react";
const PageOne = () => <div>Page One</div>;
const PageTwo = () => <div>Page Two</div>;
function App() {
const [currentPage, setCurrentPage] = useState("pageOne");
const [isPending, startTransition] = useTransition();
const handleNavigation = (page, _isPending) => {
if(_isPending) return;
startTransition(() => {
setCurrentPage(page);
});
};
const renderPage = () => {
switch (currentPage) {
case "pageOne":
return <PageOne />;
case "pageTwo":
return <PageTwo />;
default:
return <div>Unknown page</div>;
}
};
return (
<div>
<nav>
<button onClick={() => handleNavigation("pageOne")}>Page One</button>
<button onClick={() => handleNavigation("pageTwo")}>Page Two</button>
</nav>
{isPending && <p>Loading...</p>}
{renderPage()}
// 혹은 조건부 렌더링으로 currentPage와 PageOne,Two를 명시할 수 있다.
</div>
);
}
export default App;
만약 App 컴포넌트에 이동 시에 다른 고우선 순위 업데이트(예: input 입력)가 있다면, useTransition을 사용해 컴포넌트 전환을 우선순위 뒤로 밀어 렌더링 차단을 방지할 수 있다. 또한 페이지 전환이 진행되는 동안 isPending 상태를 활용해 사용자가 버튼을 클릭할 때 즉시 로딩 표시기를 표시할 수 있다. 전환이 완료되면 isPending 상태가 false가 되고 새 페이지가 렌더링된다. 다음 페이지가 Suspense를 사용하여 데이터를 가져와야 하는 경우에도 유용하게 사용할 수 있다. startTransition없이는 페이지 전환이 다른 업데이트를 차단할 수 있기 때문이다.
useDefferedValue가 반환하는 value가 새 값으로 바뀔 때는 다중 리렌더링이 아니라 제어된 상태에서 업데이트 된다. 이는 stale-while-revalidate 전략과 유사하며, 새 값이 도착할 때까지 이전 값을 보유하여 UI가 반응성을 유지한다.
React의 이전 커밋에서 useDeferredValue의 첫 구현을 확인할 수 있다. 이는 useDefferedValue를 이해하는데 좋은 참고가 된다.
function useDeferredValue(value) {
// 1. 인자를 초기값으로만 사용하고
const [newValue, setNewValue] = useState(value);
useEffect(() {
// 2. startTransition의 전환transition 내부에서 상태가 바뀔 때마다 업데이트하여 '연기defer'시킨다.
startTransition(() => {
setNewValue(value);
});
}, [value]);
return newValue;
}
useDeferredValue의 사용 목적은 덜 중요한 업데이트의 렌더링을 지연시키는 것이다. 사용자 인터랙션과 같은 더 중요한 업데이트를 먼저 업데이트하고 서버에서 업데이트된 데이터를 표시하는 것과 같은 덜 중요한 작업을 나중에 업데이트할 수 있다.
다음은 useDeferredValue를 사용하는간단한 예시다.
import React, { memo useState, useDeferredValue } from "react";
function App() {
const [searchValue, setSearchValue] = useState("");
const deferredSearchValue = useDeferredValue(searchValue);
return (
<div>
<input
type="text"
value={searchValue}
onChange={(event) => setSearchValue(event.target.value)}
/>
<SearchResults searchValue={deferredSearchValue} />
</div>
);
}
const SearchResults = memo(({ searchValue }) => {
// Perform the search and render the results
})
이 예제에서는 useDeferredValue를 사용하여 검색 결과의 렌더링을 지연시킨다. 검색 결과를 보여주는 SearchResult는 상대적으로 렌더링하는 데 더 많은 비용이 든다. 이때 사용자 입력에 따라 searchValue가 먼저 업데이트 되어, SearchResults의 렌더링 작업이 input의 searchValue 렌더링을 막지않아 UI 응답성을 유지할 수 있다. 또한 SearchResults에 memo를 사용하여 불필요한 업데이트가 발생하지 않도록 하였다.deferredSearchValue은 setSearchValue로 더 중요한 업데이트가 끝난 이후에 업데이트되고, 이를 prop으로 사용하는 컴포넌트도 그때 업데이트 된다. 결과적으로 컴포넌트는 텍스트 입력 필드를 업데이트하는 것과 같이 더 이상 중요한 작업이 없을 때에만 렌더링되는 효과를 얻을 수 있다.
debounce, throttle 보다 나은 이유
Debouncing
사용자가 타이핑을 마칠 때까지 업데이트를 지연시키는 일정 시간의 일시 정지. (ex: 1초의 지연)
Throttling
일정한 간격으로 목록을 업데이트한다. (ex: 1초에 한 번 이상)
차이점:
useDeferredValue는 지연된 리렌더링을 중단할 수 있는 장점을 갖는다. React가 큰 목록을 처리하는 동안 사용자가 새로운 키를 입력하는 경우, React는 다시 렌더링을 일시 중지하고 새 입력에 응답 한 다음 백그라운드에서 다시 렌더링 프로세스를 재개 할 수 있다. 이는 업데이트를 지연시켜도, 렌더링 중에 인터랙션을 차단할 가능성이 있는 debouncing 및 throttling과 대조적이다. useDefferedValue를 통해 이러한 방해 없이 일관성있는 경험을 제공할 수있다.
사용자가 만일 고성능의 노트북과 같은 디바이스를 사용한다면, 다시 렌더링하는 임의의 delay가 거의 눈에 띄지 않으며 거의 즉시 발생한다. 반면 느린 디바이스에서는 delay 시간이 그에 따라 조정되어 입력에 대한 응답에 대한 디바이스의 속도에 비례하여 지연이 발생할 수 있다.
하지만 debouncing과 throttling은 렌더링과 직접적으로 관련되지 않은 시나리오에서 여전히 유용할 수 있다. 예를 들어 네트워크 요청의 빈도를 줄이는 데 효과적일 수 있다. 이런 경우 debouncing과 throttling은 포괄적인 최적화 전략으로 useDeferredValue와 함께 사용될 수도 있다.
이 예에서 사용자가 검색 상자에 입력하면 입력 필드가 즉시 업데이트되고 결과는 지연된다. 사용자가 연속으로 다섯 문자를 빠르게 입력하면 입력 필드가 즉시 다섯 번 업데이트되고, 검색 결과는 사용자가 타이핑을 멈출 때까지 한 번만 렌더링된다. 문자 1~4에 대해서는 새 값으로 렌더링이 중단된다.
useDeferredValue는 애플리케이션에서 업데이트의 우선 순위를 관리하는 간단하고 선언적인 방법을 제공한다. 훅 내에서 업데이트를 지연시키는 논리를 캡슐화함으로써 컴포넌트 코드를 깔끔하고 앱의 중요한 측면에 집중시킬 수 있다.
useDeferredValue를 사용하여 중요하지 않은 업데이트를 지연시킴으로써 애플리케이션은 사용 가능한 자원을 더 효율적으로 활용할 수 있다. 이는 성능 병목 현상의 발생 가능성을 줄이고 애플리케이션 전반적인 성능을 향상시킬 수 있다.
useDeferredValue는 특정 업데이트를 다른 것보다 우선순위를 두어야 하는 상황에서 가장 유용하다. useDeferredValue가 유용한 시나리오는 다음과 같다.
useDeferredValue가 유용할 수 있는 예시:
import React, { memo, useState, useMemo, useDeferredValue } from "react";
function App() {
const [filter, setFilter] = useState("");
const deferredFilter = useDeferredValue(filter);
const { data: items } = useQuery({
queryKey: ['searchList'],
queryFn: ...
})
const filteredItems = useMemo(() => {
return items.filter((item) => item.includes(deferredFilter));
}, [items, deferredFilter]);
return (
<div>
<input
type="text"
value={filter}
onChange={(event) => setFilter(event.target.value)}
/>
<ItemList items={filteredItems} />
</div>
);
}
const ItemList = memo(({ items }) => {
// 아이템 항목 렌더
});
useDeferredValue를 사용하여 필터링된 목록의 렌더링을 지연시킨다. 사용자가 필터 입력란에 타이핑하는 동안 filteredItems은 덜 자주 업데이트되어 사용자 입력을 우선하고 반응성을 유지할 수 있다.
useMemo 훅은 items와 filteredItems 배열을 메모이제이션하여 불필요한 다시 렌더링과 다시 계산을 방지하여 성능을 더욱 향상시킬 수 있다.
useDeferredValue를 사용하는 것이 특정 시나리오에서 유용할 수 있지만, 이에 대한 트레이드오프를 인식하는 것도 중요하다. 업데이트를 지연시켜 사용자에게 표시되는 데이터가 오래된 경우가 있을 수 있기 때문이다. 중요하지 않은 업데이트는 상관없지만, 사용자에게 오래된 데이터를 표시하는 결과를 보여줘선 안되는 경우도 고려해야한다.
useDeferredValue를 사용할지 여부를 결정할 때 스스로에게 물어볼 좋은 질문은 "이 업데이트가 사용자 입력인가요?"입니다. 사용자가 반응을 기대하는 모든 것은 지연해서는 안되지만, 그 외의 중요하지 않은 것들은 지연시켜도 괜찮습니다.
useDeferredValue의 사용은 UI의 반응성을 향상시킬 수 있지만, 모든 것의 해결책으로 보면 안된다. 성능을 향상시키는 가장 좋은 방법은 효율적인 코드를 작성하고 불필요한 계산을 피하는 것이다.
혼동될 만한 두 훅을 다시 한번 정리해보자.
'업데이트를 지연시킨다’는 문구로 혼동이 있을 수 있다. 하지만 두 훅의 사용 범위와 목적이 다르다는 것을 숙지하면 좋다. startTransition은 컴포넌트에서 특정 업데이트setter의 우선순위를 지연시키기 위해 사용하며, useDefferedValue는 특정 상태state가 자주 업데이트 될 때 (ex: input 입력), 이 상태에 더이상 업데이트가 없을 때까지 값을 유지시키기 위해 사용한다.
동시 렌더링의 대표적인 문제로, 업데이트가 처리되는 순서를 이해하기 어려운 경우가 있다. 그리고 이는 예상치 못한 동작 및 버그로 이어질 수 있다.
동기적인 React는 컴포넌트 트리를 따라 내려가 하나씩 렌더링한다. 이는 상위에서 하위로, 순서대로 이루어진다. 각 컴포넌트가 최신 상태로 렌더링되어 상태가 렌더링 프로세스 전체에서 일관되게 유지된다. 하지만 동시성 렌더에선 문제가 생길 수 있다.
동시성 렌더링에서 Tearing 버그 재연:
import { useState, useSyncExternalStore, useTransition } from "react";
// 외부 상태
let count = 0;
setInterval(() => count++, 1);
export default function App() {
const [name, setName] = useState("");
const [isPending, startTransition] = useTransition();
const updateName = (newVal) => {
startTransition(() => {
setName(newVal);
});
};
return (
<div>
<input value={name} onChange={(e) => updateName(e.target.value)} />
{isPending && <div>Loading...</div>}
<ul>
<li>
<ExpensiveComponent />
</li>
<li>
<ExpensiveComponent />
</li>
<li>
<ExpensiveComponent />
</li>
<li>
<ExpensiveComponent />
</li>
<li>
<ExpensiveComponent />
</li>
</ul>
</div>
);
}
const ExpensiveComponent = () => {
const now = performance.now();
while (performance.now() - now < 100) {
// 대기
}
return <>Expensive count is {count}</>;
};
Expensive count is 568
Expensive count is 568
Expensive count is 569
Expensive count is 569
Expensive count is 570
버그: input에 몇 글자를 입력한 후, 다섯 개의 ExpensiveComponent에서 count의 각각 다른 값으로 렌더링된다. 즉 동시성 렌더링 사용 시 같은 값이 다르게 표시될 수 있다.
원인: React가 렌더링 과정 중 다른 작업으로 '중단’되었다가 다시 시작할 때, ExpensiveComponent는 이미 변경된 count 값을 반영하지 않은 채 렌더링될 수 있다. 이는 ExpensiveComponent가 여러 번 렌더링되는 동안 count 값이 계속 업데이트되기 때문에, 각 컴포넌트가 조금씩 다른 시점의 count 값을 사용하여 렌더링하기 때문이다.
이것을 티어링tearing이라고 한다. 아직 렌더링되는 동안 일부 상태에 의존하는 컴포넌트가 업데이트될 때 발생할 수 있는 버그이다. 컴포넌트가 렌더링 과정 동안에도 업데이트되는 상태(count 변수)에 의존한다면, 일관성 없는 데이터가 렌더링될 수 있다.
중단 이전 컴포넌트 렌더링 -> 업데이트된 카운트 값이 DOM에 플러시되거나 커밋 -> transition 작업으로 중단 -> 중단 이후 컴포넌트 계속 렌더링 -> 새로운 카운트 값으로 바뀐 채로 DOM에 업데이트
리액트는 최종적으로 일관된 상태를 렌더링할 것이기 때문에 큰 문제는 아니다. 하지만 더 큰 문제는 다음과 같은 경우이다:
<UserDetails id={user.id} />
이 코드는 렌더링 사이에 전역 상태에서 사용자가 삭제된 경우 큰 오류를 발생시킬 것이다. 이것이 tearing이 큰 문제가 될 수 있는 이유다.
티어링tearing 문제를 해결하기 위해 앞서 살펴본 useSyncExternalStore 훅을 활용할 수 있다. 이전 상태 관리 글에서 간략히 언급하고 넘어갔던 부분이지만, 조금 더 깊게 알아보자.
const store = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)
// useSyncExternalStore의 인자로 사용할 객체
const store = {
subscribe(callback) {
callback();
return () => callback()
},
getSnapshot() {
return state;
},
};
subscribe:
첫 번째 인자로 콜백 함수를 받는 함수. 스토어가 변경될 때 콜백함수를 호출한다. 이는 컴포넌트의 리렌더링을 유발한다. subscribe 함수는 구독을 정리하는 clearn up 함수를 반환해야한다.
getSnapshot:
컴포넌트가 필요로 하는 스토어 내 데이터의 스냅샷을 반환하는 함수. 스토어가 변경되지 않았다면, getSnapshot에 대한 반복적인 호출은 동일한 값을 반환한다. 스토어가 변경되어 반환된 값이 다르다면(Object.is로 비교했을 때), React는 컴포넌트를 리렌더링.
반환 값:
store에 저장된 값의 현재 스냅샷.
(출처: react.dev)
useSyncExternal이 반환하는 스냅샷을 통해 ExpensiveComponent는 count의 전역 변수를 직접 읽는 대신 일관된 상태를 보장할 수 있다. 이는 count가 변경될 때마다 ExpensiveComponent가 동기적으로 리렌더링되도록 하여, 모든 ExpensiveComponent 인스턴스가 동일한 count 값을 표시하게 한다.
이 예제에서는 store.subscribe 함수를 통해 count 상태 변경을 감지하지 않는다. 대신 getSnapshot에서 매 밀리초마다 업데이트 되는 count 값의 변화가 ExpensiveComponent에 바로 반영되어, UI의 일관성을 유지하게 된다.
import { useState, useSyncExternalStore, useTransition } from "react";
let count = 0;
setInterval(() => count++, 1);
const store = {
// 1. 이 예시에서는 subscribe 콜백은 사용하지 않는다
subscribe(callback) {
callback();
},
getSnapshot() {
// 매 밀리초마다 업데이트 되는 count의 스냅샷을 얻는다
return count;
},
};
const ExpensiveComponent = () => {
// 2. count의 스냅샷을 얻어 모든 컴포넌트가 동일한 값을 갖고 리렌더링된다
const consistentCount = useSyncExternalStore(
store.subscribe,
store.getSnapshot
);
const now = performance.now();
while (performance.now() - now < 100) {
// 대기
}
return <>Expensive count is {consistentCount}</>;
};
export default function App() {
.... 위 예시와 동일
}
이제 count가 변경될 때마다 ExpensiveComponent는 새로운 count 값으로 다시 렌더링되며, 모든 ExpensiveComponent 인스턴스에서 동일한 count 값을 볼 수 있다. subscribe로 구현되는 변경 감지 로직 자체는 구현사항에 따라 반드시 필요하게 될 것이다. 여기선 useSyncExternalStore가 주요 기능을 수행하는 메커니즘을 이해하기 위해 생략되었다.
useSyncExternalStore를 통한 해결 방법:
티어링 문제와 해결법은 상태 관리 글에서 살펴봤던 내용과 일맥상통한다. 예시의 count처럼 여러 컴포넌트에서 공유된 상태를 일관되게 유지하기 위해 전역 상태를 사용했다. 리액트 내부 훅으로는 Context, useSyncExternalStore를, 외부 라이브러리는 Zustand, Jotai, Valtio를 살폈다.
스냅샷(혹은 스토어, 파생 상태, 아톰, 셀렉터)은 상태가 변경될 시에 리렌더링을 유발한다. 이는 useSyncExternalStore의 해결방법과도 일맥상통한다. 라이브러리에 따라 구현 방법은 다르지만, 충분히 대체 가능해 보인다. 이로써 전역 상태를 사용해 동시성 렌더링의 tearing 문제와 각종 라이브러리의 렌더링 최적화 효과를 한번에 해결할 수도 있을 것이다.