-
React를 직접 만들며 이해해보기(1) - Virtual DOMReact 2026. 1. 5. 00:00
1. Virtual DOM
Virtual DOM은 실제 DOM 요소를 Javscript 객체로 표현한 것입니다. 실제 DOM의 가벼운 Javascript 객체 복사본이라고 표현을 많이 하기도 하는데, 그 이유를 아래에서 같이 알아보도록 하겠습니다.
먼저 JSX에 대한 설명은 생략하도록 하겠습니다. React 팀이 만든 JSX는 바벨 등에 의해 빌드 과정에서 트랜스 파일링됩니다. 아래 코드를 통해 예시를 알아보겠습니다.
<div id="app"> <span>Hello</span> </div> // 빌드 후 변환된 코드 createElement("div", { id: "app" }, createElement("span", null, "Hello") )이렇게 React의 createElement 함수에 의해 Virtual Node 객체가 생성됩니다.
createElement 함수와 Virtual Node 객체는 어떻게 생겼는지 직접 구현해보겠습니다.
Virtual Node 인터페이스
export interface VNode { type: string | symbol | React.ComponentType; // 요소 타입 key: string | null; // 재조정 시 식별자 props: Props; // 속성 및 자식 } type Props = Record<string, any> & { children?: VNode[] };Virtual Node는 이렇게 가벼운 Javascript 객체 복사본입니다.
type을 조금 더 자세히 살펴보겠습니다.
1) string: "div", "span" 과 같은 태그 속성입니다. 이를, HOST 노드라고 합니다.
2) symbol: Fragment가 이에 속하며, 이를, 특수 노드라고 합니다.
3) React.ComponentType: <App>, <Counter> 등과 같이 컴포넌트를 나타내며 이를, COMPONENT 노드라고 합니다.
createElement
export const createElement = ( type: string | symbol | React.ComponentType, originProps?: Record<string, any> | null, ...rawChildren: any[] ) => { // 1. key를 props에서 분리 const { key = null, ...props } = originProps ?? {}; // 2. children 평탄화 및 정규화 const children = rawChildren .flat(Infinity) // 중첩 배열 펼치기 .map(normalizeNode) // 각 요소를 VNode로 변환 .filter((child): child is VNode => child !== null); // 3. VNode 반환 return normalizeNode({ type, key, props: { ...props, children: children.length > 0 ? children : undefined } })!; };여기서 인자로 받는 type, originalProps는 노드의 요소와 속성을 가져오기 때문에 비교적 간단합니다.
문제는 rawChildren입니다. 왜냐면 노드의 모든 하위 노드들이 배열로 들어오기 때문입니다.그래서 이 다양한 타입들을 모두 VNode 객체의 형태로 만들어주어야 합니다. normalizeNode 함수로 이를 해결해보겠습니다.
하지만 normalizeNode 함수를 알아보기 전에 rawChildren이 어떤 값으로 들어올 수 있는지 예시를 한번 살펴볼까요?
1) 텍스트만 들어오는 경우
<div>Hello</div> // 변환 createElement("div", null, "Hello") // rawChildren = ["Hello"] ✅ 문자열2) 여러 VNode들로 들어오는 경우
<div> <span>A</span> <span>B</span> <span>C</span> </div> // 변환: createElement("div", null, createElement("span", null, "A"), createElement("span", null, "B"), createElement("span", null, "C") ) // rawChildren = [spanVNode1, spanVNode2, spanVNode3] ✅ VNode 객체들3) 텍스트 + VNode + 숫자 혼합이 들어오는 경우
<div> Hello <span>World</span> {42} <button>Click</button> </div> // 변환: createElement("div", null, "Hello", createElement("span", null, "World"), 42, createElement("button", null, "Click") ) // rawChildren = [ // "Hello", ✅ 문자열 // spanVNode, ✅ VNode 객체 // 42, ✅ 숫자 // buttonVNode ✅ VNode 객체 // ]4) 배열 (map으로 생성된 경우)
<ul> {items.map(item => <li key={item}>{item}</li>)} <li>Extra</li> </ul> // 변환: createElement("ul", null, items.map(item => createElement("li", { key: item }, item)), createElement("li", null, "Extra") ) // rawChildren = [ // [liVNode1, liVNode2, liVNode3], ✅ VNode 배열 (중첩!) // liVNode4 ✅ VNode 객체 // ]5) null, undefined, boolean이 들어오는 경우
<div> {isLoggedIn && <span>Welcome</span>} {null} {undefined} {false} </div> // 변환: createElement("div", null, isLoggedIn && createElement("span", null, "Welcome"), null, undefined, false ) // isLoggedIn이 false라면: // rawChildren = [ // false, ✅ boolean // null, ✅ null // undefined, ✅ undefined // false ✅ boolean // ]자, 이제 normalizeNode 함수를 통해서 이 모든 값들을 Virtual Node로 객체화 시켜보겠습니다.
normalizeNode
export const isEmptyValue = (value: unknown): boolean => { return value === null || value === undefined || typeof value === "boolean"; }; export const normalizeNode = (node: VNode): VNode | null => { // null, undefined, boolean → 렌더링하지 않음 if (isEmptyValue(node)) { return null; } // 문자열/숫자 → 텍스트 VNode로 변환 if (typeof node === "string" || typeof node === "number") { return createTextElement(node); } // 배열 → Fragment로 래핑 if (Array.isArray(node)) { return createElement(Fragment, null, ...node); } // VNode는 그대로 반환 return { ...node, props: node.props ?? null, key: node.key ?? null }; };Virtual Node로 객체화가 필요한 케이스는 3가지가 있습니다.
1) 필요없는 노드: null, undefined, boolean은 렌더링 되어야할 필요가 없습니다. null을 리턴합니다.
2) string과 number 노드: 텍스트 Virtual Node로 변경되면 되겠습니다. 아래와 같은 형식으로 생기면 됩니다.
{ type: TEXT_ELEMENT, props: { nodeValue: "Hello" } }3) 배열: 배열로 값이 들어왔다는 것은, 배열이 1depth는 하위 노드들, 2depth 이상은 하위의 하위 노드들이 있다는 거겠죠? createElement 함수를 재귀적으로 실행하며 한겹 한겹 Virtual Node로 변환해줍니다.
React에서는 이런 과정을 Babel 등에 의해서 JSX가 트랜스파일링 되어 Virtual Node가 됩니다.
2. Instance (Fiber Node의 단순화된 버전)
여기서 Instance란, VNode가 실제로 렌더링된 결과물이라고 생각하면 됩니다. 이제 이 Instance에서 비교(diffing)를 통해서 reconcile 단계로 넘어가면 되겠죠.
instance의 인터페이스는 아래와 같습니다.
Instance 인터페이스
export interface Instance { kind: NodeType; // "host" | "text" | "component" | "fragment" dom: HTMLElement | Text | null; // 실제 DOM 요소 (component/fragment는 null) node: VNode; // 이전에 사용된 VNode children: (Instance | null)[]; // 자식 Instance들 key: string | null; // 재조정용 키 path: string; // 훅 상태 추적용 고유 경로 }여기까지를 Render 단계라고 할 수 있겠습니다. Render 단계란, JSX 선언 또는 React.createElement()를 통해 일반 객체인 React 엘리먼트(VNode 객체)를 생성하는 단계입니다.
다음 글에서, 이전에 렌더링된 실제 DOM 트리와 새로 렌더링할 React 엘리먼트(VNode 객체)를 어떻게 비교하여 변경되는지 알아보도록 하겠습니다.
'React' 카테고리의 다른 글
React를 직접 만들며 이해해보기(2) - 재조정(Reconciliation) (0) 2026.01.05 [토이 프로젝트] 메모장 구현 및 공지사항 (0) 2023.03.14