과제 체크포인트
배포 링크
https://hanghae-plus.github.io/front_6th_chapter1-2/
기본과제
가상돔을 기반으로 렌더링하기
- createVNode 함수를 이용하여 vNode를 만든다.
- normalizeVNode 함수를 이용하여 vNode를 정규화한다.
- createElement 함수를 이용하여 vNode를 실제 DOM으로 만든다.
- 결과적으로, JSX를 실제 DOM으로 변환할 수 있도록 만들었다.
이벤트 위임
- 노드를 생성할 때 이벤트를 직접 등록하는게 아니라 이벤트 위임 방식으로 등록해야 한다
- 동적으로 추가된 요소에도 이벤트가 정상적으로 작동해야 한다
- 이벤트 핸들러가 제거되면 더 이상 호출되지 않아야 한다
심화 과제
Diff 알고리즘 구현
- 초기 렌더링이 올바르게 수행되어야 한다
- diff 알고리즘을 통해 변경된 부분만 업데이트해야 한다
- 새로운 요소를 추가하고 불필요한 요소를 제거해야 한다
- 요소의 속성만 변경되었을 때 요소를 재사용해야 한다
- 요소의 타입이 변경되었을 때 새로운 요소를 생성해야 한다
과제 셀프회고
이번 과제에서는 ai 를 멘토로 활용하여 진행했습니다. 그래서 코드를 구현하는 것 자체에 ai 도움 받는 것을 최대한 지양하고, 제가 답을 찾아갈 수 있게 가이드를 달라고 했습니다. 이 과정에서 생소한 개념을 접하게 되면 구현 전에 정리하고 이해한 뒤에 구현하고자 했습니다. 1주차 과제는 구현하기 급급했다면, 이번 과제에서는 최대한 가상돔의 생성과정을 머릿속으로 그려보고자 했습니다.
심화과제까지 수행하는 과정에서 가장 어렵게 느껴졌던 것은 이벤트 위임이었습니다. 저는 1주차 때 이벤트 위임을 적용하지 않고 구현했었는데, 그래서 기본 과제의 이벤트 위임도 상당히 난이도가 있게 느껴졌습니다. 이벤트 위임이라는 개념을 이론상으로는 알고 있었지만, 실제 구현해보니 제가 확실하게 알고 있는게 아니었다는 것을 깨달았습니다. "어떤 과정"을 통해서 이벤트가 위임되는지 직접 작성해보고 머릿속에 그려보는 것과 단순히 글을 암기하는 것은 많은 차이가 있었습니다.
기술적 성장
1. 이벤트 위임 로직(eventManager 함수)의 메커니즘 이해
이벤트 위임 설정 함수
export function setupEventListeners(root) {
const eventTypes = ["click", "mouseover", "focus", "keydown", "submit", "change"];
// 1. 루트 컨테이너에 각 이벤트 타입별 리스너 등록
eventTypes.forEach((eventType) => {
root.addEventListener(eventType, handleEvent);
});
// 2. 정리 함수 반환
return () => {
eventTypes.forEach((eventType) => {
root.removeEventListener(eventType, handleEvent);
});
};
}
- 루트 컨테이너에 6가지 이벤트 타입에 대해 handleEvent 함수만 등록하여 메모리 사용량을 최소화하고자 했습니다.
- 새로운 이벤트 타입 추가 시 eventTypes 배열에 추가하는 확장 가능한 구조를 고려했습니다.
- 메모리 누수 방지를 위한 정리 함수 반환하여 언마운트 시 메모리 누수를 방지하고자 했습니다.
이벤트 처리 핵심 함수
const handleEvent = (event) => {
const { target, type } = event; // 이벤트 발생 요소와 타입 추출
// 이벤트 버블링을 위해 클릭된 요소부터 상위 요소들을 순회
let currentElement = target;
while (currentElement && currentElement !== document.body) {
// 1. 현재 요소에 _eventId가 있는지 확인
if (currentElement._eventId) {
const elementHandlers = eventHandlers.get(currentElement._eventId);
// 2. 해당 요소에 등록된 이벤트 핸들러가 있는지 확인
if (elementHandlers && elementHandlers.has(type)) {
const handler = elementHandlers.get(type);
handler(event); // 3. 실제 핸들러 실행
}
}
// 5. 상위 요소로 이동
currentElement = currentElement.parentElement;
}
};
흐름
- 이벤트 발생 요소(target)부터 시작
- _eventId 속성으로 등록된 핸들러 검색
- 해당 이벤트 타입의 핸들러 실행
- 상위 요소로 이동하여 반복
전체 동작 흐름 요약
- 초기화
// renderElement.js에서
if (!container._eventListenersSetup) {
setupEventListeners(container); // 루트에 6개 이벤트 리스너 등록
container._eventListenersSetup = true;
}
- 이벤트 등록
// setElementAttributes.js에서
const registerEventHandler = (target, key, handler) => {
const eventType = key.slice(2).toLowerCase(); // "onClick" → "click"
addEvent(target, eventType, handler); // 전역 저장소에 등록
};
- 이벤트 발생
// 사용자 클릭 → handleEvent 호출
// 1. target 요소부터 상위로 버블링하며 _eventId 검색
// 2. _eventId 발견 시 등록된 핸들러 실행
- 업데이트
실제 동작 예시
// 1. JSX 작성
const App = () => (
<div>
<button onClick={handleClick}>Click me</button>
</div>
);
// 2. 렌더링
renderElement(<App />, container);
// 3. 결과
// - container에 이벤트 위임 설정
// - button 요소에 _eventId = "event_1" 부여
// - eventHandlers.get("event_1").set("click", handleClick)
// - 사용자 클릭 시 handleEvent가 호출되어 handleClick 실행
2. 트러블 슈팅
normalizeVNode 함수를 재귀적으로 호출하는 과정에서, 특정 조건의 vNode를 처리할 때 아래와 같은 TypeError가 발생했습니다.
Cannot destructure property 'children' of 'object null' as it is null.
[발생 위치]
// src/lib/normalizeVNode.js:25
return {
...vNode,
children: vNode.children.filter((child) => !isFalsy(child)).flatMap((child) => normalizeVNode(child)),
};
[원인]
vNode.children이 null이 되는 시나리오들
- createVNode 함수에서의 처리
export function createVNode(type, props, ...children) {
return {
type,
props,
children, // 여기서 children이 null/undefined가 될 수 있음
};
}
2 함수형 컴포넌트에서의 처리
if (typeof vNode.type === "function") {
return normalizeVNode(vNode.type(vNode.props)); // 여기서 null이 반환될 수 있음
}
테스트 케이스 분석
// 빈 자식 배열 처리
it("빈 자식 배열을 처리해야 한다", () => {
const result = createElement(<div>{[]}</div>);
expect(result.tagName).toBe("DIV");
expect(result.childNodes.length).toBe(0);
});
// undefined 자식 처리
it("undefined 자식을 무시해야 한다", () => {
const result = createElement(<div>{undefined}</div>);
expect(result.tagName).toBe("DIV");
expect(result.childNodes.length).toBe(0);
});
// 함수형 컴포넌트 정규화
it("컴포넌트를 정규화한다.", () => {
const TestComponent = () => (
<UnorderedList>
<ListItem id="item-1">Item 1</ListItem>
<ListItem id="item-2">Item 2</ListItem>
<ListItem id="item-3" className="last-item">Item 3</ListItem>
</UnorderedList>
);
const normalized = normalizeVNode(<TestComponent />);
// 기대 결과: <ul>...</ul> 구조
});
[해결]
export function normalizeVNode(vNode) {
if (isFalsy(vNode)) return "";
const safeChildren = vNode.children ?? [];
if (typeof vNode === "string" || typeof vNode === "number") {
return String(vNode);
}
// 함수형 컴포넌트 재귀 처리
if (typeof vNode.type === "function") {
const safeProps = vNode.props ?? {};
// children을 포함하여 전달
const propsWithChildren = { ...safeProps, children: safeChildren };
return normalizeVNode(vNode.type(propsWithChildren));
}
if (Array.isArray(vNode)) {
return vNode.flatMap((child) => normalizeVNode(child));
}
return {
...vNode,
children: safeChildren.flatMap((child) => normalizeVNode(child)),
};
}
- normalizeVNode 함수에 방어 코드를 추가하여 어떤 형태의 vNode가 들어와도 안전하게 처리할 수 있도록 개선했습니다.
[주요 변경점]
- Nullish Coalescing (
??) 사용: vNode.children이 null이나 undefined일 경우, 항상 빈 배열 []을 사용하도록 하여 safeChildren 변수가 배열임을 보장 - 함수형 컴포넌트를 호출할 때, props와 children이 null일 가능성을 모두 차단하고 항상 안전한 객체와 배열을 전달하도록 수정
- 일관성 있는
safeChildren사용
코드 품질
이번 과제에서는 함수단위의 테스트를 중점적으로 수행하기 때문에 함수 단위의 리팩토링으로 코드 품질을 고려했습니다.
1. createElement
[리팩토링 전]
const createTextNode = (text) => document.createTextNode(text);
export function createElement(vNode) {
if (isFalsy(vNode)) return createTextNode("");
switch (typeof vNode) {
case "string":
case "number":
return createTextNode(vNode);
case "object": {
if (Array.isArray(vNode)) {
const fragment = document.createDocumentFragment();
for (const child of vNode) {
fragment.appendChild(createElement(child));
}
return fragment;
}
const $el = document.createElement(vNode.type);
updateAttributes($el, vNode.props);
for (const child of vNode.children) {
$el.appendChild(createElement(child));
}
return $el;
}
default:
return "";
}
}
- switch를 사용하여 타입별 분기처리했습니다.
- 관심사가 분리되어 있지 않아 한 함수 안에 여러 기능이 혼재되어 있고, 함수가 많은 책임을 가지고 있었습니다.
[리팩토링 후]
- 1차 리팩토링
// falsy 값 처리
if (isFalsy(vNode)) {
return "";
}
// 타입별 처리 함수들
const handlers = {
string: (vNode) => document.createTextNode(vNode),
number: (vNode) => document.createTextNode(vNode),
object: (vNode) => {
if (vNode === null) return "";
if (Array.isArray(vNode)) {
const fragment = document.createDocumentFragment();
vNode.forEach(child => {
fragment.appendChild(createElement(child));
});
return fragment;
}
if (vNode.type) {
const $el = document.createElement(vNode.type);
updateAttributes($el, vNode.props);
vNode.children.forEach(child => {
$el.appendChild(createElement(child));
});
return $el;
}
return "";
}
};
// switch 문으로 타입별 처리
switch (typeof vNode) {
case "string":
case "number":
return handlers[typeof vNode](vNode);
case "object":
return handlers.object(vNode);
default:
return "";
}
- 함수형으로 접근하여 관심사를 분리하고자 했습니다.
- 오히려 가독성이 더 안좋아지고 복잡하다고 느껴서 원복하려다 책임을 나누어 봤습니다.
- 2차 리팩토링
const createTextNode = (text) => document.createTextNode(text);
const createFragment = (vNode) => {
const fragment = document.createDocumentFragment();
for (const child of vNode) {
fragment.appendChild(createElement(child));
}
return fragment;
};
const createDOMElement = (vNode) => {
const $el = document.createElement(vNode.type);
updateAttributes($el, vNode.props);
for (const child of vNode.children) {
$el.appendChild(createElement(child));
}
return $el;
};
const handlers = {
string: createTextNode,
number: createTextNode,
object: (vNode) => {
if (vNode === null) return "";
if (isArray(vNode)) return createFragment(vNode);
if (vNode.type) return createDOMElement(vNode);
return "";
},
};
export function createElement(vNode) {
if (isFalsy(vNode)) return createTextNode("");
const handler = handlers[typeof vNode];
return handler ? handler(vNode) : "";
}
- 조금 더 선언적으로 표현하고자 했습니다.
- 함수를 단일책임으로 나누고자 했습니다.
- 최종 코드
const updateAttributes = ($el, props) => {
if (isFalsy(props)) return;
setElementAttributes($el, props);
};
const updateChildren = ($el, vNode) => {
for (const child of vNode.children) {
$el.appendChild(createElement(child));
}
$el._vNode = vNode;
return $el;
};
const createDOMElement = (vNode) => {
const $el = document.createElement(vNode.type);
updateAttributes($el, vNode.props);
updateChildren($el, vNode);
return $el;
};
const createFragment = (vNode) => {
const fragment = document.createDocumentFragment();
for (const child of vNode) {
fragment.appendChild(createElement(child));
}
return fragment;
};
const createComponent = (vNode) => {
if (isArray(vNode)) {
const componentResult = vNode.type(vNode.props);
return createElement(componentResult);
}
return createElement(vNode);
};
// ...
- 앞 선 리팩토링 코드에 대해 오류가 없는지 관심사가 적절히 분리 되었는지 ai에 물어보고, ai가 다듬어준 결과입니다.
- 조금 더 작은 단위로 책임이 분리 되었습니다.
ai를 활용하여 동일한 조건으로 리팩토링을 시켜보기도 했습니다.
- 관련 코드 커밋: cffc2d6
- 요구하지 않았지만 JSDocs까지 작성해주어 편하다고 느꼈습니다.
- 일관된 코드 스타일로 작성해주어 생산성을 높일 수 있었습니다.
- ai를 리팩토링에도 자주 활용하게 될 것 같습니다.
학습 효과 분석
이번 과제에서는 새로 학습한 개념이 많았습니다.
아래의 새로 학습한 개념의 자세한 기록은 docs 디렉토리에 정리했습니다.
일부 md 파일은 ai를 활용하여 작성했습니다.
1. 가상돔의 가독성을 개선하기 위해 만들어진 문법이 JSX
JSX를 사용하는 이유를 단순히 js보다 직관적이고 가독성이 좋아서라고 생각했습니다. 준일 코치님의 블로그 글을 읽다보니 "왜 직관적이고 가독성이 좋은 문법이 필요했는지"를 한번도 생각해 본 적이 없었다는 것을 알게되었습니다.
요약하자면, 가상돔은 DOM에 변경된 부분만 반영하기 위해 만든 객체 덩어리이며, 이 객체 덩어리를 h(...) 으로 생성한다. (h라고 부르는 이유는 Hyperscript라는 뜻) 이렇게 줄여서 표현한 가상돔 트리의 가독성이 "개발자" 관점에서 안좋기 때문에 JSX가 만들어진 것
2. Infinity vs Number.POSITIVE_INFINITY, String() vs toString()
저는 관성적으로 String() 과 Number.POSITIVE_INFINITY 을 선호하여 사용해왔습니다.
Number.POSITIVE_INFINITY를 더 안정적으로 사용하는 방법이라고 알고 있었고, String()은 이유가 없었습니다.
그래서 두 경우를 비교해보고 싶었고, ai를 활용해 비교 분석하는 md 파일을 작성했습니다.
결론은 null, undefined도 안전하게 처리할 수 있는String()와 좀 더 간결하게 사용할 수 있는 Infinity를 선택하여 구현했습니다.
- "
Number.POSITIVE_INFINITY를 더 안정적으로 사용하는 방법" 의 배경을 ai에게 추가로 물어봤습니다. 과거 자바스크립트(ES5 이전)에서는 전역 변수 Infinity를 다른 값으로 덮어쓸 수 있는 안정성 문제가 있었습니다. 이 때문에 개발자들은 변경이 불가능한 Number.POSITIVE_INFINITY를 사용하는 '방어적 코딩' 기법을 선호했습니다. 하지만 ES5부터 Infinity가 읽기 전용(read-only) 속성으로 표준이 변경되면서 이 문제는 해결되었고, 이제는 더 간결한 Infinity를 사용하는 것이 일반적이라는 것을 알게 되었습니다.
4. virtualDOM 에서 정규화가 필요한 이유
요구사항에서 표현하는 "재귀적 표준화"가 무슨 뜻인지 정확하게 파악하기 어려웠습니다. 과제의 코드베이스를 분석하여 virtualDOM에서 정규화 하는 과정으로 이해했습니다. 정규화가 필요한 이유를 제가 이해한 한마디로 요약 해보자면 아래와 같습니다.
컴포넌트는 다양한 형태의 값을 반환할 수 있는데, 개발자에게는 이러한 유연성이 편리하지만 알고리즘 입장에서는 복잡하고 예측 가능성이 떨어진다. 그렇기 때문에 반환되는 다양한 형태의
vNode들을 렌더링 엔진이 처리하기 쉬운 표준화된 구조로 반환한다.
5. DocumentFragment
DocumentFragment는 메모리 상에만 존재하는 계산을 위한 임시 컨테이너로 표현할 수 있으며, 실제 DOM을 생성하는 과정에서 DOM의 조작 횟수를 줄여 리플로우를 최소화 하고, 여러 DOM 요소를 한 번에 삽입(배치 처리)하기 위해 사용한다.
그 밖에도 weakMap, nNode 타입이 function일 때 children을 포함해서 재귀를 넘겨야 하는 이유 등 과제를 진행하며 의문이 드는 것들은 간단하게라도 정리하고 넘어가고자 했습니다. 가상돔을 직접 구현 해보면서, 실제로 어떤 로직들이 필요하고 자바스크립트의 어떤 내장 메서드들이 활용되어 동작하는지 뜯어볼 수 있는 배움이었습니다.
과제 피드백
- 함수 단위로 구성되어 하나씩 기능을 채워서 완성시키는 과정이 개인적으로 재밌었습니다.
- TDD 경험이 없었는데, 이번 과제에서는 TDD의 개발 방식을 어느정도 체험하는 것 같았습니다.
리뷰 받고 싶은 내용
- 앞서 언급했던 리팩토링의 과정과 방향이 적절했다고 생각되는지, 개선할 방향이 있는지 코치님의 의견이 궁금합니다.
- 현재 updateChildren 함수는 아래와 같이 배열의 인덱스(index)를 기반으로 새로운 vNode와 기존 vNode를 비교합니다. 이 방식은 새로운 노드가 뒤에 추가 되는 경우는 효율적일 수 있지만 그렇지 않거나 목록의 순서가 바뀌는 경우에는 결과 값을 보장할 수 없다고 생각 됩니다. 이 로직을 개선한다면 어떤 방법이 가장 적절한지, key 기반의 diff 알고리즘을 구현한다면 어떤 방식으로 접근해야 하는지 궁금합니다!
const updateChildren = (target, newVNodes, oldVNodes, oldNodeLength) => {
for (let i = 0; i < newVNodes.length; i++) {
const newChild = newVNodes[i];
const oldChild = oldVNodes?.[i];
const targetChild = target.childNodes[i];
if (i >= oldNodeLength || !targetChild) {
// 새 자식 추가
const newElement = createElement(newChild);
if (newElement) {
target.appendChild(newElement);
}
} else {
// 기존 자식 업데이트
updateElement(targetChild, newChild, oldChild);
}
}
};
- 저는 어떤 개념에 대해 분석하고 이해하는 것까지는 어느정도 스스로 접근할 수 있다고 생각하는데, 그 내용을 응용하거나 확장하는 사고가 부족한 것 같습니다. 그러한 사고의 확장을 하기 위해서는 평소에 어떤 것들을 하면 도움이 될까요? 비슷한 맥락의 고민으로, 지식을 접할 때는 이해가 잘 되지만, 막상 설명하거나 코드에 적용하려고 하면 막히는 경우가 많은데 이해와 실전 사이의 간극을 어떻게 줄일 수 있을지 고민입니다!
과제 피드백
지호님 고생하셨어요! 꼼꼼하게 정리해주신 회고를 보니까 저도 많은 것을 배울 수 있었던 것 같네요. 말씀해주신것처럼 AI를 통해 단순히 코드만 짜기보다는 학습을 위한 도구로 활용할 때 항해에서는 가치가 극도로 올라가는 것 같아요 ㅎㅎ
전체적인 로직도 크게 잘못 구현된 부분도 없고, 필요한 부분에 대해서도 명확하게 깔끔하게 잘 구현이 된 것 같습니다 :+1
질문 주셨던 부분 답변드려볼게요.
앞서 언급했던 리팩토링의 과정과 방향이 적절했다고 생각되는지, 개선할 방향이 있는지 코치님의 의견이 궁금합니다.
넵! 적절하게 잘 분리된 명확한 함수처럼 보이네요 ㅎㅎ 피드백 드릴게 크게 없습니다.
현재 updateChildren 함수는 아래와 같이 배열의 인덱스(index)를 기반으로 새로운 vNode와 기존 vNode를 비교합니다.
네 이부분도 명확하게 목적을 이해하고 계신 것으로 보이는데요. 결국 키 기반의 diff비교가 이뤄져야 말씀하진 문제가 해결될 것 같아요. 실제로 구현을 살펴봐야겠지만, 각 노드에 있어 키를 기반으로 저장하는 구조체가 있고 순회 시 키를 기준으로 위치를 이동시키거나 사용되지 않으면 제거, 새로운 값이면 추가하는 형태로 구현이 되어야 할 것 같아요. 입력하지 않은 값들에 대한 폴백처리도 필요할 것 같구요! 이미 아시는 내용일 것 같지만, 실제 구현이 엄청 복잡하진 않을 것 같아서 다른 구현체들을 참고해보고 구현하신다음 공유해주셔도 좋을 것 같네요 ㅎㅎ
저는 어떤 개념에 대해 분석하고 이해하는 것까지는 어느정도 스스로 접근할 수 있다고 생각하는데, 그 내용을 응용하거나 확장하는 사고가 부족한 것 같습니다. 그러한 사고의 확장을 하기 위해서는 평소에 어떤 것들을 하면 도움이 될까요?
제 개인적인 생각으로는, 응용하거나 확장하는 것도 결국 연습인 것 같아요. 딱 처음부터 잘하는 역량이 아니라요. 결국 중요한건 지금 작성해주신것처럼 기본에 대해 명확하게 공부를 해두되, 이것이 단순히 어떻게 구현이 어떻게 되어있구나 하고 마무리 하는게 아니라 좀 더 개발적 관점에서 언제 쓸수 있지라고 상황을 상상해보고 머리속에서 적용해보는 연습을 하는게 필요한 것 같아요. 추후에 쓸 수 있는 무기를 창고에 넣어두는 것처럼요. 만약 이게 잘 안된다면, 좀 더 깊게 이 기술에 대한 필요성을 이해 하는게 필요할 것 같고 듣기 싫은 말일 수 있지만, 중요하다고 느껴진다면 어느정도는 암기에 대한 필요성도 있지 않을까 싶네요!
고생하셨고 다음 주도 화이팅입니다~~