teklog

HTML 트리와 DOM api

2025/02/25

n°60

category : JavaScript

TLDR

  1. HTML는 정적 트리이고 DOM api는 동적 트리이다.

  2. 문제는 구조에서 오는 복잡성 : 참조 무결성, 실행 순서

  3. 해결은 복잡성을 줄이기 위한 전략


전제

리액트와 같은 프론트엔드 라이브러리나 프레임워크는 왜 사용하나요?

면접에서 이런 질문을 받았다. 난감한 기분이 드는 걸 보니, 그동안 너무 당연하게만 생각했던 것 같다. 현대 웹 애플리케이션의 복잡한 UX를 구현하는데에 기존 DOM api가 적합하지 않기 때문이라고 답했다. 성능 최적화, 코드 복잡성 등을 얘기했지만 핵심을 놓쳤다는 느낌이었다.

DOM API는 왜 복잡성을 증가시키는가?

최근 (다시) 공부 중인 자료 구조를 기반으로 HTML, DOM api를 이해하고, 그 질문에 다시 답해본다.


HTML 트리와 DOM API 탐색/조작

HTML

HTML의 특징은 다음과 같다.

  • HTML 자료 구조: 그래프 > 비순환 방향 그래프 (DAG) 방향 (루트에서 노드로) > 트리 > 계층적 정적 트리

  • 서버에서 전달된 HTML가 로드되면 브라우저가 이를 읽고 해석한다.

  • 브라우저에서는 HTML를 바탕으로 DOM 트리, CSSOM 트리를 구성한다.

  • 이렇게 구성된 두 트리를 결합하여 리플로우와 리페인팅으로 HTML 렌더링이 이루어진다.

  • 그러나 HTML 자체는 변경이 없다. DOM 조작이 일어나지 않는 한.

  • 서버가 전송한 HTML은 노드 관계를 정의한 그래프(트리)이다.

  • 이 트리는 계층적으로 구성되며, 루트 노드<html>이다.

  • 서버에 저장된 원본 html의 트리는 루트와 리프까지 계층 관계는 변하지 않는다. 즉 정적이다.

<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>DOM 조작 예제</title>
    <script defer src="./script.js"></script>
</head>
<body>
<div id="container">
    <button id="changeText">텍스트 변경</button>
    <p class="text">기본 텍스트</p>
</div>
</body>
</html>

DOM API

DOM 트리는 브라우저가 구성한다. JS는 DOM 트리에 접근하여 변경할 수 있는 인터페이스인 DOM api를 제공한다. document 일급 객체를 통해.

DOM 트리 : 브라우저가 HTML을 파싱하여 구성한 노드의 트리 구조

DOM api (document) : 브라우저가 구성한 DOM 트리를 조회 및 변경하기 위한 인터페이스

  • DOM 객체 자료 구조: 그래프 > 양방향 그래프 > 동적 트리 > 계층적 동적 트리

  • JS로 HTML을 조회하고, 조작하기 위한 Web API이다.

  • 서버에서 HTML이 브라우저로 전달되면 이를 바탕으로 document 전역객체를 만든다.

  • document는 HTML 노드(태그)의 계층 그래프를 객체 형태로 표현한다.

  • **HTML과 달리, DOM api에서 루트 노드는 document이다.

  • 서버에 저장된 원본 HTML과 브라우저의 DOM 트리는 DOM 트리 변경 시 일치하지 않는다.

