본문 바로가기

Web

[Web] 여러 방식으로 DOM 조작하기 - createElement 에서 JSX 까지

이 글은 순수 Vanilla JS 만으로 DOM 을 직접 다뤄야 했던 경험에서 시작해,
이후 React의 JSX 구조를 이해하게 되기까지의 학습 흐름을 정리한 기록이다.


직접 DOM 을 조립하며 느꼈던 불편함과 시행착오,
그리고 그 과정에서 선언적인 UI 방식으로 사고가 어떻게 확장되었는지를 시간의 흐름에 따라 작성했다.


따라서 특정 기술을 소개하거나 정답을 제시하려는 목적이라기보다,
createElement 중심의 명령형 코드 에서 시작해 선언적 UI 방식 에 도달하기까지의 고민 과정을 순서대로 담은 글임을 먼저 알린다.

JS 기본 DOM API

document.createElement()

React 없이 순수 Vanilla JS 로 FE 개발을 할 때, 이런 코드를 작성했었다.

const header = document.createElement("div");
header.textContent = "Hello";
document.getElementById("app").appendChild(header);

DOM API를 직접 호출해서 요소를 만들고, 속성을 붙이고, 자식을 넣고, 원하는 위치에 삽입한다.
이런 방식은 화면에 요소 하나를 올리기 위해 어떻게 해야 하는지를 매 줄마다 지시하는 명령형 방식이라고 할 수 있다.


간단한 페이지라면 이 정도로 충분했지만,
컴포넌트가 늘어나고, 상태에 따라 UI가 바뀌어야 하는 순간부터 불편함이 느껴지기 시작했다.


const card = document.createElement("div");

const title = document.createElement("h3");
title.textContent = "Post Title";

const button = document.createElement("button");
button.appendChild(document.createTextNode("Like"));

card.appendChild(title);
card.appendChild(button);

document.getElementById("app").appendChild(card);

코드가 길어질수록 이 코드가 결국 화면에 어떤 모양을 그리는 건지 파악하기가 어려워졌다.
만들고, 속성 붙이고, 자식 넣고, 또 만들고... DOM 구조가 코드의 실행 순서에 묻혀 보이지 않았다.


결국 레이아웃의 계층 구조는 한눈에 들어오지 않았고, 어떤 요소가 무엇을 감싸고 있는지 매번 머릿속에서 다시 조립해야 했다.


클래스 기반 컴포넌트

이를 해결하기 위해 처음 떠올린 것이 클래스 기반 컴포넌트였다.


Java 기반 OOP에 익숙했던 나는, 흩어져 있던 DOM 생성 로직을 정리하기 위해 UI 컴포넌트도 클래스로 구조화해보려 했다.
공통 로직을 담은 Base Component 를 만들고, 구체적인 컴포넌트가 이를 extends 하는 방식이다.


클래스 내부에는 render() 메서드를 두어 화면을 그리는 책임을 한 곳에 모으고, 컴포넌트마다 역할을 분리하려는 시도였다.

상속을 통해 공통 동작을 재사용하고, 각 컴포넌트는 자신이 어떤 UI를 반환하는지만 정의하도록 만들고 싶었다.

class Component {
  constructor(props) { this.props = props; }
  render() { throw new Error("render() must be implemented"); }
}

class Card extends Component {
  render() { return document.createElement("div"); }
}

class Title extends Component {
  render() { return document.createElement("h3"); }
}

class ActionButton extends Component {
  render() { return document.createElement("button"); }
}

이 구조를 이용해 PostCard 를 구성하면 다음과 같은 형태가 된다.

class PostCard extends Component {
  render() {
    const card = new Card().render();
    card.className = "card";

    const title = new Title({ text: "Post Title" }).render();
    card.appendChild(title);

    const button = new ActionButton({ text: "Like" }).render();
    card.appendChild(button);

    return card;
  }
}

클래스로 컴포넌트를 나누면서 책임은 분리되었고, DOM 생성 로직도 어느 정도 정리된 것처럼 보였다.
컴포넌트마다 render() 만 구현하면 된다는 규칙이 생기면서 구조는 이전보다 분명해졌다.


클래스 구조만으로는 힘든 레이아웃 표현

하지만 결국 내부에서는 createElementappendChild 같은 DOM API 를 사용해야 했다.

클래스로 컴포넌트를 나누었음에도, UI의 계층 구조는 코드에 직접 드러나지 않았다.


겉보기에는 컴포넌트가 분리된 것처럼 보였지만,
레이아웃의 형태는 여전히 코드의 실행 순서를 따라가야만 이해할 수 있었다.


