항해플러스 프론트엔드 5기 후기(2주차) - 프레임워크 없이 SPA 만들기(Virtual DOM)

유한별·2025년 4월 5일
4
post-thumbnail

JSX는 익숙했다. 매일처럼 쓰고 있었고, 특별히 불편함을 느낀 적도 없었다.
그러나 이번 과제는 달랐다. 컴포넌트를 "사용하는" 게 아니라 "만드는" 걸 요구했다.
createVNode부터 시작해서, JSX를 직접 가상 DOM 형태로 바꾸고, 그걸 다시 실제 DOM으로 만들어야 했다.

막상 시작해보니, 내가 JSX에 대해 아무것도 몰랐다는 걸 깨달았다.
"그냥 return 안에 태그만 넣어주면" 되었던 것이, 실제로는 복잡한 객체 구조와 변환 과정을 거쳐야 가능한 일이었다.

그동안 당연하게 생각했던 것들이 하나씩 의문으로 바뀌기 시작했다.
그리고 그걸 직접 구현해보면서, 내가 뭘 모르고 있었는지를 조금씩 알게 됐다.

VNode 생성

처음 구현한 부분은 createVNode였다. type, props, children을 받아서 { type, props, children } 형태의 객체를 반환하는 함수인데, 처음에는 단순히 그 역할만 해주면 되는 줄 알았다. 그런데 JSX와 연결하고 테스트를 돌려보는 순간, 예상하지 못한 오류들이 하나둘씩 터지기 시작했다.

JSX의 Children

가장 먼저 마주한 건 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 생성이, 생각보다 더 많은 예외를 다뤄야 하는 복잡한 작업이라는 걸 처음부터 느꼈다.

JSX 정규화 처리(normalized)

flattenChildren으로 children 구조를 정리한 이후에도, 해결해야 할 구조적 과제가 하나 더 있었다.

바로 normalizeVNode다. 사실 이 함수는 이미 초기 코드에 존재하고 있었지만, 처음엔 그 역할이 왜 필요한지 잘 와닿지 않았다.
createVNode로 VNode가 만들어지고 나면 그걸 바로 DOM으로 바꾸면 되지 않을까? 라는 생각이었다.

하지만 과제를 진행하며 createElementupdateElement 등의 내부 구현을 하나씩 채워가다 보니, 모든 VNode가 동일한 구조를 가질 거라는 가정이 무너졌다.
특히 JSX에서는 함수형 컴포넌트가 들어오면 type이 문자열이 아니라 함수가 되며, null, undefined, boolean 같은 렌더링 불가능한 값들도 그대로 섞여 들어온다.
결국 createElement 안에서 분기 처리를 계속 늘려가는 게 아니라, 사전에 모든 VNode를 표준화하는 단계가 필요하다는 걸 자연스럽게 체감하게 되었다.

그래서 결국 렌더링 흐름 앞단에 normalizeVNode를 명시적으로 넣고, 이 함수 안에서 다음과 같은 처리들을 하도록 구성했다.

  • null, undefined, boolean → 빈 문자열로 변환
  • string, numberstring으로 변환
  • 함수형 컴포넌트 → 실행 후 결과를 다시 normalizeVNode로 정규화
  • 배열 → 재귀적으로 normalizeVNode를 호출한 뒤 필터링

정규화는 렌더링 시점에서 불필요한 예외 처리를 줄이고, 모든 로직이 일관된 구조를 가정하고 동작할 수 있도록 만드는 기반이 되었다.
초반에는 "그냥 정해진 흐름이니까 쓰는 건가?" 정도로 생각했지만, JSX 패턴이 점점 다양해지고 복잡해지면서 정규화 단계의 필요성이 뚜렷하게 느껴졌다.

이벤트 위임 구조

이번 과제에선 각 DOM 요소에 이벤트를 직접 바인딩하는 대신, 루트 엘리먼트에 한 번만 이벤트를 등록하고 하위 요소로 이벤트를 위임하는 방식으로 설계했다.
이벤트 위임은 리렌더링이 자주 일어나는 SPA에서 특히 유용한데, 매 렌더링마다 DOM 요소가 새로 생기고 사라지는 상황에서 addEventListener를 반복 호출하면 성능과 메모리 측면에서 문제가 될 수 있기 때문이다.

EventManager 클래스와 구조 설계

이벤트 위임을 위해 EventManager 클래스를 만들어, 이벤트 바인딩과 해제를 전역적으로 관리하도록 했다. 이 클래스는 크게 두 가지 역할을 수행한다:

  • 어떤 DOM 요소가 어떤 이벤트를 가지고 있는지 기억하기 위해 Map<HTMLElement, Function> 구조를 사용
  • 루트 엘리먼트에 어떤 이벤트 타입이 이미 바인딩됐는지 추적하기 위해 WeakMap<HTMLElement, Set<string>> 사용

이렇게 하면 동일한 루트에 동일한 이벤트가 중복 등록되는 걸 막을 수 있고, 루트 엘리먼트가 제거될 경우 WeakMap을 통해 자동으로 GC 대상이 되므로 메모리 누수도 예방할 수 있다.

