YeongseoYoon-hanghae 님의 상세페이지[5팀 윤영서] Chapter 1-2. 프레임워크 없이 SPA 만들기 (2)

과제 체크포인트

배포 링크

https://yeongseoyoon-hanghae.github.io/front_6th_chapter1-2/

기본과제

가상돔을 기반으로 렌더링하기

  • createVNode 함수를 이용하여 vNode를 만든다.
  • normalizeVNode 함수를 이용하여 vNode를 정규화한다.
  • createElement 함수를 이용하여 vNode를 실제 DOM으로 만든다.
  • 결과적으로, JSX를 실제 DOM으로 변환할 수 있도록 만들었다.

이벤트 위임

  • 노드를 생성할 때 이벤트를 직접 등록하는게 아니라 이벤트 위임 방식으로 등록해야 한다
  • 동적으로 추가된 요소에도 이벤트가 정상적으로 작동해야 한다
  • 이벤트 핸들러가 제거되면 더 이상 호출되지 않아야 한다

심화 과제

Diff 알고리즘 구현

  • 초기 렌더링이 올바르게 수행되어야 한다
  • diff 알고리즘을 통해 변경된 부분만 업데이트해야 한다
  • 새로운 요소를 추가하고 불필요한 요소를 제거해야 한다
  • 요소의 속성만 변경되었을 때 요소를 재사용해야 한다
  • 요소의 타입이 변경되었을 때 새로운 요소를 생성해야 한다

과제 셀프회고

솔직히 과제 자체는 쉽지는 않았습니다.(지난주보다는 나았을 뿐...) 그래도 이번 주차 과제를 진행하면서 각각의 함수가 하는 일에 대해 정리하고, 왜 이렇게 구현했는지 스스로 탐구하는 시간을 가질 수 있어서 의미 있었다고 생각합니다. 과제보다 문서화를 하는 것에 더 시간이 많이 걸렸는데 다음부터는 시간 날때마다 틈틈히 과제를 진행해서... 좀 더 빨리 끝낼 수 있으면 좋겠습니다. 🥹

기술적 성장

이번 주차 과제를 진행하면서 각각의 함수가 하는 일에 대해서 정리해보고 제가 겪었던 이슈들과 왜 이렇게 구현했는지에 대해 스스로 탐구하고 싶었습니다. 좀 더 명확하게 이해하고 싶어 다음과 같이 문서화를 진행했습니다.

createVNode

JSX와 트랜스파일에 대한 이해

글에 앞서, 리액트 공식문서에서는 compile이라는 단어를 사용하고 있지만 JSX에서 Javascript로의 변환은 트랜스파일의 성격이 강해 하위에서는 혼란 방지를 위해 '트랜스파일'이라는 단어를 사용하였습니다.

일단 createVNode의 정체(?)에 대해 이해하려면, JSX를 먼저 이해해야합니다.

JSX는 React가 브라우저에 마크업을 렌더링할 수 있는 JavaScript 함수입니다. React 컴포넌트는 JSX라는 확장된 문법을 사용하여 마크업을 나타냅니다. JSX는 HTML과 비슷해 보이지만, 조금 더 엄격하며 동적으로 정보를 표시할 수 있습니다.

브라우저는 기본적으로 JSX를 이해하지 못하므로, 대부분의 React 사용자들은 JSX 코드를 일반 JavaScript로 변환하기 위해 Babel 같은 트랜스파일러에 의존합니다.

// 브라우저가 이해하지 못하는 JSX
function App() {
  return <div>Hello World</div>;
}

React 생태계에서는 이런 변환 과정을 'JSX Transform'이라고 부릅니다.

레포지토리를 살펴보면 vite.config.js에 다음과 같은 내용이 있는데요.

    esbuild: {
      jsx: "transform",
      jsxFactory: "createVNode",
    },
    optimizeDeps: {
      esbuildOptions: {
        jsx: "transform",
        jsxFactory: "createVNode",
      },
    },

esbuild는 프로덕션 빌드 과정에서 개발 서버에서 파일을 실시간으로 변환할 때, optimizeDeps 설정은 node_modules에 있는 외부 라이브러리의 JSX를 변환할 때 사용됩니다. 개발 서버 시작 시 의존성을 최적화할 때, 혹은 외부 패키지들을 사전 번들링할 때 동작하게 됩니다.

jsx부분을 보면 transform이라고 되어있는데 이부분은 어떤 것을 의미할까요? React 17버전 이전에는 JSX를 createElement를 통해 transform 했습니다.

// 변환 전
import React from 'react';

function App() {
  return <h1>Hello World</h1>;
}

// 변환 후 
import React from 'react';

function App() {
  return React.createElement('h1', null, 'Hello world');
}

이때 당시의 문제점은 JSX가 React.createElement로 트랜스파일되기 때문에, JSX를 사용할 때마다 React가 스코프에 있어야 했습니다. 때문에 그때는 명시적으로 React import문을 작성해주어야 했습니다.

React 17버전 이후로 이 문제들을 해결하기 위해 Babel이나 TypeScript 같은 트랜스파일러만 사용하도록 의도된 React 패키지의 두 개의 새로운 진입점을 도입했습니다. JSX를 React.createElement로 변환하는 대신, 새로운 JSX transform은 React 패키지의 새로운 진입점에서 특별한 함수들을 자동으로 import하고 호출합니다.

// 변환 전
function App() {
  return <h1>Hello World</h1>;
}

// 변환 후 (트랜스파일러가 자동으로 import 추가)
import {jsx as _jsx} from 'react/jsx-runtime';

function App() {
  return _jsx('h1', { children: 'Hello world' });
}

언급한대로 이후로는 React를 import 하지 않고도 JSX를 사용할 수 있게 되었습니다.

다시 돌아가서 vite.config.js를 살펴보겠습니다.

    esbuild: {
      jsx: "transform",
      jsxFactory: "createVNode",
    },
    optimizeDeps: {
      esbuildOptions: {
        jsx: "transform",
        jsxFactory: "createVNode",
      },
    },

여기서 transform은 단순히 '변환'의 늬앙스로 해석될 수도 있지만 React 17버전 이하의 트랜스파일 방식을 의미합니다.

// Classic Transform에서는 jsxFactory 사용 가능
{
  jsx: "react",           // 또는 "transform"
  jsxFactory: "h"         // ✅ 적용됨
}

// Automatic Transform에서는 jsxFactory 무시됨
{
  jsx: "react-jsx",       // 또는 "automatic"  
  jsxFactory: "h",        // ❌ 무시됨
  jsxImportSource: "preact" // ✅ 대신 이것 사용
}

즉 우리의 config 설정은,

  1. jsxFactory에 명시된 부분을 통해 JSX를 어떤 함수 호출로 변환할지 결정
  2. jsx: "transform"을 통해 Classic Transform에서 동작 이라고 이해할 수 있을 것 같습니다.

그럼 그런 설정으로 JSX가 어떻게 변환되는지 확인해보겠습니다.

// JSX 코드 (우리가 작성하는 코드)
/** @jsx createVNode */ -> 이때 상단에 주석을 명시적으로 작성하여 해당 파일에서 JSX가 createVNode로 변환됩니다.
function App() {
  return <div className="container">Hello World</div>;
}

// esbuild가 변환한 결과 (jsxFactory: "createVNode" 설정에 따라)
function App() {
  return createVNode('div', { className: 'container' }, 'Hello World');
}

전체 플로우는 다음과 같을 것입니다.

1. JSX 작성 
2. esbuild가 createVNode 호출로 변환 
3. 실행 시 createVNode 함수 호출 
4. createVNode 함수가 가상 DOM 객체 반환

그러나 이 플로우에서 esbuild는 createVNode 함수 호출 코드만 생성할 뿐, 실제 함수는 우리가 직접 구현해야 합니다.

JSX와 esbuild 속성에 대하여 어느정도 알아본 것 같으니 createVNode에 대해 알아보도록 하겠습니다.

createVNode의 역할

createVNode는 가상 노드객체를 생성하는 함수입니다. 위에서 말한대로 JSX를 파싱하거나, 컴포넌트 함수에서 엘리먼트를 만들 때 사용하고, 실제 DOM이 아니라, 단순히 JS 객체를 반환합니다.

제가 구현한 createVNode는 다음과 같습니다.

function createVNode(type, props, ...children) {
  return {
    type,
    props,
    children: children.flat(Infinity).filter((value) => value === 0 || Boolean(value)),
  };
}
  1. type: 무엇을 만들것인가? 에 해당됩니다. html 태그의 종류 등이 여기 type에 해당됩니다.
  2. props: 어떻게 만들지에 해당됩니다. CSS 클래스, ID, 이벤트등이 해당됩니다.
  3. children: 안에 뭘 넣을지 정합니다.

그렇다면 왜 flat(Infinity)구문이 필요했을까요?

children = [
  { type: 'li', props: null, children: ['제목'] },
  [  // 문제! 배열 안에 배열이 들어있음
    { type: 'li', props: {key: '할일1'}, children: ['할일1'] },
    { type: 'li', props: {key: '할일2'}, children: ['할일2'] },
    { type: 'li', props: {key: '할일3'}, children: ['할일3'] }
  ],
  { type: 'li', props: null, children: ['끝'] }
]