즉, 클래스 기반 구조는 코드를 정리하는 데에는 도움이 되었지만,
UI의 구조 자체를 표현하는 방식으로는 충분하지 않다는 고질적인 문제가 남아 있었다.

render() {
    const card = new Card().render();
    card.className = "card";

    const title = new Title({ text: "Post Title" }).render();
    card.appendChild(title);

    const button = new ActionButton({ text: "Like" }).render();
    card.appendChild(button);

    return card;
}

render() 만 보아서는, 이 컴포넌트가 어떤 레이아웃을 그리는지 한눈에 파악하기 어렵다.

선언적 방식으로의 전환

당시 Vanilla JS로 DOM을 직접 조작하는 경험은 처음이었지만,
이전에 React 를 조금 사용해 본 적이 있었기 때문에
React 의 선언적 방식 과 JS DOM API 의 명령형 방식 사이의 괴리를 어느 정도 체감하고 있었다.


명령형 코드로 UI를 조립하다 보니,
레이아웃을 코드에서 그대로 읽을 수 있었던 React의 방식이 자연스럽게 떠올랐다.


Vanilla JS 환경이었지만, React 처럼 코드만 보아도 레이아웃 구조가 바로 드러나는 형태를 만들고 싶었다.


createElementappendChild 를 완전히 없앨 수는 없었지만,
적어도 작성하는 코드에서는 보이지 않도록 내부로 숨기고, UI의 구조만 선언적으로 표현할 수 있도록 만들고자 했다.


그래서 DOM 조립 로직을 감싸는 간단한 헬퍼 함수 Tag 를 만들었다.

export function Tag(tag, props = {}, ...children) {
  const el = document.createElement(tag);

  Object.entries(props).forEach(([k, v]) => {
    if (k === "className") el.className = v;
    else if (k.startsWith("on")) el.addEventListener(k.slice(2).toLowerCase(), v);
    else el.setAttribute(k, String(v));
  });

  children.forEach(child => {
    if (child instanceof Node) el.appendChild(child);
    else el.appendChild(document.createTextNode(String(child)));
  });

  return el;
}

이제 Vanilla JS 에서도 DOM 을 직접 조립하는 대신, 구조를 함수 호출 형태로 표현할 수 있게 된다.

Tag("div", { className: "card" },
  Tag("h3", {}, "Post Title"),
  Tag("button", {}, "Like")
);

더 나아가 자주 사용하는 태그들은 미리 API 형태로 만들어 두기로 했다.

export const buildTag = (tag) => (props = {}, ...children) => Tag(tag, props, ...children);

export const tags = [
  "fragment",
  "h1", "h2", "h3", "h4", "h5", "h6",
  "div", "span", "p", "button", "input", "label", "textarea", 
  "ul", "li", "img", "a", "section", "main", "header", "footer", "form",
  "pre", "code",
].reduce((acc, tag) => { acc[tag] = buildTag(tag); return acc; }, {});

이렇게 태그별 함수를 만들어 두면, 코드는 다음과 같은 형태로 작성할 수 있다.

tags.div({ className: "card" },
  tags.h3({}, "Post Title"),
  tags.button({}, "Like")
);

위 코드는 다음과 같은 HTML 구조를 그대로 드러낸다.

<div class="card">
  <h3>Post Title</h3>
  <button>Like</button>
</div>

이제 위 코드 역시 하나의 함수 내부에서 반환하도록 만들 수 있다.

function PostCard() {
  return tags.div({ className: "card" },
    tags.h3({}, "Post Title"),
    tags.button({}, "Like")
  );
}

이렇게 작성하면 특정 UI 조각을 하나의 함수로 묶어 재사용할 수 있고, 이 함수 자체가 작은 단위의 컴포넌트처럼 동작하게 된다.


여전히 내부에서는 DOM API가 사용되고 있지만,
작성하는 코드에서는 레이아웃 구조만 드러나기 때문에
이전보다 훨씬 선언적인 형태로 UI를 표현할 수 있었다.


function PostList() {
  return tags.section({ className: "post-list" },
    PostCard(),
    PostCard(),
    PostCard(),
  );
}

그리고 PostCard 역시 결국 하나의 함수이기 때문에,
다른 함수 안에서 그대로 호출해 구조를 확장할 수 있었다.

React

어찌저찌 스포가 되었지만, React 에서도 비슷한 방식을 사용한다.

function PostCard() {
  return (
    <div className="card">
      <h3>Post Title</h3>
      <button>Like</button>
    </div>
  );
}

function PostList() {
  return (
    <section className="post-list">
      <PostCard />
      <PostCard />
      <PostCard />
    </section>
  );
}

결국 <div>, <PostCard /> 같은 표현도 내부적으로는 함수 호출과 깊은 연관이 있다는 점을 깨닫게 되었다.


