nemobim 님의 상세페이지[2팀 정도은] Chapter 1-2. 프레임워크 없이 SPA 만들기 (2)

과제 체크포인트

배포 링크

https://nemobim.github.io/front_6th_chapter1-2/

기본과제

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

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

이벤트 위임

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

심화 과제

Diff 알고리즘 구현

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

과제 셀프회고

기술적 성장

중첩 배열과 JSX 트랜스파일 이해

<div>
  Hello
  world
  !
</div>

위와 같은 JSX 구조는 트랜스파일 과정을 거치며 다음과 같이 변환될 수 있습니다:

createVNode("div", null, "Hello", ["world", "!"]);

이 과정에서 중첩 배열이 생기는 이유에 대해 궁금했는데, 아래와 같은 경우들로 인해 발생할 수 있음을 이해하게 되었습니다.

왜 중첩 배열이 발생하는가?

  • map() 함수 사용 시 → 각 순회 결과가 배열로 반환되므로 전체 결과가 중첩 구조가 됨
{items.map(item => <span>{item}</span>)} // 배열의 배열
  • 조건부 렌더링condition && <Component /> 패턴은 false를 반환할 수 있고, 결과적으로 배열 내에 다양한 값이 혼재함
{isLoggedIn && <UserInfo />}
  • 복합 표현식 → JSX 안에서 여러 표현식을 콤마 없이 나열하면 내부적으로 배열처럼 처리됨
<>{"Hello"}{"World"}{"!"}</>
  • 함수형 컴포넌트가 배열을 반환할 때 → 일부 컴포넌트는 return [<A />, <B />] 형태로 배열을 직접 반환함
const List = () => [<Item1 />, <Item2 />];

WeakMap이란 무엇인가

WeakMap은 자바스크립트 내장 객체 중 하나로 key로 "객체만" 가질 수 있는 Map으로 이름처럼 "약하게 연결(weakly held)"되어있다.

특징MapWeakMap
key 타입어떤 값이든 가능❗️ 객체만 가능
순회 가능✅ 가능❌ 불가능
GC(가비지 컬렉션)❌ 키가 메모리에 남음✅ 키가 참조 사라지면 자동 삭제됨

코어 시스템 이해

1. createVNode.js - Virtual Node 생성

React의 createElement와 유사한 Virtual Node 생성 함수

  • 기능: type, props, children을 가진 VNode 객체 생성
  • 역할: JSX나 함수 호출을 Virtual DOM 객체로 변환
// 예시
createVNode('div', { className: 'container' }, ['Hello', 'World'])
// → { type: 'div', props: { className: 'container' }, children: ['Hello', 'World'] }

2. createElement.js - DOM 요소 생성

VNode를 실제 DOM 요소로 변환하는 핵심 함수

주요 처리 로직:

  • 배열DocumentFragment 생성
  • null/undefined/boolean → 빈 텍스트 노드
  • 문자열/숫자 → 텍스트 노드
  • VNode → 실제 DOM 요소 생성

속성 처리:

  • classNameclass 어트리뷰트
  • 이벤트 핸들러 (onClick 등)
  • 불린 속성 (checked, disabled 등)
  • style 객체 처리

3. normalizeVNode.js - VNode 정규화

다양한 타입의 노드를 일관된 형태로 정규화

  • null/undefined/boolean → 빈 문자열
  • 문자열/숫자 → 문자열 변환
  • 함수 컴포넌트 → 재귀적 실행 후 정규화
  • VNode → children도 재귀적으로 정규화
// 예시
normalizeVNode(null) // → ""
normalizeVNode(42) // → "42"
normalizeVNode(MyComponent) // → 컴포넌트 실행 결과

4. renderElement.js - 렌더링 엔진

VNode를 실제 DOM에 렌더링하는 메인 함수

  • 최초 렌더링:
    • 컨테이너 초기화 (innerHTML = '')
    • 새 요소 생성 및 추가
    • 이벤트 리스너 설정
  • 업데이트:
    • updateElement를 통한 diff 기반 효율적 업데이트
    • 변경된 부분만 선택적 업데이트

5. updateElement.js - DOM 업데이트

Virtual DOM의 핵심인 diff 알고리즘 구현

  1. 노드 제거 - 새 노드 없음
  2. 노드 추가 - 기존 노드 없음
  3. 텍스트 노드 업데이트 - 문자열/숫자 변경
  4. 타입 변경 - 완전 교체 (<div><span>)
  5. 같은 타입 - 속성 및 자식 업데이트
// Diff 과정 예시
// 이전: <div>Hello</div>
// 새로: <div>World</div>
// → 텍스트 노드만 "Hello" → "World"로 변경

6. eventManager.js - 이벤트 위임 시스템

효율적인 이벤트 관리를 위한 이벤트 위임 구현

  • WeakMap 사용한 요소별 이벤트 저장
  • 루트 요소에서 모든 이벤트 위임 처리
  • event.target 기반 상위 노드 탐색
  • 자동 정리 - 요소 제거 시 이벤트도 자동 정리