위와 같은 상황에서 배열을 createElement로 전달하게 되면 다음과 같이 동작할 것입니다.

children.forEach(child => {
  if (Array.isArray(child)) {
    // createElement는 vnode 객체를 기대하는데 배열이 들어옴
  } else {
    createElement(child) // 정상 처리
  }
})

createElement에서 배열을 처리하지 않고 모든 children이 vNode 객체가 되어 일관적으로 동작하도록 평탄화 했다고 이해하면 될 것 같습니다.

출처

https://ko.react.dev/learn/writing-markup-with-jsx# https://ko.legacy.reactjs.org/blog/2020/09/22/introducing-the-new-jsx-transform.html#whats-a-jsx-transform https://www.typescriptlang.org/tsconfig/#jsxFactory https://esbuild.github.io/api/#jsx-factory

normalizeVNode

Virtual DOM을 구현하다 보면 createVNode만으로는 해결되지 않는 문제들을 마주하게 됩니다. 'JSX가 JavaScript로 트랜스파일되면서 생성되는 다양한 타입의 값들을 어떻게 안전하게 처리할 것인가?' 바로 이 지점에서 normalizeVNode의 필요성이 드러납니다.

문제 상황 발생!

JSX를 사용하다 보면 다음과 같은 상황들이 빈번하게 발생합니다.

function UserProfile({ user }) {
  return (
    <div>
      <h1>{user.name}</h1>                    // 문자열
      <span>나이: {user.age}</span>            // 숫자  
      {user.isActive && <span>활성 사용자</span>} // boolean 또는 VNode
      {user.email || '이메일 없음'}             // 문자열 또는 빈 문자열
      <ProfileImage user={user} />             // 함수 컴포넌트
    </div>
  );
}

이 JSX가 트랜스파일되면 createVNode의 children으로 다음과 같은 값들이 전달됩니다.

children = [
  'John Doe',           // string
  '나이: ', 25,         // string, number
  false,                // boolean (조건부 렌더링 실패)
  'john@email.com',     // string
  { type: ProfileImage, props: { user }, children: [] }  // 함수 컴포넌트
]

만약 정규화 없이 바로 createElement를 호출한다면 어떻게 될까요?

function createElement(vNode) {
  // vNode가 false인 경우
  const element = document.createElement(vNode.type);  // 에러!
  // TypeError: Cannot read property 'type' of false
}

// 이런 상황들이 발생 createElement(false); // 조건부 렌더링 실패 시 createElement(25); // 숫자가 직접 전달된 경우
createElement(null); // null 값이 들어온 경우 각각의 타입에 대해 createElement에서 모든 경우를 처리하는 것은 함수를 복잡하게 만들고, 단일 책임 원칙에도 위배됩니다. (때문에 정규화를 하는 normalizeVNode가 분리된 것이 아닐까...생각합니다)

normalizeVNode의 역할

JSX는 유연하기 때문에 다양한 값의 타입들이 들어올 수 있습니다.

children = [
  false,                    // boolean
  0,                        // 0 (falsy로 처리되면 안되는)
  undefined,           // undefined
  '텍스트',               // 텍스트
  [<li>아이템1</li>, <li>아이템2</li>], // 배열 매핑 결과
  { type: UserCard, props: {...}, children: [] }, // 함수 컴포넌트
  null,                     // 명시적 null
]

이러한 복잡한 값들을 normalizeVNode가 체계적으로 해결합니다.

export function normalizeVNode(vNode) {
  // 1. Falsy 값 처리: 렌더링하지 않을 값들을 빈 문자열로 변환
  if (vNode === null || vNode === undefined || typeof vNode === "boolean") {
    return "";
  }

  // 2. 원시값 처리: 텍스트 노드로 만들 값들을 문자열로 통일
  if (typeof vNode === "string" || typeof vNode === "number") {
    return String(vNode);
  }

  // 3. 함수형 컴포넌트 처리: 컴포넌트 실행 후 재귀 정규화
  if (typeof vNode.type === "function") {
    return normalizeVNode(vNode.type({ ...vNode.props, children: vNode.children }));
  }

  // 4. 일반 VNode 처리: children을 재귀적으로 정규화
  return {
    ...vNode,
    children: vNode.children.map(normalizeVNode).filter(Boolean),
  };
}

각 단계별로 어떻게 처리되는지 살펴보기

1단계: Falsy 값 제거 (단, 0은 보존)

// JSX에서 자주 발생하는 패턴들
{isLoading && <Spinner />}              // false → ""
{user?.name}                            // undefined → ""  
{showModal && <Modal />}                // false → ""
{data || '데이터 없음'}                  // 문자열은 그대로

// 중요: 0은 의미있는 값이므로 보존
{score}                                 // 0 → "0" (제거되지 않음)
{remainingCount}                        // 0 → "0" (화면에 표시됨)

// normalizeVNode 처리 결과
false → ""        // filter(Boolean)에서 제거됨
null → ""         // filter(Boolean)에서 제거됨  
undefined → ""    // filter(Boolean)에서 제거됨
0 → "0"          // 문자열이므로 유지됨

0은 자바스크립트에서 falsy한 값이지만, 정규화되면서 처리되면 안되는 의미있는 값입니다.

그리고 그것이 createVNode에서 filter((value) => value === 0 || Boolean(value))를 사용한 이유입니다. 0은 falsy하지만 화면에 표시되기 때문입니다.

2단계: 원시값 문자열화

// JSX에서 숫자나 문자열 직접 렌더링
<span>현재 점수: {score}</span>  // score가 숫자
<div>{userName}</div>           // userName이 문자열

// normalizeVNode 처리 후
42"42"        // 숫자를 문자열로 변환
"윤영서""윤영서"  // 문자열은 그대로 유지

이렇게 처리하면 createElement에서 텍스트 노드 생성 시 일관된 타입(문자열)으로 처리할 수 있습니다.

3단계: 함수 컴포넌트 실행

const ProfileImage = ({ user }) => {
  return user.avatar 
    ? createVNode('img', { src: user.avatar, alt: user.name })
    : createVNode('div', { class: 'default-avatar' }, user.name[0]);
};

// 1. VNode 형태로 전달된 컴포넌트
{
  type: ProfileImage,  // 함수 자체
  props: { user: { name: 'John', avatar: null } },
  children: []
}

// 2. normalizeVNode에서 실행
vNode.type({ ...vNode.props, children: vNode.children })
// ↓ ProfileImage({ user: { name: 'John', avatar: null }, children: [] })
// ↓ createVNode('div', { class: 'default-avatar' }, 'J') 호출됨

// 3. 반환된 VNode를 다시 normalizeVNode로 재귀 처리
return normalizeVNode({
  type: 'div',
  props: { class: 'default-avatar' },
  children: ['J']
});

4단계: 재귀적 children 정규화

// 중첩된 구조의 VNode
{
  type: 'div',
  props: null,
  children: [
    'Hello ',
    42,
    null,
    { type: 'span', props: null, children: ['World', false, 123] }
  ]
}

// 정규화 후
{
  type: 'div', 
  props: null,
  children: [
    'Hello ',
    '42',
    // null은 ""이 되고 filter(Boolean)로 제거
    { type: 'span', props: null, children: ['World', '123'] }
  ]
}

children.map(normalizeVNode).filter(Boolean)를 통해 모든 하위 노드가 재귀적으로 정규화되고, 빈 값들은 제거됩니다.

전체적인 파이프라인에서의 역할

// 1. JSX 트랜스파일
<div>{name}{age}</div>
// ↓
createVNode('div', null, name, age)

// 2. VNode 생성 (구조적 정리만)
{
  type: 'div',
  props: null, 
  children: ['John', 25]  // 아직 타입이 혼재
}

// 3. 정규화 (의미적 변환)
const normalized = normalizeVNode(vNode);
{
  type: 'div',
  props: null,
  children: ['John', '25']  // 모든 children이 문자열로 통일
}

// 4. DOM 생성 (안전한 createElement)
const element = createElement(normalized);  // 에러 없이 실행

이러한 이유로 좀 더 안전하게 실제 요소를 생성하기 위해서라도 요소 생성 이전의 정규화는 필요한 단계라고 할 수 있습니다.

createElement

여태까지 createVNode, normalizeVNode의 역할에 대해서 알아봤으니 이제는 그들이 반환한 가상 DOM 노드를 실제 브라우저 DOM으로 반환하는 함수가 필요합니다. 이번 포스트에서는 createElement 함수의 구현과 동작 원리를 자세히 살펴보겠습니다.

우리 과제의 createElement vs React.createElement

앞서 얘기했던 것처럼, React 17버전 이전에는 JSX를 사용할 때 반드시 React.createElement를 import해야 했습니다. 하지만 우리가 구현하는 createElement는 완전히 다른 역할을 담당합니다.

// React.createElement: VNode 객체 생성 (팩토리 함수)
const vNode = React.createElement('div', { className: 'box' }, 'Hello');
// 결과: { type: 'div', props: { className: 'box', children: 'Hello' }, ... }

