리액트와 같은 SPA 프레임워크에서 지원하는 기능들을 비슷하게 구현해보며 학습하는 것을 목표로 합니다 📚
이전 글에서 JSX로 작성된 코드를 직접 만든 팩토리 함수를 통해 트랜스파일링하고 객체로 변환하는 과정을 거쳤다.
이번 글에서는 변환된 객체(가상DOM의 구성요소)를 통해 실제 DOM에 렌더링을 해보고자 한다. 그리고 재조정을 통해 변경될 부분만 렌더링해보자!
브라우저는 브라우저 엔진, 렌더링 엔진, 네트워크 통신부, 자바스크립트 해석기, UI 백엔드, 자료 저장소 등으로 구성되어 있고, 이 중 렌더링 엔진이 브라우저의 렌더링 과정을 처리한다.
display: none
처럼 화면에 보이지 않는 요소는 렌더 트리에 포함되지 않는다. (따라서 렌더트리와 DOM트리는 1:1 대응이 아니다.)브라우저의 렌더링 과정은 복잡하고 비용이 많이 든다. 특히, SPA처럼 사용자 인터랙션에 따라 요소를 계속 삽입, 삭제, 재계산해야 하는 앱에서는 성능 부담이 크다.
예를 들어, 특정 요소의 색상만 변경되면 페인팅만 발생하지만, 위치나 크기 변경이 있으면 레이아웃과 리페인팅까지 수행된다. 이를 최적화하기 위해 탄생한 것이 가상 DOM이다.
가상 DOM은 브라우저의 실제 DOM이 아닌, 리액트가 관리하는 메모리 상의 가상 구조다. 변경 사항을 가상 DOM에서 처리해 최종 결과를 계산한 뒤, 필요한 부분만 실제 DOM에 반영한다. 이를 통해 불필요한 연산을 줄이고 성능을 최적화한다.
가상 DOM의 동작 원리를 보면 반드시 더 빠르다고는 할 수 없다. 가상 DOM을 사용하면 다음과 같은 단계를 거친다:
이 과정에서 추가적인 연산(가상 DOM 생성 및 비교)이 발생하므로, 단순히 DOM을 직접 조작하는 것보다 더 빠르다고 보장할 수는 없다.
가상 DOM의 강점은 효율적인 업데이트에 있다. 브라우저의 DOM 조작은 매우 느리고, 잦은 렌더링은 성능에 큰 영향을 미친다. 가상 DOM은 이러한 DOM 작업을 최소화하고 다음과 같은 이점을 제공한다:
즉, 가상 DOM은 실제 DOM 조작의 복잡성을 숨기고, 변경 관리와 성능 최적화를 돕는 도구로 사용된다.
결론적으로, 가상 DOM이 항상 더 빠른 것은 아니지만, 복잡한 상태 변화와 대규모 DOM 업데이트가 필요한 앱에서 성능 최적화를 제공한다.
React는 가상 DOM을 활용해 효율적으로 UI를 업데이트하는데, 이 과정에서 Diff 알고리즘이 핵심 역할을 한다.
Diff 알고리즘은 이전 가상 DOM과 새로운 가상 DOM을 비교(diff)하여 변경된 부분만 찾아내는 방식이다. 변경된 부분을 효율적으로 계산해 실제 DOM에 최소한의 작업으로 반영한다.
key
를 기준으로 비교. key
가 없다면 순서대로 비교하며 삽입, 삭제, 이동 작업을 결정. const App = () => <div className="red">Hello</div>;
ReactDOM.render(<App />, rootElement);
// 이후
const App = () => <div className="blue">Hello</div>;
ReactDOM.render(<App />, rootElement);
<div class="red">Hello</div>
<div class="blue">Hello</div>
div
)은 동일 → className
속성만 업데이트. const App = () => (
<ul>
<li key="1">A</li>
<li key="2">B</li>
</ul>
);
ReactDOM.render(<App />, rootElement);
// 이후
const App = () => (
<ul>
<li key="2">B</li>
<li key="1">A</li>
</ul>
);
ReactDOM.render(<App />, rootElement);
<li key="1">A</li>
, <li key="2">B</li>
<li key="2">B</li>
, <li key="1">A</li>
key
를 기준으로 비교 → 요소를 재배치. React는 Diff 알고리즘을 통해 변경 사항을 계산한 뒤, 실제 DOM에 반영하는 재조정(Reconciliation)을 수행한다.
이 모든 작업은 최소한의 연산으로 이루어지며, React의 성능 최적화의 핵심이다.
구현 코드는 개발자 황준일님의 [Vanilla Javascript로 가상돔(VirtualDOM) 만들기] 포스팅을 참고하여 구성했습니다.
위의 렌더링 과정을 이해하면서, 가상 DOM의 요소를 실제 DOM으로 변환하는 createDOM
메서드와 이를 DOM 트리에 추가하여 브라우저에 렌더링하는 render
메서드를 만들어보자.
createDOM 함수
는 지난 글에서 구현했던 createElement 함수
를 통해 생성된 가상 DOM(Virtual DOM) 노드를 실제 DOM으로 변환하는 역할을 한다.
리액트에서는 이벤트핸들러와 같은 props를 camelCase형식으로 처리하고 있으므로 이를 고려해서 이벤트를 등록해주자.
// createDOM 함수는 가상 DOM을 실제 DOM으로 변환하는 역할을 수행한다.
export const createDOM = (virtualDOM) => {
const { type, props } = virtualDOM;
// 텍스트 요소의 경우 텍스트 노드를 생성해 반환한다.
if (type === "TEXT_ELEMENT") {
return document.createTextNode(props.nodeValue);
}
// 프래그먼트(Fragment) 타입인 경우 DocumentFragment를 생성하여 반환한다.
if (type === "FRAGMENT") {
const fragment = document.createDocumentFragment();
// 자식 요소를 순회하며 각각의 DOM 요소를 생성하고 Fragment에 추가한다.
props.children.forEach((child) => {
if (child) {
fragment.appendChild(createDOM(child));
}
});
return fragment;
}
// 일반 DOM 요소를 생성한다.
const $element = document.createElement(type);
// props 객체의 각 항목을 순회하며 해당 속성을 설정한다.
Object.entries(props).forEach(([key, value]) => {
// "children"은 별도로 처리되므로 건너뛴다.
if (key === "children") return;
// 이벤트 핸들러일 경우 처리
if (key.startsWith("on")) {
const eventType = key.slice(2).toLowerCase(); // "onClick" -> "click"
$element.addEventListener(eventType, value); // 이벤트를 요소에 바인딩
} else {
// 일반 속성은 요소에 직접 할당
$element[key] = value;
}
});
// 자식 요소가 있을 경우 재귀적으로 DOM을 생성하여 현재 요소에 추가
props.children.forEach((child) => {
if (child) {
$element.appendChild(createDOM(child));
}
});
// 가상 DOM 객체에 실제 DOM 참조를 저장하여 이후 업데이트에 활용
virtualDOM.ref = $element;
return $element; // 생성된 DOM 요소를 반환
};
이 버전의 render 함수는 가상 DOM을 실제 DOM으로 변환하고 컨테이너에 삽입하는 기본적인 초기 구현이다. 단순하고 직관적인 방식이지만, 매번 container.innerHTML = "";
로 컨테이너의 내용을 모두 삭제한 후 DOM을 그리기 때문에 성능 최적화에 한계가 있다.
import { createDOM } from "./createDOM";
export const render = (virtualDOM, container) => {
container.innerHTML = "";
const $domElement = createDOM(virtualDOM);
container.appendChild($domElement);
};
updateDOM 함수
는 가상 DOM과 실제 DOM 간의 차이를 비교하여 변경된 부분만 업데이트하는 Diff 알고리즘을 구현한다. 위의 전체 DOM을 삭제하고 새로 생성하는 방식과 달리, 효율적으로 필요한 부분만 업데이트하여 성능을 최적화할 수 있다.
업데이트의 각 케이스는 다음과 같을 것이다.
1. 이전에 없던게 생김 → 새로 생성
2. 이전에 있던게 없어짐 → 이전 요소 제거
3. 이전 type과 현재 type이 다름 → 새로운 dom을 만들어서 이전 요소 자리에 위치
4. 이전 props와 현재 props가 다름 → 해당 props만 변경
5. 텍스트가 변경 → 새로운 textNode만들어서 위치시키기
위와 같은 케이스에 대응할 수 있으며, 재귀적인 구조를 통해서 자식 요소까지 탐색하며 변경될 부분을 찾아내도록 구현해보자.
import { createDOM } from "./createDOM";
// 가상 DOM과 실제 DOM을 비교하여 변경된 부분만 업데이트하는 함수
export const updateDOM = (parent, newNode, oldNode, index = 0) => {
// 이전 노드가 없을 경우 -> 새 노드를 추가
if (!oldNode) {
return parent.appendChild(createDOM(newNode));
}
// 새로운 노드가 없을 경우 -> 이전 노드를 제거
if (!newNode) {
return oldNode.ref.remove();
}
// 새로운 노드와 이전 노드의 타입이 다를 경우 -> 노드를 교체
if (newNode.type !== oldNode.type) {
const newElement = createDOM(newNode);
parent.replaceChild(newElement, parent.childNodes[index]);
return;
}
// 텍스트 노드인 경우 -> 텍스트 내용이 변경되었는지 확인 후 업데이트
if (newNode.type === "TEXT_ELEMENT") {
if (newNode.props.nodeValue !== oldNode.props.nodeValue) {
parent.childNodes[index].nodeValue = newNode.props.nodeValue;
}
return;
}
// Fragment 노드인 경우 -> 자식 노드를 순회하며 업데이트
if (newNode.type === "FRAGMENT") {
const maxLength = Math.max(
newNode.props.children.length,
oldNode.props.children.length
);
for (let i = 0; i < maxLength; i++) {
updateDOM(
parent,
newNode.props.children[i],
oldNode.props.children[i],
i
);
}
return;
}
// 입력 필드(input)인 경우 -> value 속성을 비교하고 업데이트
if (newNode.type === "input" && newNode.props.type === "text") {
const { value: newValue } = newNode.props;
const { value: oldValue } = oldNode.props;
if (newValue !== oldValue) {
oldNode.ref.value = newValue;
}
newNode.ref = oldNode.ref; // 참조를 업데이트
return;
}
// DOM 속성 업데이트
updateAttributes(parent.childNodes[index], newNode.props, oldNode.props);
// 자식 노드 업데이트 (재귀 호출)
const maxLength = Math.max(
newNode.props.children.length,
oldNode.props.children.length
);
for (let i = 0; i < maxLength; i++) {
updateDOM(
parent.childNodes[index],
newNode.props.children[i],
oldNode.props.children[i],
i
);
}
// 새로운 노드에 참조(ref) 설정
newNode.ref = oldNode.ref;
};
// DOM 속성을 업데이트하는 함수
const updateAttributes = (domElement, newProps, oldProps) => {
if (!(domElement instanceof Element)) return; // DOM 요소가 아닌 경우 처리하지 않음
// 이전 속성과 새로운 속성을 병합하여 처리
const allProps = { ...oldProps, ...newProps };
Object.keys(allProps).forEach((key) => {
if (key === "children") return; // children 속성은 무시
// 새로운 속성에 없으면 제거
if (!newProps[key]) {
handleAttributeRemoval(domElement, oldProps, key);
}
// 이전 속성과 다르거나 Boolean 속성인 경우 업데이트
else if (
!oldProps[key] ||
newProps[key] !== oldProps[key] ||
isBooleanAttribute(key)
) {
handleAttributeUpdate(domElement, newProps, oldProps, key);
}
});
// 클래스 이름 업데이트
updateClassName(domElement, newProps.className, oldProps.className);
};
// DOM 속성을 제거하는 함수
const handleAttributeRemoval = (domElement, oldProps, key) => {
// 이벤트 리스너 제거
if (key.startsWith("on")) {
const eventType = key.toLowerCase().substring(2);
domElement.removeEventListener(eventType, oldProps[key]);
}
// Boolean 속성인 경우 false로 설정
else if (isBooleanAttribute(key)) {
domElement[key] = false;
}
// 일반 속성인 경우 제거
else {
domElement.removeAttribute(key);
}
};
// DOM 속성을 업데이트하는 함수
const handleAttributeUpdate = (domElement, newProps, oldProps, key) => {
// 이벤트 리스너 추가 및 제거
if (key.startsWith("on")) {
const eventType = key.slice(2).toLowerCase();
domElement.addEventListener(eventType, newProps[key]);
if (oldProps[key]) {
domElement.removeEventListener(eventType, oldProps[key]);
}
}
// Boolean 속성인 경우 true로 설정
else if (isBooleanAttribute(key)) {
domElement[key] = true;
}
// 일반 속성인 경우 값 설정
else {
domElement[key] = newProps[key];
}
};
// Boolean 속성인지 확인하는 함수
const isBooleanAttribute = (key) => {
return (
key === "checked" ||
key === "disabled" ||
key === "readonly" ||
key === "selected"
);
};
// 클래스 이름을 업데이트하는 함수
const updateClassName = (domElement, newClassName, oldClassName) => {
if (newClassName === oldClassName) return; // 변경이 없으면 처리하지 않음
const newClasses = newClassName ? newClassName.split(" ") : [];
const oldClasses = oldClassName ? oldClassName.split(" ") : [];
// 이전 클래스에서 제거할 항목 처리
oldClasses.forEach((cls) => {
if (!newClasses.includes(cls)) {
domElement.classList.remove(cls);
}
});
// 새로운 클래스에서 추가할 항목 처리
newClasses.forEach((cls) => {
if (!oldClasses.includes(cls)) {
domElement.classList.add(cls);
}
});
// 클래스가 없으면 class 속성 제거
if (domElement.classList.length === 0) {
domElement.removeAttribute("class");
}
};
모든 케이스에 대응할 수는 없기 때문에 updateDOM 함수는 일반적으로 가장 흔히 발생하는 상황들에 중점을 두고 설계되었다.
이미 이전에 렌더링된 가상 DOM이 존재할 경우에는 updateDOM
을 호출해 변경 사항만 반영하도록 해 성능 저하를 최소화해보고자 했다.
import { createDOM } from "./createDOM";
import { updateDOM } from "./updateDOM";
export const render = (newVirtualDOM, container) => {
// container에 저장된 이전 가상 DOM을 가져온다.
const oldVirtualDOM = container._virtualDOM;
// 이전 가상 DOM이 없는 경우 (초기 렌더링)
if (!oldVirtualDOM) {
const $domElement = createDOM(newVirtualDOM);
container.appendChild($domElement);
} else {
// 이전 가상 DOM이 존재하면, 업데이트 로직을 실행한다.
updateDOM(container, newVirtualDOM, oldVirtualDOM);
}
// 새로운 가상 DOM을 container에 저장하여 이후 업데이트에 활용
container._virtualDOM = newVirtualDOM;
};
구현 결과, 변경이 필요한 노드만 업데이트되는 것을 확인할 수 있다.
이렇게 가상 DOM과 재조정을 통한 실제 렌더링까지 구현할 수 있었다. 하지만 위 과정에는 비효율적인 부분이 있다. 재조정 과정에서 하나의 스택에 렌더링에 필요한 작업들이 쌓이고, 스택이 빌 때까지 싱글 스레드인 자바스크립트는 동기적으로 작업이 이루어지기 때문에 해당 과정 동안 다른 작업이 수행될 수 없었다!
이러한 기존 렌더링 스택의 비효율성을 해결하기 위해 리액트 팀은 파이버(fiber)를 도입했다.
파이버는 리액트가 렌더링을 효율적으로 처리하기 위해 도입한 새로운 알고리즘이다. 기존의 동기 렌더링은 작업이 쌓이면 대기 시간이 길어지고 UI가 멈추는 문제가 있었다. 파이버는 렌더링 작업을 작은 단위로 나누어 비동기적으로 처리해 UI의 응답성을 개선하고, 중요한 작업을 우선 처리할 수 있도록 한다.
리액트 파이버의 동작 원리는 복잡하기 때문에, 다음 글에서 더 자세히 다뤄보겠다 😄
모던 리액트 Deep Dive
면접을 위한 CS 전공지식노트
https://junilhwang.github.io/TIL/Javascript/Design/Vanilla-JS-Virtual-DOM/
멋져요! ๑╹ワ╹๑