주요 함수

  • setupEventListeners: 루트에 이벤트 리스너 등록
  • addEvent: 요소에 이벤트 핸들러 등록
  • removeEvent: 이벤트 핸들러 제거
// 이벤트 위임 동작 예시
// 1. 루트에 click 리스너 등록
// 2. 자식 요소 클릭 시 이벤트 버블링
// 3. 루트에서 실제 타겟 확인 후 핸들러 실행

전체 동작 흐름

graph TD
    A[JSX/함수 호출] --> B[createVNode]
    B --> C[normalizeVNode]
    C --> D[renderElement]
    D --> E{최초 렌더링?}
    E -->|Yes| F[createElement]
    E -->|No| G[updateElement]
    F --> H[setupEventListeners]
    G --> I[DOM 업데이트 완료]
    H --> I

코드 품질

/**
 * vNode를 실제 DOM에 렌더링하는 함수
 * @param {Object|string|number|null} vNode - 렌더링할 가상 DOM 노드
 * @param {HTMLElement} container - 렌더링할 대상 컨테이너 요소
 */
export function renderElement(vNode, container) {
  // vNode 정규화
  const normalizedNode = normalizeVNode(vNode);
  // 이전 vNode 저장
  const oldVNode = container._vNode;

  if (!oldVNode) {
    // 최초 렌더링
    container.innerHTML = "";
    const element = createElement(normalizedNode);
    container.appendChild(element);

    //이벤트는 초기에 한번만 등록
    setupEventListeners(container);
  } else {
    // 업데이트: 기존 DOM과 비교하여 변경사항만 적용
    updateElement(container, normalizedNode, oldVNode, 0);
  }

  // 현재 vNode 저장
  container._vNode = normalizedNode;
}

처음 렌더링된 VNode는 container의 _vNode 속성에 저장해두고 이후 화면이 다시 렌더링될 경우 이전 VNode와 새 VNode를 비교해 필요한 부분만 변경하도록 updateElement를 호출하는 방식으로 구현했습니다. 매번 전체를 다시 그리는 게 아니라 변경된 부분만 DOM에 반영하도록 하고 이때 이벤트는 초기에 한 번만 등록되도록 처리했습니다.

// 전체 시스템에서 사용 중인 이벤트 타입들 추적
const delegatedEvents = new Set();

...생략

// 새로운 이벤트 타입이면 delegatedEvents에 추가하고 루트에 리스너 재등록
  if (!delegatedEvents.has(eventType)) {
    delegatedEvents.add(eventType);

    // 루트 요소를 찾아서 새로운 이벤트 타입 리스너 등록
    let current = element;
    while (current) {
      if (current._eventHandler) {
        current.removeEventListener(eventType, current._eventHandler);
        current.addEventListener(eventType, current._eventHandler, false);
        break;
      }
      current = current.parentElement;
    }
  }


..생략

/**
 * @param {HTMLElement} root - 이벤트 리스너를 등록할 루트 엘리먼트
 */
export function setupEventListeners(root) {
  // 루트 요소 저장
  rootElement = root;

  // 리스너 등록
  delegatedEvents.forEach((eventType) => {
    rootElement.removeEventListener(eventType, handleEvent);
    root.addEventListener(eventType, handleEvent, false);
  });
}

처음에는 사용할 이벤트 타입들을 코드에 직접 하드코딩했는데, 이렇게 하면 새로운 이벤트가 생길 때마다 일일이 코드를 수정해줘야 할거 같아서 이벤트 타입을 동적으로 관리할 수 있도록 delegatedEvents라는 Set을 만들었습니다. 새로운 이벤트 타입이 등장하면 해당 타입을 Set에 등록하고, 루트 요소에 한 번만 리스너를 붙이도록 구조를 변경했습니다.

학습 효과 분석

eventManager 관련

if (eventListeners.has(type)) return;
eventListeners.set(type, true);

Map을 사용하여 같은 이벤트 타입이 여러 번 등록되는 것을 방지할 수 있었다.

버블링을 활용한 이벤트 탐색

let target = e.target;
while (target && target !== container) {
  const events = eventStore.get(target);
  if (events?.[type]) {
    for (const handler of events[type]) {
      handler.call(target, e);
    }
  }
  target = target.parentNode;
}

실제 클릭된 요소부터 컨테이너까지 DOM 트리를 역순으로 탐색하며 등록된 핸들러를 찾아 실행해준다. 이 방법을 사용하면 수천 개의 요소에 개별 리스너를 다는 대신, 하나의 부모에서 모든 이벤트를 처리할 수 있다.

과제 피드백

  • 나머지 부분들이 구현되어 있어서 vNode 작성 함수에 집중할 수 있어서 좋았습니다.
  • 이론만 봤을 땐 너무 추상적이라 어려웠는데, 테스트 코드 가이드를 따라가다 보니 구현이 가능해서 이해하기 수월했습니다..