브라우저에서 실행된 스크립트로 브라우저가 구성한 DOM 트리가 변경된 것이니, 어떻게 보면 당연하다. 하지만 서버사이드 렌더링, 서버 컴포넌트 분리 등 서버와 브라우저의 분리된 실행 환경을 고려하지 않을 수 없다.

  • DOM 트리 생성 과정은 다음과 같다.

    1. 브라우저는 HTML의 문자를 파싱한다

    2. 토큰으로 만든다 (토크나이즈)

    3. 토큰으로 document 객체를 생성

    4. 브라우저에서 실행되는 JS 스크립트는 브라우저가 생성한 document 객체에 접근할 수 있다.

  • DOM 탐색: dom은 HTML 노드를 탐색 및 조회할 수 있는 기능을 제공한다.

  • DOM 조작: 탐색으로 가져온 노드의 HTML attribute을 재할당하여 HTML을 변경한다.

  • document는 전역 객체이므로, 자식 노드에서 부모 노드에 접근할 수 있고, 변경할 수 있다.

  • DOM에서 루트 노드는 document이며, html 태그는 그 하위 요소이다.

// 버튼 클릭 시 텍스트 변경 
const button = document.getElementById("changeText");

button.addEventListener("click", () => {
    const text = document.querySelector(".text");
    text.textContent = "텍스트가 변경되었습니다!";
});

DOM 인터페이스의 원리

노드 조회와 변경

  • document 객체를 통해 HTML 요소를 탐색하고 조작할 수 있다.

  • document의 메서드로 html에 접근할 수 있다. (getElementById, getElementById, querySelector, querySelectorAll, getElementsByClassName, getElementsByTagName 등)

  • 탐색한 html 태그에 새로운 데이터를 재할당하여 DOM 트리를 변경할 수 있다.

  • 속성이나 내용이 변경되면, 브라우저는 즉시 이를 감지한다.

  • 브라우저는 이를 바탕으로 DOM 트리를 재구성하고 CSSOM과 결합하여 렌더링 과정(리플로우, 리페인트)을 수행한다.

  • DOM 트리가 변경되는 시점은 함수 실행 시점에 따라 다를 수 있다.

    • 초기 스크립트 실행, 이벤트, api 요청, 타이머 등 다른 시점에 DOM 트리가 변경될 수 있다.

여기서 든 의문은 두가지이다.

  • document 객체의 루트 노드는 document 객체 자체인데, HTML의 루트는 <html>이다. 그럼 document와 HTML는 다른 구조의 트리가 아닌가?

  • document로 DOM 트리로 변경되면 변경 이전에 데이터는 어떻게 되지?

복잡성 증가의 원인

결론: HTML 트리는 계층적 정적 트리로 부모-자식 관계가 고정되지만, DOM API는 이를 변경 가능한 그래프로 다룬다. 이로 인해 코드 복잡성이 증가한다. 문제는 document의 구조와 사용법에서 온다.

1. 참조 무결성

  • 첫 번째 원인은 DOM api는 HTML의 정적 트리를 변경 가능한(mutable) 그래프로 구성하기 때문이다.

  • DOM 트리는 트리 구조를 가지지만, documentapi는 양방향 그래프이며 부모와 자식이 서로 참조할 수 있다.

    • 예를 들어, 자식 노드에서 일어나는 변경이 부모 노드에도 영향을 줄 수 있다.

  • DOM 트리가 변경되면, 그동안의 변경사항이 기록되지 않아 제거된 노드의 정보가 손실될 수 있다.

  • 제거된 노드를 복원하는 것이 어렵고, 삭제된 HTML이 포함한 정보를 보존하기 어렵다.

  • 이러한 문제를 참조 무결성이라고 한다.

  • 참조 무결성을 지키려면 코드 복잡성이 증가한다.

2. 함수 실행 시점

  • 코드 복잡성 증가의 두번째 원인은 함수 실행 시점의 관리이다.

  • 특히 함수가 실행이 완료된 이후에 DOM 조작으로 발생하는 부수효과가 코드 복잡성을 높인다.

    • 생명주기가 끝난 함수 내부 데이터가 필요해지는 경우가 많아 추가적인 로직이 필요해진다.

  • DOM 조작은 트리 계층에 영향을 미쳐, 부모 노드의 변경이 모든 자식 노드에 영향을 미칠 수 있다.

    • 앞선 문제인 참조 무결성을 지키는 추가적인 로직이 필요하다.

  • 이러한 문제들은 HTML을 DOM으로 변환하여 조작할 때 발생하기 쉬운 문제들이다.

  • 코드가 증가할 수록 함수 실행 시점의 관리 복잡성이 증가한다.

