Skip to content

[1팀 주산들] Chapter 1-2. 프레임워크 없이 SPA 만들기 (2) #38

Open
DEV4N4 wants to merge 8 commits into
hanghae-plus:mainfrom
DEV4N4:main
Open

[1팀 주산들] Chapter 1-2. 프레임워크 없이 SPA 만들기 (2) #38
DEV4N4 wants to merge 8 commits into
hanghae-plus:mainfrom
DEV4N4:main

Conversation

@DEV4N4
Copy link
Copy Markdown

@DEV4N4 DEV4N4 commented Jul 14, 2025

과제 체크포인트

배포 링크

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

기본과제

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

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

이벤트 위임

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

심화 과제

Diff 알고리즘 구현

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

과제 셀프회고

음.. 설명이 친절해서 과제를 어렵지 않게 진행할 수 있을 줄 알았는데 생각보다 어려웠다.

디버깅을 할 줄 몰라서 초반에 조금 고생을 했고, 디버깅 하는 방법을 배우고 나서도 생각대로 로그가 찍히지 않거나 예상했던 결과가 나오지 않아서 고생을 했다.

과제 초반에는 이런 함수를 왜 구현해야 하는지 의문을 가지고 한참 찾아보면서 이해하려고 애썼고, 조금 공부한 결과 흐름이 이해 되었지만 개념이나 원리를 이해하고 진행을 하더라도 그걸 코드로 녹여내 보여주는 것이 생각보다 까다로웠다. (개념이랑 흐름, 여기서 이 함수가 왜 필요한지나 이 조건문을 왜 넣는지 같은건 알겠는데 그래서 그 다음엔 어떻게 진행해야 할지를 잘 모르겠어서 다른 분들의 PR도 많이 참고하고 많이 물어가면서 개발을 진행했던 것 같다…)

기술적 성장

이번 과제를 수행하면서 Virtual DOM에 대해 깊게 이해하게 된 것 같다.

구현한 각각의 함수의 역할에 대해 간략한 정리 & 설명을 한다면

1. createVNode

createVNode 함수는 Virtual Node를 생성하는 함수이다.

각 페이지 파일마다 존재하는 /** @jsx createVNode */ (JSX 프래그마(pragma) 주석)을 통해 JSX가 createVNode(...) 함수로 변환되도록 지시해 사용한다.

children을 평탄화하여 사용하였다.

2. normalizeVNode

normalizeVNode 함수는 주어진 가상 노드(vNode)를 표준화된 형태로 변환하는 역할을 한다.

이 함수는 다양한 타입의 입력을 처리하여 일관된 형식의 가상 노드를 반환하여 DOM 조작이나 렌더링 과정에서 일관된 데이터 구조를 사용할 수 있도록 하는 역할을 수행한다.

3. createElement

vNode를 Javascript Element로(가상돔(VirtualDOM)을 돔으로) 변환해주는 함수이다.

4. eventManager

  1. addEvent와 removeEvent를 통해 element에 대한 이벤트 함수를 어딘가에 저장하거나 삭제한다.
  2. setupEventListeners를 이용해서 이벤트 함수를 가져와서 한 번에 root에 이벤트를 등록한다.

findParent 함수를 통해 이벤트 위임(버블링) 방식으로 등록하였다.

5. renderElement

renderElement 함수는 앞에서 작성된 함수들을 조합하여 vNode를 container에 렌더링하는 작업을 수행한다.

최초 렌더링일 때(container._previousVNode == null 일 때)는 createElement 함수를 사용하여 랜더링이 되고, 리렌더링일 때는 updateElement 함수를 사용한다.

6. updateElement

updateElement 함수는 모든 태그를 비교하여 변경된 부분에 대해 수정/추가/삭제 작업을 수행해준다.

이렇게 정리가 될 수 있을 것 같다.

이 함수들의 역할과 서로 각자 무슨 연관관계가 있는지가 처음에는 잘 와닿지 않았고 리액트 내부에서 왜 이런 동작이 필요한지 잘 모르고 진행을 했었는데, 여러 자료들을 찾아보고 코드를 작성해 나가면서 이해가 되었다..

코드 품질

updateElement 함수를 구현하면서 고민을 좀 했다.