한 가지 궁금했던 점은,
도대체 React 는 어떻게 함수 호출을 <> 태그 형태의 태그 문법으로 표현할 수 있는지였고, 조금 더 학습해보았다.


사실 이것은 브라우저가 이해할 수 없는 JSX(JavaScript XML) 라는 확장 문법을 지원하기 때문이었다.



JSX 는 브라우저에서 직접 실행되는 문법이 아니라, 빌드 과정에서 일반적인 JS 코드로 변환된다.

그때 React를 사용할 때 .jsx 같은 확장자를 쓰던 이유도,
결국 JSX 문법을 컴파일하기 위한 도구들이 필요했기 때문이라는 걸 뒤늦게 이해하게 되었다.



즉, React는 JSX를 그대로 실행하는 것이 아니라, 빌드 과정에서 함수 호출 형태의 JS 코드로 변환한 뒤
그 결과로 만들어진 Virtual DOM 을 기반으로 실제 DOM 을 업데이트하는 방식으로 동작함을 알 수 있었다.

빌드 파일 확인

나는 Vite로 프로젝트를 빌드하고 있었고,
JSX가 실제로 어떻게 변환되는지 궁금해서 빌드된 파일을 직접 열어본 적이 있다.


JSX로 작성했던 컴포넌트 호출은 그대로 남아 있는 것이 아니라, 정말 다음과 같이 함수 호출 형태로 변환되어 있었다.

var jsxRuntimeExports = requireJsxRuntime();

function PostCard() {
  return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "card", children: [
    /* @__PURE__ */ jsxRuntimeExports.jsx("h3", { children: "Post Title" }),
    /* @__PURE__ */ jsxRuntimeExports.jsx("button", { children: "Like" })
  ] });
}

여기서 jsxRuntimeExports.jsx, jsxRuntimeExports.jsxs 함수의 형태가 내가 만들었던 Tag 함수와 꽤 비슷해서 신기했다.


조금 더 학습해보니,
jsxRuntimeExports.jsx, jsxRuntimeExports.jsxs 는 실제 DOM을 생성하는 함수가 아니라
type, props, children 정보를 담은 React Element 객체를 만들어내는 함수였다.


겉으로 보이는 구조는 내가 만들었던 Tag 와 비슷했지만,
React 는 즉시 DOM 을 만들지 않고 Virtual DOM 객체를 먼저 생성한 뒤
이후 단계에서 실제 DOM 을 업데이트한다는 점에서 차이가 있었다.

Virtual DOM

Virtual DOM 은 또 무엇일까?

Virtual DOM 은 브라우저의 실제 DOM 을 그대로 복사한 것이 아니라,
현재 UI 구조를 JS 객체 형태로 표현해 둔 가상의 트리이다.

React 에서 JSX 를 작성하면 바로 DOM 이 만들어지는 것이 아니라,
먼저 _jsx, _jsxs 같은 함수 호출을 통해 React Element 객체가 생성되고,
이 객체들이 모여 Virtual DOM 구조를 이루게 된다.


즉, 화면을 직접 그리는 것이 아니라 지금 UI 가 어떤 구조인지를 먼저 데이터 형태로 만들어 두는 과정이라고 볼 수 있다.


Vanilla JS 에서 appendChild, removeChild 같은 DOM API 를 직접 호출하면
브라우저는 레이아웃 계산과 페인팅 과정을 다시 수행해야 한다.

DOM 변경은 생각보다 비용이 큰 작업이다.


React 는 이 과정을 조금 다른 방식으로 처리한다고 한다.


  1. 상태가 바뀌면 새로운 Virtual DOM 을 만든다.
  2. 이전 Virtual DOM 과 비교한다.
  3. 실제로 달라진 부분만 Real DOM 에 반영한다.

즉, DOM을 매번 직접 조작하는 대신, 변경 사항을 먼저 계산한 뒤 필요한 부분만 업데이트 하는 방식이다.

DOM 조작 횟수를 줄여서 불필요한 reflow, repaint 과정을 최소화하기 위함임을 알 수 있었다.

마무리

이번 문서에서는 Vanilla JS 로 DOM 을 직접 다루던 시점부터, React 의 JSX 구조를 이해하게 되기까지의 흐름을 정리해 보았다.


이전에는 단순히 FE 에서는 React 를 많이 사용하기 때문에 특별한 고민 없이 자연스럽게 사용했던 것 같다.

하지만 DOM을 직접 조립해 보고, 클래스로 구조를 나누어 보고, 선언적인 형태를 스스로 만들어 보는 과정을 거치면서
조금씩 UI를 표현하는 방식의 원리를 이해할 수 있었다.

728x90