리액트는 기본적으로 가상 DOM을 생성하여 해당 DOM에 변경사항을 반영하고, 이후 실제 DOM과의 비교를 통해 최종 업데이트 하는 방식을 가집니다.
Stack Reconciler
리액트 v16 이전에는 가상 DOM에서의 변경사항을 찾기 위해서 재귀적으로 노드를 DFS 방식으로 변경점을 찾았습니다.
이걸 코드로 간단히 나타내면 아래와 같이 표현될수있습니다.
// 가상 DOM
const prevVDOM = {
type: "div",
props: { id: "root" },
children: [
{ type: "p", props: { text: "Hello" }, children: [] },
{ type: "span", props: { text: "World" }, children: [] },
],
};
const nextVDOM = {
type: "div",
props: { id: "root" },
children: [
{ type: "p", props: { text: "Hello, Updated!" }, children: [] },
{ type: "span", props: { text: "World" }, children: [] },
{ type: "p", props: { text: "New Node" }, children: [] },
],
};
// 재귀적 DFS 방식의 비교 함수
function diff(prevNode, nextNode, path = "root") {
if (!prevNode && nextNode) {
console.log(`노드 추가됨: ${path}`, nextNode);
return;
}
if (prevNode && !nextNode) {
console.log(`노드 삭제됨: ${path}`, prevNode);
return;
}
if (prevNode.type !== nextNode.type) {
console.log(`노드 타입 변경됨: ${path}`, prevNode.type, "→", nextNode.type);
return;
}
// 속성(props) 비교
if (JSON.stringify(prevNode.props) !== JSON.stringify(nextNode.props)) {
console.log(`노드 props 변경됨: ${path}`, prevNode.props, "→", nextNode.props);
}
// 자식 노드 재귀적으로 비교
const maxChildrenLength = Math.max(prevNode.children.length, nextNode.children.length);
for (let i = 0; i < maxChildrenLength; i++) {
diff(
prevNode.children[i],
nextNode.children[i],
`${path}.${nextNode.type}[${i}]`
);
}
}
// 예시 실행
diff(prevVDOM, nextVDOM);
// 노드 props 변경됨: root.div[0] { text: 'Hello' } → { text: 'Hello, Updated!' }
// 노드 추가됨: root.div[2] { type: 'p', props: { text: 'New Node' }, children: [] }
이 방식은 문제가 없어 보일수있지만, 문제는 자바스크립트가 싱글쓰레드 언어라는 점이였습니다.
Stack Reconciler 한계
렌더링 차단과 UI 응답 지연
위의 과정의 동작은 콜 스택에서 이루어졌기때문에 -> 이러한 업데이트가 이루어지는 동안은 콜스택이 점유되어있고, 해당 방식에서는 업데이트를 일시 중지하거나 중단하는 매커니즘이 없었기 때문에 다른 작업이 불가능했습니다.
극단적인 예로, 1000개의 항목을 가진 리스트 컴포넌트가 있고 각 항목을 렌더링하는데 1ms가 걸린다면, 한 번의 업데이트에 약 1초(1000ms)가 소요됩니다. 그 1초 동안은 메인 스레드가 리액트 렌더링 작업에 묶여 있으므로 사용자 입력이나 화면 업데이트가 지연되며, 사용자는 UI가 멈춘 것처럼 느끼게 됩니다
[사용자 입력] → 입력 이벤트 발생 (ex. 클릭/타이핑)
↓
[메인 스레드]
|----------- 렌더링 시작 (동기적, Blocking) -----------|
| 컴포넌트#1 렌더링 (1ms) | 컴포넌트#2 렌더링 (1ms) | ... | 컴포넌트#1000 렌더링 (1ms) |
|----------------------------------------------------|
(총 1000ms 소요)
↓ ↓
[UI 반응] 사용자 입력이 1초 후에야 반영됨
대량의 DOM 업데이트로 인한 성능 저하
브라우저는 일반적으로 60fps (16ms)에 한번씩 갱신되는데 지금 방식에서 대량의 DOM 업데이트를 진행하게되면 별다른 중단/최적화 개념이 없기 때문에 이 갱신 주기를 넘어갈 가능성이 존재하고, 이러면 다음 프레임으로 넘어가게됩니다.
이러한 과정으로 인해 화면 업데이트가 매끄럽게 업데이트되는게 아닌 일종의 끊기는 업데이트(Jank)가 발생합니다
[한 프레임 (16ms)] [다음 프레임 (16ms)]
|----------------------------|----------------------------|
| DOM 변경이 너무 많아 16ms 초과 | (프레임을 그리지 못하고 건너뜀) |
Stack Reconciler의 근본적 한계와 해결되지 않은 문제점
Stack Reconciler 방식은 근본적으로 작업을 잘게 나누거나, 우선순위를 조정하는 스케줄링이 불가능한 구조적 한계를 가지고 있었습니다. 렌더링 과정에서 발생하는 모든 작업은 자바스크립트의 콜 스택에 의존하여 재귀적으로 진행되기 때문에, 작업을 중간에 중단하거나 우선순위가 높은 다른 작업을 먼저 처리할 방법이 없었습니다. 이로 인해, 한 번 렌더링이 시작되면 완료될 때까지 다른 작업은 모두 차단되는 구조적 문제가 있었습니다.
만약 중간에 렌더링을 중단하고 이후 다시 재개하려고 하면, 이전 호출 스택의 모든 상태를 보존하고 복원해야 하는 복잡한 문제가 발생하게 됩니다. 이 같은 접근은 자바스크립트의 기본 동기적 호출 스택을 사용하는 Stack Reconciler에서는 비효율적이고 현실적으로 구현이 어렵습니다.
결국 리액트 팀은 2017년 v16에서 이 문제를 해결하기 위해 완전히 새로운 Fiber Reconciler를 설계했습니다. Fiber Reconciler는 기존 가상 DOM 트리를 링크드 리스트 형태의 Fiber 노드로 표현하고, 렌더링 작업을 작은 단위로 쪼갠 뒤 작업을 중간에 일시 정지하거나 우선순위를 조정할 수 있도록 설계되었습니다. 이를 통해, 긴 렌더링 작업을 여러 프레임에 나누어 실행하거나, 긴급한 사용자 인터랙션 작업을 우선적으로 처리하는 등 유연한 스케줄링이 가능해졌습니다.
정리하면, React 15까지의 Stack Reconciler는 단순하고 직관적인 방식임에도 불구하고, 동기적 처리로 인해 렌더링 차단, 프레임 드롭, 인터랙션 지연과 같은 성능적 한계를 극복하지 못했습니다. React 16 이후 Fiber의 도입으로 이와 같은 문제는 대부분 해결되었고, 개발자들이 직접 최적화 작업을 수행할 필요 없이 React 라이브러리 자체가 최적화를 내재화하게 되었습니다.
결국 Stack Reconciler는 “모든 업데이트를 한 번에 처리하려는 방식” 때문에 렌더링 차단, 프레임 드롭 등 성능 문제를 극복하지 못했습니다. React 16 이후 도입된 Fiber는 이러한 근본적인 한계를 해결한 새로운 방식이며, 이 Fiber 방식에 대해서는 다음 글에서 더 자세히 알아보겠습니다.
'React' 카테고리의 다른 글
[React] useEvent 미리 살펴보기 (0) | 2025.02.13 |
---|---|
[React] React + SSR과 서버 컴포넌트는 뭐가 다를까? (2) | 2024.12.27 |
[React] React Query 알아보기 03 - React Query with SSR (1) | 2024.11.24 |
[React] React Query 알아보기 02 - React Query with Suspense (0) | 2024.11.24 |
[React] useSyncExternalStore 알아보기 (0) | 2024.11.09 |