근본적 원인은 DOM API와 HTML 구조에 일관성이 부족하기 때문이다. 이에 대한 해결은 1) HTML 계층을 불필요하게 변경하지 않으면서 2) DOM 조작 실행 타이밍을 일관되게 관리할 로직이 필요하다.

  • 노드 격리화: 부모-자식 노드 간 영향이 제한되도록 설계.

  • 함수 생명주기 관리: DOM 조작 이후에도 유지해야 할 데이터를 관리하도록 한다.

    • HTML 계층에 영향을 주지 않으면서, DOM 조작이 가져오는 부수효과를 관리하기 위한 함수와 저장 데이터의 로직을 구성한다.


HTML 트리 효율적으로 관리하기

이러한 문제를 염두한 DOM 조작 JS 스크립트를 작성해보자.

격리화

  • 각 노드 계층을 분명하게 격리하고, 노드 단위로 DOM 조작을 관리한다.

  • DOM 조작의 영향을 부모나 형제 노드에 전달하지 않고, 자식 노드만 변경하도록 설계한다.

  • (리)렌더링이 각 노드에서 일관되게 작동하도록 추상화한다.

DOM api가 일으킬 문제를 방지하기 위해 이러한 전략을 사용할 수 있다. 하지만 한계도 분명하다. 무엇보다 문제 해결에 여전히 많은 코드가 필요하고, 결국엔 복잡성이 증가한다.

한계

결론: 구조적 문제를 해결하려 할수록, 프레임워크나 라이브러리 수준의 추가적인 로직이 필요하게 된다.

DOM api의 구조적 문제는 여전히 많은 작업을 수반한다. 그리고 이는 DOM 직접 조작이 일으키는 성능 최적화와 별개의 문제이다. (실제로는 이미 여러 프레임워크에서 DOM 트리를 직접 조작하여 성능 최적화를 달성하고 있다.)

  • JS 파일을 단위별로 분리하면 의존성 그래프가 추가되어 복잡성이 증가한다.

    • JS 스크립트에 루트 노드 없이 개별 스크립트를 병렬적으로 로드하면 복잡성이 늘어난다.

    • 루트 노드를 설정하여 트리를 구성하여도 추가적인 코드가 필요하다.

      • 노드 간의 의존성이 생기면, 노드 간의 데이터 교환을 위해 추가적인 로직이 필요하다.

    • DOM 노드를 개별 JS 스크립트에서 다룰수록 코드가 분산되어 유지보수가 어려워진다(=응집도가 떨어진다).

  • 루트 JS 파일이 전체적으로 다시 실행되므로 변경된 노드만 업데이트되지 않는다.

  • 계층적으로 분리된 구조에서는 렌더링 로직의 추상화가 필요하다.

  • 초기 렌더링 이후 리렌더링을 트리거하기 위한 일관된 로직이 필요하다.

  • 실행 시점 관리가 완전히 해결되지 않을 수 있다. (부수효과)

    • 이벤트 핸들링 및 API 호출과 같은 부수효과를 최소화하기 어렵다.


구현하기

추상 구현체

한계에도 불구하고, 조금 더 깊은 학습을 위해 구현해보자. DOM 트리 구조로 인해 발생하는 문제를 해결하는 핵심 로직을 만들어보자. 코드 작성에 앞서 추상적인 로직을 정의했다.

DOM API 렌더링 로직: JS 실행 → DOM 변경 → 브라우저에서 HTML 트리 변경 → 리플로우 및 리페인트.