// 우리 과제의 createElement: VNode → 실제 DOM 변환 (렌더러 함수)
const domElement = createElement(vNode);
// 결과: <div class="box">Hello</div> (실제 HTMLElement)

쉽게 말하자면 과제에서의 가상 DOM 시스템은 다음과 같은 3단계 파이프라인으로 구성된다고 이해할 수 있을 것 같습니다.

  1. createVNode: VNode 생성을 위한 인자들(태그명, props, children)을 받아서 표준 VNode 객체로 변환
  2. normalizeVNode: VNode를 정규화하여 좀 더 예상 가능한 구조로 정리
  3. createElement: 정규화된 VNode를 실제 DOM 엘리먼트로 변환

그렇다면 과제에서의 createElement는 어떻게 구현해야할까?

저는 과제에서 createElement를 이런 방식으로 구현했습니다.

export function createElement(vNode) {
    // 1. Falsy 값 처리
    if (vNode === null || vNode === undefined || typeof vNode === "boolean") {
      return document.createTextNode("");
    }
    
    // 2. 원시 타입 처리
    if (typeof vNode === "string" || typeof vNode === "number") {
      return document.createTextNode(vNode);
    }
    
    // 3. 배열 처리
    if (Array.isArray(vNode)) {
      const fragment = document.createDocumentFragment();
      vNode.forEach((child) => fragment.appendChild(createElement(child)));
      return fragment;
    }
    
    // 4. 객체 처리
    const $el = document.createElement(vNode.type);
    updateAttributes($el, vNode.props ?? {});
    $el.append(...vNode.children.map(createElement));
    return $el;
}

사실 이전의 createVNode, normalizeVNode에서도 타입에 따라 처리를 하고 있는 부분이 존재하지만, 각각의 함수는 모두 다른 함수의 내부를 알지 못하며 인터페이스를 통해서만 소통하고 각 함수는 독립적으로 동작할 수 있어야 한다는 생각에 createElement 내부에서도 동일하게 타입에 대한 처리를 구현하였습니다.

1. Falsy 값 처리

legacy문서긴 하지만 React legacy 공식문서에는 false, null, undefined, and true are valid children. They simply don't render라고 명시되어 있습니다. createElement의 구현에서는 해당 내용을 반영하여 빈 텍스트 노드를 반환하도록 했습니다.

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

사실 falsy한 경우에는 값을 return하지 않는다거나 null을 통하여 진행하는 것이 더 좋을 것 같다고 생각하지만, 과제에서는 테스트코드를 위해 텍스트 노드를 반환하도록 작성하였습니다.

2. 원시 타입 처리

if (typeof vNode === "string" || typeof vNode === "number") {
  return document.createTextNode(vNode);
}

문자열과 숫자 모두 DOM에서는 문자열로 표시되므로 document.createTextNode를 통해 나타냅니다.

3. 배열 처리

if (Array.isArray(vNode)) {
 const fragment = document.createDocumentFragment();
 vNode.forEach((child) => fragment.appendChild(createElement(child)));
 return fragment;
}

배열 처리에서 DocumentFragment를 사용하는 것은 성능 최적화의 핵심입니다. MDN의 DocumentFragment문서에서 언급하고 있는 내용을 읽어보면

DocumentFragment는 일반 문서처럼 노드로 구성된 문서 구조를 저장할 수 있는 Document의 가벼운 버전입다. DocumentFragment는 활성화된 문서 트리 구조의 일부가 아니기 때문에 내부의 트리를 변경해도 문서나 성능에 아무 영향도 주지 않으며도 방지할 수 있습니다.

라고 설명하고 있는데, '활성화된 문서 트리 구조의 일부가 아니' 라는것은 어떤 것을 의미할까요?

활성화된 문서 트리 구조는 현재 브라우저에서 실제로 렌더링되고 있는 DOM 구조를 의미합니다. 즉, 사용자가 볼 수 있는 웹페이지의 실제 DOM 트리를 말합니다. 반대로 활성화된 문서 트리 구조가 아닌 DocumentFragment는 메인 DOM 트리의 일부가 되지 않습니다. 다큐먼트 조각을 생성하고, 엘리먼트들을 다큐먼트 조각에 추가하고 그 다큐먼트 조각을 DOM 트리에 추가하는 방식으로 DOM이 생성됩니다. DOM 트리 내에서 다큐먼트 조각은 그것의 모든 자식들로 대체됩니다.

createDocumentFragment를 살펴보면 '메모리 내에 다큐먼트 조각이 존재하고'라는 부분이 있습니다. 결론적으로 DocumentFragment는 메모리 내에 존재하는, 그렇지만 document에는 부착되지 않은 문서 조각에 불과하므로 내부적으로 바뀌고 나서 한번에 document에 부착되어 아무리 변경이 되어도 일정시점까지는 리플로우나 리페인트가 일어나지 않습니다.

그리고 재귀적 처리를 통하여 중첩된 배열이나 복잡한 구조도 안전하게 처리합니다.

4. VNode 객체 처리

const $el = document.createElement(vNode.type);
updateAttributes($el, vNode.props ?? {});
$el.append(...vNode.children.map(createElement));
return $el;

이 부분에서는 이제 앞에서 언급한 Falsy값이 아니고, 원시값도 아니며, 배열도 아닌 객체를 처리하게 됩니다.

createElement에서의 updateAttributes

updateAttributes 함수는 가상 DOM 객체의 props를 실제 DOM 엘리먼트에 적용하는 핵심 함수입니다. 이때 props 내부의 값들은 다양한 값이 들어올 수 있으므로 그에 따른 처리가 필요합니다.

function updateAttributes($el, props) {
  Object.entries(props).forEach(([attr, value]) => {
    if (attr.startsWith("on") && typeof value === "function") {
      const eventType = attr.toLowerCase().slice(2);
      addEvent($el, eventType, value);
    } else if (["checked", "disabled", "selected", "readOnly"].includes(attr)) {
      $el[attr] = Boolean(value);
    } else if (attr === "className") {
      if (value) {
        $el.setAttribute("class", value);
      } else {
        $el.removeAttribute("class");
      }
    } else if (attr === "style" && typeof value === "object") {
      Object.assign($el.style, value);
    } else {
      $el.setAttribute(attr, value);
    }
  });
}

해당 함수는 5가지 속성을 통해 props를 처리합니다.

1. 이벤트 핸들러 처리 (on* 속성)

if (attr.startsWith("on") && typeof value === "function") {
  const eventType = attr.toLowerCase().slice(2);
  addEvent($el, eventType, value);
}

우리가 React를 사용할때를 생각해보면, onXXX와 같이 생긴 이벤트 핸들러를 처리하게 될 때가 있는데 이때를 의미합니다. 이벤트 핸들러는 HTML 속성으로 설정하기 보다는, DOM 엘리먼트의 이벤트 리스너로 등록해야 합니다.

React에서는 JSX의 onClick, onChange 등의 이벤트 핸들러를 실제 DOM 이벤트로 변환할 때 SyntheticEvent를 사용합니다. 하지만 우리의 구현에서는 네이티브 DOM 이벤트를 직접 사용하여 더 간단하게 처리합니다.

// JSX: <button onClick={handleClick}>클릭</button>
// 변환: addEvent($el, "click", handleClick);

addEvent에 대해서는 후속 글에서 다뤄보겠습니다.

2. 불리언 속성 처리 (checked, disabled, selected, readOnly)

else if (["checked", "disabled", "selected", "readOnly"].includes(attr)) {
      $el[attr] = Boolean(value);
} 

상위의 전체 코드를 보면 불리언 속성을 제외한 다른 속성들은 setAttribute을 통해 속성을 처리해주는데, setAttribute을 살펴보면 다음과 같은 내용이 나옵니다.

Any non-string value specified is converted automatically into a string. 불리언 값(true/false)을 넣어도 무조건 문자열로 바뀝니다.

// setAttribute의 문제점
$el.setAttribute("disabled", false); // "false" 문자열이 설정되어 여전히 disabled
$el.setAttribute("checked", true);   // "true" 문자열이 설정됨

// DOM 프로퍼티 방식 (권장)
$el.disabled = false;
$el.checked = true;   

불리언 속성에서는 속성의 존재 여부가 true/false를 결정하므로, DOM 프로퍼티로 직접 설정하는 것이 더 안전하고 예측 가능합니다.

3. className 속성 처리

else if (attr === "className") {
  if (value) {
    $el.setAttribute("class", value);
  } else {
    $el.removeAttribute("class");
  }
}

React에서는 JavaScript의 예약어인 class를 피하기 위해 className을 사용합니다. 하지만 실제 DOM에서는 class 속성을 사용해야 합니다. 또한 값이 없을 때 removeAttribute를 사용하는 이유는 빈 문자열이 설정되는 것을 방지하기 위함입니다.

4. 스타일 객체 처리

else if (attr === "style" && typeof value === "object") {
  Object.assign($el.style, value);
}

React에서는 스타일을 객체로 전달할 수 있습니다.

<div style={{ backgroundColor: 'red', fontSize: '16px' }}>

이를 처리하기 위해 Object.assign을 사용하여 스타일 객체를 DOM 엘리먼트의 style 프로퍼티에 직접 할당합니다.

