과제 체크포인트
배포 링크
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 요소 생성
속성 처리:
className→class어트리뷰트- 이벤트 핸들러 (
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 알고리즘 구현
- 노드 제거 - 새 노드 없음
- 노드 추가 - 기존 노드 없음
- 텍스트 노드 업데이트 - 문자열/숫자 변경
- 타입 변경 - 완전 교체 (
<div>→<span>) - 같은 타입 - 속성 및 자식 업데이트
// 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 연산 과정에서 꼬임이 발생하는 것 같아요...! 제가 시간 되면 조금 더 찾아보겠습니다.