구현 목표

  • 노드 영향 최소화: HTML 계층을 따라 각 노드를 분리 및 격리하여 불필요한 변경 전파를 차단.

  • 최적화된 렌더링: 특정 노드가 변경될 때, 해당 노드만 리페인트·리플로우되도록 렌더링 로직을 추상화하여 성능을 개선.

추상 구현체

graph TD 
A[초기 스크립트 실행] -->|JS 실행| B[DOM API 호출]  
B -->|노드 찾기| C[타겟 노드 식별]  
C -->|변경할 노드 격리| D[독립적 DOM 업데이트]  
D -->|부모 및 형제 노드 영향 없음| E[변경된 노드만 리렌더링]  
E -->|렌더링 엔진 요청| F[리플로우 발생]  
F -->|스타일 & 레이아웃 계산| G[리페인트 수행]  
G -->|화면 갱신| H[최종 렌더링 완료]  
  
D -->|최적화 적용| I[필요한 노드만 업데이트]  
I -->|불필요한 렌더링 방지| H

바닐라 JS로 구현하기

1. 렌더링 로직 추상화

목적: 참조 무결성 유지

자식이 부모 노드에 영향을 주지 않도록, 각 계층을 격리화하고 필요한 범위에만 영향이 가도록 DOM 조작 로직을 작성해보았다.

// 특정 노드의 내용만 변경하거나 추가 및 제거하면서 트리 계층을 관리하는 렌더링 함수
function updateDOM(targetNode, actionCallback) {
  if (!targetNode || typeof actionCallback !== 'function') return;

  const parentNode = targetNode.parentNode;
  if (!parentNode) return;

  const newNode = targetNode.cloneNode(true);
  actionCallback(newNode);

  // 부모 내에서 대상 노드를 추가
  if (!parentNode.contains(targetNode)) {
    parentNode.appendChild(newNode);
    return;
  }
  // 부모 내에서 대상 노드를 교체 또는 제거
  if (newNode) {
    parentNode.replaceChild(newNode, targetNode);
  } else {
    parentNode.removeChild(targetNode);
  }
}

// 예제 사용처 : Before
const container = document.getElementById("app");
const newElement = document.createElement("p");
newElement.textContent = "기존 내용";
container.appendChild(newElement);

// 적용 이후 : After
updateDOM(newElement, (node) => {
  node.textContent = "새로운 콘텐츠 추가됨";
});

// 새로운 노드 추가
updateDOM(document.createElement("span"), (node) => {
  node.textContent = "새로운 요소 추가";
});

// 노드 제거
updateDOM(newElement, () => null);

DOM 조작이 부모-형제 노드의 변경을 방지하는 추상화된 DOM 변경 로직을 작성하였다. 그러나 DOM 조작은 최초 DOM 업데이트 이후에 필요할 때가 많다. 많은 경우 이벤트, API 호출 등으로 노드 내부의 데이터가 변경되면 다시 DOM을 업데이트를 해야한다. 바닐라 JS에서는 노드 데이터의 변경을 감지하고, 변경된 데이터 기반으로 updateDOM을 트리거할 필요가 생긴다. 노드 변경의 격리화를 유지하면서, 이를 추상화한 "상태"를 만들어보자.

(DOM 업데이트: "리렌더링"이라고 익숙하게 여길 수 있지만, 사실 이 개념은 바닐라 JS와 프레임워크에서 약간 다르다. 일반적인 바닐라 JS에서 "리렌더링"은 DOM 트리를 처음부터 재구성하기 때문에, 가상 DOM 트리의 "리렌더링"과 메커니즘이 다르기 때문이다.)

2. 상태 로직 추상화

목적 : 렌더링 실행 시점 관리

