AI로 Vanilla TypeScript JSX와 싱글톤 리팩토링하기

김가희·2026년 3월 18일

예전에 만들다가 중간에 멈춘 프로젝트가 하나 있었다. 이번에 다시 개발을 이어가려고 보니, 바로 다음 기능을 추가하기보다 먼저 기존 코드를 정리하는 게 맞겠다는 생각이 들었다.
이번에는 혼자 감으로 고치기보다, AI와 질답을 주고받으면서 왜 이 구조가 아쉬운지, 어떻게 개선할 수 있는지를 짚어 가며 리팩토링해 보고 싶었다.


1. 싱글톤

프레임워크에서 라우터는 앱 전체에서 하나만 존재해야 했다.
예전에도 싱글톤처럼 보이게 만들긴 했지만, 다시 보니 설계가 꽤 허술했다.

Before

// src/core/Router.ts
export class Router {
  private static instance: Router;
  routes: any = {};

  constructor() {
    window.addEventListener('popstate', this.resolve.bind(this));
  }

  public static getInstance(): Router {
    if (!Router.instance) {
      Router.instance = new Router();
    }
    return Router.instance;
  }

  resolve() {
    // 라우팅 처리
  }
}

겉으로 보면 getInstance()가 있으니 싱글톤처럼 보인다.
그런데 실제로는 문제가 있다.

문제점 1. constructor가 열려 있다

constructor()가 public 상태라서 외부에서 그냥 new Router()를 할 수 있다.
즉, “싱글톤처럼 쓰자”는 약속은 있지만, 언어 차원에서 그 약속을 강제하지 못한다.

문제점 2. routes가 너무 느슨하다

routes: any = {} 는 타입 정보가 지나치게 느슨하고, 외부에서 직접 수정할 수도 있다.
라우팅 테이블은 라우터의 핵심 내부 상태인데, 이걸 마음대로 수정할 수 있게 두는 건 설계상 불안하다.


After

// src/core/Router.ts
import { Component } from './Component';

export class Router {
  private static instance: Router;
  private routes: Record<string, typeof Component> = {};

  private constructor() {
    window.addEventListener('popstate', this.resolve.bind(this));
  }

  public static getInstance(): Router {
    if (!Router.instance) {
      Router.instance = new Router();
    }
    return Router.instance;
  }

  public add(path: string, page: typeof Component) {
    this.routes[path] = page;
  }

  public resolve() {
    // 라우팅 처리
  }
}

바뀐 점 1. private constructor

이제 외부에서 new Router()를 할 수 없다.

const router = new Router(); // 에러

이렇게 막아야 진짜 싱글톤에 가깝다. 싱글톤은 단순히 “한 개만 쓰는 패턴”이 아니라, 더 정확히는 “한 개만 생성될 수 있도록 제한하는 설계”라고 보는 편이 맞다.

바뀐 점 2. routes 캡슐화

routes를 private으로 감쌌다. 이제 외부에서는 add() 같은 명시적인 API를 통해서만 라우팅 정보를 등록할 수 있다.
이렇게 하면 라우터 내부 상태의 무결성을 지키기 쉬워진다.

바뀐 점 3. 타입 명확화

Record<string, typeof Component>로 어떤 값이 들어와야 하는지 분명하게 했다.
예전에는 일단 아무거나 넣을 수 있었고, 런타임에서야 문제가 드러날 수 있었다.
지금은 TypeScript가 미리 막아준다.


이번에 다시 느낀 건, “작동하는 코드”와 “의도가 드러나는 코드”는 다르다는 점이다.
예전 코드도 동작은 했다. 하지만 지금 구조는 “이 클래스는 이런 방식으로만 써야 한다”는 규칙을 설계에 녹여 둔다. 이 차이는 생각보다 크다. 특히 프레임워크 코어처럼 여러 곳에서 재사용되는 코드는, 편의보다 제약이 더 중요할 때가 많다.



2. JSX 렌더링 방식

이번에 더 크게 손본 부분은 JSX 렌더링 방식이었다.
예전에는 JSX 비슷한 문법을 태그드 템플릿으로 처리한 뒤, 문자열을 합쳐 innerHTML로 실제 DOM을 바로 만들어내는 방식을 썼다. 처음 구현할 때는 이 방식이 꽤 직관적이었다. 하지만 상태 변경과 UI 업데이트까지 고려하기 시작하면 금방 한계가 드러난다.

Before: 문자열 → innerHTML → 실제 DOM

const TEMP = {
  PREFIX: 'tempindex:',
  REGEX: /tempindex:(\d+):/,
  SEPARATOR_REGEX_G: /(tempindex:\d+:)/g,
};

type JsxArg = Node | string | null | boolean | JsxArg[];

function bindEventHandler(name: string, value: any, element: Element) {
  if (typeof value === 'function') {
    element.addEventListener(name.replace('on', '').toLowerCase(), value);
    element.removeAttribute(name);
  }
}

