Skip to content

[2팀 정도은] Chapter 1-2. 프레임워크 없이 SPA 만들기 (2)#43

Open
nemobim wants to merge 15 commits into
hanghae-plus:mainfrom
nemobim:main
Open

[2팀 정도은] Chapter 1-2. 프레임워크 없이 SPA 만들기 (2)#43
nemobim wants to merge 15 commits into
hanghae-plus:mainfrom
nemobim:main

Conversation

@nemobim
Copy link
Copy Markdown

@nemobim nemobim commented Jul 14, 2025

과제 체크포인트

배포 링크

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)"되어있다.

특징 Map WeakMap
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
Loading

코드 품질

/**
 * 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 처리되어 걸러지는지 정확히 이해하지 못하겠습니다… 혹시 어떤 케이스 때문에 이런 처리가 꼭 필요한 걸까요?

nemobim added 13 commits July 15, 2025 02:54
- children 배열을 평탄화하는 flattenArray 작업 추가
- createElement에서 속성/이벤트 처리를 updateAttributes로 위임하고 배열/자식 처리 개선

- eventManager에서 위임된 이벤트 리스너와 루트 요소 추적 관리 개선

- normalizeVNode에서 자식 요소 정규화 및 함수형 컴포넌트 재귀 처리 단순화

- renderElement에서 초기 렌더링과 업데이트 구분 개선

- updateElement에서 속성 및 자식 요소에 대한 더 강력한 diff 알고리즘 적용
@eveneul
Copy link
Copy Markdown

eveneul commented Jul 17, 2025

소문듣고왔습니다

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants