티스토리 뷰
관련글
[챗지피티와 공부를 해보자] Virtual DOM과 React Fiber 구조
가상돔 (Virtual DOM) 이란?
가상돔은 실제돔의 변경을 최소화하여 성능을 최적화하는 개념이다.
React(Vue, Preact 등....)에서 UI를 효율적으로 업데이트하기 위해 사용된다.
- DOM (Document Object Model, 문서 객체 모델)은 웹 브라우저가 HTML 문서를 해석하여 트리 구조(객체 모델)로 표현한 것이다. 즉, HTML을 브라우저가 이해할 수 있도록 만든 객체 형태의 구조.
- DOM을 통해 자바스크립트에서 HTML 요소를 조작할수있다.
기존 DOM을 직접 조작하면 성능 비용이 크고 DOM의 업데이트가 많아질수록 느려진다는 기존 DOM의 문제점으로 인해 가상 DOM이 등장했다.
- 요소의 추가, 수정될 때마다 브라우저는 리플로우와 리페인트가 발생하며, DOM 이 클수록 브라우저의 렌더링 성능이 저하된다.
가상돔의 문제 해결 방식
- 메모리 상에 가상 DOM을 자바스크립트 객체로 유지하고 변경 사항을 비교한 후, 최소한의 실제 DOM 조작을 수행한다.
- 가상 DOM은 실제 DOM 조작 전 변경 사항이 필요한 부분만 실제 DOM에 업데이트 하는 방식으로 성능 최적화를 하기 위해 생성하며 이전의 가상 DOM과 새로 생성된 가상 DOM을 비교하여 변경된 부분만 계산하고 해당 부분만 실제 DOM에 반영한다.
- React에서 리액트 엘리먼트(React Elements)를 통해 가상돔을 구성한다.
- React Elements는 돔 구성 시에는 가상돔에서만 직접 사용되며, 실제돔에서는 React가 변환한 후 적용된다.
- 실제돔에 간접적으로 관여한다. -> useRef 사용 시 실제돔 요소에 직접 접근할 수 있도록 영향을 준다.
- 즉, React Elements는 가상돔을 구성하는 단위이고 React가 이를 해석하여 실제돔에 업데이트하는 과정이 필요하다.
- Diffing 알고리즘을 이용해 변경된 부분만 찾아서 업데이트한다.
Q. React Elements가 뭐야?
A. React에서 UI를 표현하는 기본 단위를 말한다. (객체 형태)
React Elements는 JSX로 작성되지만, 실제로는 React.createElement() 함수를 통해 생성된다.
즉, React의 모든 컴포넌트(JSX로 작성된)는 결국 React Elements를 반환한다. 하지만 컴포넌트 자체가 React Element는 아니며 컴포넌트는 React Element를 생성(반환)하는 함수(또는 클래스) 역할을 한다. (= 컴포넌트는 React Elements를 반환하는 팩토리 같은 개념)
브라우저에 적용되는 과정
JSX 작성
const element = <h1>Hello, React!</h1>;
실제로는 아래와 같은 React Element로 변환하기 위한 함수 실행
const element = React.createElement(
"h1",
null,
"Hello, React!"
);
위 코드를 실행하게되면 React Element객체가 생성
- 이 객체는 단순한 자바스크립트 객체일뿐 아직 실제돔 요소가 아니다.
- 이 객체를 기반으로 가상돔이 구성된다.
{
type: "h1",
props: {
children: "Hello, React!"
}
}
이후, React가 React Element를 실제돔으로 변환하는 과정 진행
- React는 ReactDOM.createRoot().render()를 통해 React Elements를 실제 DOM으로 변환
import React from "react";
import ReactDOM from "react-dom/client";
const element = <h1>Hello, React!</h1>;
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(element);
- React Element 생성
- React가 가상돔 구성
- React가 비교(Diffing)후 변경된 부분을 실제 DOM에 반영
- 최종적으로 실제 돔에 <h1>Hello, React!</h1>이 추가
가상돔 동작과정
- 가상돔 생성: 리액트 컴포넌트가 렌더링딜때 가상돔 트리를 생성한다.
- 상태(State) 또는 속성(Props) 변경: 컴포넌트의 상태 또는 속성이 변경되면 리액트는 새로운 가상돔을 생성한다.
- Diffing 알고리즘 적용: 리액트는 이전 가상돔과 새 가상돔을 비교하여 변경된 부분만 찾아내어 최소한의 업데이트를 수행한다.
- 실제돔을 업데이트 (재조정, Reconciliation): 변경된 부분만 실제돔에 적용하고 최소한의 돔 조작을 수행한다.
예제
import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<h1>현재 카운트: {count}</h1>
<button onClick={() => setCount(count + 1)}>+1 증가</button>
</div>
);
}
export default Counter;
새로운 가상돔을 생성하고, 기존 가상돔과 비교하여 변경되는 부분인 h1 태그의 count값만 감지한다.
<h1> 태그 하위의 count의 텍스트 노드만 감지하여, 실제돔에 변경된 텍스트노드 부분만 업데이트하여 성능을 최적화한다.
- 컴포넌트 전체가 리렌더링되지만, 실제돔 변경은 텍스트노드만 변경된다.
장점
- 변경된 부분만 업데이트 되므로, 리플로우/리페인트 최소화되어 성능 최적화
- 복잡한 돔 조작을 자동화
단점
- 가상돔을 유지하는 추가 메모리가 필요하므로 메모리 사용이 증가한다.
- 첫 렌더링 시에는 약간의 오버헤드가 발생하므로 초기 렌더링 속도가 느려질 수 있다.
Q. 가상돔의 장점에서 '복잡한 돔 조작을 자동화' 한다는 건 어떤걸 말하는거며, 어떻게 가능하지?
A. 가상돔이 제공하는 근본적인 편의성 중 하나
선언형 UI란,
'UI가 이 상태일때는 이렇게 보여줘'라고 할때 '어떻게 만드는지(절차)'가 아니라 '무엇을 보여줄지(결과)'만 선언하는 방식이다.
어떻게 가상돔이 제공하고 있을까?
실제돔은 바로 화면에 그려지는 구조이기때문에 상태가 바뀔때마다 수동으로 조작해야한다.
가상돔은 메모리안에서 먼저 가상트리를 만들고 변경점만 실제돔에 반영하기때문에, '무엇을 그릴지'만 선언하게되면 리액트가 가상돔을 통해 '어떻게 업데이트 할지'를 자동으로 처리하기 때문에 가상돔은 '복잡한 돔 조작을 자동화'하는 장점을 가지고 있다.
명령형과 선언형 예시
명령형
돔의 생성부터 추가 갱신, 제거 까지 어떻게 바꿀지 직접 지시
// vanilla JS
const count = 0;
const el = document.createElement('div');
el.textContent = `카운트: ${count}`;
document.body.appendChild(el);
// 나중에 값 바꾸려면:
el.textContent = `카운트: ${count + 1}`;
선언형
보여줄 화면에 대한 것만 선언
어떻게 바뀌는지는 리액트가 알아서 가상돔과 비교해서 실제 돔에 반영한다.
function Counter({ count }) {
return <div>카운트: {count}</div>;
}
React Fiber 구조
리액트의 렌더링 엔진 아키텍쳐로 리액트 16부터 도입된 새로운 가상돔 구현 구조이다.
렌더링 작업을 더 잘게 쪼개어 비동기적으로 처리할 수 있게 한다.
기존 가상돔의 문제점(리액트 15이전)과 한계로 인해 도입하게 되었다.
리액트는 컴포넌트를 렌더링할때, 트리 구조로 하위 컴포넌트들을 따라 내려가며 처리한다.
- 부모 -> 자식 -> 자식의 자식 -> ....
이 모든 작업이 동기적으로 한번에 연속적으로 처리되어 렌더링 중에는 중간에 멈출 수 없으므로 중간에 다른 작업을 할 수 없다.
즉, 그 동안 브라우저는 클릭이나 스크롤 등의 이벤트 처리를 하지 못한다.
이때 컴포넌트가 많아서 너무 오래 걸리게되면 UI가 멈춘것처럼 보이며, 인터랙션이 불가능하므로 사용자 경험이 안좋고 렌더링 도중에 취소나 중단이 불가능하다.
즉, 리액트 15이전의 모델에서는 '무조건 다 계산한 뒤 한번에 렌더링 했어야한다'
그래서 등장하게 된 리액트 파이버 (React Fiber)
리액트 파이버는 렌더링 작업을 잘게 나눠서 처리가능하게 만들며, 인터랙션과 같은 사용자 경험과 관련된 작업의 처리를 우선순위로 가져가는 것이 핵심이다.
Fiber Tree
기존 가상돔 트리를 대체하는 구조이며 React Element를 Fiber 노드로 변환한다.
각 파이버는 자신만의 정보, 부모/형제/자식 레퍼런스를 가지고 있다.
FiberNode {
// 컴포넌트 타입
type,
props,
state,
return, // 부모 child, // 자식 sibling // 형제 ...
}
작업 단위 (Work Units)
파이버 구조는 렌더링 작업을 작은 단위의 일(Job)로 분리하고 리액트는 이 작은 단위를 하나씩 처리한다.
시간이 부족하면 일시 중단하고 나중에 이어서 다시 처리한다.
리액트 파이버의 협력 스케줄링(Cooperative Scheduling)은 리액트가 렌더링 작업을 중간에 멈추고 브라우저에게 기회를 넘겨주는 방식을 말한다.
즉, 렌더링을 마칠때까지 중단없이 진행하는 것이 아니라, 브라우저에게 렌더링을 계속 진행해도 되는지에 대한 여부를 물어보며 가능할 경우에 일정량씩 작업을 나누어 수행하는 방식이다.
- 시간이 가능하면 렌더링 더 진행, 없다면 중단하고 다음 프레임에서 다시 시작한다.
- 브라우저가 우선순위 높은 작업을 먼저 처리가능하게 한다.
리액트 18 이후의 파이버 발전 (동시성 렌더링, Concurrent Mode)
- Fiber + Concurrent Renderer (Concurrent Mode)
리액트 파이버는 렌더링을 나눠서 처리할 수 있게 만든 구조
작업 단위를 쪼개어 중단, 재시작, 우선순위를 조절하여 처리할 수 있게 만든 구조에 불가하다. 이것은 엔진 구조이며 어떻게 렌더링 할지에 대한 부분까지 해결된 것을 말하는 것은 아니다.
즉, 동시성 렌더러는 Fiber를 활용한 새로운 렌더링 방식을 말한다.
동시성 렌더러는 렌더링을 동기 대신 비동기로 처리할 수 있게 만드는 실전 병렬 엔진으로, 렌더링을 중단하고 나중에 다시 시작할 수 있게 한다.
리액트 18부터 동시성 모드는 기본 적용된 렌더링 모드이다. (createRoot() 사용 시)
요약
- 파이버: 렌더링을 쪼갤 수 있게 만든 구조 (인프라)
- 동시성 렌더러: 그 쪼갠 렌더링을 실제로 병렬 처리하는 엔진 (실행 엔진)
- 동시성 모드: 그 기능이 동작하는 환경, React 18부터 기본 적용 (실제 환경 설정)
대표 기능
- startTransition(): 낮은 우선순위 업데이트 분리해서 부드럽게 처리
- Suspense: 비동기 컴포넌트 로딩 중 로딩 UI 표시
- useDeferredValue(): 상태 업데이트를 지연시켜 성능 향상
- Automatic Batching: 여러 상태 업데이트를 한 번에 묶어 처리
정리
항목 | 설명 |
기존 Virtual DOM | 동기 렌더링, 큰 작업이 UI를 블로킹함 |
Fiber의 역할 | 작업 단위를 잘게 쪼개고, 비동기 처리 가능하게 함 |
Fiber Tree | React Element를 기반으로 만든 새로운 트리 구조 |
React 18 변화 | Concurrent Mode, Suspense 개선, 자동 배치 등 도입 |
장점 | 빠르고 부드러운 UI, 작업 중단/취소/우선순위 처리 가능 |
ConcurrentFeatures(동시성 기능) 대표 기능
Concurrent Mode 기반에서 동작하는 기능들
startTransition
우선순위를 조절하여 "급하지 않은" 상태 업데이트를 지연 처리해서 UI를 부드럽게 유지하는 함수이다.
사용자 입력은 즉시 반응되지만, 무거운 작업은 나중에 부드럽게 처리되도록 할 수 있다.
startTransition() 안에서 꼭 필요한 값이 아닌 업데이트만 해야 하며, 많이 사용할 경우 오히려 UI가 반응이 느려질 수 있다.
import { useState, startTransition } from 'react';
function SearchComponent() {
const [input, setInput] = useState('');
const [results, setResults] = useState([]);
const handleChange = (e) => {
const value = e.target.value;
setInput(value); // ✨ 사용자 입력은 즉시 반영
// ✨ 무거운 결과 업데이트는 낮은 우선순위로 처리
startTransition(() => {
const filtered = hugeList.filter((item) => item.includes(value));
setResults(filtered);
});
};
return (
<>
<input value={input} onChange={handleChange} />
<ul>
{results.map((r) => <li key={r}>{r}</li>)}
</ul>
</>
);
}
- React는 startTransition 안에 있는 setState를 긴급하지 않은 업데이트로 인식한다.
- 렌더링이 취소되고, 중간에 끊길 수 있고, 다른 더 중요한 작업(입력 등)이 먼저 처리된다.
- 따라서 사용자는 버벅임 없이 부드럽게 타이핑하는 경험을 한다.
Suspense
비동기 컴포넌트 로딩 중 로딩 UI 표시
서버 컴포넌트 (SC), 리액트 18의 SSR(Server-Side Rendering)에서도 사용 가능하다.
지연된 컴포넌트에 대해 로딩 상태를 보여주는 기술이며, 로딩 중일때 보여줄 UI를 fallback 속성으로 지정할 수 있다.
Fiber가 렌더링을 "중단 → 대기 → 재시작" 가능하게 만들었기 때문에 가능한 기술이다.
import React, { Suspense } from 'react';
// 비동기 컴포넌트
const LazyProfile = React.lazy(() => import('./Profile'));
function App() {
return (
<div>
<h1>My App</h1>
{/* Suspense로 감싸서 로딩 처리 */}
<Suspense fallback={<div>로딩 중...</div>}>
<LazyProfile />
</Suspense>
</div>
);
}
useDeferredValue()
상태 업데이트를 지연시켜 성능 향상하는 훅 중 하나이다.
리액트 18에 도입되었고, 사용자의 입력과 UI 반응 간에 '우선순위'를 조절하는데 사용된다.
값을 '낮은 우선순위로' 업데이트하도록 지연(defer) 시켜주는 훅
- 입력 값이 바뀔때마다 바로 무거운 컴포넌트를 렌더링하지 않고, 브라우저가 여유 있을때 업데이트를 수행한다.
import { useState, useDeferredValue } from 'react';
function Search() {
const [input, setInput] = useState('');
const deferredInput = useDeferredValue(input);
return (
<div>
<input value={input} onChange={(e) => setInput(e.target.value)} />
<Results query={deferredInput} />
</div>
);
}
function Results({ query }: { query: string }) {
const items = Array.from({ length: 5000 }, (_, i) => `Item ${i}`);
const filtered = items.filter((item) => item.includes(query));
return (
<ul>
{filtered.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
);
}
내부적으로 startTrasition()과 유사한 우선순위 제어를 해준다.
Automatic Batching
여러 상태 업데이트를 한 번에 묶어 처리
리액트 18부터 도입된 자동 배치(Automatic Batching)은 렌더링 성능 최적화 기능 중 하나이다.
여러개의 상태 업데이트가 발생했을때, 리액트가 이들을 하나의 렌더링 사이클로 묶어서 처리(batch)하는 기능이다.
이전까지는 비동기 코드 안의 상태 업데이트는 각각 렌더링되었지만, 리액트 18부터 자동으로 묶어 처리해준다.
자동 배치는 이벤트 핸들러 내부, setTimeout, Promise.then, fetch().then(...)에서 일어난다.
리액트 17이하에서의 배치
function App() {
const [count, setCount] = useState(0);
const [text, setText] = useState('');
const update = () => {
setTimeout(() => {
setCount((c) => c + 1); // ⏱️ 첫 번째 렌더
setText('Updated'); // ⏱️ 두 번째 렌더 (따로 일어남)
}, 100);
};
return (
<div>
<button onClick={update}>Update</button>
<p>{count} - {text}</p>
</div>
);
}
리액트 18 배치
예시와 같은 코드임에도 setCount와 setText를 하나로 묶어 자동으로 한번만 렌더링한다.
import { useState } from 'react';
function App() {
const [count, setCount] = useState(0);
const [text, setText] = useState('');
const update = () => {
setTimeout(() => {
setCount((c) => c + 1);
setText('Updated');
}, 100);
};
return (
<div>
<button onClick={update}>Update</button>
<p>{count} - {text}</p>
</div>
);
}
Q. 자동 배치(Automatic Batching)를 끊고 싶다면?
A. flushSync를 사용할 수 있다.
flushSync는 리액트에서 동기적으로 상태 업데이트를 강제로 반영(render) 하도록 하는 함수이다.
안에서 호출된 state 업데이트는 즉시 렌더링되며 리액트의 배치 처리를 우회한다.
바로 렌더링하도록 강제하는 기능이며, 자주 사용 시 성능 저하가 발생할 수 있기에 정말 필요한 DOM 업데이트 타이밍 제어에만 사용해야한다.
import { flushSync } from 'react-dom';
import { useState } from 'react';
function Example() {
const [count, setCount] = useState(0);
const handleClick = () => {
flushSync(() => {
setCount((prev) => prev + 1); // 💥 이건 바로 렌더됨
});
console.log('렌더 직후 count:', document.getElementById('count')?.textContent);
};
return (
<div>
<p id="count">{count}</p>
<button onClick={handleClick}>+1</button>
</div>
);
}
flushSync안의 setCount는 즉시 렌더 트리거된다.
console.log는 업데이트된 DOM을 바로 볼 수 있다.
상황 | flushSync 필요 여부 |
일반 상태 변경 | ❌ 필요 없음 (React가 알아서 함) |
DOM 조작 직전 상태 동기화 필요 | ✅ flushSync 추천 |
외부 라이브러리 DOM 조작 직전 | ✅ 필요할 수 있음 |
Q. 자동배치와 Suspense 혹은 startTransition을 결합했을때의 동작 구조 설명
A.
개념 | 역할 |
Suspense | 비동기 렌더링 중 fallback UI를 보여주는 컴포넌트 |
Automatic Batching | 여러 상태 업데이트를 하나의 렌더링 사이클로 묶어주는 기능 |
startTransition | 낮은 우선순위 업데이트를 명시적으로 구분해서 처리함 |
React 18 | 이 모든 기능이 본격적으로 지원되는 버전 |
예제 흐름
사용자 입력 또는 비동기 fetch ↓ 상태 업데이트 발생 (예: setState) ↓ [React 18의 Automatic Batching, 여러 setState를 한 번의 렌더로 묶어서 처리 (성능 최적화)] ↓ ↓ 비동기 컴포넌트 or Lazy 로딩 발생 ↓ [Suspense fallback 렌더링, (예: "로딩 중...")) ↓ ↓ (Promise.resolve) 컴포넌트가 준비됨 ↓ 실제 컴포넌트로 교체 렌더링 |
예제 코드
import {
Suspense,
useState,
useTransition,
startTransition,
} from 'react';
import { lazy } from 'react';
const LazyComponent = lazy(() => import('./SomeHeavyComponent'));
export default function App() {
const [show, setShow] = useState(false);
const [isPending, startTransition] = useTransition();
const handleClick = () => {
// Automatic Batching + Transition
startTransition(() => {
setShow(true); // 낮은 우선순위로 처리됨
});
};
return (
<div>
<button onClick={handleClick}>컴포넌트 보여줘</button>
<Suspense fallback={<div>로딩 중...</div>}>
{show && <LazyComponent />}
</Suspense>
</div>
);
}
풀이
- 사용자가 버튼을 클릭한다.
- startTransition()안의 setShow(true)는 낮은 우선순위로 처리된다.
- 리액트는 UI가 당장 급하지 않다고 판단한다.
- LazyComponent는 비동기 로드를 하고 이때 Suspense가 fallback을 먼저 보여준다.
- LazyComponent가 로드되면 컴포넌트가 자연스럽게 교체된다.
이때 자동 배치(Automatic Batching)와의 연계
만약 startTransition() 내부에 여러 setState가 일어나더라도, 전부 한번의 렌더링으로 처리된다.
이것이 바로 낮은 우선순위 + 자동배치(startTransition + Automatic Batching) 이다.
startTransition(() => {
setShow(true);
setUserName('Alice');
setAge(30);
});
결론은 셋을 조합하면 복잡한 비동기 UI도 빠르고 부드럽게 처리 가능하다.
- Suspense는 비동기 렌더링 시점을 제어
- Automatic Batching은 상태 업데이트를 묶어 렌더 성능 향상
- startTransition()은 낮은 우선순위 업데이트를 따로 구분
'개념 > AI와 함께' 카테고리의 다른 글
[챗지피티와 공부를 해보자] 이벤트 루프와 Web APIs의 관계 (0) | 2025.03.06 |
---|---|
[챗지피티와 공부를 해보자] 이벤트 위임(Event Delegation)과 성능 최적화 (0) | 2025.02.27 |
[챗지피티와 공부를 해보자] 프로토타입 체인 (0) | 2025.02.25 |
[챗지피티와 공부를 해보자] 프로미스 체이닝 (Promise Chaining) (0) | 2025.02.12 |
[챗지피티와 공부를 해보자] 원시값(Primitive Value)과 참조값(Reference Value), 객체 복사(얕은 복사 Shallow Copy, 깊은 복사 Deep Copy) (0) | 2025.02.08 |
[챗지피티와 공부를 해보자] 이터러블(iterable)과 이터레이터(iterator) (0) | 2025.02.06 |
[챗지피티와 공부를 해보자] 구조분해할당 (Destructuring) (0) | 2025.02.03 |
[챗지피티와 공부를 해보자] 실행 컨텍스트(Execution Context) (2) | 2025.01.30 |