리뷰 받고 싶은 내용

export function createVNode(type, props, ...children) {
  return {
    type,
    props,
    children: flattenArray(children),
  };
}


/**
 * 배열 평탄화
 * @param {Array} arr - 평탄화할 배열
 * @returns {Array} 평탄화된 배열
 */
export function flattenArray(arr) {
  const result = [];

  for (const item of arr) {
    if (Array.isArray(item)) {
      result.push(...flattenArray(item));
    } else if (item !== null && item !== undefined && typeof item !== "boolean") {
      result.push(item); // falsy 값이 아닐 때만 추가, boolean 값도 제외
    }
  }

  return result;
}

처음에는 flat(Infinity)를 사용했지만, 배열의 깊이가 2차원으로 고정되어 있는 것 같아 재귀 함수로 다시 구현했습니다. 이처럼 배열의 뎁스를 명확히 알고 있을 경우, flat() 대신 직접 구현하는 방식이 성능면에서 더 좋을까요... 차이가 미미하다면 가독성을 우선하는 게 더 나을 것 같은데 일반적으로 어느 정도를 "미미하다"고 보는지도 궁금합니다.


처음에는 normalizeVNode를 아래와 같이 작성했습니다:

export function normalizeVNode(vNode) {
  if (typeof vNode === "object" && vNode?.type) {
    return {
      ...vNode,
      children: (vNode.children || []).map(normalizeVNode),
    };
  }

  return vNode;
}

이 코드는 단위 테스트에서는 통과했지만, e2e 테스트에서 실패해서 다음과 같이 filter(Boolean)을 추가해 수정했습니다:

const normalized = {
  ...vNode,
  children: (vNode.children || []).map(normalizeVNode).filter(Boolean),
};

앞단에서도 filter(Boolean) 처리를 해주는 부분이 많았고 이 시점에서 추가로 필터링하지 않아도 된다고 생각했는데 이 코드를 추가하자 테스트가 통과했습니다. 왜 이 필터링이 필요한 건지, 어떤 값이 false 처리되어 걸러지는지 정확히 이해하지 못하겠습니다… 혹시 어떤 케이스 때문에 이런 처리가 꼭 필요한 걸까요?

과제 피드백

안녕하세요 도은님! 2주차 과제 잘 진행해주셨네요!! 너무 고생하셨습니다 ㅎㅎ 무엇보다 학습 과정과 전체적인 흐름에 대해 정리를 꼼꼼하게 해주셔서 좋았어요! 저도 다시 복습하는 느낌!?


처음에는 flat(Infinity)를 사용했지만, 배열의 깊이가 2차원으로 고정되어 있는 것 같아 재귀 함수로 다시 구현했습니다. 이처럼 배열의 뎁스를 명확히 알고 있을 경우, flat() 대신 직접 구현하는 방식이 성능면에서 더 좋을까요... 차이가 미미하다면 가독성을 우선하는 게 더 나을 것 같은데 일반적으로 어느 정도를 "미미하다"고 보는지도 궁금합니다.

2차원으로 고정되어있진 않답니다 ㅎㅎ 배열안에 배열이 있을수도 있어서요. 구현해주신 내용이 flat 내부에 그대로 표현되어 있지 않을까요!? 그래서 가독성과 성능 모두 flat(Infinity) 를 사용하는게 더 좋다고 생각해요. 무엇보다 native api를 사용해야 브라우저가 업데이트 될 때 자연스럽게 내부 로직도 개선이 되고 성능도 자연스럽게 좋아질 수 있답니다!

다만, 이와는 별개로 직접 구현하는 과정도 중요하다고 생각해요. 저희는 "학습"을 하고 있기 때문이죠!

normalizeVNode: 앞단에서도 filter(Boolean) 처리를 해주는 부분이 많았고 이 시점에서 추가로 필터링하지 않아도 된다고 생각했는데 이 코드를 추가하자 테스트가 통과했습니다. 왜 이 필터링이 필요한 건지, 어떤 값이 false 처리되어 걸러지는지 정확히 이해하지 못하겠습니다… 혹시 어떤 케이스 때문에 이런 처리가 꼭 필요한 걸까요?

흠... 그러게요 ㅋㅋ 제가 작성한 솔루션에서도 똑같은데요, e2e:ui로 확인해보면 없어져야할 콘텐츠가 아직 남아있는 등의 문제가 있어요. 궁금해서 조금 디버깅을 해보니까

대략 이런 상황인데요,

filter를 하지 않을 때는 비어있는 text node를 하나 만들게 됩니다.

children: ["", vnode, "", vnode, "", vnode, ...]

filter를 하면 빈 노드를 제거합니다.

children: [vnode, vnode, vnode, ...]

빈 노드가 만들어지고, 빈 노드로 인한 diff 연산 과정에서 꼬임이 발생하는 것 같아요...! 제가 시간 되면 조금 더 찾아보겠습니다.