상태는 DOM 조작의 실행의 시점을 관리하기 위해 사용한다. 즉 DOM 업데이트를 트리거하는 "변경된 데이터"를 관리하는 로직이다. DOM 업데이트는 보통 새로운 데이터를 기반으로 실행된다. 기존 변수에서 변경이 발생하는 시점을 트리거로 삼을 수 있다. 값에 변경이 생기면, 렌더링(DOM 조작)을 트리거한다. 즉, 상태는 렌더링의 기반이 되는 변수이고, 이 값이 변경되는 시점에 새로운 값으로 "리렌더링"이 필요하다.

클래스 : 구독형 상태 관리

JS 클래스를 사용해 구독형으로 상태를 관리하고, 노드 변경 시 해당 노드만 업데이트 하도록 구현한다. GPT의 도움을 받았으나, 이해가지 않는 부분이 꽤 있어 직접 수정과 주석을 남겼다.

// 상태 관리 클래스: 개별 인스턴스별로 상태를 관리하며, 노드별 상태 분리
// 상태 변경 시 해당 인스턴스에 등록된 노드만 업데이트됨
class StateManager {
  constructor() {
    this.nodeStates = new Map(); // 개별 노드 상태 관리
    this.listeners = new Map(); // 노드별 구독 목록 관리
  }

  // 특정 노드의 상태를 초기화하고, 렌더링 함수 등록
  subscribe(targetNode, initialState, renderCallback) {
    if (!targetNode || typeof renderCallback !== 'function') return;
    this.nodeStates.set(targetNode, initialState);
    this.listeners.set(targetNode, renderCallback);
    this.updateNode(targetNode, initialState); // 초기 상태로 초기 렌더링 실행
  }

  // 특정 노드의 상태 변경 
  setState(targetNode, newState) {
  // 현재 노드 상태에 targetNode가 없다면 DOM 조작을 막음
    if (!this.nodeStates.has(targetNode)) return; 
    const prevState = this.nodeStates.get(targetNode);
    
    const updatedState = { ...prevState, ...newState };
    // 노드의 상태 업데이트
    this.nodeStates.set(targetNode, updatedState);
    // 변경된 상태로 리렌더링
    this.updateNode(targetNode, updatedState);
  }

  // 상태 변경 시 updateDOM을 호출하여 해당 노드만 업데이트
  updateNode(targetNode, state) {
	// 1번째 인자: 렌더링 함수 updateDOM에서 클론할 targetNode
	// 2번째 인자: actionCallback. 클론된 newNode를 인자로 받아 실행할 함수
    updateDOM(targetNode, (node // = 클론된 newNode) => {
      if (this.listeners.has(targetNode)) {
		  // 구독된 상태에 타겟 노드가 있을 때만, 상태를 기반으로 렌더링 수행
        this.listeners.get(targetNode)(node, state);
      }
    });
  }

  // 특정 노드의 구독 해제
  unsubscribe(targetNode) {
    this.nodeStates.delete(targetNode);
    this.listeners.delete(targetNode);
  }
}


// Before
// 버튼 클릭 시 DOM 직접 접근하여 텍스트 변경 
const button = document.getElementById("changeText");

button.addEventListener("click", () => {
    const text = document.querySelector(".text");
    text.textContent = "텍스트가 변경되었습니다!";
});

// After
// StateManager 인스턴스 생성
const stateManager = new StateManager();

// 텍스트 요소 구독 및 초기 상태 설정
// 사실 함수 조차 넘길 필요 없게 추상화할 수 있다.
const textElement = document.querySelector(".text");
stateManager.subscribe(textElement, { content: textElement.textContent }, (node, state) => {
  node.textContent = state.content;
});

// 버튼 클릭 시 상태 변경
const button = document.getElementById("changeText");
button.addEventListener("click", () => {
  stateManager.setState(textElement, { content: "텍스트가 변경되었습니다!" });
});

다음은 StateManager를 HTML에서 사용하는 예시이다.

HTML

<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>StateManager 예제</title>
    <script defer src="./script.js"></script>
