JSX Transform과 createElement
React로 개발할 때 가장 자연스럽게 사용하는 문법이 JSX다. HTML처럼 생긴 이 문법을 .jsx나 .tsx 파일에 작성하면 컴포넌트가 렌더링된다. 하지만 브라우저는 JSX를 전혀 이해하지 못한다. JSX는 JavaScript 표준 문법이 아니기 때문이다.
const element = <div className="container">Hello</div>;
이 코드를 브라우저에 그대로 넘기면 SyntaxError가 발생한다. 그렇다면 JSX는 어떻게 실행 가능한 JavaScript로 바뀌는 걸까? 답은 트랜스파일러에 있다. Babel이나 TypeScript 컴파일러 같은 도구가 빌드 시점에 JSX를 함수 호출로 변환한다. 이 변환 과정을 이해하면 React가 UI를 어떻게 표현하는지, 그리고 왜 createElement라는 함수가 핵심인지 알 수 있다.
JSX는 문법 설탕이다
JSX는 createElement 함수 호출의 문법 설탕(syntactic sugar)이다. 트랜스파일러가 JSX를 만나면 정해진 규칙에 따라 함수 호출로 변환한다.
// JSX로 작성한 코드
const element = (
<div className="container">
<h1>제목</h1>
<p>본문입니다</p>
</div>
);
이 코드는 빌드 시점에 다음과 같이 변환된다.
// 트랜스파일 결과
const element = createElement(
"div",
{ className: "container" },
createElement("h1", null, "제목"),
createElement("p", null, "본문입니다")
);
변환 규칙은 단순하다.
- 태그 이름 → 첫 번째 인자 (문자열 또는 컴포넌트 참조)
- 속성들 → 두 번째 인자 (객체 또는 null)
- 자식 요소들 → 세 번째 이후 인자 (나머지 매개변수)
중첩된 JSX는 중첩된 createElement 호출이 된다. 가장 안쪽 자식부터 평가되어 바깥쪽으로 올라간다.
createElement 함수의 구조
createElement가 하는 일은 명확하다. 전달받은 인자들을 조합해서 Virtual DOM 노드(VNode) 객체를 반환하는 것이다. 실제 DOM을 건드리는 게 아니라, DOM의 구조를 표현하는 순수한 JavaScript 객체를 만든다.
interface VNode {
type: string;
props: Record<string, any>;
children: (VNode | string)[];
}
function createElement(
type: string,
props: Record<string, any> | null,
...children: any[]
): VNode {
return {
type,
props: props || {},
children,
};
}
type은 태그 이름이다. "div", "span", "h1" 같은 문자열이 들어온다. props는 해당 요소의 속성을 담은 객체다. className, id, onClick 같은 것들이 여기에 들어간다. 속성이 없으면 null이 전달되므로 빈 객체로 폴백한다. children은 나머지 매개변수(rest parameter)로 받는다. 자식이 하나든 열 개든 배열로 수집된다.
이 함수를 호출하면 다음과 같은 객체가 만들어진다.
createElement("div", { className: "container" }, "Hello")
// 반환값:
// {
// type: "div",
// props: { className: "container" },
// children: ["Hello"]
// }
이게 전부다. DOM API를 호출하지도, 화면에 뭔가를 그리지도 않는다. 그저 "이런 구조의 UI를 원한다"는 의도를 객체로 표현한 것이다. 실제 DOM 생성은 이후 render 함수가 이 객체를 받아서 처리한다.
함수 컴포넌트와 type의 확장
HTML 태그만 사용한다면 type은 항상 문자열이다. 하지만 컴포넌트를 사용하면 이야기가 달라진다.
function Greeting({ name }) {
return <p>안녕, {name}!</p>;
}
const element = <Greeting name="클로이" />;
이 JSX는 다음과 같이 변환된다.
const element = createElement(Greeting, { name: "클로이" });
여기서 type에 문자열 "Greeting"이 아니라 함수 참조 Greeting이 들어간다. JSX에서 태그 이름이 대문자로 시작하면 트랜스파일러가 이를 사용자 정의 컴포넌트로 인식하고, 변수 참조로 변환한다. 소문자로 시작하면 HTML 태그로 인식하고 문자열로 변환한다.
<div /> // → createElement("div", null) ← 문자열
<MyComponent /> // → createElement(MyComponent, null) ← 함수 참조
이 규칙 때문에 React에서 컴포넌트 이름은 반드시 대문자로 시작해야 한다. 소문자로 시작하면 HTML 태그로 해석되어 의도한 대로 동작하지 않는다.
render 함수는 type이 문자열인지 함수인지에 따라 분기한다. 문자열이면 document.createElement(type)으로 DOM 요소를 만들고, 함수면 type(props)를 호출해서 반환된 VNode를 다시 재귀적으로 처리한다.
자식 요소의 다양한 형태
children으로 들어올 수 있는 값은 다양하다. 문자열, 숫자, VNode 객체는 물론이고, 배열이나 null/undefined/boolean도 올 수 있다. 실제로 createElement를 구현할 때는 이 다양한 케이스를 처리해야 한다.
function createElement(
type: string | Function,
props: Record<string, any> | null,
...children: any[]
): VNode {
const flatChildren = children
.flat(Infinity)
.filter(child => child != null && child !== true && child !== false);
return {
type,
props: props || {},
children: flatChildren,
};
}
flat(Infinity)로 중첩 배열을 펼치는 이유는 JSX에서 배열을 렌더링하는 패턴 때문이다.
const items = ["사과", "바나나", "포도"];
const list = (
<ul>
{items.map(item => <li>{item}</li>)}
</ul>
);
items.map()이 배열을 반환하므로 children에 [VNode, VNode, VNode]이 배열째로 들어온다. 이걸 flat하지 않으면 나중에 render할 때 배열 안의 배열을 또 처리해야 해서 복잡해진다.
null, undefined, true, false를 필터링하는 이유는 조건부 렌더링 패턴 때문이다.
const element = (
<div>
{isLoggedIn && <UserProfile />}
{error && <ErrorMessage />}
</div>
);
isLoggedIn이 false면 false && <UserProfile />는 false를 반환한다. 이 false가 화면에 렌더링되면 안 되니까 미리 걸러내는 것이다.
tsconfig.json에서 JSX 팩토리 설정
트랜스파일러는 기본적으로 JSX를 React.createElement 호출로 변환한다. 하지만 커스텀 createElement를 사용하려면 트랜스파일러에게 "이 함수를 대신 사용하라"고 알려줘야 한다.
TypeScript에서는 tsconfig.json의 compilerOptions에서 설정한다.
{
"compilerOptions": {
"jsx": "react",
"jsxFactory": "createElement"
}
}
jsx 옵션
jsx 옵션은 JSX를 어떤 방식으로 변환할지 결정한다.
| 값 | 동작 | 출력 |
|---|---|---|
"react" | JSX를 jsxFactory 함수 호출로 변환 | .js |
"react-jsx" | React 17+ 자동 변환 (import 자동 추가) | .js |
"react-jsxdev" | 개발 모드 자동 변환 (디버그 정보 포함) | .js |
"preserve" | JSX를 변환하지 않고 그대로 유지 | .jsx |
"react-native" | JSX를 유지하되 확장자는 .js | .js |
"react" 모드를 사용하면 jsxFactory에 지정한 함수로 변환된다. "react-jsx"는 React 17에서 도입된 새로운 변환 방식으로, 파일마다 import React from 'react'를 작성하지 않아도 자동으로 jsx 함수를 import해준다.
jsxFactory 옵션
{
"jsxFactory": "createElement"
}
이 설정이 있으면 TypeScript 컴파일러는 모든 JSX 표현식을 createElement() 호출로 변환한다. 기본값은 "React.createElement"다.
주의할 점은 jsxFactory로 지정한 함수가 파일 스코프에 존재해야 한다는 것이다. 트랜스파일러는 JSX를 함수 호출로 바꿔줄 뿐, 그 함수를 자동으로 import해주지 않는다("react" 모드의 경우). 그래서 JSX를 사용하는 모든 파일에서 해당 함수를 직접 import해야 한다.
// JSX를 사용하는 파일마다 필요
import { createElement } from "@core/createElement";
이게 귀찮다면 "react-jsx" 모드를 사용하거나, 번들러 설정에서 전역 import를 추가하는 방법이 있다.
Vite에서의 JSX 설정
Vite를 사용한다면 vite.config.ts의 esbuild 옵션에서도 JSX 팩토리를 설정할 수 있다. Vite는 내부적으로 esbuild를 사용해서 TypeScript와 JSX를 트랜스파일하기 때문이다.
import { defineConfig } from "vite";
export default defineConfig({
esbuild: {
jsxFactory: "createElement",
jsxFragment: "Fragment",
jsxInject: `import { createElement } from '@core/createElement'`,
},
});
| 옵션 | 설명 |
|---|---|
jsxFactory | JSX 요소를 변환할 함수 이름 |
jsxFragment | <></> Fragment를 변환할 식별자 |
jsxInject | 모든 JSX 파일 상단에 자동 삽입할 import 문 |
jsxInject가 특히 편리하다. 이 옵션을 설정하면 JSX를 사용하는 파일마다 수동으로 createElement를 import할 필요가 없다. Vite가 빌드 시점에 자동으로 import 문을 삽입해준다.
tsconfig.json과 Vite 설정이 충돌하면 혼란이 생길 수 있다. 일반적으로 tsconfig.json의 jsx 설정은 TypeScript의 타입 체크에 사용되고, 실제 트랜스파일은 Vite(esbuild)가 담당한다. 두 설정의 jsxFactory 값을 동일하게 맞춰놓는 게 안전하다.
Classic Transform vs Automatic Transform
React 17 이전과 이후로 JSX 변환 방식이 크게 나뉜다.
Classic Transform (React 16 이하)
import { createElement } from "./my-react";
const element = <div>Hello</div>;
// → createElement("div", null, "Hello")
모든 JSX 파일에서 팩토리 함수를 직접 import해야 한다. jsxFactory 설정으로 함수 이름을 바꿀 수 있지만, import는 수동이다.
Automatic Transform (React 17+)
// import 문 없이 JSX 사용 가능
const element = <div>Hello</div>;
// 변환 결과:
import { jsx as _jsx } from "react/jsx-runtime";
const element = _jsx("div", { children: "Hello" });
automatic 모드에서는 트랜스파일러가 jsx-runtime에서 함수를 자동으로 import한다. 개발자가 신경 쓸 필요가 없다. 또 하나 눈에 띄는 차이는 children의 전달 방식이다. classic에서는 세 번째 이후 인자로 전달하지만, automatic에서는 props.children에 포함시킨다.
// Classic
createElement("div", null, child1, child2)
// Automatic
jsx("div", { children: [child1, child2] })
커스텀 렌더러를 만들 때는 classic 방식이 더 직관적이다. children이 별도 인자로 들어오기 때문에 함수 시그니처가 명확하고, 별도의 jsx-runtime 모듈을 만들 필요도 없다.
전체 흐름 정리
JSX가 화면에 렌더링되기까지의 전체 파이프라인을 정리하면 다음과 같다.
JSX 코드 작성
↓ (트랜스파일 — 빌드 시점)
createElement() 함수 호출로 변환
↓ (런타임 — 실행 시점)
VNode 객체 트리 생성
↓ (render 함수)
실제 DOM 생성 및 마운트
- 개발자가 JSX로 UI를 선언적으로 작성한다
- 빌드 도구가 JSX를
createElement호출로 변환한다 - 런타임에
createElement가 실행되면서 VNode 객체 트리가 만들어진다 - render 함수가 VNode 트리를 순회하면서 실제 DOM을 생성한다
JSX는 이 파이프라인의 입구이고, createElement는 선언적 UI 표현을 데이터 구조로 바꾸는 변환기다. 이 변환기 덕분에 DOM을 직접 조작하는 명령형 코드 대신 "원하는 UI의 모습"만 기술하면 되는 것이다.