function createTextFragment(str?: string): DocumentFragment {
  const fragment = document.createDocumentFragment();
  if (str) fragment.appendChild(document.createTextNode(str));
  return fragment;
}

function convertJsxArgToNode(arg: JsxArg): DocumentFragment {
  const fragment = document.createDocumentFragment();

  if (arg instanceof Node) {
    fragment.appendChild(arg);
  } else if (Array.isArray(arg)) {
    arg.forEach((item) => {
      if (item instanceof Node) {
        fragment.appendChild(item);
      } else {
        const container = document.createElement('div');
        container.innerHTML = item as string;
        while (container.firstChild) {
          fragment.appendChild(container.firstChild);
        }
      }
    });
  } else if (arg !== null && arg !== false) {
    fragment.appendChild(createTextFragment(String(arg)));
  }

  return fragment;
}

const jsx = (strings: TemplateStringsArray, ...args: any[]): Element => {
  const template = document.createElement('div');

  template.innerHTML = strings
    .map((str, i) => `${str}${i < args.length ? `${TEMP.PREFIX}${i}:` : ''}`)
    .join('');

  const walker = document.createNodeIterator(template, NodeFilter.SHOW_ALL);
  let node: Node | null;

  while ((node = walker.nextNode())) {
    if (node.nodeType === Node.TEXT_NODE) {
      // 텍스트 치환
    } else if (node.nodeType === Node.ELEMENT_NODE) {
      const element = node as Element;
      Array.from(element.attributes).forEach(({ name, value }) => {
        if (value.includes(TEMP.PREFIX)) {
          const match = TEMP.REGEX.exec(value);
          if (match) {
            bindEventHandler(name, args[parseInt(match[1], 10)], element);
          }
        }
      });
    }
  }

  return template;
};

export default jsx;

이 방식은 처음엔 꽤 그럴듯하다. 문자열 템플릿 안에 동적 값을 끼워 넣고, 결과를 DOM으로 바꾸면 되니까.

하지만 구조적으로 몇 가지 문제가 있다.

문제점 1. UI의 중간 표현이 없다

문자열에서 바로 실제 DOM으로 가버린다. 즉, “현재 UI를 표현하는 가벼운 데이터 구조”가 없다.
그래서 이전 상태와 이후 상태를 비교하기 어렵다. 결국 변화가 생기면 실제 DOM을 통째로 다시 만들 가능성이 커진다.

문제점 2. innerHTML 의존

innerHTML은 편하지만 무겁다. 실제로는 브라우저가 DOM 파서를 돌리고, 엘리먼트를 생성하고, 다시 이벤트를 연결하는 과정을 거쳐야 한다. 렌더링 엔진을 공부하는 입장에서는 이 과정을 더 잘게 쪼개서 이해할 필요가 있었다.

문제점 3. 구조보다 결과물에 치우쳐 있다

이 방식은 최종 DOM을 빠르게 만드는 데는 편하지만, UI를 비교 가능한 구조로 보관하지 않기 때문에 나중에 상태가 바뀌었을 때 어느 부분만 갱신해야 하는지 판단하기 어렵다.


After

그래서 이번에는 중간 단계로 VNode(Virtual Node)를 두는 방식으로 바꾸기 시작했다.

2-1. VNode 정의

먼저 UI를 표현할 최소 단위를 타입으로 정의했다.

export type VNodeType = string | Function;

export interface VNode {
  type: VNodeType;
  props: Record<string, any>;
  children: VNodeChild[];
}

export type VNodeChild =
  | VNode
  | string
  | number
  | boolean
  | null
  | undefined;
  • type: 어떤 태그인지, 혹은 어떤 컴포넌트인지
  • props: 속성, 이벤트 핸들러 등
  • children: 자식 노드들

예를 들어 아래 구조가 있다고 하자.

<div id="app">
  <h1>Hello</h1>
</div>

이건 대략 이런 객체로 표현된다.

{
  type: 'div',
  props: { id: 'app' },
  children: [
    {
      type: 'h1',
      props: {},
      children: ['Hello']
    }
  ]
}

이 객체는 실제 DOM이 아니다. 그냥 UI 구조를 묘사한 데이터다. 이게 Virtual DOM의 출발점이다.

2-2. h 함수: 설계도 생성기

이제 매번 VNode 객체를 손으로 만들지 않도록 h 함수를 만든다.

export function h(
  type: VNodeType,
  props: Record<string, any> | null,
  ...children: VNodeChild[]
): VNode {
  return {
    type,
    props: props || {},
    children: children
      .flat()
      .filter((child) => child !== null && child !== undefined && child !== false),
  };
}

이 함수는 말 그대로 VNode 공장이다.

예를 들어 이렇게 호출하면,

const node = h(
  'div',
  { id: 'app' },
  h('h1', null, 'Hello'),
  h('p', null, 'Virtual DOM')
);

