Skip to content

[4팀 김수민] Chapter 1-2. 프레임워크 없이 SPA 만들기#56

Open
nimusmix wants to merge 13 commits into
hanghae-plus:mainfrom
nimusmix:main
Open

[4팀 김수민] Chapter 1-2. 프레임워크 없이 SPA 만들기#56
nimusmix wants to merge 13 commits into
hanghae-plus:mainfrom
nimusmix:main

Conversation

@nimusmix
Copy link
Copy Markdown

@nimusmix nimusmix commented Jul 17, 2025

과제 체크포인트

배포 링크

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

기본과제

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

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

이벤트 위임

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

심화 과제

Diff 알고리즘 구현

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

과제 셀프회고

면접 단골 질문~~ 가상 돔~~ 직접 만들어볼 기회가 생길 줄 몰랐어요!
사실 지난 주 과제에 지쳐서 이번 주에는 컴퓨터 앞에 앉기가 두려웠는데요(..)
막상 과제를 시작하니 가상 돔이 어떤 과정을 통해 동작하는지,
그 안에서 함수들은 어떤 역할을 하고 있는지 알아가는 게 정말 재밌었습니다!

기술적 성장

Boolean attribute
Boolean attribute는 setAttribute로 할당하지 않고 DOM 프로퍼티에 직접 할당해야 한다는 사실을 알게 되었습니다.
el.checked = true/false, el.selected = true/false 식으로 처리해야 실제 요소의 상태가 즉시 반영되고,
removeAttribute로 완전히 제거할 수 있어 렌더링과 자바스크립트 로직이 완전히 일치한다는 것을 알게 되었습니다.

(질문)
언젠가 실무에서 <div readOnly></div> 이런 식으로 코드를 짠 적이 있어요.
그 때 사수가 readOnly={true} 이렇게 항상 명시해주는 게 좋다고 말씀해주셨어요.
어.. 그런데 오늘 보니까 어떤 의도로 그렇게 말씀하신 건지 잘 이해가 안 갑니다..!!!

WeakMap
eventManager에서 event를 저장하기 위한 자료구조로 WeakMap을 사용했습니다.
객체가 메모리에서 없어지면 자동으로 가비지 컬렉션이 된다는 점이 흥미로웠어요.
특히 dom에서 사라지면 unmount 되기 때문에 딱 맞는 자료구조라고 생각했습니다.
적절한 자료구조를 쓰는 것만으로 손으로 쓰는 코드를 얼마나 줄일 수 있던지!
자료구조의 중요성에 대해 알 수 있었던 시간이었습니다. ><

가상 돔 동작 과정
createVNode, normalizeVNode 등 각 함수들이 어떤 역할을 하는지 알 수 있었습니다.
그 과정에서 몇 가지 의문이 생기기도 했는데요. 그 질문들은 아래와 같았습니다!

1 . createVNode에서 왜 자식을 평탄화(flatten)해야 하지? 어떤 경우에 중첩되는 거지?
-> 아래와 같은 경우가 흔하게 발생한다!