// 5. 같은 타입의 노드 업데이트
  //   - 속성 업데이트
  //   - 자식 노드 재귀적 업데이트
  //   - 불필요한 자식 노드 제거
  if (target) {
    const oldProps = oldNode.props;
    const newProps = newNode.props;
    updateAttributes(target, newProps ?? {}, oldProps ?? {});

    const oldChildren = oldNode.children ?? [];
    const newChildren = newNode.children ?? [];
    for (let i = 0; i < Math.max(newChildren.length, oldChildren.length); i++) {
      updateElement(target, newChildren[i], oldChildren[i], i);
    }

// 이 부분!!
// ************************************************ //
    if (oldChildren.length > newChildren.length) {
      for (let i = oldChildren.length - 1; i >= newChildren.length; i--) {
        if (target.childNodes[i]) {
          target.removeChild(target.childNodes[i]);
        }
      }
    }
  }
// ************************************************ //

구현을 끝내고 코드를 다시 점검할 때,
갑자기 주석으로 표시한 부분의 코드를 최적화 할 수 있지 않을까 하는 의문이 생겼었다.

왜냐하면 상단에서 newChildren 혹은 oldChildren 중 length가 긴 쪽을 중심으로 이미 for문이 한번 돌아가면서 node들을 제거 혹은 교체를 한번씩 진행해줄텐데 굳이 아랫부분에서 한번 더 for문을 돌려서 제거를 진행할 필요가 있는지 의문이 들었다.

그래서 GPT에게 더 좋은 방법이 없을지, 저 부분이 꼭 필요한 코드일지 물어보며 답을 얻었다.
결과적으로 저 부분은 최적화를 해야하는 부분이 아니라 정확한 구현을 위해 그대로 필요한 코드였다.

예를 들어서 설명하자면 아래와 같다.

1. 초기 상태

  • oldChildren: [A, B, C, D]
  • newChildren: [A, C]
  • 실제 DOM의 parent.childNodesA, B, C, D 순이라고 가정을 하자.
parent
 ├── A
 ├── B
 ├── C
 └── D

2. 재귀 호출(재귀 for 루프) 진행

for (let i = 0; i < Math.max(...); i++) 루프가 i = 0에서 3까지 돌게 된다.

  1. i = 0

    • oldChildren[0] = A, newChildren[0] = A
    • 타입·키가 같으므로 속성만 업데이트하고 건너뜀
    • DOM: A, B, C, D
  2. i = 1

    • oldChildren[1] = B, newChildren[1] = C
    • 타입이 다르므로 parent.replaceChild(createElement(C), targetAtIndex1) 수행
    • 이때 B가 C로 교체되어, DOM이 [A, C, C, D]가 됨.
  3. i = 2

    • oldChildren[2] = C, newChildren[2] = undefined

    • newNode가 없고 oldNode가 있으므로 removeChild(targetAtIndex2) 수행

    • 이때 두 번째 인덱스(0-based)였던 childNodes[2](원래 교체 후의 두 번째 C)가 삭제되어

      DOM이 [A, C, D]가 됨.

  4. i = 3

    • oldChildren[3] = D, newChildren[3] = undefined

    • removeChild(targetAtIndex3)을 시도하나,

      현재 childNodes 길이는 3 (인덱스 0,1,2)라서 아무 일도 일어나지 않아

    • 결과적으로 D가 여전히 남아 있는 문제가 발생.

    • DOM: [A, C, D]

3. 클린업(Cleanup) 단계

if (oldChildren.length > newChildren.length) {
  // oldChildren.length = 4, newChildren.length = 2
  for (let i = oldChildren.length - 1; i >= newChildren.length; i--) {
    if (target.childNodes[i]) {
      target.removeChild(target.childNodes[i]);
    }
  }
}
  • i = 3 (인덱스 3): childNodes[3]이 없으므로 건너뜀
  • i = 2 (인덱스 2): childNodes[2]은 D → 제거
  • 이후 i = 1이면 반복 종료 조건(i >= 2)가 false가 되어 루프 끝

최종 DOM 구조는 [A, C]가 되어 새로운 자식 배열(newChildren)과 정확히 일치한다.

어차피 newChildren의 length보다 많게 남아있는 요소들은 필요가 없으므로 이 단계에서 삭제해줘야 한다. 그래서 필요한 로직이고 앞에서 for문을 한번 돌리는 것으로 모든것을 해결하기는 어려웠을 것 같다.

결론은 처음 구현한 형태가 최선일 것 같아서 수정하지 않았다 였지만, 최적화를 시도해보려고 다시 한번 고민했던 경험이 좋았다.

학습 효과 분석

우리가 어떻게 JSX 문법을 쓸 수 있게 되었는지에 대해서 알게 되었고, 디버깅 하는 방법에 대해서도 새로 알게 되었다. 또한 구현을 위해 mdn에 들락날락 하면서 다양한 document 메서드에 대해서 읽어보는 기회도 되었던 것 같다.

막연히 마법처럼 느껴졌던 과정이 좀 더 현실적으로 와닿아서 의의가 있었다는 생각이 들었다.

