
React로 기능 구현을 할 줄 아는 것만으로 과연 React를 잘 안다고 할 수 있을까?
컴포넌트의 렌더링 과정이나 동작 원리를 깊이 이해하지 못하면, 성능 최적화나 문제 해결이 필요한 시점에서 결국 한계를 마주하게 될것이다.
그래서 이번 시리즈에서는 React 라이브러리의 주요 기능들을 직접 구현하며 동작 원리를 학습한 과정을 기록하려 한다.
이번 글에서는 React 렌더링의 가장 기본 개념인 JSX가 자바스크립트 코드로 변환되는 과정을 살펴보고,
이를 바탕으로 Virtual DOM의 개념에 대해 정리해보자.
JSX는 자바스크립트 확장 문법으로, JS + XML 을 합한 것이다.
한마디로 자바스크립트로 HTML을 표현할 수 있게 해주는 문법이라고 보면 된다.
// JSX
const element = <h1 className="greeting">Hello, world!</h1>
자바스크립트는 일반적으로 html 태그를 포함할 수 없다.
JSX는 문법적으로 자바스크립트가 아니기 때문에 브라우저에서 직접 실행될 수 없어 Babel 과 같은 트랜스파일러를 통해 자바스크립트 코드로 변환되는데, JSX가 트랜스파일링된 결과는 아래와 같다.

Babel은 JSX 코드를 React.createElement() 함수로 변환한다. (React 16 이하)
이 함수는 엘리먼트의 타입(type), 속성(props), 자식 엘리먼트(children) 를 인자로 받아 React 엘리먼트를 생성한다.
‼️ React 17 이상부터는 react/jsx-runtime 모듈이 도입되어 JSX를 트랜스파일링한 결과가 React.createElement()가 아니라 _jsx() 함수가 된다!
어차피 같은 기능을 수행하는 함수들이라 이 글에서는 편의상 createElement라고 부르겠다.

React.createElement() 메서드가 호출되면 React는 Virtual DOM Node (ReactNode) 를 생성하고,
React 엘리먼트를 한번 감싸서 반환한다. 아래는 JSX가 변환된 결과를 ReactElement로 표현한 예시이다
{
node: {
type: 'h1',
props: {
className: 'greeting',
children: 'Hello, world!',
}
}
};
React 엘리먼트는 '나중에 이렇게 렌더해줘' 라고 지시하는 인터페이스 설명서, 즉 컴포넌트 설명서 와 같다.
React는 이 설명서를 기반으로 Virtual DOM을 생성하고, 최종적으로 실제 DOM을 렌더링하게 된다.
React에서는 다양한 타입의 노드(요소, 텍스트, 배열 등)를 렌더링할 수 있는데,
이와 관련된 주요 타입들은 다음과 같다. 이름들이 비슷해서 헷갈리니 정리하고 넘어가자
React에서 사용할 수 있는 모든 가능한 노드 타입의 집합으로, children 타입으로 가장 많이 사용된다.
(컴포넌트에 어떤 타입의 자식이 올지 알 수 없기때문에 범용적인 ReactNode를 많이 사용한다!)
포함하는 타입은 아래와 같다:
React의 Virtual DOM을 구성하는 기본 단위로, 컴포넌트를 설명하는 설명서와 같다.
JSX를 트랜스파일한 결과로 생성되는 불변 객체(Immutable Object) 이다.
TypeScript에서 JSX 문법을 사용할 때 ReactElement의 타입을 정의하는 데 사용되는 타입이다.
(사실상 ReactElement와 동일한 타입이다!)
최종적으로 세 타입의 포함관계를 정리해보면 아래와 같이 도식화할 수 있다.
ReactNode
├── ReactElement
│ └── JSX.Element (ReactElement의 타입 정의)
├── 문자열
├── 숫자
├── 배열
├── null / undefined
├── boolean
└── Fragment
자, 이제 createElement() 를 통해 Virtual DOM 객체를 생성하는 과정을 직접 구현해보자.
앞서 Javascript를 HTML로 표현하는 JSX 문법을 사용하기 위해 Babel 과 같은 트랜스파일러가 필요하다고 했는데,
vite는 내부적으로 JSX 변환을 지원해주는 esbuild의 초고속 번들러와 트랜스파일러를 사용하기 때문에
Babel을 굳이 사용할 필요가 없다.
// vite.config.js
import { defineConfig } from 'vite';
import path from 'path';
export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
},
},
esbuild: {
// https://esbuild.github.io/content-types/#using-jsx-without-react
jsxFactory: 'createElement', // jsx 변환을 해줄 함수
jsxFragment: 'Fragment',
jsxInject: `import { jsx } from '@/libs/jsx/jsx-runtime'`, // 모든파일에 해당 import 자동 추가
},
});
위에 정리한 개념들과 Virtual DOM 객체 생성 과정의 동작 원리에 대한 이해를 바탕으로,
createElement() 함수를 아래의 코드와 같이 구현해볼 수 있다.
// @/libs/jsx/jsx-runtime
export const createElement = (component, props, ...children) => {
// 컴포넌트가 함수 타입인 경우 실행 결과 반환
if (typeof component === "function") {
return component({ ...props, children });
}
// Virtual DOM 객체 생성
const element = {
tag: component,
props,
children: children.flat(Infinity).map((v) => { // 중첩된 children 배열을 평탄화
if (!checkIsVirtualNode(v)) {
// children이 문자열/숫자일 경우 Virtual DOM 노드로 래핑
return { node: v };
}
return v; // 이미 Virtual DOM인 경우 그대로 반환
})
};
return element;
};
const checkIsVirtualNode = (obj) => {
if (Array.isArray(obj)) {
return false;
}
if (typeof obj === "object" && obj != null && "node" in obj) {
return true;
}
return false;
};
export const jsx = { createElement };
이제 아래와 같이 몇개의 컴포넌트를 만들어서 App.tsx에 import한 다음,

main.js에서 App 엘리먼트를 출력하는 코드를 작성해보면
// main.js
const appElement = App();
console.log(JSON.stringify(appElement, null, 2));
브라우저 콘솔에 중첩된 Virtual DOM 객체가 잘 찍혀 나오는것을 볼 수 있다!

(참고) Vite 공식문서 - createElement
(참고) React 공식문서 - JSX
실습 코드 github