JSX 기반 프론트엔드 프레임워크 만들기
프론트엔드 생태계를 둘러보면 React를 중심으로 만들어진 도구와 학습 자료, 그리고 사고방식의 영향력이 여전히 크다는 것을 자주 느끼게 됩니다. React 자체를 쓰지 않더라도, 컴포넌트, 상태, 훅, 선언형 UI 같은 개념은 이제 프론트엔드 개발의 공통 언어에 가깝...
프론트엔드 생태계를 둘러보면 React를 중심으로 만들어진 도구와 학습 자료, 그리고 사고방식의 영향력이 여전히 크다는 것을 자주 느끼게 됩니다. React 자체를 쓰지 않더라도, 컴포넌트, 상태, 훅, 선언형 UI 같은 개념은 이제 프론트엔드 개발의 공통 언어에 가깝습니다.
저도 최근 항해에서 이와 관련된 과제를 진행했습니다. 과제의 주제는 일종의 “미니 React 만들기”였는데, 처음에는 단순히 JSX를 DOM으로 바꾸는 작은 실습 정도로 생각했습니다. 그런데 막상 구현을 시작해보니 생각보다 빨리 “상태는 어디에 저장해야 할까?”, “이벤트가 나중에 발생했을 때 원래 컴포넌트를 어떻게 다시 찾아야 할까?”, “useEffect는 왜 렌더링 직후에 돌아야 할까?” 같은 질문과 마주하게 됐습니다.
오히려 그 과정이 React를 더 잘 이해하는 계기가 됐습니다. 평소에는 익숙하게 받아들이던 규칙들이, 직접 비슷한 구조를 만들기 시작하니 하나하나 이유를 가진 설계로 보이기 시작했기 때문입니다. “훅은 왜 항상 같은 순서로 호출되어야 할까?”, “왜 렌더링과 effect 실행 시점을 구분할까?”, “왜 리렌더링은 생각보다 복잡한 문제일까?” 같은 고민들이 조금씩 React의 내부 모델을 이해하는 실마리가 됐습니다.
JSX는 React 전용 문법이 아닙니다. JSX는 결국 함수 호출이나 객체 구조로 변환되는 문법이고, 그 변환 결과를 어떻게 해석하느냐에 따라 얼마든지 다른 렌더링 시스템을 만들 수 있습니다.
이 글에서는 JSX를 입력으로 받아, 아주 작은 컴포넌트 렌더링 라이브러리를 직접 만드는 과정을 정리해보려고 합니다. 목표는 React를 그대로 복제하는 것이 아니라, 과제를 진행하면서 제가 부딪혔던 질문들을 따라가며 JSX가 어떤 형태로 바뀌고 그 결과를 어떻게 DOM으로 렌더링하는지, 그리고 useState, useEffect 같은 개념이 왜 필요한지 직접 구현해보면서 이해하는 것입니다.
먼저, JSX가 무엇인가요?
JSX는 HTML처럼 보이지만 브라우저가 그대로 이해하는 문법은 아닙니다.
예를 들어 아래 코드는:
<div id="hello">
<Greeting value="world" />
<List>
{[1, 2, 3].map((value) => (
<li>{value}</li>
))}
<li>last item</li>
</List>
</div><div id="hello">
<Greeting value="world" />
<List>
{[1, 2, 3].map((value) => (
<li>{value}</li>
))}
<li>last item</li>
</List>
</div>컴파일 과정에서 대략 이런 형태의 함수 호출이나 객체 구조로 바뀝니다.
h(
"div",
{ id: "hello" },
h(Greeting, { value: "world" }),
h(
List,
null,
[1, 2, 3].map((value) => h("li", null, value)),
h("li", null, "last item"),
),
);h(
"div",
{ id: "hello" },
h(Greeting, { value: "world" }),
h(
List,
null,
[1, 2, 3].map((value) => h("li", null, value)),
h("li", null, "last item"),
),
);다소 생소할 수도 있는 모양새의 코드인데요, 핵심은 JSX 문법으로 작성된 코드가 결국 트리 구조의 데이터로 바뀐다는 점입니다. 렌더러는 이 트리를 순회하면서 실제 DOM을 만들면 됩니다.
여기서 말하는 트리는 자료구조 책에서 보던 트리와 크게 다르지 않습니다. 가장 바깥의 <div>가 루트 노드가 되고, 그 안에 들어 있는 <Greeting />과 <List>가 자식 노드가 됩니다. 그리고 <List> 안에는 다시 여러 개의 <li>가 자식으로 들어갑니다.
방금 본 JSX를 아주 단순하게 그려보면 이런 느낌입니다.
div
|- Greeting
`- List
|- li
|- li
|- li
`- lidiv
|- Greeting
`- List
|- li
|- li
|- li
`- li함수 호출로 바뀐 코드도 구조는 같습니다. 가장 바깥의 h("div", ...)가 루트이고, 그 안에 들어 있는 h(Greeting, ...)와 h(List, ...)가 자식입니다. 즉, 문법만 달라졌을 뿐 부모와 자식의 관계는 그대로 유지됩니다.
처음에는 h(...)가 여러 번 중첩된 모습이 복잡해 보일 수 있지만, “태그 하나가 노드 하나가 된다”라고 생각하면 훨씬 읽기 쉬워집니다. 결국 렌더러는 이 중첩 구조를 따라 내려가면서 DOM 노드를 하나씩 만들어 붙이는 역할을 하게 됩니다.
이 지점에서 얻은 첫 번째 인사이트는 “JSX는 곧 React”가 아니라는 점이었습니다. React를 오래 사용하다 보면 JSX를 React의 일부처럼 느끼기 쉬운데, 실제로는 JSX를 어떤 구조로 바꾸고 그 구조를 어떻게 해석할지 정하는 런타임이 따로 있는 셈입니다.
JSX를 객체로 바꾸기
가장 먼저 필요한 건 JSX를 받아서 우리가 사용할 수 있는 형태로 바꾸는 팩토리 함수입니다.
export function h(
tag: string | ((props: Record<string, unknown>) => unknown),
props: Record<string, unknown> | null,
...children: unknown[]
) {
return {
tag,
props,
children,
};
}
export function Fragment(
props: Record<string, unknown> | null,
...children: unknown[]
) {
return {
tag: Fragment,
props,
children,
};
}export function h(
tag: string | ((props: Record<string, unknown>) => unknown),
props: Record<string, unknown> | null,
...children: unknown[]
) {
return {
tag,
props,
children,
};
}
export function Fragment(
props: Record<string, unknown> | null,
...children: unknown[]
) {
return {
tag: Fragment,
props,
children,
};
}여기서 tag는 세 가지 중 하나가 됩니다.
"div"같은 문자열이면 일반 DOM 요소입니다.- 함수이면 컴포넌트입니다.
Fragment이면 실제 DOM 없이 자식만 묶는 용도입니다.
JSX를 쓰기 위한 설정
JSX를 직접 해석하려면 TypeScript와 번들러에게 “이 JSX는 React가 아니라 우리가 만든 h 함수로 바꿔라”라고 알려줘야 합니다.
1. jsx.d.ts
jsx.d.ts는 TypeScript가 JSX 문법을 타입 수준에서 이해하도록 도와줍니다.
import { h as _h, Fragment as _Fragment } from "./src/core/jsx/factory";
declare global {
const h: typeof _h;
const Fragment: typeof _Fragment;
namespace JSX {
interface ElementChildrenAttribute {
children: {};
}
interface IntrinsicElements {
div: Record<string, unknown>;
span: Record<string, unknown>;
button: Record<string, unknown>;
ul: Record<string, unknown>;
li: Record<string, unknown>;
}
}
}import { h as _h, Fragment as _Fragment } from "./src/core/jsx/factory";
declare global {
const h: typeof _h;
const Fragment: typeof _Fragment;
namespace JSX {
interface ElementChildrenAttribute {
children: {};
}
interface IntrinsicElements {
div: Record<string, unknown>;
span: Record<string, unknown>;
button: Record<string, unknown>;
ul: Record<string, unknown>;
li: Record<string, unknown>;
}
}
}여기서 중요한 건 IntrinsicElements입니다. 이름이 조금 낯설 수 있지만, 간단히 말하면 “JSX에서 기본 태그로 인정할 요소 목록과 그 태그의 props 타입을 적어두는 곳”이라고 생각하시면 됩니다. 이 타입이 있어야 TypeScript가 <div />, <button /> 같은 JSX 태그를 허용하고 속성 타입도 추론할 수 있습니다.
2. tsconfig.json
TypeScript는 JSX를 어떤 방식으로 변환할지 설정이 필요합니다.
{
"compilerOptions": {
"jsx": "react",
"jsxFactory": "h",
"jsxFragmentFactory": "Fragment"
},
"include": ["jsx.d.ts"]
}{
"compilerOptions": {
"jsx": "react",
"jsxFactory": "h",
"jsxFragmentFactory": "Fragment"
},
"include": ["jsx.d.ts"]
}여기서 핵심은 JSX가 h(...)와 Fragment(...) 호출로 바뀌도록 만드는 것입니다.
3. vite.config.ts
Vite의 esbuild 설정도 같은 방향으로 맞춰줘야 합니다.
export default defineConfig({
esbuild: {
jsxFactory: "h",
jsxFragment: "Fragment",
jsxInject: `import { h, Fragment } from "@core/jsx/factory";`,
},
});export default defineConfig({
esbuild: {
jsxFactory: "h",
jsxFragment: "Fragment",
jsxInject: `import { h, Fragment } from "@core/jsx/factory";`,
},
});이렇게 설정하면 JSX를 사용할 때 매번 h, Fragment를 직접 import하지 않아도 됩니다.
객체 트리를 실제 DOM으로 렌더링하기
이제 JSX가 객체로 바뀌었으니, 다음 단계는 이 객체를 실제 DOM으로 옮기는 것입니다.
기본적인 렌더러는 재귀 함수로 구현할 수 있습니다.
const root = document.querySelector("#root") as HTMLElement;
function render(node: any, parent: HTMLElement = root): void {
if (node == null || typeof node === "boolean") return;
if (typeof node !== "object") {
const text = document.createTextNode(String(node));
parent.appendChild(text);
return;
}
if (Array.isArray(node)) {
node.forEach((child) => render(child, parent));
return;
}
if (node.tag === Fragment) {
node.children.forEach((child: any) => render(child, parent));
return;
}
if (typeof node.tag === "function") {
const next = node.tag({ ...node.props, children: node.children });
render(next, parent);
return;
}
const domElement = document.createElement(node.tag);
for (const [key, value] of Object.entries(node.props ?? {})) {
domElement.setAttribute(key, String(value));
}
node.children.forEach((child: any) => render(child, domElement));
parent.appendChild(domElement);
}const root = document.querySelector("#root") as HTMLElement;
function render(node: any, parent: HTMLElement = root): void {
if (node == null || typeof node === "boolean") return;
if (typeof node !== "object") {
const text = document.createTextNode(String(node));
parent.appendChild(text);
return;
}
if (Array.isArray(node)) {
node.forEach((child) => render(child, parent));
return;
}
if (node.tag === Fragment) {
node.children.forEach((child: any) => render(child, parent));
return;
}
if (typeof node.tag === "function") {
const next = node.tag({ ...node.props, children: node.children });
render(next, parent);
return;
}
const domElement = document.createElement(node.tag);
for (const [key, value] of Object.entries(node.props ?? {})) {
domElement.setAttribute(key, String(value));
}
node.children.forEach((child: any) => render(child, domElement));
parent.appendChild(domElement);
}이 코드에서 가장 중요한 포인트는 “렌더링 대상은 결국 트리”라는 점입니다.
- 원시값이면 텍스트 노드로 렌더링합니다.
- 배열이면 각 요소를 그대로 순회합니다.
- Fragment면 자식만 렌더링합니다.
- 컴포넌트면 함수를 실행한 뒤, 반환된 JSX를 다시 렌더링합니다.
- 문자열 태그면 실제 DOM 요소를 만듭니다.
즉, 렌더러는 다양한 형태의 노드를 구분하면서 재귀적으로 내려갑니다.
노드 타입을 클래스로 나누기
지금까지의 구현은 충분히 동작하지만, 슬슬 한 가지 불편함이 보이기 시작합니다. 렌더러가 노드를 해석할 때마다 “이 값은 배열인가?”, “함수인가?”, “Fragment인가?”처럼 여러 조건문을 계속 통과해야 한다는 점입니다.
처음에는 이 정도 분기만으로도 괜찮지만, 컴포넌트에 상태나 effect 같은 개념이 붙기 시작하면 노드마다 관리해야 할 정보가 달라집니다. 그러면 단순한 객체 구조만으로는 “이 노드가 정확히 어떤 역할을 하는지”를 표현하기가 점점 어려워집니다.
이 지점에서 도움이 되는 방법이 노드 타입을 역할별 클래스로 나누는 것입니다.
class Element {}
class FragmentElement extends Element {
constructor(public children: Node[]) {
super();
}
}
class DomElement extends Element {
constructor(
public tag: string,
public props: Record<string, unknown>,
public children: Node[],
) {
super();
}
}
class ComponentElement extends Element {
constructor(
public tag: (props: Record<string, unknown>) => Node,
public props: Record<string, unknown>,
public children: Node[],
public state: unknown[] = [],
public stateCursor = 0,
) {
super();
}
}class Element {}
class FragmentElement extends Element {
constructor(public children: Node[]) {
super();
}
}
class DomElement extends Element {
constructor(
public tag: string,
public props: Record<string, unknown>,
public children: Node[],
) {
super();
}
}
class ComponentElement extends Element {
constructor(
public tag: (props: Record<string, unknown>) => Node,
public props: Record<string, unknown>,
public children: Node[],
public state: unknown[] = [],
public stateCursor = 0,
) {
super();
}
}이렇게 바꿔두면 렌더러는 typeof node.tag === "function" 같은 맥락 의존적인 조건보다 node instanceof ComponentElement처럼 더 직접적으로 분기할 수 있습니다. 코드가 읽기 쉬워지는 것도 장점이지만, 더 중요한 건 각 타입에 필요한 데이터를 자연스럽게 붙일 수 있다는 점입니다.
특히 컴포넌트는 단순히 “함수를 실행해서 JSX를 반환하는 노드”에서 끝나지 않습니다. 이후에는 상태를 기억해야 하고, effect도 관리해야 하고, 필요할 때 다시 렌더링도 해야 합니다. 그래서 ComponentElement는 이제부터 그런 정보를 붙여둘 수 있는 자리 역할을 하게 됩니다.
상태는 어디에 저장할까요?
이제 바로 다음으로 생기는 문제는 상태입니다. 컴포넌트가 함수라면, 컴포넌트를 다시 렌더링할 때 함수 본문도 다시 실행됩니다. 그러면 함수 안에 있는 지역 변수는 매번 처음부터 다시 만들어집니다.
즉, count 같은 값을 함수 안에만 두면 이전 값을 기억할 수 없습니다. 상태를 유지하려면 “함수는 다시 실행되더라도 값은 어딘가에 남아 있어야 한다”는 조건이 필요합니다.
여기서 사용할 수 있는 가장 단순한 아이디어는 상태를 컴포넌트 함수 바깥, 즉 컴포넌트 인스턴스 쪽에 붙여두는 것입니다. 이 구현에서는 각 컴포넌트 인스턴스가 자기 상태 배열을 가진다고 가정해보겠습니다.
class ComponentElement extends Element {
constructor(
public tag: (props: Record<string, unknown>) => Node,
public props: Record<string, unknown>,
public children: Node[],
public state: unknown[] = [],
public stateCursor = 0,
) {
super();
}
}class ComponentElement extends Element {
constructor(
public tag: (props: Record<string, unknown>) => Node,
public props: Record<string, unknown>,
public children: Node[],
public state: unknown[] = [],
public stateCursor = 0,
) {
super();
}
}그런데 상태 배열만 있다고 해서 바로 해결되지는 않습니다. 한 컴포넌트 안에서 useState를 여러 번 호출할 수 있기 때문입니다. 그렇다면 지금 읽고 있는 상태가 첫 번째인지, 두 번째인지도 구분할 방법이 필요합니다.
여기서 등장하는 개념이 커서입니다. useState는 상태 배열과 커서를 함께 이용해 “지금 몇 번째 상태를 읽고 있는가”를 추적합니다.
function useState<T>(initialValue: T) {
const component = getCurrentComponentElement();
const { state, stateCursor } = component;
if (state[stateCursor] == null) {
state[stateCursor] = initialValue;
}
const currentValue = state[stateCursor] as T;
const currentCursor = stateCursor;
component.stateCursor += 1;
const setState = (nextValue: T) => {
state[currentCursor] = nextValue;
rerender(component);
};
return [currentValue, setState] as const;
}function useState<T>(initialValue: T) {
const component = getCurrentComponentElement();
const { state, stateCursor } = component;
if (state[stateCursor] == null) {
state[stateCursor] = initialValue;
}
const currentValue = state[stateCursor] as T;
const currentCursor = stateCursor;
component.stateCursor += 1;
const setState = (nextValue: T) => {
state[currentCursor] = nextValue;
rerender(component);
};
return [currentValue, setState] as const;
}이 방식이 성립하는 이유는 컴포넌트가 렌더링될 때 useState 호출 순서가 항상 같다고 가정하기 때문입니다. 아마 React를 써보셨다면 “훅은 조건문 안에서 호출하면 안 된다”는 규칙을 들어보셨을 텐데요, 바로 이 구조 때문에 그런 제약이 생깁니다.
function SomeComponent() {
const [count] = useState(0); // state[0]
const [name] = useState("hi"); // state[1]
}function SomeComponent() {
const [count] = useState(0); // state[0]
const [name] = useState("hi"); // state[1]
}예를 들어 첫 번째 useState는 state[0], 두 번째 useState는 state[1]을 사용한다고 약속해두면, 다음 렌더링 때도 같은 순서로 호출되는 한 각 상태는 자기 자리를 다시 찾을 수 있습니다.
문제는 이 순서가 한 번이라도 바뀌는 순간입니다. 만약 어떤 렌더링에서는 조건문 때문에 첫 번째 useState가 건너뛰어지면, 원래 state[1]을 읽어야 할 훅이 state[0] 자리를 읽게 될 수도 있습니다. 그러면 각 상태가 서로 엉뚱한 칸을 참조하게 됩니다.
이게 흔히 말하는 “훅은 항상 같은 순서로 호출되어야 한다”는 제약의 핵심입니다. 즉, 이 규칙은 단순히 외워야 하는 문장이 아니라, 커서 기반으로 상태를 찾아가는 구조에서 자연스럽게 따라나오는 조건입니다.
React를 사용할 때는 이 규칙이 다소 엄격하게 느껴질 수 있는데, 직접 구현해보면 오히려 꽤 실용적인 제약이라는 점이 보입니다. 훅 시스템이 호출 순서에 기대고 있다면, 그 순서를 흔드는 코드는 애초에 금지하는 편이 훨씬 단순하고 안전하기 때문입니다.
현재 렌더링 중인 컴포넌트는 어떻게 알 수 있을까요?
useState가 동작하려면 지금 어떤 컴포넌트가 렌더링 중인지 알아야 합니다. 가장 단순한 방법은 렌더링 직전에 전역 변수에 현재 컴포넌트를 기록하는 것입니다.
let currentRenderingNode: ComponentElement | null = null;
function render(node: Node, parent: HTMLElement = root): void {
if (node instanceof ComponentElement) {
currentRenderingNode = node;
node.stateCursor = 0;
const next = node.tag({ ...node.props, children: node.children });
render(next, parent);
return;
}
}
function getCurrentComponentElement() {
if (currentRenderingNode == null) {
throw new Error("컴포넌트 렌더링 중이 아닐 때 훅이 호출됐습니다.");
}
return currentRenderingNode;
}let currentRenderingNode: ComponentElement | null = null;
function render(node: Node, parent: HTMLElement = root): void {
if (node instanceof ComponentElement) {
currentRenderingNode = node;
node.stateCursor = 0;
const next = node.tag({ ...node.props, children: node.children });
render(next, parent);
return;
}
}
function getCurrentComponentElement() {
if (currentRenderingNode == null) {
throw new Error("컴포넌트 렌더링 중이 아닐 때 훅이 호출됐습니다.");
}
return currentRenderingNode;
}이 방식은 단순하지만, 왜 훅이 컴포넌트 바깥에서 호출되면 안 되는지도 자연스럽게 설명해줍니다.
setState가 어떤 컴포넌트를 다시 그려야 할까요?
여기서 한 번 더 생각해야 할 문제가 있습니다. setState는 나중에 이벤트 핸들러 안에서 실행됩니다. 즉, 그 시점에는 이미 컴포넌트 함수 실행이 끝난 뒤입니다.
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}여기서 문제가 하나 더 생깁니다. useState가 실행되는 시점과 setState가 실행되는 시점이 서로 다르다는 점입니다.
useState는 컴포넌트를 렌더링하는 동안 실행되지만, setState는 보통 클릭 이벤트처럼 훨씬 나중에 실행됩니다. 그때는 이미 현재 렌더링 중인 컴포넌트를 가리키던 정보가 사라진 뒤입니다.
즉, setter는 단순히 “값을 바꿔라”라는 함수로는 부족합니다. 나중에 다시 호출되더라도 어느 컴포넌트의 몇 번째 상태를 바꿔야 하는지를 스스로 알고 있어야 합니다.
그래서 아래 구현에서는 setter를 만들 때 두 가지를 함께 캡처합니다.
currentCursor: 이 값이 상태 배열의 몇 번째 칸인지componentKey: 이 상태가 어떤 컴포넌트 인스턴스에 속해 있는지
function useState<T>(initialValue: T) {
const component = getCurrentComponentElement();
const { state, stateCursor } = component;
if (state[stateCursor] == null) {
state[stateCursor] = initialValue;
}
const currentValue = state[stateCursor] as T;
const currentCursor = stateCursor;
const componentKey = component.key;
component.stateCursor += 1;
const setState = (nextValue: T) => {
const targetComponent = searchCurrentNode(componentKey);
targetComponent.state[currentCursor] = nextValue;
rerender(targetComponent);
};
return [currentValue, setState] as const;
}function useState<T>(initialValue: T) {
const component = getCurrentComponentElement();
const { state, stateCursor } = component;
if (state[stateCursor] == null) {
state[stateCursor] = initialValue;
}
const currentValue = state[stateCursor] as T;
const currentCursor = stateCursor;
const componentKey = component.key;
component.stateCursor += 1;
const setState = (nextValue: T) => {
const targetComponent = searchCurrentNode(componentKey);
targetComponent.state[currentCursor] = nextValue;
rerender(targetComponent);
};
return [currentValue, setState] as const;
}코드를 보면 setState는 실행될 때마다 먼저 searchCurrentNode(componentKey)로 대상 컴포넌트를 다시 찾고, 그다음에 state[currentCursor]를 갱신합니다.
즉, setter는 “그냥 값을 바꾸는 함수”가 아니라, “나중에 다시 호출돼도 원래 주인을 찾아갈 수 있는 함수”라고 볼 수 있습니다.
이때 핵심이 되는 정보가 바로 컴포넌트를 식별할 수 있는 key입니다.
컴포넌트 key로 현재 인스턴스 찾기
그렇다면 컴포넌트를 어떻게 다시 찾을 수 있을까요? 가장 단순한 방법은 각 컴포넌트에 식별자를 붙여두는 것입니다. 여기서는 트리 안에서의 경로를 key처럼 사용할 수 있습니다.
예를 들어 루트 아래 첫 번째 자식 컴포넌트라면 "root[0]", 그 컴포넌트 아래 두 번째 자식이라면 "root[0][1]" 같은 식입니다. 이렇게 하면 트리 안에서 현재 위치를 문자열 하나로 표현할 수 있습니다.
function render(node: Node, parent: HTMLElement, path = "root") {
if (Array.isArray(node)) {
node.forEach((child, index) => {
render(child, parent, `${path}[${index}]`);
});
return;
}
if (node instanceof ComponentElement) {
node.key = path;
currentRenderingNode = node;
node.stateCursor = 0;
const next = node.tag({ ...node.props, children: node.children });
render(next, parent, path);
return;
}
}function render(node: Node, parent: HTMLElement, path = "root") {
if (Array.isArray(node)) {
node.forEach((child, index) => {
render(child, parent, `${path}[${index}]`);
});
return;
}
if (node instanceof ComponentElement) {
node.key = path;
currentRenderingNode = node;
node.stateCursor = 0;
const next = node.tag({ ...node.props, children: node.children });
render(next, parent, path);
return;
}
}렌더링할 때 이런 경로를 함께 내려보내면, 각 ComponentElement는 자기 key를 갖게 됩니다.
그러면 나중에 setter가 실행됐을 때도 전체 트리를 순회하면서 같은 key를 가진 컴포넌트를 다시 찾을 수 있습니다.
function searchCurrentNode(
key: string,
node = renderTree.tree,
): ComponentElement | null {
if (Array.isArray(node)) {
for (const child of node) {
const found = searchCurrentNode(key, child);
if (found) return found;
}
return null;
}
if (node instanceof ComponentElement && node.key === key) {
return node;
}
for (const child of node.children ?? []) {
const found = searchCurrentNode(key, child);
if (found) return found;
}
return null;
}function searchCurrentNode(
key: string,
node = renderTree.tree,
): ComponentElement | null {
if (Array.isArray(node)) {
for (const child of node) {
const found = searchCurrentNode(key, child);
if (found) return found;
}
return null;
}
if (node instanceof ComponentElement && node.key === key) {
return node;
}
for (const child of node.children ?? []) {
const found = searchCurrentNode(key, child);
if (found) return found;
}
return null;
}조금 비유해서 보면, currentCursor는 “이 컴포넌트 안에서 몇 번째 서랍인가”를 가리키고, componentKey는 “어느 서랍장인가”를 가리킵니다. 둘 중 하나만 있어서는 정확한 상태 위치를 찾을 수 없습니다.
useEffect는 왜 렌더링 뒤에 실행돼야 할까요?
useEffect는 단순히 콜백을 저장하는 훅이 아닙니다. 중요한 건 실행 시점입니다.
function Component() {
useEffect(() => {
const element = document.querySelector(".target");
console.log(element);
}, []);
return <div className="target">hello</div>;
}function Component() {
useEffect(() => {
const element = document.querySelector(".target");
console.log(element);
}, []);
return <div className="target">hello</div>;
}이 코드는 DOM이 실제로 만들어진 뒤에 실행돼야 의미가 있습니다. 아직 렌더링되지 않은 상태에서 effect를 실행하면 원하는 요소를 찾지 못할 수 있습니다.
그래서 useEffect는 보통 렌더링이 끝난 뒤의 큐에 등록합니다.
function nextTick() {
return new Promise<void>((resolve) => {
queueMicrotask(resolve);
});
}
function useEffect(
callback: () => void | (() => void),
dependencies: unknown[],
) {
const component = getCurrentComponentElement();
const { sideEffects, sideEffectsCursor } = component;
const currentEffect = sideEffects[sideEffectsCursor];
const currentCursor = sideEffectsCursor;
component.sideEffectsCursor += 1;
if (currentEffect == null) {
nextTick().then(() => {
const cleanup = callback();
sideEffects[currentCursor] = {
dependencies,
cleanup,
};
});
return;
}
const hasChanged = !isEqual(currentEffect.dependencies, dependencies);
if (hasChanged) {
currentEffect.cleanup?.();
nextTick().then(() => {
const cleanup = callback();
currentEffect.dependencies = dependencies;
currentEffect.cleanup = cleanup;
});
}
}function nextTick() {
return new Promise<void>((resolve) => {
queueMicrotask(resolve);
});
}
function useEffect(
callback: () => void | (() => void),
dependencies: unknown[],
) {
const component = getCurrentComponentElement();
const { sideEffects, sideEffectsCursor } = component;
const currentEffect = sideEffects[sideEffectsCursor];
const currentCursor = sideEffectsCursor;
component.sideEffectsCursor += 1;
if (currentEffect == null) {
nextTick().then(() => {
const cleanup = callback();
sideEffects[currentCursor] = {
dependencies,
cleanup,
};
});
return;
}
const hasChanged = !isEqual(currentEffect.dependencies, dependencies);
if (hasChanged) {
currentEffect.cleanup?.();
nextTick().then(() => {
const cleanup = callback();
currentEffect.dependencies = dependencies;
currentEffect.cleanup = cleanup;
});
}
}여기서 중요한 포인트는 세 가지입니다.
useState처럼 커서로 각 effect를 구분합니다.- 의존성 배열을 비교해서 다시 실행할지 결정합니다.
- 다시 실행하기 전에는 이전 cleanup을 먼저 호출합니다.
이 부분을 구현하고 나면 React가 왜 렌더링과 effect를 분리해서 다루는지도 조금 더 선명해집니다. 화면을 계산하는 단계와, DOM이 준비된 뒤 부수 효과를 실행하는 단계는 성격이 다르기 때문에 한 시점에 섞어서 처리하기보다 구분하는 편이 더 자연스럽습니다.
리렌더링할 때 DOM은 어떻게 갱신할까요?
상태가 바뀌면 컴포넌트를 다시 실행해야 합니다. 그런데 다시 실행한 결과를 화면에 어떻게 반영할지는 별도의 문제입니다.
가장 단순한 방법은 해당 컴포넌트가 만든 DOM을 기억해뒀다가 전부 지우고 다시 만드는 것입니다.
class ComponentElement extends Element {
constructor(
public tag: (props: Record<string, unknown>) => Node,
public props: Record<string, unknown>,
public children: Node[],
public state: unknown[] = [],
public stateCursor = 0,
public nodes: HTMLElement[] = [],
) {
super();
}
}class ComponentElement extends Element {
constructor(
public tag: (props: Record<string, unknown>) => Node,
public props: Record<string, unknown>,
public children: Node[],
public state: unknown[] = [],
public stateCursor = 0,
public nodes: HTMLElement[] = [],
) {
super();
}
}리렌더링 시에는 기존 DOM을 제거한 뒤 같은 위치에 새 DOM을 삽입합니다.
function rerender(node: ComponentElement) {
const parent = node.parent;
const firstNode = node.nodes[0];
const position = firstNode
? Array.from(parent.children).indexOf(firstNode)
: -1;
node.nodes.forEach((domNode) => domNode.remove());
node.nodes = [];
const next = node.tag({ ...node.props, children: node.children });
render(next, parent, node.key, (domElement) => {
node.nodes.push(domElement);
if (position >= 0) {
parent.insertBefore(domElement, parent.children[position] ?? null);
return;
}
parent.appendChild(domElement);
});
}function rerender(node: ComponentElement) {
const parent = node.parent;
const firstNode = node.nodes[0];
const position = firstNode
? Array.from(parent.children).indexOf(firstNode)
: -1;
node.nodes.forEach((domNode) => domNode.remove());
node.nodes = [];
const next = node.tag({ ...node.props, children: node.children });
render(next, parent, node.key, (domElement) => {
node.nodes.push(domElement);
if (position >= 0) {
parent.insertBefore(domElement, parent.children[position] ?? null);
return;
}
parent.appendChild(domElement);
});
}이 방식은 비효율적일 수 있지만, 리렌더링의 본질을 이해하는 데는 꽤 좋습니다. 결국 문제는 “이 컴포넌트가 만든 DOM 범위를 어떻게 추적할 것인가”로 귀결되기 때문입니다.
이벤트 처리는 생각보다 단순합니다
JSX에서 이벤트는 보통 onClick, onChange처럼 속성으로 들어옵니다. 그러면 DOM 요소를 만들 때 이벤트 리스너로 연결해주면 됩니다.
for (const [key, value] of Object.entries(node.props ?? {})) {
if (key.startsWith("on") && typeof value === "function") {
const eventName = key.slice(2).toLowerCase();
domElement.addEventListener(eventName, value);
continue;
}
domElement.setAttribute(key, String(value));
}for (const [key, value] of Object.entries(node.props ?? {})) {
if (key.startsWith("on") && typeof value === "function") {
const eventName = key.slice(2).toLowerCase();
domElement.addEventListener(eventName, value);
continue;
}
domElement.setAttribute(key, String(value));
}실제로는 이보다 더 복잡한 로직을 통해 이벤트 속성을 발라낼 겁니다. 그렇지만 이렇게 단순화 해서 보면 이벤트도 특별한 마법이 있는 건 아닙니다. 결국은 props를 해석하는 규칙 중 하나일 뿐입니다.
여기까지 구현하면서 알게 되는 것
이 정도만 구현해도 JSX 기반 UI 라이브러리의 핵심 구조가 꽤 또렷하게 보입니다.
- JSX는 트리 구조의 데이터로 변환됩니다.
- 렌더러는 그 트리를 재귀적으로 순회해 DOM을 만듭니다.
- 컴포넌트는 함수지만, 상태는 컴포넌트 인스턴스 바깥에 저장해야 합니다.
- 훅은 “배열 + 커서”라는 단순한 아이디어로도 구현할 수 있습니다.
- 리렌더링은 결국 “이전 결과를 어떻게 추적하고 갱신할 것인가”의 문제입니다.
실제 React와는 무엇이 다를까요?
여기까지의 구현은 학습용으로는 충분하지만, 실제 React와 비교하면 생략한 부분이 많습니다.
- 변경된 부분만 정교하게 갱신하는 diffing과 reconciliation
- 여러 상태 변경을 묶어서 처리하는 배치 업데이트
- 렌더링 우선순위를 다루는 스케줄링
- 컴포넌트 트리 변경 시 더 안정적인 식별 전략
- 에러 처리, Context, Ref, 메모이제이션 같은 추가 기능
그래도 직접 구현해보면 React가 왜 그런 제약을 가지고 있는지 훨씬 잘 이해하게 됩니다. 예를 들어 “훅은 조건문 안에서 호출하면 안 된다”는 규칙도 외워서 아는 게 아니라, 커서 기반 상태 저장 구조를 떠올리면 자연스럽게 납득하게 됩니다.
마무리
이번 작업은 항해99 과제로 시작했지만, 정작 남은 것은 결과물보다도 과정에서 던졌던 질문들이었습니다. JSX를 객체로 바꾸는 일 자체는 비교적 빨리 시작할 수 있었지만, 상태를 유지하려면 무엇이 필요하고, 나중에 실행되는 setter는 원래 컴포넌트를 어떻게 찾아가야 하며, effect는 왜 렌더링 이후에 실행돼야 하는지 같은 문제를 만나면서 비로소 React가 풀고 있는 문제의 모양이 보이기 시작했습니다.
특히 좋았던 점은 React의 규칙들이 더 이상 외워야 하는 사용법이 아니게 되었다는 것입니다. “훅은 왜 조건문 안에서 호출하면 안 되는가”, “왜 리렌더링 최적화가 필요한가” 같은 질문들이 직접 구현 과정 안에서 자연스럽게 이어졌고, 그 덕분에 React를 사용할 때도 한 단계 더 구조적으로 바라볼 수 있게 됐습니다.
결국 이 글에서 중요한 건 “미니 React를 만들었다”는 사실 자체가 아닙니다. React 기반 생태계 안에서 너무 익숙해서 그냥 지나치기 쉬운 개념들을, 직접 작은 구현으로 다시 만나보는 과정이 꽤 좋은 학습이 된다는 점입니다. 비슷한 과제를 하게 되신다면 결과보다도, 구현하다가 막히는 지점마다 “아, React는 이걸 해결하고 있었구나”라고 연결해서 바라보시면 훨씬 재미있게 이해하실 수 있을 것 같습니다.