아직 이러한 과정들을 흐름적으로만 설명할 수 있고 영서님께서 정리해주신 것 처럼 체계적으로 설명하기는 어려운 것 같아서 기회가 된다면 나중에 블로그에 내용을 정리해서 작성하면 더욱 좋을 것 같다는 생각이 들었다.

과제 피드백

과제의 지시사항이나 설명이 친절해서 좋았습니다!!

사실 과제를 진행하면서 초반에는 지시사항을 따라서 함수를 작성하기만 하는건데 개념이 숙지가 될까..? 하는 걱정이 조금 있었지만 주신 자료를 충실히 읽어보고 이행하니 흐름이 다 이해가 되었어요

제공해주신 자료들에 수강생들이 이 과제를 통해 배웠으면 하는 사항들이 잘 설명되어 있어서 편했고
추가적인 자료를 많이 찾아볼 필요가 없이 진행되었던 것 같아 좋았습니다!!

리뷰 받고 싶은 내용

1. updateElement 함수의 “3. 텍스트 노드 업데이트” 주석 부분 코드 최적화에 대한 질문이 있습니다.

 // 3. 텍스트 노드 업데이트
  const newEl = createElement(newNode);
  const oldEl = createElement(oldNode);
  if (newNode != null && oldNode != null && newEl.nodeType === Node.TEXT_NODE && oldEl.nodeType === Node.TEXT_NODE) {
    if (newNode !== oldNode) {
      const newTextNode = document.createTextNode(String(newNode));
      if (target) {
        parentElement.replaceChild(newTextNode, target);
      } else {
        parentElement.appendChild(newTextNode);
      }
    }
    return;
  }

지금은 TEXT_NODE 프로퍼티를 통해 같은 태그끼리 비교를 할 수 있게 하려고 newNode와 oldNode를 createElement 함수를 사용하여 element로 만들어서 사용을 하고 있는데요.
정작 element로 만든 다음에 그 element를 사용하지 않는 게 성능 측면에서 안좋지 않을까 하고 걱정이 되었습니다.
하지만 이 방식이 아니라면 일일히 태그명을 하드코딩해서 비교를 해주는 방식으로 개발을 해야할 것 같은데 그렇다면 코드가 상당히 지저분해 질 것 같다는 생각도 들었습니다.
혹시 다른 더 좋은 구현 방법이 있을까요? 지금 방법이 성능에 많이 안좋은 방향일까요? 태그명으로 하드코딩 하는 방식으로 진행했어야 할까요?

2. eventManager의 setupEventListeners 함수 구현에 대해서 더 나은 방법을 얻고 싶습니다.

export function setupEventListeners(root) {
  const allDomEvents = [
    // 마우스 이벤트
    "click",
    "dblclick",
    "mousedown",
    "mouseup",
    "mousemove",
    "mouseenter",
    "mouseleave",
    "mouseover",
    "mouseout",
    "contextmenu",
    "auxclick",

    // 키보드 이벤트
    "keydown",
    "keypress",
    "keyup",

    // 입력/폼 이벤트
    "input",
    "change",
    "submit",
    "reset",
    "focus",
    "blur",
    "focusin",
    "focusout",
    "invalid",

    // 터치 이벤트
    "touchstart",
    "touchmove",
    "touchend",
    "touchcancel",

    // 포인터 이벤트
    "pointerdown",
    "pointerup",
    "pointermove",
    "pointercancel",
    "pointerover",
    "pointerout",
    "pointerenter",
    "pointerleave",
    "gotpointercapture",
    "lostpointercapture",

    // 휠 및 스크롤 이벤트
    "wheel",
    "scroll",

    // 드래그 & 드롭 이벤트
    "drag",
    "dragstart",
    "dragend",
    "dragenter",
    "dragleave",
    "dragover",
    "drop",

    // 클립보드 이벤트
    "copy",
    "cut",
    "paste",

    // 컴포지션 이벤트
    "compositionstart",
    "compositionupdate",
    "compositionend",

    // 윈도우 이벤트
    "load",
    "beforeunload",
    "unload",
    "resize",
    "hashchange",
    "popstate",
    "DOMContentLoaded",
    "visibilitychange",
    "storage",

    // 네트워크 이벤트
    "online",
    "offline",

    // 미디어 이벤트
    "play",
    "pause",
    "playing",
    "waiting",
    "ended",
    "volumechange",
    "timeupdate",
    "seeking",
    "seeked",
    "loadeddata",
    "loadedmetadata",
    "canplay",
    "canplaythrough",

    // 애니메이션/트랜지션 이벤트
    "animationstart",
    "animationend",
    "animationiteration",
    "transitionstart",
    "transitionend",
    "transitionrun",
    "transitioncancel",

    // 오류 및 기타
    "error",
    "abort",
    "close",
    "open",
  ];

  for (const eventType of allDomEvents) {
    root.addEventListener(eventType, (e) => {
      const events = eventList.filter((event) => event.eventType === eventType);
      if (events.length <= 0) {
        return;
      }

      for (const event of events) {
        if (event.element === findParent(e.target, event.element)) {
          event.handler(e);
          break;
        }
      }
    });
  }
}