5. 기본 속성 처리

else {
  $el.setAttribute(attr, value);
}

위의 특별한 케이스들에 해당하지 않는 모든 속성은 setAttribute를 사용하여 설정합니다. 이는 id, data-*, aria-* 등의 일반적인 HTML 속성들을 처리합니다.

다음 글에서는 updateAttributes의 내부에서 사용되는 이벤트 위임 방식에 대해 다뤄보겠습니다.

출처

https://developer.mozilla.org/ko/docs/Web/API/DocumentFragment#%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80_%ED%98%B8%ED%99%98%EC%84%B1 https://react.dev/reference/react-dom/components/common https://developer.mozilla.org/en-US/docs/Web/API/Element/setAttribute

eventManager

지난 글에서는 createElement 함수와 updateAttributes 함수를 통해 가상 DOM을 실제 DOM으로 변환하는 과정을 살펴봤습니다. 특히 updateAttributes 함수에서 이벤트 핸들러 처리를 위해 addEvent 함수를 사용한다고 언급했었는데, 이번 글에서는 이벤트 위임 패턴을 사용한 이벤트 관리 시스템의 구현과 동작 원리를 자세히 살펴보겠습니다.

일반적으로 DOM 엘리먼트에 이벤트를 등록하는 방법은 각 엘리먼트마다 직접 리스너를 등록하는 것입니다.

const button1 = document.createElement('button');
button1.addEventListener('click', handleClick1);

const button2 = document.createElement('button');
button2.addEventListener('click', handleClick2);

const button3 = document.createElement('button');
button3.addEventListener('click', handleClick3);

이러한 방식은 직관적이지만 어느정도의 문제를 가지는데, 만약 버튼이 10개가 있다면 10개의 리스너를 붙이게 되고, 그로 인해 메모리 누수의 위험이 높아집니다.(무조건 해제해줘야함) 또한 각각의 변수에 리스너가 부착되므로 관리가 복잡해집니다.

이벤트 위임이란?

이벤트 위임은 부모 엘리먼트에서 자식 엘리먼트들의 이벤트를 대신 처리하는 패턴입니다. 이는 이벤트 버블링 메커니즘을 활용합니다.

<div id="parent">
  <button id="child">클릭</button>
</div>

버튼을 클릭하면 이벤트가 다음 순서로 전파됩니다.

  1. button 엘리먼트에서 이벤트 발생
  2. div 엘리먼트로 이벤트 버블링
  3. document까지 계속 버블링

React 공식문서에 따르면

Event handlers will also catch events from any children your component might have. We say that an event “bubbles” or “propagates” up the tree: it starts with where the event happened, and then goes up the tree. 이벤트 핸들러는 컴포넌트가 가질 수 있는 모든 자식 엘리먼트의 이벤트도 포착합니다. 이벤트가 트리 위로 '버블링' 또는 '전파'된다고 표현하는데, 이는 이벤트가 발생한 곳에서 시작해서 트리 위로 올라가기 때문입니다.

function ParentComponent() {
  const handleClick = (event) => {
    console.log('클릭된 엘리먼트:', event.target.tagName);
    // 자식 버튼을 클릭해도 이 핸들러가 실행됨
  };

  return (
    <div onClick={handleClick}>
      <button>버튼 1</button>
      <button>버튼 2</button>
      <span>텍스트</span>
    </div>
  );
}

이벤트 위임은 바로 이 버블링 특성을 활용하여 부모 엘리먼트에서 모든 자식 엘리먼트의 이벤트를 처리합니다. 이벤트 위임은 하나의 이벤트 리스너로 여러 엘리먼트를 처리할 수 있고, 나중에 엘리먼트가 추가되더라도 동적으로 엘리먼트의 이벤트 처리가 가능합니다. 또한 이벤트 리스너의 등록/해제가 간단합니다.

어떻게 구현해야할까?

1. 전역 상태 관리

const eventMap = new WeakMap();
const delegatedEvents = new Set();
let rootElement = null;

먼저 전역적인 상태를 정의했습니다. 여기서 eventMap은 각 DOM 엘리먼트와 그에 등록된 이벤트 핸들러들을 매핑하는 WeakMap입니다. delegatedEvents는 현재 위임되고 있는 이벤트 타입들을 저장하는 Set입니다. 그리고 rootElement는 이벤트 위임이 적용될 루트 컨테이너 엘리먼트입니다.

WeakMap을 사용하는 이유(feat. Map과의 차이)

eventMapWeakMap을 사용하는 이유는 메모리 누수를 방지하기 위함입니다. WeakMap은 키/값 쌍의 모음이지만, 일반적인 Map과는 중요한 차이점이 있습니다.

// Map을 사용한 경우 (메모리 누수 위험)
const eventMap = new Map();
const element = document.createElement('div');
eventMap.set(element, handlers);

// DOM에서 element를 제거해도
element.remove();
element = null;

// Map이 여전히 element를 참조하고 있어서 가비지 컬렉션되지 않음
// 메모리 누수 발생!

Map은 키에 대해 강한 참조를 사용합니다. 이는 키가 Map에서 사용되는 한, 코드의 다른 곳에서 더 이상 참조되지 않더라도 가비지 컬렉션되지 않는다는 의미입니다.

// WeakMap을 사용한 경우 (메모리 누수 방지)
const eventMap = new WeakMap();
const element = document.createElement('div');
eventMap.set(element, handlers);

// DOM에서 element를 제거하면
element.remove();
element = null;

// WeakMap의 해당 항목도 자동으로 가비지 컬렉션됨
// 메모리 누수 없음!

이와 반해 WeakMap은 키에 대해 약한 참조를 사용합니다. 때문에 객체가 WeakMap의 키에 포함되더라도 가비지 컬렉션의 대상이 됩니다. 이는 특히 DOM 요소가 동적으로 생성/제거되는 환경에서 매우 중요합니다. 일반적인 웹 애플리케이션에서는 수많은 DOM 요소들이 생성되고 제거되는데, 만약 Map을 사용한다면 제거된 요소들의 이벤트 핸들러 정보가 계속 메모리에 남아있게 됩니다. 때문에 이번 구현에서는 WeakMap을 사용했습니다.

WeakMap은 왜 이터러블(Iterable)하지 않을까

WeakMap이 이터러블하지 않은 이유는 가비지 컬렉션의 비결정성 때문입니다. WeakMap은 키에 대해 약한 참조를 사용하는데, 이는 키가 언제 가비지 컬렉션될지 예측할 수 없다는 의미입니다.

예를 들어, WeakMap에 객체를 키로 저장한 후 해당 객체의 참조를 해제하면, 가비지 컬렉터가 언제 그 객체를 수집할지는 브라우저 엔진과 메모리 상황에 따라 달라집니다. 같은 코드라도 실행할 때마다 다른 결과가 나올 수 있어 이터레이션의 일관성을 보장할 수 없습니다.

const weakMap = new WeakMap();
let obj = { name: 'test' };
weakMap.set(obj, 'value');

// 만약 WeakMap이 이터러블하다면?
console.log([...weakMap]); // [obj, 'value'] 예상

obj = null; // 참조 해제

// 가비지 컬렉션이 언제 일어날지 모름
setTimeout(() => {
  console.log([...weakMap]); // obj가 있을까? 없을까? 예측 불가
}, 100);

또한 WeakMap이 이터러블하려면 살아있는 키들을 추적해야 하는데, 이는 WeakMap의 핵심 목적인 "메모리 누수 방지"와 모순됩니다. 키들을 추적하는 순간 그것은 강한 참조가 되어버리기 때문입니다.

반면 Map은 키에 대해 강한 참조를 사용하므로 키가 항상 존재함을 보장할 수 있어 안전하게 이터레이션을 제공할 수 있습니다. WeakMap은 "메모리 안전한 연관 관계"를 목적으로 설계된 것이지 "순회 가능한 컬렉션"이 아니므로, 명시적으로 접근하는 것만을 지원합니다.

2. 이벤트 리스너 설정

export function setupEventListeners(container) {
  rootElement = container;
  delegatedEvents.forEach((eventType) => {
    container.removeEventListener(eventType, handleEvent);
    container.addEventListener(eventType, handleEvent);
  });
}

setupEventListeners 함수는 컨테이너에 이벤트 리스너를 등록합니다. 이미 등록된 이벤트 리스너가 있다면 먼저 제거한 후 새로 등록하여 중복 등록을 방지합니다.

3. 이벤트 핸들러 등록

export function addEvent(element, eventType, handler) {
  if (!eventMap.has(element)) {
    eventMap.set(element, new Map());
  }

  const elementEvents = eventMap.get(element);
  if (!elementEvents.has(eventType)) {
    elementEvents.set(eventType, new Set());
  }
  elementEvents.get(eventType).add(handler);

  if (!delegatedEvents.has(eventType)) {
    delegatedEvents.add(eventType);

    if (rootElement) {
      rootElement.removeEventListener(eventType, handleEvent);
      rootElement.addEventListener(eventType, handleEvent);
    }
  }
}

addEvent 함수는 다음과 같은 구조로 이벤트 핸들러를 관리합니다.