</head>
<body>
    <div id="container">
        <input type="text" id="textInput" placeholder="텍스트 입력...">
        <button id="updateText">입력 반영</button>
        <p class="text">기본 텍스트</p>
    </div>
</body>
</html>

JS 스크립트

// ./script.js 
// StateManager 인스턴스 생성 
const stateManager = new StateManager(); 

// 요소 가져오기
const textElement = document.querySelector(".text");
const textInput = document.getElementById("textInput");
const updateButton = document.getElementById("updateText");

// 

 태그의 상태를 구독 및 초기화
stateManager.subscribe(textElement, { content: textElement.textContent }, (node, state) => {
node.textContent = state.content;
});

// 버튼 클릭 시 입력값을 반영하여 상태 업데이트
updateButton.addEventListener("click", () => {
const userInput = textInput.value.trim();
if (userInput) {
stateManager.setState(textElement, { content: userInput });
}
});

함수

함수로도 상태 관리를 구현할 수 있다. 클래스보다 간단하게 구현할 수 있지만, 상태 관리가 복잡해질수록 클래스로 구현하는 것이 유리하다. 위에 살펴본 방식을 구독형이라 한다면, pull 방식으로 간단하게 작성할 수도 있다.

function useState(initialState, targetNode) {
  if (!targetNode) throw new Error("useState: targetNode가 필요합니다.");

  let state = initialState; // 내부 상태 (클로저로 유지)

  

  function setState(newState) {
 
    if (typeof state === "object" && state !== null) {
      Object.assign(state, newState);
    } else {
      state = newState;
    }

    // ✅ 상태 변경 시 자동으로 updateDOM 실행
    updateDOM((node) => {
      if (typeof state === "object" && state.content !== undefined) {
        node.textContent = state.content;
      } else {
        node.textContent = String(state);
      }
    });
  }

  // ✅ 클로저를 활용하여 상태 값을 변수처럼 반환
  return [() => state, setState];
}

// After 적용 예시
// 요소 가져오기
const textElement = document.querySelector(".text");
const textInput = document.getElementById("textInput");
const updateButton = document.getElementById("updateText");

// ✅ useState 호출 (상태 변수처럼 사용)
const [textState, setTextState] = useState({ content: "기본 텍스트" }, textElement);

// 버튼 클릭 시 상태 변경 (자동 리렌더링)
updateButton.addEventListener("click", () => {
  const userInput = textInput.value.trim();
  if (userInput) {
    setTextState({ content: userInput });

    // ✅ 함수 호출 없이 변경된 상태 직접 접근
    console.log("변경된 상태:", textState().content);
  }
});

Proxy, MutationObserver

Immer, Valtio와 같은 여러 상태 라이브러리는 재할당으로 상태를 변경하는 문법을 제공한다. 그럼에도 "상태의 불변성"이 지켜지는 이유는 JS 일급 객체를 기반으로 작동하기 때문이다. 이 객체들을 사용하여 상태를 구현할 수 있다. 이를 기반으로 상태 기능 뿐만 아니라 프론트엔드 프레임워크까지 구성한다.

function createReactiveState(initialState, onUpdate) {
    return new Proxy(initialState, {
        set(target, key, value) {
            target[key] = value;
            onUpdate(target);
            return true;
        }
    });
}

const state = createReactiveState({ content: "기본 텍스트" }, (updatedState) => {
    updateDOM(document.querySelector(".text"), (node) => {
        node.textContent = updatedState.content;
    });
});

// 상태 변경 시 자동으로 updateDOM 실행
setTimeout(() => {
    state.content = "Proxy를 사용한 자동 업데이트";
}, 2000);


const observer = new MutationObserver((mutations) => {
  mutations.forEach(mutation => {
    if (mutation.type === "childList" || mutation.type === "characterData") {
      console.log("DOM 변경 감지:", mutation);
    }
  });
});

const targetNode = document.querySelector(".text");
observer.observe(targetNode, { childList: true, characterData: true, subtree: true });