setupEventListeners는 매 렌더링마다 호출되지만, 내부적으로 한 번만 이벤트를 바인딩하도록 처리되어 있어 비용이 최소화된다.
실제로 각 VNode에 정의된 핸들러는 addEventremoveEvent를 통해 관리되며, 위임 구조와 자연스럽게 연결된다.

EventManager 코드

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 업데이트

컴포넌트가 리렌더링되었을 때, 실제 DOM을 얼마나 효율적으로 갱신하느냐는 Virtual DOM 구현에서 가장 중요한 포인트 중 하나다. 이번 과제에서는 이를 담당하는 updateElement 함수를 중심으로 DOM 동기화 로직을 구현했다.

최소 변경만 반영하는 updateElement

updateElement는 이전 VNode와 새로운 VNode를 비교해 최소한의 변경만 실제 DOM에 반영하는 핵심 함수다.

노드의 타입이 다르면 즉시 교체하고, 같을 경우에는 propschildren을 비교해 바뀐 부분만 실제 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의 변경 여부를 어떻게 판단하고 반영할 것인가였다.
특히 이벤트 핸들러와 일반 속성은 적용 방식이 다르기 때문에, 다음과 같이 별도의 헬퍼 함수로 분리해 처리했다.

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 속성 간의 최소한의 차이만 반영할 수 있었고, 불필요한 속성 설정이나 이벤트 재등록 없이 효율적인 업데이트가 가능해졌다.

Virtual 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);
}

핸들러가 달라졌다고 판단되면 무조건 removeadd를 반복하는 구조였고, 결국 계속된 핸들러 재등록이 상태 변화를 유발해 무한 루프가 생겼던 것이다.

이 문제를 해결하기 위해 로직을 아래처럼 분리했다:

// 핸들러 변경 시에도 remove 없이 등록만 수행
if (oldValue !== newValue) {
  addEvent(target, eventType, newValue); 
}
// 아예 props에서 사라졌을 때만 제거
if (!(originNewProps && key in originNewProps)) {
  removeEvent(target, eventType, oldValue); 
}

이처럼 등록과 제거의 책임을 분리하자, 불필요한 제거-재등록 루프가 사라지고 안정적인 렌더링 흐름을 유지할 수 있었다.
렌더링 시점마다 바뀌는 함수 참조로 인해 상태 변경이 반복되던 문제가, 단순한 조건 분기로 해결되었다는 점이 인상 깊었다.

Typescript 마이그레이션

이전까지는 JavaScript로 구조를 잡아왔지만, virtual DOM의 흐름과 각 함수 간의 데이터 전달 구조를 더 깊이 이해하고 싶어서 TypeScript로 직접 마이그레이션을 시도했다.
단순히 타입 안정성을 높이려는 목적이라기보다는, 코드의 형태를 더 분명하게 설계하고 싶었던 게 크다.

JSX 설정과 타입 선언

React 환경이 아닌 상태에서 TSX를 사용하기 위해서는 몇 가지 설정이 필요했다. 먼저 tsconfig.json에서 jsxFactorycreateVNode로 설정하고, 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 등 내부 타입들을 설계해나가는 과정이었다.

예를 들어 VNodestring, number, null, Component, VNode[] 등 다양한 형태를 포괄해야 했고, 이 과정을 통해 내가 다루고 있는 데이터의 범위와 구조를 훨씬 명확히 인식하게 되었다.

또한 flattenChildren이나 normalizeVNode처럼 입력 타입이 복잡하게 섞이는 함수에서, 타입 선언 덕분에 미리 오류를 감지하고 구조를 정리할 수 있었다.
이전에는 런타임에서야 오류를 확인하곤 했지만, 타입이 추가되면서 컴파일 단계에서 잘못된 흐름을 빠르게 파악할 수 있었고, 그만큼 디버깅 시간도 줄일 수 있었다.

VNode 타입 정의

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 업데이트를 위한 비교 로직, 이벤트 위임까지—익숙하게 써왔던 개념들을 직접 구현하며 그 이면의 구조를 이해할 수 있었다.

특히 렌더링 흐름과 이벤트 핸들링 사이의 관계를 고민하면서, 사소해 보이는 코드 하나가 전체 앱의 동작에 어떤 영향을 줄 수 있는지를 체감했다.
타입스크립트로 전환하면서는 각 데이터의 흐름과 책임을 더 명확히 구분할 수 있었고, 구조적인 측면에서도 관심사를 어떻게 나누고 연결할지에 대한 고민을 깊이 해볼 수 있었다.

이미 알고 있다고 생각했던 개념들을 직접 구현하며, 그 동작 원리를 더 구체적으로 이해할 수 있었던 시간이었다.

과제 결과 및 코드

profile
세상에 못할 일은 없어!

1개의 댓글

comment-user-thumbnail
2025년 4월 7일

BP 대장 한별님 이번주 회고도 기깔나네요..한수 배워갑니다.

답글 달기