eventMap (WeakMap)
└── element (HTMLElement)
    └── elementEvents (Map)
        └── eventType (string)
            └── handlers (Set)
                ├── handler1 (Function)
                ├── handler2 (Function)
                └── ...

이 구조를 통해 하나의 엘리먼트에 같은 이벤트 타입의 여러 핸들러를 등록할 수 있고, Set을 사용하여 중복 핸들러 등록을 방지합니다.

4. 이벤트 처리 로직

function handleEvent(event) {
  let target = event.target;
  while (target && target !== rootElement) {
    const elementEvents = eventMap.get(target);
    if (elementEvents) {
      const handlers = elementEvents.get(event.type);
      if (handlers) {
        handlers.forEach((handler) => handler(event));
        return;
      }
    }
    target = target.parentElement;
  }
}

handleEvent 함수는 이벤트 위임의 핵심 로직입니다.

내부의 로직을 살펴보면 다음과 같습니다.

  1. 이벤트 타겟부터 시작: event.target에서 시작하여 부모 엘리먼트들을 순회합니다.
  2. 핸들러 검색: 각 엘리먼트에 등록된 해당 이벤트 타입의 핸들러를 찾습니다.
  3. 핸들러 실행: 핸들러를 찾으면 모든 핸들러를 실행하고 순회를 종료합니다.
  4. 부모로 이동: 핸들러가 없으면 부모 엘리먼트로 이동하여 계속 검색합니다.

여기서 중요한 점은 실제 DOM에는 각 엘리먼트마다 개별 이벤트 리스너가 등록되어 있지 않다는 것입니다. 대신 루트 컨테이너에 하나의 handleEvent 함수만 등록되어 있고, 이 함수가 이벤트가 발생하면 위와 같은 방식으로 적절한 핸들러를 찾아서 실행합니다.

합성 이벤트(Synthetic Event) 고려사항

현재 우리의 구현은 네이티브 DOM 이벤트를 그대로 핸들러에 전달하고 있습니다. 하지만 실제 React에서는 브라우저 호환성과 일관성을 위해 합성 이벤트를 사용합니다.

// 현재 구현: 네이티브 이벤트 직접 전달
handlers.forEach((handler) => handler(event));

// React의 접근: 합성 이벤트 생성 후 전달
// handlers.forEach((handler) => handler(createSyntheticEvent(event)));

합성 이벤트가 필요한 이유는 브라우저마다 이벤트 객체의 속성이 다르기 때문입니다. 예를 들어 마우스 위치를 얻을 때 IE는 event.x, Firefox는 event.layerX, Chrome은 event.offsetX를 사용하는 등의 차이가 있습니다. React는 이런 차이점들을 추상화하여 모든 브라우저에서 동일한 API를 제공합니다.

Image

현재 대부분의 모던 브라우저들이 웹 표준을 잘 지키고 있어서 네이티브 이벤트만으로도 충분한 경우가 많지만, 크로스 브라우저 지원이나 일관된 개발 경험을 위해서는 합성 이벤트를 고려해볼 수 있습니다. 다만 과제 범위에서는 단순성을 위해 네이티브 이벤트를 직접 사용하는 방식을 채택했습니다.

5. 이벤트 핸들러 제거

export function removeEvent(element, eventType, handler) {
  const elementEvents = eventMap.get(element);
  if (!elementEvents) return;

  const handlers = elementEvents.get(eventType);
  if (!handlers) return;

  handlers.delete(handler);
}

특정 핸들러만 제거하는 기능을 제공합니다. WeakMap을 사용하면 DOM 엘리먼트가 제거될 때 관련된 모든 핸들러가 자동으로 가비지 컬렉션되지만, removeEvent는 엘리먼트는 유지하면서 특정 핸들러만 제거하고 싶을 때나 명시적인 정리가 필요할 때 사용합니다.

const button = document.createElement('button');

// 여러 핸들러 등록
addEvent(button, 'click', handler1);
addEvent(button, 'click', handler2);

// 나중에 handler1만 제거 (button과 handler2는 유지)
removeEvent(button, 'click', handler1);

다음 글에서는 updateElement와 이러한 이벤트 시스템과 함께 동작하는 가상 DOM의 diffing 알고리즘에 대해 다뤄보겠습니다.

출처

https://react.dev/learn/responding-to-events https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakMap https://ko.legacy.reactjs.org/docs/events.html https://react.dev/reference/react-dom/components/common

updateElement

지난 글에서는 이벤트 위임 시스템을 통해 효율적인 이벤트 관리 방법을 살펴봤습니다. 이번 글에서는 가상 DOM의 핵심인 diffing 알고리즘updateElement 함수를 통해 실제 DOM을 효율적으로 업데이트하는 방법을 자세히 살펴보겠습니다.

DOM 업데이트의 문제점

일반적으로 DOM 조작은 웹 애플리케이션에서 가장 비용이 큰 연산 중 하나입니다. 상태가 변경될 때마다 전체 DOM을 다시 렌더링한다면 성능이 크게 저하될 것입니다.

// 비효율적인 방식
function updateUI(newData) {
  // 전체 DOM을 다시 생성
  document.body.innerHTML = '';
  document.body.appendChild(createWholeApp(newData));
}

이러한 방식은 전체 DOM을 재생성하므로 불필요한 연산이 많아 성능 저하가 발생하고, 깜빡임과 상태 손실로 인해 사용자 경험이 악화됩니다. 또한 불필요한 DOM 노드의 생성과 제거로 메모리 낭비도 심각합니다.

가상 DOM과 재조정(Reconciliation)의 개념

가상 DOM은 실제 DOM 구조를 JavaScript 객체로 표현한 것으로, 메모리에서 빠르게 조작할 수 있는 DOM의 가벼운 복사본입니다. 이 가상 DOM을 실제 DOM에 반영하는 과정을 재조정(Reconciliation)이라고 합니다. React 컴포넌트의 render() 함수는 현재 상태를 반영한 React 엘리먼트 트리를 반환합니다. state나 props가 변경될 때마다 새로운 엘리먼트 트리가 생성되며, React는 이전 트리와 새로운 트리를 비교하여 실제 DOM을 효율적으로 업데이트해야 합니다. 이때 두 트리 간의 차이를 찾아 최소한의 연산으로 변환하는 일반적인 알고리즘들이 존재합니다. 하지만 이러한 알고리즘들은 n개의 엘리먼트가 있는 트리에 대해 O(n³)의 복잡도를 가집니다. 만약 React에 이 알고리즘을 적용한다면, 1,000개의 엘리먼트가 있는 트리에 대해 약 10억 번(1000³)의 비교 연산을 수행해야 합니다. React는 대신, 두 가지 가정을 기반하여 O(n) 복잡도의 휴리스틱 알고리즘을 구현했습니다.

React의 Diffing 알고리즘 핵심 가정

React의 diffing 알고리즘은 두 가지의 가정을 기반으로 합니다.

  1. 서로 다른 타입의 두 엘리먼트는 서로 다른 트리를 만들어낸다.
  2. 개발자가 key prop을 통해, 여러 렌더링 사이에서 어떤 자식 엘리먼트가 변경되지 않아야 할지 표시해 줄 수 있다.

Diffing 알고리즘 상세 분석

그럼 Diffing 알고리즘에 대해 분석해보겠습니다.

1. 엘리먼트의 타입이 다른 경우

React는 엘리먼트의 type(태그 이름이나 커스텀 컴포넌트 타입)이 바뀌면, 내부 구조도 완전히 달라질 것이라 가정하고 기존 서브트리 전체를 파괴 후 새로 생성합니다. 이때 해당 서브트리의 모든 자식 요소 및 컴포넌트가 언마운트→ 마운트 주기를 거치며, 그 과정에서 컴포넌트 상태가 초기화됩니다.

// 이전 트리
<div>
  <Counter />
</div>

// 새로운 트리
<span>
  <Counter />
</span>

이때의 플로우는 다음과 같습니다.

  1. 루트 엘리먼트가 div → section으로 변경됨을 감지
  2. 내부 내용은 완전히 동일하지만, 전체 서브트리를 파괴하고 재생성
  3. 모든 자식 컴포넌트가 언마운트→ 마운트
  4. 모든 상태(state)가 초기화됨

이러한 가정상의 이점으로 React는 모든 자식 노드들을 재귀적으로 비교하고, 각 속성들을 비교하며 내부적으로 다른 점이 있는지 확인 할 필요 없이 타입이 다르기만하면 즉시 교체해버림으로써 복잡한 비교 과정을 생략할 수 있습니다.

2. 같은 타입의 DOM 엘리먼트

같은 타입의 두 React DOM 엘리먼트를 비교할 때, React는 두 엘리먼트의 속성을 확인하여, 동일한 내역은 유지하고 변경된 속성들만 갱신합니다.

// 이전
<div className="before" title="stuff" />

// 새로운
<div className="after" title="stuff" />

// 결과: className만 업데이트

3. 같은 타입의 컴포넌트 엘리먼트

컴포넌트가 갱신되면 인스턴스는 동일하게 유지되어 렌더링 간 state가 유지됩니다. React는 새로운 엘리먼트의 내용을 반영하기 위해 현재 컴포넌트 인스턴스의 props를 갱신합니다.