// 2초 후 강제 변경하여 MutationObserver가 감지하도록 함
setTimeout(() => {
  targetNode.textContent = "MutationObserver 감지됨";
}, 2000);

프레임워크의 접근법

일반적인 프론트엔드 라이브러리와 프레임워크는 DOM에서 파생되는 복잡성 문제를 해결하는데 집중한다. 이들이 각각 어떤 프레임으로 문제 해결에 접근하는 지 간략하게 알아보고, 이번 글을 마무리한다.

React

개념 모델

  • 리액트는 선언적인 UI 관리를 지향한다.

  • 리액트에 맡겨라: 앞서 작성한 렌더링, 상태 추상화 로직은 이미 리액트가 제공한다.

    • 개발자는 JSX와 컴포넌트 기반 아키텍처에 집중하고, 상태 관리와 DOM 조작의 최적화까지 리액트 엔진이 담당한다.

    • 개발자는 리액트 앨리먼트 JSX와 로직 작성, 컴포넌트 구성에만 신경쓰면 된다.

  • 데이터 흐름은 루트에서 리프 노드까지 단방향으로 전달된다.

    • 컴포넌트 단위로 트리 계층에 따라 노드를 격리하고, 의존성을 최소화한다.

    • 형제 및 부모-자식 간의 의존성을 최소화하여 컴포넌트 독립성을 지킨다.

  • 변경 사항이 발생하면 가상 DOM(vDOM)을 사용해 최소한의 업데이트만 반영한다.

  • 예전에는 vDOM을 깊이 우선 탐색(DFS) 방식으로 변경 사항을 찾았지만, 현재는 Fiber 구조를 사용하여 작업 우선순위를 조정하며 최적화한다.

// React의 useState 내부 구현 (간략화된 버전)

// React의 상태 관리 방식 요약:
// 1. useState는 Fiber 노드와 연동된 개별 훅 객체를 관리.
// 2. setState 호출 시, 변경 사항을 감지하고 Fiber 트리의 업데이트를 예약.
// 3. Reconciliation (조정) 과정을 통해 vDOM을 비교하고, 변경된 부분만 DOM에 반영.```

function useState(initialState) {
  let hook = resolveCurrentHook(); // 현재 실행 중인 훅을 참조한다 (Fiber Tree에서 관리)

  if (!hook) {
    hook = {
      state: initialState,
      queue: [], // 동일한 setState 상태 변경을 batch할 때 사용한다
      setState: (action) => {
        hook.state = typeof action === 'function' ? action(hook.state) : action;
        scheduleUpdateOnFiber(); // 변경 감지 후 컴포넌트 업데이트 트리거
      },
    };
    mountHook(hook); // 새로운 훅을 Fiber 트리에 추가한다
  }

  return [hook.state, hook.setState];
}

// Fiber Tree의 관리 시스템 (간략화)
function resolveCurrentHook() {
  return currentFiber?.hook || null; // 현재 Fiber 노드에서 훅 참조
}

function scheduleUpdateOnFiber() {
  // 현재 Fiber의 변경을 감지하고, 최적화된 렌더링을 스케줄링
  requestIdleCallback(() => performWorkOnFiber());
}

function performWorkOnFiber() {
  console.log("Fiber Reconciliation 실행 및 필요한 노드만 업데이트");
  // vDOM 비교 및 필요한 변경 사항 적용 (Reconciliation 과정)
}

scheduleUpdateOnFiber 등 리액트 내부의 자세한 내용은 이전에 작성한 바가 있다.

Vue

개념 모델

  • Vue는 반응형(Reactive) 데이터 바인딩을 핵심으로 한다.

  • data 객체를 선언하면 Vue는 이를 Proxy로 감싸서 반응형으로 만든다.

  • 변경 사항이 감지되면 자동으로 UI를 업데이트한다.

  • 템플릿 기반 선언적 렌더링을 제공하여 HTML 구조와 로직을 자연스럽게 결합한다.

  • 컴포넌트 기반 아키텍처를 활용하여 재사용성과 유지보수성을 극대화한다.

