Intro
createBrowserRouter와 BrowserRouter의 차이를 파고들다가, 정작 막힌건 라우터가 아니라 더 근본적인 질문이었습니다. “JSX는 언제 실행되는것인지, 함수는 언제 실행되는 건지 React는 언제부터 일을 시작하는 것인지” 에 대해 물음을 답할 수 없어서 렌더링 전 후 차이가 있다고 이해했지만 정작 그 그리는 단계 자체가 머릿속에 그려지지 않았습니다. 화면이 어떻게 만들어지는지 그 원초적인 과정을 모르니 아무리 설명을 읽어도 겉돌기만 했습니다.
JSX가 왜 등장했는지, 정확히 무엇인지 그 객체가 언제 실행되는지, 어느 시점에 화면이 그려지는지 탐구해보려한다.
JSX란 무엇일까요?
JSX는 무엇일까요? JavaScript 파일 안에서 HTML과 유사한 마크업을 작성할 수 있도록 해주는 JavaScript 구문 확장입니다. 화면을 그리는 함수 호출을 사람이 읽기 편한 HTML 형태로 적게 해주는 문법입니다.
이 한줄만으로는 이해하기 어렵습니다. JSX를 이해하기 위해, JSX 없던 시절로 돌아가보겠습니다.
처음엔 파일을 나눠 작성했다.
웹 개발을 처음 할 때, 우리는 HTML, CSS, JS를 모두 파일로 분리해서 각각 작성했습니다. 관심사 분리 방식으로, HTML에는 구조와 내용을, CSS는 스타일을, JS는 동작을 각각 다른 파일에 담는 구조였습니다.
HTML에는 어떻게 화면에 그려질지 마크업으로 작성하고,
<h1>카운터</h1>
<p id="count">0</p>
<button id="plus">+1</button>
색상은 어떻게 지정하고 싶은지 CSS로 작성하고,
body {
font-family: sans-serif;
text-align: center;
}
#count {
font-size: 48px;
color: tomato;
}
버튼을 눌렀을 때 어떻게 동작하고 싶은지, 로직은 JavaScript 파일이 담당하게 했습니다.
const countEl = document.getElementById("count");
const button = document.getElementById("plus");
let count = 0;
button.addEventListener("click", () => {
count += 1;
countEl.textContent = count;
});
세가지의 파일이 각각 어떻게 연결될까요?
index.html은 화면이 보여지는 주인공이기 때문에, 브라우저는 항상 html을 먼저 읽고, 그 안에 있는 link로 css를 가져와 그림을 그리고, script로 js를 불러옵니다.
이때 id를 통해 html와 css/js를 연결합니다. id=”count”, id=”plus”를 붙여두면 css는 #coun로 그리고, JS는 getElementById로 요소를 확인할수있습니다.
웹 화면은 상태에 따라 계속 바뀝니다. 로그인 했을 때 “환영합니다”, 안했으면 “로그인하세요”, 좋아요 수가 5에서 6으로 바뀝니다.
이렇듯 상태에 따라 내용이 바뀌기 때문에 html에 고정할 수 없는 것들입니다. 그래서 상황에 따라 무엇을 보여줄지(콘텐츠)에 대한 역할이 JS 로직으로 넘어가게 되었습니다.
예시로, 상황에 따른 화면이 다르다는 것을 확인하기 위해, JS로 로그인했을때 환영합니다 안하면 로그인하세요를 작성해보겠습니다.
function Sidebar() {
const aside = document.createElement("aside");
if (isLoggedIn()) {
const p = document.createElement("p");
p.textContent = "환영합니다";
aside.appendChild(p);
} else {
const p = document.createElement("p");
p.textContent = "로그인하세요";
const button = document.createElement("button");
button.textContent = "로그인";
aside.appendChild(p);
aside.appendChild(button);
}
return aside;
}
개발자는 화면에 <p>환영합니다</p> 하나를 띄우기 위해 createElement로 요소를 만들고, textContent로 내용을 채우고, appendChild로 붙이는 세단계를 거쳐야합니다. 최종 결과물이 어떤 모양인지 코드만 봐서는 한눈에 들어오지 않습니다.
요소가 하나뿐인데도 이렇게 복잡한데, 요소가 늘어날수록 이 조립과정이 점점 기하급수적으로 복잡해질 것입니다.
웹 화면은 한번그리고 끝나는게 아니라, 상태에 따라 계속 그립니다.
예를 들어 버튼의 좋아요 갯수를 증가시키는 코드를 작성해보겠습니다.
- 처음 디폴트 값을 선언하는 코드코드의 디폴트값을 선언해줍니다.
- const button = document.createElement("button"); button.textContent = "좋아요 0개"; //처음 선언 document.body.appendChild(button);
- 상태가 바뀌도록 하는 코드클릭(click)이 일어나면 1번에서 만들어둔 그 버튼(buttion)를 찾아서 글자를 바꿉니다. (button.textContent)
- button.addEventListener("click", () => { count += 1; button.textContent = `좋아요 ${count}개`; // ← 이미 만든 버튼을 찾아서 다시 고침 });
현재 단계에서 문제점은 버튼 모양을 바꾸고 싶을 때 두곳을 모두 고쳐야합니다.
button.textContent라는 똑같은 화면 요소를 두군데에서 건드리고 있기 때문입니다.
요소가 많아지면 개발자는 이 요소를 어디서 만들었고 어디서 고치는지 추적하는게 힘들어집니다. 즉, 유지보수가 힘들어집니다.
이런 방식을 명령형이라고 합니다. 화면을 어떻게 바꿀지, 그 절차를 개발자가 하나하나 직접 지시하는 방식입니다. 버튼을 찾고, 글자를 바꾸고 하는 모든 단계를 개발자가(사람)가 책임져야합니다.
절차를 사람이 직접 들고있다보니, 같은 화면 요소를 다루는 코드가 흩어질수밖에 없고 유지보수가 힘들어집니다.
어떻게 개선할 수 있을까요?
- 두군데로 흩어졌던 요소를 한군데로 모은다. : 버튼이 어떻게 생겼는지를 나눠 적지 않고, 한곳에서만 선언합니다.
- 상태에 따라 달라지는 모양이어야 한다. : 좋아요 0개 처럼 값을 박아 넣는대신, 좋아요 {count}개 처럼 상태를 끼워 넣어 화면이 상태로부터 계산하도록 한다.
- 상태가 바뀔때 화면을 고치는 일은 사람이 아니라 시스템에 맡긴다. : 개발자는 어떤 상황에서 상태가 바뀐다만 지시하고, 화면 어디에 어떻게 고칠지는 시스템에서 지시할 수 있도록합니다.
선언형으로 구현하고자합니다. 개발자는 “이 상태일때 화면은 이 모양”이라고 결과만 한 곳에서 선언하고, 나머지는 React가 처리합니다.
개발자는 상태를 선언하고, 프레임워크가 그 어떻게할지 명령하는 것입니다.
위의 3가지를 반영해서 JS로 어떻게 구현될까요?
let count = 0;
//1 : 기본 화면
const button = document.createElement("button");
render();
document.body.appendChild(button);
//2 : 버튼을 클릭했을때
button.addEventListener("click", () => {
setCount(count + 1);
});
//함수
function render() {
**button.textContent** = `좋아요 ${count}개`;
}
function setCount(newCount) {
count = newCount;
render();
}
- 버튼을 만들고(createElement), 추가하고(appendChild), 그립니다(render).
- 이후, 버튼을 **클릭(click)**할때마다 상태를 변환시키고, 그림을 다시 그립니다.
- setCount는 count 상태를 변경 후, 자동으로 화면을 다시 갱신합니다.
상태를 바꾸면 화면이 따라 갱신되는 구조가 만들어졌습니다. 하지만 아직 아쉬운 점이 남아있습니다.
위의 코드에서 좋아요 버튼이라는 하나의 대상 정보가 네 군데에 흩어져있습니다
const button = document.createElement("button"); // 1. 종류: button 태그
button.textContent = `좋아요 ${count}개`; // 2. 내용: 좋아요 N개
button.addEventListener("click", () => { // 3. 동작: 누르면 setCount
setCount(count + 1);
});
document.body.appendChild(button); // 4. 위치: body에 붙임
종류, 내용, 동작, 위치가 따로따로입니다. 이 흩어진 정보를 한군데에 모을순 없을까? 라는 생각을 하게 됩니다.
const 버튼정보 = {
tag: "button",// 1. 종류
text: `좋아요 ${count}개`,//2. 내용
onClick: () => setCount(count + 1),//3. 동작
};
흩어진 정보를 하나의 주머니(객체)에 담았습니다. 그런데 이 객체는 그냥 데이터일 뿐이라, 만든다고 해서 화면에 아무 일도 일어나지 않습니다. 이 객체는 "버튼은 이렇게 생겨야 한다"는 설명서를 적어둔 것에 가깝습니다.
그렇다면, 이 데이터를 보여주는 건 누가할까요? 위에서 보았던 render() 함수에 역할을 줍니다.
function render(node) {
const el = document.createElement(node.tag); // 설명서의 종류 > 요소 생성
el.textContent = node.text; // 설명서의 내용
if (node.onClick) el.addEventListener("click", node.onClick); // 동작 연결
return el;
}
document.body.appendChild(render(버튼정보));
이렇게 되면 개발자는 무엇을 보여줄지 설명만 작성하고, createElement,appendChild같은 어떻게 만드는지 절차는 render에게 맡깁니다. 앞에서 언급했던 시스템에 맡긴다(3)를 코드로 실현하게 된것입니다.
설명서인 객체를 반환하도록 한다면 아래와 같은 객체 반환 함수를 작성할 수 있습니다.
function LikeButton() {
return {
tag: "button",
text: `좋아요 ${count}개`,
onClick: () => setCount(count + 1),
};
} //{tag, text, onClick }
근데 이 반환 생각보다 힘듭니다. tag, text,onClick 하나하나 다 작성해줘야되는 노고가 발생할 수 있습니다.
설명서를 객체로 직접 적는 방식에는 두가지 불편점이 있습니다.
- 형식이 제각각이 되기 쉽다.
- 어떤 요소는 tag로, 어떤 요소는 type으로, 내용은 text또는 children으로 사람마다 다르게 적으면 render함수가 그 설명서를 읽을 수 없습니다.
- 요소 안에 요소를 넣는 중첩의 경우입니다.이걸 직접 위에서 작성한대로 객체로 적으면 중첩이 금세 지저분해집니다.
- <aside> <p>좋아요 5개</p> <button>+1</button> </aside>
const tree = {
tag: "aside",
children: [
{ tag: "p", text: "좋아요 5개" },
{ tag: "button", text: "+1", onClick: handleClick },
],
};
설명서 객체를 대신 만들어주는 함수를 하나 둡니다.
function h(type, props, ...children) {
return { type, props: props || {}, children: children.flat() };
}
h()를 거치면 항상 {type,props,children}이라는 같은 형식의 객체를 얻을 수 있고, 자식을 인자로 넘길수있어 중첩을 함수 호출의 중첩으로 표현할수있습니다.
// 버튼 하나
h("button", { onClick: () => setCount(count + 1) }, `좋아요 ${count}개`);
// 중첩된 화면도 자연스럽게
h("aside", null,
h("p", null, `좋아요 ${count}개`),
h("button", { onClick: () => setCount(count + 1) }, "+1")
);
하나의 관심사가 흩어져있던 명령형 코드를 하나의 데이터로 모으고, 그리는 역할을 시스템에 맡기도록 했더니 화면을 h() 호출로 선언하는 형태에 도달했습니다.
그리고 이 h() 호출 형태가, JSX를 실제로 작성했을때 내부에서 만들어지는 모습과 똑같습니다.
💡지금까지 본 변환 방식 React.createElement()는 classic 방식입니다.
JSX는 React.createElement로 변환하면 변환 코드는 React를 참조할수있어야했습니다. 그래서 classic 방식에서는 import React from "react”가 파일마다 맨 위에 반드시 적어야했습니다.
하지만 React 17부터 automatic runtime이 기본값이 되면서 JSX는 react.createElement가 아니라 react/jsx-runtime의 jsx() 함수 호출로 변환되고, 변환 도구가 그 함수를 알아서 import 를 해줄수있게 되었습니다.
// automatic 방식의 변환 결과 import { jsx as _jsx } from "react/jsx-runtime"; _jsx("h1", { className: "hi", children: "안녕" });
그래서 요즘 React 코드에는 import React가 보이지 않습니다. 함수 이름과 children의 위치가 달라졌을뿐, 설계 객체를 만든다는 동작원리는 똑같습니다.
JSX의 실체
JS가 위에서 아래로 한줄씩 실행한다고 하면, JSX 코드를 만나면 즉시 렌더링이 되는 것이 아닌가 라고 생각해볼수있습니다.
<Route path="/" element={<Home />} />
이 라우트 JSX는 빌드 타임에 Babel(또는 SWC)같은 도구가 아래와 같이 변환합니다
React.createElement(Route, { path: "/", element: ... })
React.createElement는 document.createElement처럼 생겨서 실제 dom에 반영하나? 하는 생각이 들겠지만, 아쉽게도 실제 dom과는 전혀 무관합니다.
- React.createElement는 객체를 만드는 코드이고, 화면 반영은 나중에 React가 처리하는 설계도를 만드는 역할을 합니다
- document.createElement는 즉시 화면에 반영이 되며 실제 DOM노드가 만들어진다.
바벨이 변환한 React.createElement가 반환하는 것은 아래와 같은 React가 처리할수있도록하는 React Element 객체이다.
{ type: Route, props: { path: "/", element: ... } }
이렇게 객체로 만드는 시점에서는 객체가 실행되거나 안에 있는 요소가 실행되거나 하는 일은 일어나지 않는다. 단지 JSX를 객체로 반환하는 일만 한다. 즉, 이후에 react가 실제로 DOM을 그릴 수 있도록 설계도를 만듭니다.
그리고 위의 객체가 바로 Virtual DOM(VDOM)의 한 조각입니다.
console.log(<h1 className="hi">안녕</h1>);
해당 코드는 빌드(번들링)과정에서 JSX는 아래와 같이 전부 React.createElement() 호출로 바뀌고, 브라우저에 전달되는 최종 JS 파일에는 JSX 문법이 없습니다.
console.log(React.createElement("h1", { className: "hi" }, "안녕"));
위와 같이 받은 브라우저는 JSX의 존재는 모릅니다.
JSX는 표준 JavaScript 문법이 아니기 때문에, 브라우저의 JS 엔진(V8)은 코드로 이해하지 못하기 때문입니다. 그래서 반드시 빌드 타임에 누군가가 미리 일반 JS로 바꾸어야 하는데, 역할을 Babel/SWC/번들러가 하는 것입니다.
정리
처음 질문으로 돌아가보겠습니다.
JSX는 화면에 언제 그려질까? 리액트는 언제부터 일을 시작할까?
이제 답할 수 있습니다. JSX를 만나는 순간에는 화면이 그려지지 않습니다. 그 줄이 실행되면 단지 { type, props, children }이라는 설계도 객체 하나가 만들어집니다.
React가 일을 시작하는 것은, render가 이 객체를 받아 VDOM을 구성하고 실제 DOM에 반영하는 단계입니다. JSX는 그리는 행위가 아니라 그릴 내용을 적은 설계도를 만드는 작업입니다.
- 명령형의 한계 → 화면을 만드는 코드와 동작하는 코드가 흩어져있어, 사람이 추적하해야만 했습니다.
- 선언형으로의 전환 → 이 상태면 이 모양이라고 결과만 선언하고, 그리는 절차는 시스템에 맡깁니다.
- 객체 → 그 선언을 한곳에모으니 화면이 데이터가 되었고 데이터라서 비교하고 재사용할 수 있게 되었습니다.
- JSX → React.createElement 함수 호출을 HTML처럼 개발자가 읽기 편하게 작성하도록 하는 문법이 JSX입니다.
💡선언형의 주체는 JSX가 아니라 React 입니다.
- JSX는 읽기 좋게 적는 문법일 뿐, "이 상태면 이 모양이다." 라는 결과만 받고 실제 DOM을 알아서 갱신해주는 것은 React 입니다. JSX없이 React.createElement를 직접 써도 선언형은 똑같이 성립합니다.
💡변경 부분만 갱신한다.
- VDOM 비교는 JSX 덕분이 아닙니다. 그것이 가능한 이유는 React가 화면을 객체로 표현하기 때문에 가능한 일입니다. JSX를 쓰든, 안 쓰든 만들어지는 객체는 같고, 비교도 똑같이 일어납니다. JSX는 객체를 편하게 적게 해주는 문법이고, 객체를 비교해 화면을 갱신하는 것은 React의 몫입니다.
그럼 JSX는 하는 일이 뭘까요?
JSX는 우리가 명령형의 불편을 풀어가다 자연스럽게 도착한 화면을 선언적으로 적는 방법이고, React는 그 선언을 받아 화면을 그려주는 시스템입니다.
추가
JS와 React의 경계
- [JS 실행]브라우저 JS 엔진이 코드를 위에서 아래로 실행한다.
- React.createElement() 호출문을 만나면 실행되고, {type, prorps} 객체를 반환한다.
- createRoot().render() 호출
- 이 시점에 React에게 일을 넘긴다.
- [React]React Fiber가 그 객체를 받아서 렌더링을 시도한다. VDOM을 구성하고, 렌더링 단계, 커밋단계를 지나 실제 DOM에 반영된다.
React는 왜 JSX를 쓸까?
개발자의 편의성을 위해 등장했다 JSX는 결국 React.createElement() 호출을 사람이 읽기 편하게 쓰는 문법일 뿐입니다.
JSX 없이도, React.createElement를 직접 쓸 수 있지만, 최악의 경우 중첩이 깊어지면 읽기 힘듭니다.
한가지 구분할 점이 있습니다. 바뀐부분만 실제 DOM에 반영되는 VDOM 비교는 JSX 덕분이 아닙니다. 그것은 React가 화면을 객체로 표현하기 때문에 가능한 일입니다.
JSX는 그 객체를 편하게 적게 해주는 문법이고, 객체를 비교해서 화면을 갱신하는 것은 React의 몫입니다.
React와 JSX의 공존 관계
react가 최종적으로 필요한것은 객체입니다.
JSX는 먼저 함수 호출로 변환되고, 그 함수가 실행되어 객체를 반환합니다. 그렇다면 JSX를 함수 호출로 바꾸라고 지시한 것은 누구일까요?
JSX를 변환하는 일꾼은 바벨/SWC/… 이고, 무엇으로 바꿔라고 지시하는 건 변환 도구의 설정입니다.
예를 들어 @babel/preset-react는 “JSX를 만나면 React.createElement() 형태로 바꿔라”라는 규칙을 갖고 있습니다.
그리고 이 변환 대상은 react 전용 문법이 아니기 때문에 그걸 무슨 함수로 바꿀지는 설정에서 변경할 수 있다.
그래서 앞서 본것처럼 우리가 만든 h를 지정하면 JSX가 h로 변환해주고 automatic 방식에서는 jsx()를 변환되는 것입니다.
// 1. 빌드 타임: JSX → 함수 호출로 변환 (이게 바벨의 일)
<h1>안녕</h1> → React.createElement("h1", null, "안녕")
// 2. 런타임: 그 함수가 실행되어 객체를 반환 (이게 React의 일)
React.createElement("h1", null, "안녕") → { type: "h1", props: {...} }
그리고, 바벨 뿐만 아니라 vite 8.0.16 에서는 vitejs/plugin-react 6.0.2 플러그인에서는 babel 없이도 Oxc으로 jsx 변환을 담당하고 있습니다.
'JavaScript' 카테고리의 다른 글
| 고정 높이의 가상 스크롤 구현하기 (0) | 2026.06.23 |
|---|---|
| 렉시컬 환경과 클로저 (0) | 2025.11.28 |
| 실행 컨텍스트로 이해하는 호이스팅과 TDZ (0) | 2025.11.26 |