// 컴포넌트 인스턴스는 동일하게 유지
<MyComponent count={1} />
<MyComponent count={2} />  // props만 업데이트

Key의 중요성

DOM 노드의 자식들을 재귀적으로 처리할 때, React는 기본적으로 동시에 두 리스트를 순회하고 차이점이 있으면 변경을 생성합니다.

// Key를 사용한 효율적인 업데이트
<ul>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>

<ul>
  <li key="2014">Connecticut</li>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>
// React는 2014가 새로 추가되었고, 2015와 2016은 이동만 하면 됨을 인식

updateElement를 분석해보자 - updateAttributes

React의 Diffing 알고리즘을 모방한 updateElement내부에서 updateAttributes 사용하는 에 대해서 한 번 분석해 보겠습니다.

  1. updateAttributes 함수 분석

전체적인 처리는 단계적으로 불필요한 속성을 제거하고 새로운 속성을 적용하는 형태로 구현되었습니다.

export function updateAttributes(element, newProps, oldProps = null) {
  if (!newProps && !oldProps) return;

  if (oldProps) {
    Object.keys(oldProps).forEach((key) => {
      if (key === "children") return;

      if (key.startsWith("on")) {
        const eventType = key.substring(2).toLowerCase();
        removeEvent(element, eventType, oldProps[key]);
      } else if (!newProps || !(key in newProps)) {
        if (key === "className") {
          element.removeAttribute("class");
        } else if (["checked", "disabled", "selected", "readOnly"].includes(key)) {
          element[key] = false;
          element.removeAttribute(key);
        } else {
          element.removeAttribute(key);
        }
      }
    });
  }

  if (newProps) {
    Object.entries(newProps).forEach(([key, value]) => {
      if (key === "children") return;

      if (key === "className") {
        if (value) {
          element.setAttribute("class", value);
        } else {
          element.removeAttribute("class");
        }
        return;
      }

      if (key.startsWith("on")) {
        const eventType = key.substring(2).toLowerCase();
        addEvent(element, eventType, value);
        return;
      }

      if (["checked", "disabled", "selected", "readOnly"].includes(key)) {
        element[key] = Boolean(value);
        return;
      }

      if (value != null && (!oldProps || oldProps[key] !== value)) {
        element.setAttribute(key, String(value));
      }
    });
  }
}

1. 이벤트 핸들러 제거

if (key.startsWith("on")) {
  const eventType = key.substring(2).toLowerCase();
  removeEvent(element, eventType, oldProps[key]);
}

함수는 참조로 비교되므로 같은 로직이어도 다른 함수면 다르며, addEventListener는 같은 이벤트에 여러 핸들러를 등록할 수 있습니다. 때문에 만약 제거하지 않으면 이전 핸들러가 중복 등록되어 메모리에 남아 있을 수 있게 됩니다.

2. 조건부 제거 로직

else if (!newProps || !(key in newProps)) {
  // 제거 로직
}

새로운 속성이 없거나, 새로운 속성에 key값이 없으면 제거한다는 의미 입니다.

3. 속성 별 제거

if (key === "className") {
  element.removeAttribute("class");
} else if (["checked", "disabled", "selected", "readOnly"].includes(key)) {
  element[key] = false;
  element.removeAttribute(key);
} else {
  element.removeAttribute(key);
}
  1. className 특수 처리 React에서는 JavaScript의 예약어 충돌을 피하기 위해 className을 사용하지만, 실제 HTML에서는 class 속성으로 매핑되어야 합니다. 따라서 className 속성을 제거할 때는 HTML의 class 속성을 제거해야 합니다.

  2. Boolean 속성 처리 HTML의 Boolean 속성들은 일반 속성과 다르게 동작합니다. 존재 자체가 true를 의미하므로, 값이 "false"여도 속성이 존재하면 true로 처리됩니다. 더 복잡한 점은 이런 속성들이 DOM Property와 HTML Attribute 두 곳에 모두 존재한다는 것입니다. DOM Property만 제거하면 HTML Attribute가 여전히 남아있어 시각적으로는 disabled 상태로 보일 수 있고, HTML Attribute만 제거하면 DOM Property가 남아있어 JavaScript로 접근할 때 예상과 다른 값을 얻을 수 있습니다. 따라서 완전한 제거를 위해서는 둘 다 처리해야 합니다. DOM Property를 false로 설정하고, HTML Attribute를 완전히 제거하는 것입니다.

  3. 일반 속성 처리 나머지 일반 속성들은 단순히 removeAttribute로 HTML에서 제거하면 됩니다.

4. className 적용

if (key === "className") {
  if (value) {
    element.setAttribute("class", value);
  } else {
    element.removeAttribute("class");
  }
  return;
}

className의 경우 값이 있으면 설정하고, 없으면 제거하는 방식으로 처리합니다. 이는 React에서 className={null}이나 className=""와 같은 경우를 올바르게 처리하기 위함입니다.

5. 이벤트 핸들러 등록

if (key.startsWith("on")) {
  const eventType = key.substring(2).toLowerCase();
  addEvent(element, eventType, value);
  return;
}

이벤트 핸들러는 제거 단계에서 이미 이전 핸들러를 제거했으므로, 새로운 핸들러를 등록하기만 하면 됩니다.

6. Boolean 속성 설정

if (["checked", "disabled", "selected", "readOnly"].includes(key)) {
  element[key] = Boolean(value);
  return;
}

Boolean 속성들은 DOM Property로 설정합니다.

7. 일반 속성 처리

if (value != null && (!oldProps || oldProps[key] !== value)) {
  element.setAttribute(key, String(value));
}

null/undefined가 아닌 값이고, 이전 값과 다를 때만 DOM을 조작합니다.

updateElement를 분석해보자 - updateElement

export function updateElement(parentElement, newNode, oldNode, index = 0) {
  if (!newNode && oldNode) {
    if (parentElement.childNodes[index]) {
      parentElement.removeChild(parentElement.childNodes[index]);
    }
    return;
  }

  if (newNode && !oldNode) {
    parentElement.appendChild(createElement(newNode));
    return;
  }

  if (typeof newNode === "string" || typeof newNode === "number") {
    if (newNode !== oldNode) {
      const newTextNode = document.createTextNode(String(newNode));
      if (parentElement.childNodes[index]) {
        parentElement.replaceChild(newTextNode, parentElement.childNodes[index]);
      } else {
        parentElement.appendChild(newTextNode);
      }
    }
    return;
  }

  if (newNode.type !== oldNode.type) {
    if (parentElement.childNodes[index]) {
      parentElement.replaceChild(createElement(newNode), parentElement.childNodes[index]);
    } else {
      parentElement.appendChild(createElement(newNode));
    }
    return;
  }

  const childNode = parentElement.childNodes[index];
  if (childNode) {
    updateAttributes(childNode, 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(childNode, newChildren[i], oldChildren[i], i);
    }

    if (oldChildren.length > newChildren.length) {
      for (let i = oldChildren.length - 1; i >= newChildren.length; i--) {
        const extraChild = childNode.childNodes[i];
        if (extraChild) {
          childNode.removeChild(extraChild);
        }
      }
    }
  }
}

1. 노드 제거 및 추가

// 노드 제거
if (!newNode && oldNode) {
  if (parentElement.childNodes[index]) {
    parentElement.removeChild(parentElement.childNodes[index]);
  }
  return;
}

// 노드 추가
if (newNode && !oldNode) {
  parentElement.appendChild(createElement(newNode));
  return;
}

새로운 노드가 없고 이전 노드가 존재하는 경우, 해당 노드를 DOM에서 제거합니다. 또한 새로운 노드가 있고 이전 노드가 없는 경우, 새로운 노드를 DOM에 추가합니다.

2. 텍스트 노드 처리

if (typeof newNode === "string" || typeof newNode === "number") {
  if (newNode !== oldNode) {
    const newTextNode = document.createTextNode(String(newNode));
    if (parentElement.childNodes[index]) {
      parentElement.replaceChild(newTextNode, parentElement.childNodes[index]);
    } else {
      parentElement.appendChild(newTextNode);
    }
  }
  return;
}

텍스트 노드의 경우 별도로 처리합니다. 텍스트 내용이 실제로 변경된 경우에만 DOM을 업데이트하여 불필요한 조작을 방지합니다. 문자열과 숫자는 String() 변환을 통해 텍스트 노드로 생성됩니다.

3. 타입 비교 및 트리 재구성

if (newNode.type !== oldNode.type) {
  if (parentElement.childNodes[index]) {
    parentElement.replaceChild(createElement(newNode), parentElement.childNodes[index]);
  } else {
    parentElement.appendChild(createElement(newNode));
  }
  return;
}

이 부분이 React의 핵심 가정인 서로 다른 타입의 두 엘리먼트는 서로 다른 트리를 만들어낸다를 구현한 것입니다. 노드의 타입이 다르면 기존 서브트리를 완전히 제거하고 새로운 트리를 생성합니다.

4. 동일 타입 노드의 속성 업데이트