// Vue의 반응형 데이터 시스템 개요

const { reactive, effect } = Vue;

const state = reactive({ count: 0 }); // 반응형 상태 선언

effect(() => {
  console.log(`현재 카운트: ${state.count}`); // 상태 변화 감지 후 실행
});

state.count++; // count가 변경되면 effect 내부 코드가 자동 실행됨

Svelt

개념 모델

  • Svelte는 컴파일 단계에서 상태 관리를 최적화하는 접근 방식을 취한다.

  • let 변수를 선언하면 Svelte는 이를 내부적으로 반응형 상태로 관리한다.

  • 구독 기반 반응형 시스템을 사용하여, 상태가 변경되면 관련된 DOM만 갱신한다.

  • 컴포넌트의 상태가 변경될 때 별도의 가상 DOM을 사용하지 않고 직접 DOM을 업데이트한다.

  • 스토어(Store) 시스템을 제공하여 글로벌 상태 관리가 가능하다.

// Svelte의 반응형 변수와 스토어 예제
  import { writable } from 'svelte/store';

  let count = 0; // 반응형 변수
  const storeCount = writable(0); // 구독형 상태

  function increment() {
  count += 1 // 자동 UI 업데이트
  storeCount.update(n => n + 1); // 스토어 업데이트
}


HTMX

개념 모델

  • HTMX는 HTML을 확장하여 서버와의 상호작용을 단순화하는 접근 방식을 취한다.

  • HTMX는 요청을 통해 서버에서 새 HTML을 받아와 해당 요소를 교체하며 UI를 갱신한다.

  • 이로써 별도의 클라이언트 상태 관리 없이, 서버에서 HTML을 렌더링하여 동적으로 갱신한다.

  • 클라이언트 상태 관리가 필요 없는 서버 중심 렌더링 방식에 적합하다.

  • hx-get, hx-post 등의 속성을 활용하여 AJAX 요청을 간편하게 처리한다.

  • 가상 DOM을 사용하지 않고, 직접적인 DOM 조작을 수행하여 성능을 최적화한다.

<!-- HTMX의 간단한 버튼 예제 -->
<button hx-get="/clicked" hx-target="#result" hx-swap="outerHTML">
  클릭
</button>
<div id="result"></div>

<!-- 입력 필드 값이 변경될 때마다 서버에서 새 데이터 가져오기 -->
<input type="text" hx-get="/search" hx-trigger="keyup changed" hx-target="#search-results" />
<div id="search-results"></div>

브라우저에서의 상태 관리를 완전히 배제하고, 서버 데이터와 동일한 HTML을 구성하는 방식으로 이해했다. 일면으로 서버 중심으로 발전하는 리액트가 떠오른다. 더 단순하고 직관적인 방식으로 브라우저와 서버에 분리된 상태 관리를 일관화하는 방식이 아닐까 싶었다. 깊게 사용해보지 않아서, 그 한계에 대해서도 궁금하다.

HTMX의 주요 메서드는 다음과 같다:

  • hx-get: 지정된 URL로 GET 요청을 보내고, 응답을 받아서 갱신함.

  • hx-post: 지정된 URL로 POST 요청을 보냄.

  • hx-put: 데이터를 갱신하는 PUT 요청을 보냄.

  • hx-delete: 지정된 리소스를 삭제하는 DELETE 요청을 수행함.

  • hx-target: 응답으로 받은 HTML을 삽입할 대상 요소 지정.

  • hx-swap: 응답을 기존 요소와 어떻게 교체할지 정의 (outerHTML, innerHTML, beforebegin 등 지원).

  • hx-trigger: 특정 이벤트(click, change 등) 발생 시 요청 실행.