JSX
는 익숙했다. 매일처럼 쓰고 있었고, 특별히 불편함을 느낀 적도 없었다.
그러나 이번 과제는 달랐다. 컴포넌트를 "사용하는" 게 아니라 "만드는" 걸 요구했다.
createVNode
부터 시작해서, JSX
를 직접 가상 DOM
형태로 바꾸고, 그걸 다시 실제 DOM
으로 만들어야 했다.
막상 시작해보니, 내가 JSX
에 대해 아무것도 몰랐다는 걸 깨달았다.
"그냥 return
안에 태그만 넣어주면" 되었던 것이, 실제로는 복잡한 객체 구조와 변환 과정을 거쳐야 가능한 일이었다.
그동안 당연하게 생각했던 것들이 하나씩 의문으로 바뀌기 시작했다.
그리고 그걸 직접 구현해보면서, 내가 뭘 모르고 있었는지를 조금씩 알게 됐다.
처음 구현한 부분은 createVNode
였다. type
, props
, children
을 받아서 { type, props, children }
형태의 객체를 반환하는 함수인데, 처음에는 단순히 그 역할만 해주면 되는 줄 알았다. 그런데 JSX
와 연결하고 테스트를 돌려보는 순간, 예상하지 못한 오류들이 하나둘씩 터지기 시작했다.
가장 먼저 마주한 건 children
문제였다.
JSX
에서 넘겨받은 children
이 배열 안에 또 배열로 들어가 있는 경우가 있었고, null
, undefined
, false
같은 값들도 걸러지지 않고 들어오고 있었다.
예를 들어 다음과 같은 JSX 코드를 생각해보자:
<Post>
{isLoading && <Spinner />}
<div>글 내용</div>
{[<Button />, <Button />]}
</Post>
이런 경우 children
은 [false, VNode, [VNode, VNode]]
처럼 중첩되고 불필요한 값이 섞인 구조로 전달된다.
이걸 그대로 넘기면 렌더링 과정에서 순회 도중 오류가 나거나, 잘못된 값이 그대로 DOM
에 출력되는 문제가 생긴다.
이를 해결하기 위해 flattenChildren
이라는 전처리 함수를 만들어, 중첩된 배열을 평탄화하고 렌더링 불가능한 값은 걸러주는 로직을 추가해주었다.
function flattenChildren(children: RawVNode[]): RawVNode[] {
return children.reduce<RawVNode[]>((flat, child) => {
if (child === null || child === undefined || child === false) return flat;
return flat.concat(Array.isArray(child) ? flattenChildren(child) : child);
}, []);
}
단순한 구조처럼 보였던 VNode
생성이, 생각보다 더 많은 예외를 다뤄야 하는 복잡한 작업이라는 걸 처음부터 느꼈다.
flattenChildren
으로 children
구조를 정리한 이후에도, 해결해야 할 구조적 과제가 하나 더 있었다.
바로 normalizeVNode
다. 사실 이 함수는 이미 초기 코드에 존재하고 있었지만, 처음엔 그 역할이 왜 필요한지 잘 와닿지 않았다.
createVNode
로 VNode가 만들어지고 나면 그걸 바로 DOM
으로 바꾸면 되지 않을까? 라는 생각이었다.
하지만 과제를 진행하며 createElement
나 updateElement
등의 내부 구현을 하나씩 채워가다 보니, 모든 VNode
가 동일한 구조를 가질 거라는 가정이 무너졌다.
특히 JSX
에서는 함수형 컴포넌트가 들어오면 type
이 문자열이 아니라 함수가 되며, null
, undefined
, boolean
같은 렌더링 불가능한 값들도 그대로 섞여 들어온다.
결국 createElement
안에서 분기 처리를 계속 늘려가는 게 아니라, 사전에 모든 VNode
를 표준화하는 단계가 필요하다는 걸 자연스럽게 체감하게 되었다.
그래서 결국 렌더링 흐름 앞단에 normalizeVNode
를 명시적으로 넣고, 이 함수 안에서 다음과 같은 처리들을 하도록 구성했다.
null
, undefined
, boolean
→ 빈 문자열로 변환string
, number
→ string
으로 변환normalizeVNode
로 정규화normalizeVNode
를 호출한 뒤 필터링정규화는 렌더링 시점에서 불필요한 예외 처리를 줄이고, 모든 로직이 일관된 구조를 가정하고 동작할 수 있도록 만드는 기반이 되었다.
초반에는 "그냥 정해진 흐름이니까 쓰는 건가?" 정도로 생각했지만, JSX
패턴이 점점 다양해지고 복잡해지면서 정규화 단계의 필요성이 뚜렷하게 느껴졌다.
이번 과제에선 각 DOM
요소에 이벤트를 직접 바인딩하는 대신, 루트 엘리먼트에 한 번만 이벤트를 등록하고 하위 요소로 이벤트를 위임하는 방식으로 설계했다.
이벤트 위임은 리렌더링이 자주 일어나는 SPA
에서 특히 유용한데, 매 렌더링마다 DOM 요소가 새로 생기고 사라지는 상황에서 addEventListener
를 반복 호출하면 성능과 메모리 측면에서 문제가 될 수 있기 때문이다.
이벤트 위임을 위해 EventManager
클래스를 만들어, 이벤트 바인딩과 해제를 전역적으로 관리하도록 했다. 이 클래스는 크게 두 가지 역할을 수행한다:
DOM
요소가 어떤 이벤트를 가지고 있는지 기억하기 위해 Map<HTMLElement, Function>
구조를 사용WeakMap<HTMLElement, Set<string>>
사용이렇게 하면 동일한 루트에 동일한 이벤트가 중복 등록되는 걸 막을 수 있고, 루트 엘리먼트가 제거될 경우 WeakMap
을 통해 자동으로 GC
대상이 되므로 메모리 누수도 예방할 수 있다.
setupEventListeners
는 매 렌더링마다 호출되지만, 내부적으로 한 번만 이벤트를 바인딩하도록 처리되어 있어 비용이 최소화된다.
실제로 각 VNode
에 정의된 핸들러는 addEvent
와 removeEvent
를 통해 관리되며, 위임 구조와 자연스럽게 연결된다.
class EventManager {
// 이벤트 종류별로 (타겟 요소 → 핸들러)를 저장하는 Map
#delegatedEvents = new Map();
// 루트 요소마다 어떤 이벤트 타입이 바인딩됐는지를 기억하는 WeakMap
#delegatedRoots = new WeakMap();
setupEventListeners(delegateRoot: HTMLElement) {
// 루트가 처음 등록되는 경우 Set 초기화
if (!this.#delegatedRoots.has(delegateRoot)) {
this.#delegatedRoots.set(delegateRoot, new Set());
}
const boundEvents = this.#delegatedRoots.get(delegateRoot);
this.#delegatedEvents.forEach((delegates, eventType) => {
// 이미 이 루트에 해당 이벤트가 바인딩된 경우 스킵
if (boundEvents.has(eventType)) return;
// 이벤트 위임: 루트에서 캡처하여 일괄 처리
delegateRoot.addEventListener(eventType, (event) => {
for (const [delegateTarget, delegateHandler] of delegates) {
if (delegateTarget.contains(event.target)) {
delegateHandler.call(delegateTarget, event);
}
}
});
boundEvents.add(eventType);
});
}
// 타겟과 핸들러를 이벤트 종류별로 등록
addEvent(target: HTMLElement, type: string, handler: (e: Event) => void) {
if (!this.#delegatedEvents.has(type)) {
this.#delegatedEvents.set(type, new Map());
}
this.#delegatedEvents.get(type).set(target, handler);
}
// 정확히 일치하는 핸들러가 있을 경우에만 제거
removeEvent(target: HTMLElement, type: string, handler: (e: Event) => void) {
const delegates = this.#delegatedEvents.get(type);
if (!delegates) return;
const existing = delegates.get(target);
if (existing === handler) {
delegates.delete(target);
}
}
}
export const eventManager = new EventManager();
컴포넌트가 리렌더링되었을 때, 실제 DOM
을 얼마나 효율적으로 갱신하느냐는 Virtual DOM
구현에서 가장 중요한 포인트 중 하나다. 이번 과제에서는 이를 담당하는 updateElement
함수를 중심으로 DOM
동기화 로직을 구현했다.
updateElement
는 이전 VNode
와 새로운 VNode
를 비교해 최소한의 변경만 실제 DOM
에 반영하는 핵심 함수다.
노드의 타입이 다르면 즉시 교체하고, 같을 경우에는 props
와 children
을 비교해 바뀐 부분만 실제 DOM
에 반영하는 방식으로 구성했다.
export function updateElement(
parentElement: HTMLElement,
newNode: RawVNode,
oldNode: RawVNode,
index: number = 0,
) {
const existingElement = parentElement.childNodes[index] as HTMLElement;
// 1. 추가 / 제거
if (newNode && !oldNode)
return parentElement.appendChild(createElement(newNode));
if (!newNode && oldNode) return parentElement.removeChild(existingElement);
// 2. 텍스트 노드 처리
if (typeof newNode === "string" || typeof newNode === "number") {
// ...텍스트 비교 후 nodeValue 갱신
return;
}
// 3. 타입이 다르면 교체
if (newNode.type !== oldNode.type) {
parentElement.replaceChild(createElement(newNode), existingElement);
return;
}
// 4. props 및 children 비교
updateAttributes(existingElement, newNode.props, oldNode.props);
const newChildren = newNode.children || [];
const oldChildren = oldNode.children || [];
const maxLength = Math.max(newChildren.length, oldChildren.length);
for (let i = 0; i < maxLength; i++) {
updateElement(existingElement, newChildren[i], oldChildren[i], i);
}
}
여기서 가장 까다로웠던 부분은 props
의 변경 여부를 어떻게 판단하고 반영할 것인가였다.
특히 이벤트 핸들러와 일반 속성은 적용 방식이 다르기 때문에, 다음과 같이 별도의 헬퍼 함수로 분리해 처리했다.
// 기존 핸들러와 다르면 새로 등록
function handleEventAttribute(
target: HTMLElement,
key: string,
newValue: (event: Event) => void,
oldValue: (event: Event) => void,
) {
const eventType = key.slice(2).toLowerCase();
if (oldValue !== newValue) {
addEvent(target, eventType, newValue);
}
}
// 변경된 속성만 반영
function handleNormalAttribute(
target: HTMLElement,
key: string,
newValue: string,
oldValue: string,
) {
const attrName = key === "className" ? "class" : key;
if (oldValue !== newValue) {
target.setAttribute(attrName, newValue);
}
}
// props에서 사라진 이벤트 제거
function removeEventAttribute(
target: HTMLElement,
key: string,
oldValue: (event: Event) => void,
) {
const eventType = key.slice(2).toLowerCase();
removeEvent(target, eventType, oldValue);
}
// props에서 사라진 속성 제거
function removeNormalAttribute(target: HTMLElement, key: string) {
const attrName = key === "className" ? "class" : key;
target.removeAttribute(attrName);
}
이 함수들은 updateAttributes
에서 아래와 같은 방식으로 사용된다.
핵심은 이전 props
와 새 props
를 비교한 후, 변경이 생긴 경우만 처리하고 새 props
에서 사라진 항목은 제거하는 것이다.
export function updateAttributes(
target: HTMLElement,
originNewProps: Props,
originOldProps: Props,
) {
// 새로운 props를 순회하면서 변경 사항 적용
if (originNewProps) {
Object.entries(originNewProps).forEach(([key, value]) => {
if (key.startsWith("on")) {
// 이벤트 핸들러인 경우 - 이전 핸들러와 비교하여 변경되었으면 addEvent 호출
handleEventAttribute(
target,
key,
value as (event: Event) => void,
(originOldProps?.[key] as (event: Event) => void) || (() => {}),
);
} else {
// 일반 속성인 경우 - 이전 값과 비교하여 변경되었으면 setAttribute 호출
handleNormalAttribute(
target,
key,
value as string,
(originOldProps?.[key] as string) || "",
);
}
});
}
// 이전 props를 순회하면서 사라진 속성 제거
if (originOldProps) {
Object.keys(originOldProps).forEach((key) => {
if (!(originNewProps && key in originNewProps)) {
if (key.startsWith("on")) {
// 이벤트 핸들러가 사라진 경우 - removeEvent 호출
removeEventAttribute(
target,
key,
originOldProps[key] as (event: Event) => void,
);
} else {
// 일반 속성이 사라진 경우 - removeAttribute 호출
removeNormalAttribute(target, key);
}
}
});
}
}
이 구조를 통해 VNode.props
와 실제 DOM
속성 간의 최소한의 차이만 반영할 수 있었고, 불필요한 속성 설정이나 이벤트 재등록 없이 효율적인 업데이트가 가능해졌다.
아래는 JSX로부터 시작해 실제 DOM이 생성되기까지의 흐름을 정리한 구조도다.
이벤트 위임 구조를 갖춘 상태에서는 처음 렌더링(createElement
)까지는 별다른 문제가 없었다.
하지만 이후 상태가 변경되어 updateElement
가 호출되는 상황에서, 예상치 못한 무한 렌더링이 발생했다. router.push()
가 무한히 호출되며 앱이 멈추는 현상이 발생한 것이다.
처음엔 라우팅 로직이나 상태 관리 쪽을 의심했지만, 디버깅을 거치며 원인은 전혀 다른 곳에 있다는 걸 확인할 수 있었다.
기존 update에는 이벤트 핸들러가 바뀌었는지 여부를 oldValue !== newValue
조건으로 판단한 뒤, 매 렌더마다 removeEvent()
후 addEvent()
를 호출하는 방식으로 처리하고 있었다.
하지만 이 구조는 핸들러가 매번 새로 생성되는 경우(예: 화살표 함수, 인라인 정의 등), 동일한 동작임에도 항상 새로운 참조로 인식되어 불필요하게 이벤트가 제거되고 다시 등록되는 결과를 낳았다.
그 결과, 렌더링이 상태 변경을 유발하고 → 다시 렌더링이 발생하고 → 또 이벤트가 재등록되는 무한 루프에 빠지는 상황이 발생했다.
다음은 당시 작성했던 코드이다:
if (originOldProps[key] !== value) {
if (originOldProps[key]) {
removeEvent(target, eventType, originOldProps[key]);
}
addEvent(target, eventType, value);
}
핸들러가 달라졌다고 판단되면 무조건 remove
→ add
를 반복하는 구조였고, 결국 계속된 핸들러 재등록이 상태 변화를 유발해 무한 루프가 생겼던 것이다.
이 문제를 해결하기 위해 로직을 아래처럼 분리했다:
// 핸들러 변경 시에도 remove 없이 등록만 수행
if (oldValue !== newValue) {
addEvent(target, eventType, newValue);
}
// 아예 props에서 사라졌을 때만 제거
if (!(originNewProps && key in originNewProps)) {
removeEvent(target, eventType, oldValue);
}
이처럼 등록과 제거의 책임을 분리하자, 불필요한 제거-재등록 루프가 사라지고 안정적인 렌더링 흐름을 유지할 수 있었다.
렌더링 시점마다 바뀌는 함수 참조로 인해 상태 변경이 반복되던 문제가, 단순한 조건 분기로 해결되었다는 점이 인상 깊었다.
이전까지는 JavaScript
로 구조를 잡아왔지만, virtual DOM
의 흐름과 각 함수 간의 데이터 전달 구조를 더 깊이 이해하고 싶어서 TypeScript
로 직접 마이그레이션을 시도했다.
단순히 타입 안정성을 높이려는 목적이라기보다는, 코드의 형태를 더 분명하게 설계하고 싶었던 게 크다.
React
환경이 아닌 상태에서 TSX
를 사용하기 위해서는 몇 가지 설정이 필요했다. 먼저 tsconfig.json
에서 jsxFactory
를 createVNode
로 설정하고, JSX.Element
, JSX.IntrinsicElements
같은 JSX
관련 타입들을 수동으로 선언해줘야 했다.
// tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"jsx": "react",
"jsxFactory": "createVNode",
"jsxFragmentFactory": "Fragment",
"moduleResolution": "node",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"baseUrl": ".",
"outDir": "dist"
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
// src/types/jsx.d.ts
declare global {
namespace JSX {
interface IntrinsicElements {
[elemName: string]: Props;
}
interface Element extends VNode {}
interface ElementClass {}
interface ElementAttributesProperty {
props: {};
}
}
}
이 과정을 통해 JSX가 실제로 어떤 방식으로 타입 검사를 수행하고, 컴파일될 때 어떤 함수를 호출하는지를 자연스럽게 이해하게 되었다.
그 다음은 VNode, Props, Component 등 내부 타입들을 설계해나가는 과정이었다.
예를 들어 VNode
는 string
, number
, null
, Component
, VNode[]
등 다양한 형태를 포괄해야 했고, 이 과정을 통해 내가 다루고 있는 데이터의 범위와 구조를 훨씬 명확히 인식하게 되었다.
또한 flattenChildren
이나 normalizeVNode
처럼 입력 타입이 복잡하게 섞이는 함수에서, 타입 선언 덕분에 미리 오류를 감지하고 구조를 정리할 수 있었다.
이전에는 런타임에서야 오류를 확인하곤 했지만, 타입이 추가되면서 컴파일 단계에서 잘못된 흐름을 빠르게 파악할 수 있었고, 그만큼 디버깅 시간도 줄일 수 있었다.
JSX
에서 표현한 컴포넌트를 VNode
로 변환하기 위해, 다양한 타입의 입력을 처리할 수 있도록 아래와 같은 타입들을 직접 정의했다:
export type RawVNode = string | number | boolean | null | undefined | VNode;
export type Props = Record<string, unknown> | null;
export type Component = (props: Props) => VNode | string;
export type ElementType = keyof HTMLElementTagNameMap | Component;
export interface VNode {
type: ElementType;
props: Props;
children: RawVNode[];
}
여기서 RawVNode
는 정규화 이전 단계의 JSX
에서 받을 수 있는 모든 타입을 포괄하며, VNode
는 정규화된 결과의 기본 단위가 된다.
특히 type
에는 실제 HTML
태그명 또는 함수형 컴포넌트가 올 수 있기 때문에 ElementType
이라는 타입으로 나눠 관리했고, props
는 존재하지 않는 경우도 고려해 null
을 허용했다.
이 타입들을 정의하면서 JSX
가 단순히 DOM
트리를 만드는 문법이 아니라, 다양한 형태의 값을 포함할 수 있는 복합 구조라는 걸 다시 한번 체감할 수 있었다.
또 타입을 먼저 정의해두고 나니, 이후 createVNode
, createElement
, normalizeVNode
같은 함수들의 입력/출력이 자연스럽게 정리되면서 전체 흐름을 이해하기도 쉬워졌다.
TypeScript
마이그레이션을 진행하면서 자연스럽게 각 모듈의 책임이 더 뚜렷하게 보이기 시작했고, 이를 기반으로 전체 디렉토리 구조를 정리하게 되었다.
기존에는 lib
폴더 안에 거의 모든 기능이 뒤섞여 있었지만, 이제는 vdom
, router
, store
, observer
등 역할에 따라 파일을 나누고 흐름을 정리할 수 있었다.
특히 createObserver
는 라우터와 상태 관리 두 곳에서 공통으로 사용되었기 때문에 observer
라는 별도의 폴더로 분리해 재사용성과 독립성을 확보했다.
또, VNode
생성/정규화/렌더링 등 Virtual DOM
구성과 관련된 함수들은 모두 vdom
디렉토리 아래에 위치시켜, 해당 책임 범위를 명확히 구분했다.
기능을 구조화하는 과정에서 “이건 클래스로 만들어야 할까, 함수로 두는 게 나을까?”라는 질문도 여러 번 하게 됐다.
예를 들어 EventManager
는 내부에 상태(Map
, WeakMap
)를 저장하고, 전역적으로 이벤트를 위임하는 역할을 수행해야 했기 때문에 클래스 형태로 구성하는 것이 자연스러웠다.
이렇게 하면 단일 인스턴스로 선언해서 여러 곳에서 공유할 수 있었고, 루트 이벤트와 핸들러 등록 상태도 내부에서 은닉할 수 있었다.
반면, VNode
를 생성하거나 정규화하는 작업은 외부 상태를 갖지 않는 순수 함수로 처리하는 것이 더 적합했고, 타입이 명확해졌을 때 오히려 함수 구조가 더 깔끔하게 정리되었다.
이번 구조 정리를 통해 가장 크게 느낀 점은, 관심사를 분리한다는 건 단순히 파일을 나누는 일이 아니라 각 기능의 역할과 책임 범위를 설계하는 일이라는 것이다.
단지 “이건 렌더링이니까 vdom
에 넣자”는 수준이 아니라, 이 기능이 어느 흐름에서 작동하는지, 어디까지 알고 있어야 하는지를 계속 따져야 했다.
특히 updateElement
처럼 VNode
, DOM
, Props
라는 세 가지 개념이 얽힌 함수의 경우, 로직을 분리할수록 오히려 책임이 불명확해지기도 했고, 적당한 경계를 찾기 위한 시행착오도 많았다.
하지만 이런 고민을 거치면서 “이 코드가 어떤 역할을 갖고, 어떤 흐름의 일부인지”를 더 깊이 이해할 수 있었고, 전체 시스템을 조율하는 감각도 함께 생겼다.
이번 과제를 통해 Virtual DOM
의 구조와 동작 원리를 처음부터 다시 살펴볼 수 있었다.
JSX
에서 VNode
로의 변환, 렌더링 과정에서의 정규화 처리, DOM
업데이트를 위한 비교 로직, 이벤트 위임까지—익숙하게 써왔던 개념들을 직접 구현하며 그 이면의 구조를 이해할 수 있었다.
특히 렌더링 흐름과 이벤트 핸들링 사이의 관계를 고민하면서, 사소해 보이는 코드 하나가 전체 앱의 동작에 어떤 영향을 줄 수 있는지를 체감했다.
타입스크립트로 전환하면서는 각 데이터의 흐름과 책임을 더 명확히 구분할 수 있었고, 구조적인 측면에서도 관심사를 어떻게 나누고 연결할지에 대한 고민을 깊이 해볼 수 있었다.
이미 알고 있다고 생각했던 개념들을 직접 구현하며, 그 동작 원리를 더 구체적으로 이해할 수 있었던 시간이었다.
BP 대장 한별님 이번주 회고도 기깔나네요..한수 배워갑니다.