<div>
  {selecetedItems.map((item) => <div>{item}</div>}
  {unselectedItems.map((item) => <div>{item}</div>}
</div>

이 경우 평탄화하지 않는다면 vNode.children에 중첩 배열이 생기고, 이후에 재귀적으로 순회하거나 비교하기 어려워진다.
그래서 children.flat(Infinity)로 평탄화해서 한 번에 처리 가능한 배열로 만들어줘야 한다.

  1. createVNode를 거쳤다면 전부 객체일 텐데, normalizeVNode에서 왜 객체가 아닌 타입에 대한 처리를 해줘야 하지?
    -> createVNode를 거치지 않고 직접 값이 들어오는 경우가 있다.
<div>
  Hello
  <span>World</span>
</div>

위 코드는 결국 createVNode('div', null, 'Hello', createVNode('span', null, 'World'))으로 파싱되기 때문에,
children 배열에 'Hello' 같은 순수 문자열이 들어올 수 있다.

또한, 사용자 정의 컴포넌트가 반환하는 값이 string, number, null일 수도 있다.

function MyComponent() {
  return "Hello world";
}

코드 품질

  1. 조건 분기 시 typeof 나 복잡한 null 체크 대신, isNil, isBoolean, isArray 등의 의미가 명확한 헬퍼 함수를 사용했습니다.
  • 덕분에 normalizeVNode 내부의 로직이 한눈에 읽히는 선언형 스타일로 바뀌어 가독성이 크게 향상되었습니다.
export function normalizeVNode(vNode) {
  if (isNil(vNode) || isBoolean(vNode)) {
    return "";
  }

  if (isString(vNode) || isNumber(vNode)) {
    return vNode.toString();
  }

  if (isArray(vNode)) {
    return vNode.map(normalizeVNode).filter((v) => !isEmptyString(v));
  }

  if (isFunction(vNode.type)) {
    const props = { ...(vNode.props ?? {}), children: normalizeChildren(vNode.children) };
    return normalizeVNode(vNode.type(props));
  }

  return {
    ...vNode,
    children: normalizeChildren(vNode.children),
  };
}
  1. 단일 책임 원칙을 적용해 각 함수가 하나의 일만 하도록 분리했습니다.
  • setAttributes: 필요한 속성을 추가하거나 수정
  • removeAttributes: 더 이상 필요 없는 속성을 제거
  • 이로 인해 코드의 가독성이 높아졌고, 이후 유지보수나 리팩토링이 쉬워졌습니다.
const updateAttributes = (target, originNewProps, originOldProps) => {
  const newProps = originNewProps || {};
  const oldProps = originOldProps || {};

  setAttributes(target, newProps, oldProps);
  removeAttributes(target, newProps, oldProps);
};

const setAttributes = (target, newProps, oldProps) => {
  for (const [key, newValue] of Object.entries(newProps)) {
    const oldValue = oldProps[key];
    if (newValue === oldValue) continue;

...중략...
};

const removeAttributes = (target, newProps, oldProps) => {
  for (const key in oldProps) {
    if (key in newProps) continue;

...중략...

    target.removeAttribute(key);
  }
};
  1. updateChildren 최적화
  • minLength를 기준으로 newChildren과 oldChildren의 공통 구간에서만 children을 update하고,
  • 이후 남은 부분은 삽입/제거로 분리했습니다.
  • 불필요한 연산을 줄이고, 가독성을 높일 수 있었습니다.

학습 효과 분석

이렇게 바닐라 자바스크립트로 가상 돔을 만들어 보니까, 과정을 이해하면 다 논리적으로 이해가 되는구나~ 깨닫게 됐어요
면접 질문에 대한 답을 달달 외우기 보다 이렇게 공부하면 좋겠다!는 생각이 들었습니다.

과제 피드백

이번 과제는 구조적으로 틀이 짜져 있어서 너무나 마음이 편안했습니다..!!
과제 제출이 4시간 남은 시점에 PR을 쓰면서 이런 말 웃기긴 하지만(ㅋㅋ)
선택 과제로 좀 더 deep한 주제가 주어졌어도 좋을 것 같아요.

회사 사람한테 자바스크립트로 가상 돔 만든다고 하니까
Reconciliation 구현하는 게 어려울 거다, 제약 조건(예를 들면 react의 key) 같은 것을 두는 게 편할 거다
라고 이야기해서 좀 긴장했는데
그 부분을 경험하지 못해서 좋으면서 아쉬우면서 다행이면서 궁금합니다!
(물론 난이도가 엄청 높다고 들었어요! 나왔으면 울었을지도 흑흑)

리뷰 받고 싶은 내용

  1. (질문) eventManager에서 각 eventType에 대해 handler를 여러 개 걸 수 있어야 할까요?
    바닐라 자바스크립트로 가능한 것은 알고 있습니다만, 리액트의 경우에는 한 이벤트에 여러 개의 handler가 전달되지는 않으니..
    이번 과제에서도 하나만 받게 했다가 remove 시 handler를 쓸 데가 없어서 (..) 수정했었습니다!
export function addEvent(element, eventType, handler) {
  if (!eventStore.has(element)) {
    eventStore.set(element, new Map());
  }

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

  eventMap.get(eventType).add(handler);
  eventTypes.add(eventType);
}
  1. eventManager에서 eventTypes를 전역으로 저장해두고 add해서 쓰고 있는데,
    다른 친구들 코드를 보니 미리 constant로 선언해두고 쓰는 경우가 많더라구요.
    constant로 선언해두고 써도 Array의 크기가 크지 않아 메모리, 연산 측면에서도 유의미한 차이는 나지 않을 거라고 생각하는데,
    이런 경우에는 그냥 아무거나 (!!) 쓰면 될까요?
const eventTypes = new Set();

...중략...
  eventTypes.add(eventType);

@nimusmix nimusmix changed the title [4팀 김수민] [4팀 김수민] Chapter 1-2. 프레임워크 없이 SPA 만들기 Jul 17, 2025
@eveneul
Copy link
Copy Markdown

eveneul commented Jul 17, 2025

공주님 코드 블럭 쓰실 때 ```javascript 이렇게 써 주시면 이쁘게 된답니다

Copy link
Copy Markdown

@2Estella 2Estella left a comment

Choose a reason for hiding this comment

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

수민 공주님~ 2주차 과제 너무너무 고생하셨어요! ❤️❤️💗💗
개인적으로 회고가 너무 재밌어서 술술 읽혔어요 ㅎㅎㅎ

코드 구경 잘하고 갑니다!
다음주차도 화이팅 🫶🫶

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

오 GitHub Pages 자동 배포 워크플로우까지 작성해주셨네요 👍

Comment thread src/lib/eventManager.js
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

전체적으로 이벤트 관리를 유연하게 설계하신 점이 매우 인상적이네요!
저는 WeakMap<요소, 핸들러> 형태로 구현했는데, 수민님 방식처럼 WeakMap<요소, Map<타입, Set<핸들러>>> 구조를 사용하면 “여러 핸들러” 시나리오를 자연스럽게 처리할 수 있겠다는 생각이 들었어요.
특히 하나의 요소에 동일한 이벤트 타입을 복수로 등록할 수 있게 한 부분이 참신한데요, 실제 프로젝트에서 이런 요구가 자주 발생하는 편인지도 궁금해요!

Comment thread src/lib/eventManager.js
let el = e.target;

export function removeEvent(element, eventType, handler) {}
while (el && el !== root.parentNode) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

위임 범위를 el !== root로 제한하지않고, 왜 root.parentNode까지 순회하도록 하셨는지 궁금해요!

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

root까지 순회해야 한다고 생각해서 저렇게 작성했어요!
el === root 가 될 때까지만 순회하려구요ㅎㅎ

Comment thread src/lib/normalizeVNode.js
Comment on lines +27 to +29
const normalizeChildren = (children) => {
return isArray(children) ? children.map(normalizeVNode).filter((c) => !isEmptyString(c)) : normalizeVNode(children);
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

normalizeVNode 내부의 배열 처리 로직과 normalizeChildren 함수의 로직이 거의 동일한 것 같아요! normalizeChildren를 제거해도 되지않을까요?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

앗 그러네요..! normalizeVNode(children)만 해도 되겠네요!ㅎㅎ 감사합니당

Comment thread src/lib/updateElement.js
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

전체적으로 diff 알고리즘을 단순하면서도 명료하게 구현하셨네요!
updateAttributes와 removeAttributes로 속성 변경과 제거 로직을 깔끔하게 분리해 가독성을 높인 점도 좋고, isTextNode, isEvent, isClassName 같은 유틸 함수를 활용해 조건 분기가 한눈에 쏙 들어오도록 정리한 부분도 좋은 것 같습니다!

@eveneul
Copy link
Copy Markdown

eveneul commented Jul 19, 2025

안녕하세요~ 수민 공주님! 오하늘입니다.
나도 이 과제에 100 퍼센트 다 이해를 못 했는데 수민 님 코드에 감히 리뷰를 해도 될까요? 그럼 바로 레쭈고 하겠습니다.

Comment thread src/lib/eventManager.js
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

공주님은 Weakmap을 사용하셔서
Weakmap(Element, Map(EventType, Set(Handler))) 이렇게 구성을 하셨었네요.. 저도 그런 생각을 했었습니다만..
handler 재사용 방식에 따라서 저는

const handler = (elementMap) => (event) => {
  for (const [element, handlers] of elementMap.entries()) {
    if (element === event.target || element.contains(event.target)) {
      for (const handler of handlers) {
        handler.call(element, event);
      }
    }
  }
};

이런 식으로 핸들러 함수를 따로 빼서 그 핸들러 함수를 add 하고 remove 하는 식으로 작업했습니다.

setupEventListeners에서 root에 addEvent어쩌구 하실 때 매번 새로 정의한 e => {...} 함수를 바로 넘겨주시는 람다를 사용한다면, 나중에 remove 할 때 동일한 함수? 참조?를 찾기가 어려워서 위처럼 핸들러를 따로 뺐었습니다.

제가 한 방식이 아닐 수도 있으니.. 그리고 제 리뷰가 틀렸을 수도 있으니.. 그냥 참고만 부탁드립니다.

@esoby
Copy link
Copy Markdown

esoby commented Jul 19, 2025

수민님 코드가 아름다워서 살짝 눈이 멀었더요 항상 꼼꼼하게 과제하시는 거 같아서 마니 배움미다

1번 질문에 대해 ,, 저도 remove 어쩌구의 매개변수 때문에 의문이 들었던 부분인데요
이 과제를 리액트 같은 상태 관리 라이브러리를 구현하는 일련의 과정 중에 있다고 치면 ..!
라이브러리 사용자는 모를 성능 측정, 로깅, 이벤트 합성 어쩌구 등등등 여러 핸들러를 붙여 사용해야 할 경우가 있다고 하더라고요
당장은 안 쓰더라도 확장성 있는 방향으로 개발하길 원하셨던 걸까 ~ 결론을 내려보았읍니다.

2번! 둘 다 무거운 연산이 아니니까 아무거나 쓰자! 유지 보수 관점에서는 set으로 관리하는 게 조아보여요
만에 하나 이벤트 타입이 추가되면 적어둔 텍스트 찾으러 가서 사사삭 수정하는 거 너무 노간지 < ?

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.

4 participants