const childNode = parentElement.childNodes[index];
if (childNode) {
  updateAttributes(childNode, newNode.props || {}, oldNode.props || {});

같은 타입의 노드인 경우, 기존 DOM 노드를 재사용하고 속성만 업데이트합니다. 이는 React의 "같은 타입의 엘리먼트는 속성만 업데이트한다"는 가정을 구현한 것입니다. updateAttributes 함수를 통해 효율적으로 속성을 비교하고 업데이트합니다.

5. 자식 재귀 처리

if (oldChildren.length > newChildren.length) {
  for (let i = oldChildren.length - 1; i >= newChildren.length; i--) {
    const extraChild = childNode.childNodes[i];
    if (extraChild) {
      childNode.removeChild(extraChild);
    }
  }
}

이전 자식 노드가 새로운 자식 노드보다 많은 경우, 초과된 노드들을 제거합니다. 뒤에서부터 제거하는 이유는 앞에서부터 제거하면 인덱스가 변경되어 올바른 노드를 제거하지 못할 수 있기 때문입니다. 이는 배열 렌더링에서 아이템이 줄어들 때 발생하는 상황을 처리합니다.

지금까지 React의 핵심인 Diffing 알고리즘을 직접 구현하며 가상 DOM이 어떻게 실제 DOM을 효율적으로 업데이트하는지 살펴보았습니다. 다음에는 마지막으로 renderElement에 대해 어떻게 구현했는지 알아보겠습니다.

출처

https://ko.legacy.reactjs.org/docs/reconciliation.html https://ko.legacy.reactjs.org/docs/faq-internals.html

renderElement

지금까지 우리는 가상 DOM의 구성 요소들인 createElement, updateElement, updateAttributes를 살펴봤습니다. 이번 글에서는 이 모든 것을 하나로 묶어 실제 렌더링을 수행하는 renderElement 함수를 분석해보겠습니다.

renderElement의 역할

renderElement 함수는 React의 ReactDOM.render()와 유사한 역할을 하는 핵심 함수입니다. 가상 DOM 노드를 받아서 실제 DOM에 렌더링하고, 이후 변경사항이 있을 때 효율적으로 업데이트하는 역할을 담당합니다.

const vNodeMap = new WeakMap();

export function renderElement(vNode, container) {
  const normalizedNode = normalizeVNode(vNode);
  const oldVNode = vNodeMap.get(container);

  if (!oldVNode) {
    const element = createElement(normalizedNode);
    container.appendChild(element);
  } else {
    updateElement(container, normalizedNode, oldVNode, 0);
  }

  setupEventListeners(container);
  vNodeMap.set(container, normalizedNode);
}

그렇다면 언제 호출되는 함수일까요? renderElement는 애플리케이션 초기 렌더링, 상태 변경으로 인한 리렌더링 등의 경우에 호출됩니다.

renderElement의 구현

1. WeakMap을 활용한 이전 상태 저장

const vNodeMap = new WeakMap();

일단 WeakMap을 사용해서 각 컨테이너에 대응하는 이전 vNode를 저장합니다. WeakMap에 대한 내용은 eventManager글을 참고하면 좋습니다.

2. 가상 DOM 정규화

  const normalizedNode = normalizeVNode(vNode);

입력으로 받은 vNode를 표준화합니다. 이는 다음과 같은 다양한 입력을 일관된 형태로 변환하기 위함입니다.

3. 이전 상태 확인 및 분기 처리

  const oldVNode = vNodeMap.get(container);

  if (!oldVNode) {
    const element = createElement(normalizedNode);
    container.appendChild(element);
  } else {
    updateElement(container, normalizedNode, oldVNode, 0);
  }

만약 최초의 렌더링이라면 (즉, oldVNode가 없는 경우) 이때는 단순히 vNode를 실제 DOM 요소로 변환하여 컨테이너에 추가합니다. 그러나 리렌더링이라면, updateElement를 호출하여 diffing 알고리즘을 통해 변경된 부분만 효율적으로 업데이트(재조정 - Reconciliation)합니다.

4. 이벤트 시스템 초기화

  setupEventListeners(container);

렌더링이 완료된 후 새로 추가된 요소들에 대해서 이벤트 처리가 가능하도록 이벤트 위임 시스템을 초기화합니다.

5. 현재 상태 저장

  vNodeMap.set(container, normalizedNode);

다음 렌더링을 위해 현재 vNode를 저장합니다. 이는 다음번 renderElement 호출시 diffing의 기준이 됩니다.

지금까지 renderElement 함수를 통해 가상 DOM 시스템의 핵심 렌더링 로직을 살펴보았습니다. 물론 실제 React는 훨씬 더 복잡하고 정교합니다. 특히 최근의 React는 Fiber 아키텍처를 통해 렌더링 작업을 작은 단위로 나누어 비동기적으로 처리하고, 우선순위 기반 스케줄링, 시간 분할 렌더링 등의 최적화 기법들을 사용합니다. 하지만 우리는 가상 DOM의 핵심 아이디어와 diffing 알고리즘의 본질을 파악할 수 있었습니다. 대략적인 React의 이벤트 관리, 렌더링 플로우를 이해하는 것에 누군가에 도움이 되길 바라며 글을 마칩니다.

출처

https://github.com/acdlite/react-fiber-architecture

각각을 문서화하면서 좀 더 파고들게 된 개념은 다음과 같습니다.

  1. JSX Transform과 트랜스파일링 React 17 이전과 이후의 JSX 처리 방식에 차이가 있어 React를 import 해주는것이 불필요해졌으며, createVNode를 직접적으로 참조하지 않고 있는데도 어디서 트랜스파일링 하고 있는지 이해할 수 있었습니다.

  2. 가상 DOM의 정규화 과정 normalizeVNode에서의 정규화가 왜 필요한지에 대해 이해할 수 있었습니다.

  3. 실제 DOM 생성의 복잡성 React.createElement와 우리의 createElement가 어떻게 다른지, 그리고 DocumentFragment를 통하여 어떻게 성능적으로 좀 더 개선되게 DOM생성이 가능한지를 이해할 수 있었습니다.

  4. 이벤트 위임 시스템 WeakMap과 Map의 메모리 관리 방식이 어떻게 차이나는 지에 대해 이해하고, WeakMap을 사용하여 메모리 누수를 방지할 수 있음을 알게 되었습니다.

  5. React의 Diffing 알고리즘 핵심 원리 Diffing 알고리즘과 Reconciliation의 원리와 내부적으로 어떻게 구현할 수 있을지에 대해서 이해했습니다.

코드 품질

제가 코드적으로 많이 고민했던 부분은 이벤트 위임 시스템과 관련된 함수들인데요,

const eventMap = new WeakMap();
const delegatedEvents = new Set();
let rootElement = null;

/**
 * 컨테이너에 이벤트 리스너를 한 번만 등록합니다.
 *
 * @param {HTMLElement} container - 이벤트 리스너를 등록할 컨테이너 엘리먼트
 */
export function setupEventListeners(container) {
  rootElement = container;
  delegatedEvents.forEach((eventType) => {
    container.removeEventListener(eventType, handleEvent);
    container.addEventListener(eventType, handleEvent);
  });
}

이벤트 위임 시스템에서 WeakMap을 사용하여 DOM 요소가 제거될 때 관련된 이벤트 핸들러 정보도 자동으로 가비지 컬렉션도록 구현한 부분이 좋았다고 생각합니다.

updateElement 관련해서 많이 고민을 했었는데, 특히 자식 노드들의 순서 변경과 추가/제거를 어떻게 효율적으로 처리할지에 대한 부분이었습니다.

const newChildren = newNode.children || [];
const oldChildren = oldNode.children || [];
const maxLength = Math.max(newChildren.length, oldChildren.length);

for (let i = 0; i < maxLength; i++) {
  updateElement(childNode, newChildren[i], oldChildren[i], i);
}

if (oldChildren.length > newChildren.length) {
  for (let i = oldChildren.length - 1; i >= newChildren.length; i--) {
    const extraChild = childNode.childNodes[i];
    if (extraChild) {
      childNode.removeChild(extraChild);
    }
  }
}

처음에는 단순히 모든 자식을 제거하고 다시 추가하는 방식을 생각했었는데, 이는 성능상 비효율적이라는 것을 깨달았습니다. 그래서 기존 DOM 노드를 최대한 재사용하면서 필요한 부분만 업데이트하는 방향으로 구현했습니다. 특히 뒤에서부터 제거하는 부분이 중요했는데, 앞에서부터 제거하면 인덱스가 변경되어 잘못된 노드를 제거할 위험이 있기 때문입니다.

또한 타입이 변경되었는지 확인하고 비교를 하는 부분도 고민 포인트였습니다.

if (newNode.type !== oldNode.type) {
  if (parentElement.childNodes[index]) {
    parentElement.replaceChild(createElement(newNode), parentElement.childNodes[index]);
  } else {
    parentElement.appendChild(createElement(newNode));
  }
  return; // 하위 비교를 생략하여 성능 향상
}

React의 '서로 다른 타입의 엘리먼트는 서로 다른 트리를 만든다"를 직접 구현하면서, 왜 이런 가정이 휴리스틱 알고리즘을 통한 복잡도 개선을 가능하게 하는지 이해할 수 있었다고 생각합니다.

학습 효과 분석

문서화에 시간을 많이 쓰게 되어 추가적으로 해보려다 실패한 부분이 있는데, 위에서 정리한 문서에도 나와 있지만 현재 제 코드에는 React의 key값에 대한 고려 없이 자식 노드 비교 시 단순히 인덱스 기반으로만 비교하고 있습니다. 때문에 key의 비교를 통해서 렌더링을 진행하게 된다면 좀 더 React의 내부 구현과 가까워지지 않을까하는 생각이 듭니다.

또 사실 이번 과제는 Fiber 아키텍처에 대한 고민이나 학습이 되진 않았는데, 좀 더 시간을 들여서 공부해봐야겠다고 생각했습니다.

과제 피드백

다른 분들께서 언급하신 비동기 컴포넌트 등에 대해서도 좀 더 심화되어 과제로 나갈 수 있다면 어떨까...하는 생각이 듭니다.

리뷰 받고 싶은 내용

  1. 이벤트 위임 시스템을 구현하면서 WeakMap + Map + Set의 3단계 중첩 구조를 선택했는데, 이 설계가 적절한지 궁금합니다. 각 DOM 요소마다 이벤트 타입별로 여러 핸들러를 관리해야 했는데, 메모리 안전성(WeakMap), 타입별 분류(Map), 중복 방지(Set)를 모두 고려하다 보니 복잡한 구조가 되었습니다.
export function addEvent(element, eventType, handler) {
  // 1단계: element에 대한 Map이 없으면 생성
  if (!eventMap.has(element)) {
    eventMap.set(element, new Map());
  }

  // 2단계: eventType에 대한 Set이 없으면 생성
  const elementEvents = eventMap.get(element);
  if (!elementEvents.has(eventType)) {
    elementEvents.set(eventType, new Set());
  }
  
  // 3단계: 실제 handler 추가
  elementEvents.get(eventType).add(handler);
  
  // ... 나머지 로직
}

특히 addEvent에서 매번 3단계 초기화를 체크하는 부분이 번거로워 보이는데, 더 깔끔한 방법이 있을지 의견을 듣고 싶습니다.

  1. 모듈 레벨의 전역 변수
const eventMap = new WeakMap();
const delegatedEvents = new Set();
let rootElement = null;

export function setupEventListeners(container) {
  rootElement = container;
  delegatedEvents.forEach((eventType) => {
    container.removeEventListener(eventType, handleEvent);
    container.addEventListener(eventType, handleEvent);
  });
}

rootElement, eventMap, delegatedEvents를 모듈 레벨 전역 변수로 관리하고 있는데, 이로 인해 다중 컨테이너 지원이 어렵고 테스트 격리에 문제가 있을 수 있을 것 같습니다. 하지만 과제 범위에서는 테스트코드가 통과하기도 하고, 단일 컨테이너 사용을 가정하므로 현재 구조를 유지했는데, 팩토리 함수등의 패턴을 통하여 좀 더 확장성 있고 격리된 코드를 구성해야 했을까요?

  1. 지금 현재 React는 key를 기반으로 하지 않고 인덱스를 기반으로 하고 있는데, 만약 key로 구현하고 싶다면 어떻게 구현해야할지에 대해 고민이 되었습니다. 그런데 구현에 어려움이 있어 제가 로직상에 놓친 부분이 있을지 여쭤보고싶습니다.

제가 구현 시도했던 부분은 다음과 같습니다. (복잡한 코드들은 수도코드로 남겨두겠습니다.)

// node에서 key값 가져오기
function getNodeKey(node) {
  if (!node || typeof node === "string" || typeof node === "number") {
    return null;
  }
  return node.props?.key || null;
}

// key를 통한 재조정 함수
function reconcileChildrenWithKeys(parentElement, newChildren, oldChildren) {
  const operations = analyzeKeyChanges(oldChildren, newChildren);
  applyDOMOperations(parentElement, operations);
}

function analyzeKeyChanges(oldChildren, newChildren) {
  // 1. Key 매핑 테이블 생성
  oldKeyMap = old 자식들을 key로 분류
  newKeyMap = new 자식들을 key로 분류
  
  operations = {업데이트[], 생성[], 제거[], 이동[]}
  
  // 2. 새로운 자식들 기준으로 작업 분류  
  for (새로운 자식 in newKeyMap) {
    if (oldKeyMap에 같은 key 있음) {
      → 기존 노드 재사용 (toUpdate)
      if (위치가 변경됨) {
        → 이동 필요 (toMove)
      }
    } else {
      → 새로 생성 (toCreate)
    }
  }
  
  // 3. 제거할 노드들 찾기
  for (이전 자식 in oldKeyMap) {
    if (newKeyMap에 같은 key 없음) {
      → 제거 필요 (toRemove)
    }
  }
  
  return operations
}

function applyDOMOperations(parentElement, operations) {
  // 1. 제거 (뒤에서부터)
  
  // 2. 업데이트
  
  // 3. 생성 및 삽입
  
  // 4. 재배치 - 모든 노드를 올바른 순서로 재정렬
}

과제 피드백

영서님 한 주 고생하셨습니다. 정리한 내용을 공유해주신 부분도 그렇고, 함께 성장하려고 하시는 부분에 있어서 매우 인상깊었습니다. 남겨주신 회고도 아주 고봉밥인데요. 다른 분들도 함께 보면서 이야기 나눌 수 있으면 좋았겠네요. 과제에 있어서는 필요한 부분을 명확하게 잘 구현해주신 것 같습니다. 질문 주셨던 부분 답변 드려보고 마무리 해볼게요.

이벤트 위임 시스템을 구현하면서 WeakMap + Map + Set의 3단계 중첩 구조를 선택했는데, 이 설계가 적절한지 궁금합니다.

저는 명확한 것 같습니다. 실제 fiber구조에서도 비슷한 목적에 맞춰 구현이 되어 있는 걸로 알고 있고 굳이굳이 나누면 더 최적화를 할 수 있을 것 같지만 지금의 수준에서는 충분한 것 같아요. 사실 이걸 구현하는데 있어서 영서님이 명확한 목적을 갖고 있고, 그걸 구현하는데 선택하는 자료구조이기 때문에 해당 자료구조를 사용하는 메서드가 약간 복잡해지고 하는 부분은 그 자료구조에서 오는 장점이 더 도움이 되기 때문에 크게 고민할 필요는 없다고 생각해요.

모듈 레벨의 전역 변수

넵 말씀해주신것처럼 지금 과제의 수준에서는 크게 문제가 없을 것 같은데요. 정답을 이미 알고 있는것처럼 팩토리 함수같은 것을 사용해서 함수 내에 컨텍스트를 묶어 사용하는게 오염 측면이나 캡슐화 측면에서 유리한 것 같아요. 회사에서도 이런 비슷한 코드를 작성하는 경험이 많이 있는데, 저같은 경우에는 처음부터 분리를 하는 편인데요. 하지만, 이미 알고 있는 것처럼 그리고 우리가 자주 말하는 여러 원칙처럼 필요한 만큼만 최대한 단순하게 작성하는게 좋지 않을까 싶습니다. (근데 우리는 지금 공부를 하는 과정이니까 여유가 된다면 분리해보는 연습을 하는게 좋아보여요)

지금 현재 React는 key를 기반으로 하지 않고 인덱스를 기반으로 하고 있는데, 만약 key로 구현하고 싶다면 어떻게 구현해야할지에 대해 고민이 되었습니다. 그런데 구현에 어려움이 있어 제가 로직상에 놓친 부분이 있을지 여쭤보고싶습니다.

이 부분도 뭔가 이미 답을 알고 있는 것 같은 것 같은 느낌인데요 ㅎㅎ 구현을 실제로 해봐야 알겠지만, 전반적인 흐름은 리액트 구현과 유사하다고 생각이 듭니다. 다만, 키가 없는 여러 노드들에 대한 처리나 key가 중복되서 입력될 때의 처리, 그리고 결국 모든게 시점이 중요한 것 같아서 DOM을 조작하는 여러 순서들을 최적화 하는게 핵심이지 않을까 싶긴하네요. 좀 찾아보니 리액트에서는 이펙트나 트리 순회에 맞춰서 많이 복잡하게 되어 있는 것 같은데, 단순화 해서 삭제 / 이동 / 생성 및 삽입 / 업데이트 요런 흐름으로 진행하는 것 같아서요. 이런 부분도 이미 찾아보셨겠지만 참고해보면서 구현하면 좋을 것 같아요.

  • 지금 충분히 잘해주고 계셔서 이대로도 사실 상관 없을 것 같긴한데요. 그냥 첨언을 해보면, 지금의 과제는 변경된 지점이 있어서 새로운 부분도 있었을 것 같은데요! 추후에 과제를 진행하는데 있어서 여러번 경험한 과제가 있다면 이전에 아쉬웠던 부분이나 기술적으로 시도해보고 싶었던 부분을 더 확장해서 해보는것도 좋을 것 같아요. 고생하셨고 지금처럼 다음 주 과제도 화이팅입니다~