실제 DOM이 생기는 게 아니라, 메모리 안에 VNode 트리가 만들어진다.
예전 방식은 문자열을 받아 바로 실제 DOM을 만들었다면, 이제는 먼저 비교 가능한 데이터 구조를 만든다.

2-3. createDOM: 설계도를 실제 DOM으로 변환

설계도만 있다고 화면에 보이는 건 아니다.
이 VNode를 실제 DOM으로 바꾸는 함수도 필요하다.

export function createDOM(vNode: VNodeChild): Node {
  if (typeof vNode === 'string' || typeof vNode === 'number') {
    return document.createTextNode(String(vNode));
  }

  if (vNode === null || vNode === undefined || typeof vNode === 'boolean') {
    return document.createTextNode('');
  }

  if (typeof vNode.type === 'string') {
    const $el = document.createElement(vNode.type);

    Object.entries(vNode.props).forEach(([key, value]) => {
      if (key.startsWith('on') && typeof value === 'function') {
        const eventName = key.toLowerCase().substring(2);
        $el.addEventListener(eventName, value);
      } else {
        $el.setAttribute(key, value);
      }
    });

    vNode.children.forEach((child) => {
      $el.appendChild(createDOM(child));
    });

    return $el;
  }

  return document.createTextNode('');
}

이 함수에서 핵심은 재귀다.

  • 텍스트면 텍스트 노드를 만든다
  • 태그면 엘리먼트를 만든다
  • props를 적용한다
  • children에 대해 다시 같은 작업을 반복한다

즉, 트리 형태의 데이터를 다시 트리 형태의 DOM으로 풀어내는 함수다.

const appVNode = h(
  'div',
  { class: 'container' },
  h('h1', null, '가상 돔 실험'),
  h('button', { onClick: () => console.log('clicked') }, '클릭')
);

const $app = createDOM(appVNode);
document.getElementById('root')?.appendChild($app);

이 코드는 다음 순서로 생각하면 이해가 쉽다.

  • h(...)가 먼저 설계도를 만든다
  • createDOM(...)이 설계도를 실제 DOM으로 바꾼다
  • 최종 결과를 root에 붙인다

이제 렌더링은 문자열을 DOM으로 바로 만들기가 아니라, VNode 생성 → DOM 변환이라는 두 단계 구조가 된다.



3. 왜 이 변화가 중요한가

아직 diffing까지 구현한 단계는 아니다.
하지만 예전에는 문자열 → 실제 DOM이었다면, 이제는 상태 → VNode → 실제 DOM이라는 흐름을 갖게 됐다. 이렇게 중간에 VNode가 들어오면 다음 단계가 가능해진다.

  • 이전 VNode와 새 VNode 비교
  • 바뀐 부분만 실제 DOM에 반영
  • 전역 상태가 바뀌어도 전체를 갈아엎지 않고 부분 업데이트

즉, Virtual DOM은 그 자체가 목적이라기보다 업데이트 전략을 정교하게 가져가기 위한 기반이다.



4. 이번 작업에서 제일 중요했던 건 “AI 사용 방식”이었다

사실 이번 글에서 가장 쓰고 싶었던 건 이 부분이다.
나는 이번에 Gemini CLI를 쓰면서, 일부러 “뚝딱 다 고쳐 달라”고 하지 않았다.

대신 아래처럼 질답 중심으로 진행했다.

  • 지금 구조의 문제를 먼저 분석해 달라고 요청
  • 왜 이 방식이 문제인지 설명받기
  • “그럼 VNode에는 뭐가 들어가야 하지?”처럼 내가 먼저 답해보기
  • 모르면 솔직하게 모르겠다고 말하기
  • 힌트를 받아서 개념을 하나씩 연결하기
  • 마지막에 필요한 최소 코드만 반영하기

AI를 코드 생성기로만 쓰면 결과물은 빨리 나온다.
하지만 AI를 질문받는 튜터처럼 쓰면, 결과물보다 더 중요한 사고 과정이 남는다. 이번 리팩토링에서 내가 얻고 싶었던 것도 바로 그쪽에 가까웠다.


2024년에 이 프로젝트를 처음 진행할 때보다 지금은 DOM에 대한 이해도가 조금은 높아져 있어서인지, 이번에는 예전보다 코드가 더 이해되는 느낌이 들었다. 그때는 일단 동작하게 만드는 데 집중했다면, 이번에는 렌더링 흐름과 구조적인 한계까지 같이 보였다. 그래서 이번 리팩토링은 단순히 코드를 고치는 작업이라기보다, 예전에 만들었던 구조를 지금의 시선으로 다시 이해해 보는 과정에 더 가까웠다. 다음 단계에서는 여기서 만든 VNode 구조를 바탕으로 diffing과 상태 업데이트까지 직접 이어가 보려고 한다.

0개의 댓글