현재 allDomEvents 라는 이벤트명들을 모아둔 배열을 만들어서 여기서 이벤트 타입을 찾는 형태로 구현을 해두었습니다.
사실상 하드코딩이고 이 방식이 유지보수 측면에서 좋을 것 같다는 생각이 들진 않았는데..
기본 제공하는 함수들 중에서 이벤트 타입들을 모아서 반환해주는 함수는 없다고 하여 미봉책으로 이렇게 개발하였지만 더 좋은 방법은 없을까 싶어 여쭙게 되었습니다.
코치님께서는 이 함수를 리팩토링 하신다면 어떻게 개선하실지가 궁금합니다.

@DEV4N4 DEV4N4 changed the title [5팀 주산들] Chapter 1-2. 프레임워크 없이 SPA 만들기 (2) [1팀 주산들] Chapter 1-2. 프레임워크 없이 SPA 만들기 (2) Jul 14, 2025
Comment thread src/lib/createElement.js
$el.setAttribute(key, value);
}
});
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

각 조건문에서 반환하면 코드가 더 깔끔해질 것 같아요!

AS-IS

 if (key === "className") {
      $el.setAttribute("class", value);
    } else if (key.startsWith("on") && typeof value === "function") {
      const event = key.slice(2).toLowerCase(); // onClick -> click
      addEvent($el, event, value);
    }

TO-BE

 if (key === "className") {
      $el.setAttribute("class", value);
     return
    }

f (key.startsWith("on") && typeof value === "function") {
      const event = key.slice(2).toLowerCase(); // onClick -> click
      addEvent($el, event, value);
     return
}

//...생략

Comment thread src/lib/eventManager.js
}

export function setupEventListeners(root) {
const allDomEvents = [
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이벤트 목록은 따로 파일을 분류해서 상수처럼 관리하면 더 깔끔할 것 같아요!

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저도 같은생각입니다. 따로 상수관리파일을 두면 가독성이 좋아질것같습니다!

Copy link
Copy Markdown

@Legitgoons Legitgoons Jul 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

따로 상수 파일을 만들면 확실히 좋아질 것 같은데, 만약 만든다면 내부에서 allEvent로 묶지 않고 Event의 종류에 따라서 나눈다음 묶어서 export하면 더 좋을것 같아요!

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요거 5팀 영서님 PR 보면 delegatedEvent 라고 실제로 프로젝트에서 쓰이는 이벤트들만 추가할 수 있게 해두었더라구요

Comment thread src/lib/eventManager.js
}

export function removeEvent(element, eventType, handler) {
const index = eventList.findIndex(
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이벤트 종류가 많아지면 배열은 배열 길이만큼 순회하는 시간이 걸리니까, 객체나 Map, Set 데이터구조를 사용하시면 더 성능이 좋아질 것 같네요!

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

O(n)에서 O(1)로

Comment thread src/lib/normalizeVNode.js
children: vNode.children.flat(Infinity).map(normalizeVNode),
}),
);
} else {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이미 앞 조건문에서 return을 해주고 있기 때문에 else는 없는 게 더 깔끔할 것 같아요

Comment thread src/lib/updateElement.js

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

밑에서 continue 쓰셨는데 여기서도 continue 쓰면 되지 않나요??

@angielxx
Copy link
Copy Markdown

allDomEvents안에서만 이벤트 타입을 읽어서 핸들러를 등록하게 되면 커스텀이벤트는 커버하지 못하기 때문에 확장성을 고려했을 때 좋은 방법은 아닌 것 같아요! allDomEvents를 읽어서 이벤트 타입의 핸들러를 등록하기 보단, addEvents에서 등록하려는 이벤트 타입을 데이터로 모아서 루트에 등록하는 방식이 좋을 것 같습니다.

Comment thread src/lib/normalizeVNode.js
);
} else {
vNode.children = vNode.children.flat(Infinity).map(normalizeVNode).filter(Boolean);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

createVNode에서 이미 children을 평탄화 해줘서 이 부분은 딱히 없어도 될것같은데 혹시 필요했던 이유가 있나요..??

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.

5 participants