과제 체크포인트
배포 링크
https://soyalattee.github.io/front_6th_chapter1-2/
기본과제
가상돔을 기반으로 렌더링하기
- createVNode 함수를 이용하여 vNode를 만든다.
- normalizeVNode 함수를 이용하여 vNode를 정규화한다.
- createElement 함수를 이용하여 vNode를 실제 DOM으로 만든다.
- 결과적으로, JSX를 실제 DOM으로 변환할 수 있도록 만들었다.
이벤트 위임
- 노드를 생성할 때 이벤트를 직접 등록하는게 아니라 이벤트 위임 방식으로 등록해야 한다
- 동적으로 추가된 요소에도 이벤트가 정상적으로 작동해야 한다
- 이벤트 핸들러가 제거되면 더 이상 호출되지 않아야 한다
심화 과제
Diff 알고리즘 구현
- 초기 렌더링이 올바르게 수행되어야 한다
- diff 알고리즘을 통해 변경된 부분만 업데이트해야 한다
- 새로운 요소를 추가하고 불필요한 요소를 제거해야 한다
- 요소의 속성만 변경되었을 때 요소를 재사용해야 한다
- 요소의 타입이 변경되었을 때 새로운 요소를 생성해야 한다
과제 셀프회고
기술적 성장
Virtual DOM 과 vNode 에 대한 이해
처음 createVNode 에서 노드가 함수형태로 들어올것이라 예상. 그리고 함수를 풀어서 object화 해야한다고 생각했는데 object 형태로 들어오고 있었고, type 안에 function 이들어오는 구조. 그럼 type 값은? 이란 의문을 가짐. 해결 : JSX함수는 안에 또 element 가 될 노드를 가지고있으니, 재귀형태로 vNode를 생성 가능.
if (typeof vNode.type === "function") {
const propsWithChildren = {
...(vNode.props || {}),
children: vNode.children, // children을 props에 포함시켜서 함수에 전달
};
return normalizeVNode(vNode.type(propsWithChildren));
}
//해당 JSX함수는 안에 또 노드를 가지고있잖아.
const UnorderedList = ({ children, ...props }) => (
<ul {...props}>{children}</ul> // ← 여기서 type: "ul" 결정!
);
과제를 통해 JSX → createVNode → normalizeVNode → createElement의 흐름을 직접 구현하며 Virtual DOM의 작동 원리를 좀 깊게 이해할 수 있었습니다. 특히 함수형 컴포넌트가 재귀적으로 vNode 트리를 생성하는 방식에서 type이 함수인 경우의 처리와, children을 props에 포함시켜 다시 렌더링하는 구조를 구현하며 JSX의 변환 과정을 깊게 이해했습니다.
이벤트매니저를 통해 메모리 관리
초기에는 eventRegistry를 배열로 관리하며 메모리 해제를 시도했으나, 테스트 환경에서 DOM 제거 후에도 이벤트가 남아 있는 문제가 발생
테스트코드 내에서 기존 container를 제거하고 새로운 container를 붙히는데 (beforeEach, afterEach) 이때 eventRegistry가 비워지지않아, 다음 테스트에서 이미 이벤트가 붙어있는 문제 발생
해결 :
- setup시 eventRegistry를 정리하는 작업 추가. filter를 사용해 document에 해당 element가 없으면 제거하지만 여전히 event 가 제대로 remove 되지않음.
- 이때 윤우님과 태영님의 조언으로 eventRegistry 를 배열에서 WeakMap 으로 수정. 배열로 작업시 이벤트 제거가 제대로 되지않으며, WeakMap 을 사용하여 GC 가 돌도록하라고 조언을 얻음.
기존 코드
eventRegistry.forEach(({ eventType }) => {
root.addEventListener(eventType, (e) => {
eventRegistry
.filter((entry) => entry.eventType === eventType)
.forEach(({ element, handler }) => {
if (element.contains(e.target)) {
e.stopPropagation();
handler(e);
}
});
수정된 코드
// 모든 이벤트 타입에 대해 위임 리스너 등록
allTypes.forEach((type) => {
root.addEventListener(type, (e) => {
let currentElement = e.target;
// 이벤트 버블링: target에서 root까지 올라가면서 핸들러 찾기
while (currentElement && root.contains(currentElement)) {
// WeakMap에서 현재 element의 이벤트 맵 가져오기
const eventMap = eventRegistry.get(currentElement);
if (eventMap && eventMap.has(type)) {
const handlers = eventMap.get(type);
// 해당 이벤트 타입의 모든 핸들러 실행
handlers.forEach((handler) => {
try {
handler(e);
} catch (error) {
console.error("이벤트 핸들러 실행 중 오류:", error);
}
});
}
// 부모 element로 이동
currentElement = currentElement.parentElement;
// root에 도달하면 중단
if (currentElement === root) break;
}
WeakMap 기반으로 element → Map(eventType → Set(handler)) 구조를 갖도록 했고, root에서 버블링을 타고 올라가며 최적의 핸들러를 찾아 실행하는 방식으로 재설계했습니다. 구조로 수정하며 테스트 환경에서도 이벤트 중복, 누수 없이 안정적으로 작동하도록 구현할 수 있었고, 메모리 관리와 성능 개선에 대한 감각을 좀 얻었습니다.
document 의 이해
문제: innerHTML +=를 사용하면 기존에 appendChild로 추가한 DOM 요소들이 다시 파싱되면서 이벤트 핸들러가 모두 사라지는 이슈 발생
해결: 자식노드 추가를 appendChild로 코드수정
해결을 위해 appendChild 방식으로 DOM을 조작하며, DOM 파싱의 작동 방식과 이벤트 핸들러 보존의 중요성을 체감했습니다.
코드 품질
과제를 빠듯하게 마무리하여 전체적인 리팩토링 여지가 많은 상태로 마무리되었습니다..
특히 createElement와 updateElement에서 요소의 속성을 설정하는 부분에서 반복되는 로직이 많았고, 조건 분기도 지나치게 세분화되어 있어 중복 제거 및 함수 분리가 필요하다고 느꼈습니다. (특히 updateAttribute 와 createAttribute의 중복코드 다수)
이후 작업에서 이런 공통 속성 처리 로직을 별도 유틸로 추출하고자 합니다.
학습 효과 분석
이번 학습을 통해 프레임워크가 제공하는 추상화 뒤에 숨겨진 핵심 렌더링 원리와 이벤트 관리를 직접 다뤄보면서 실무에서 발생할 수 있는 문제 상황에서도 내부 동작을 추론하고 디버깅할 수 있는 근거를 마련할 수 있었습니다.
과제 피드백
저번 과제보다 양이 적은줄 알고, 금방하겠지? 했는데 오산이였습니다.
개인적으로 저번과제보다 더 어려웠습니다.. 기초가 부족해서 그런거라 생각됩니다.
그런의미에서 너무 좋은 과제였고 이번에도 머리 싸매면서 즐겁게 코딩했습니다. 감사합니다.
리뷰 받고 싶은 내용
렌더링 흐름에서 renderElement 함수가 최초 렌더링과 업데이트 렌더링을 구분하기 위해 container._prevVNode를 직접 추가하여 상태를 유지하는 방식을 사용했습니다.
기존코드
if (!container._prevVNode) {
//최초 렌더링
}else{
//업데이트 렌더링
...
// 새로운 vNode로 업데이트
container._prevVNode = vNode;
}
하지만 이 방식으로 렌더링을 반복할 경우, 특정 상황에서 자식 엘리먼트들이 의도하지 않은 부모로 이동하거나, 기존 자식이 새로운 부모 아래로 재배치되는 버그가 발생했습니다.
ex)
초기 DOM 구조
<div>
<div class="first-node">
<div> 자식1 </div>
<div> 자식1 </div>
</div>
<div class="second-node"></div>
</div>
업데이트시 DOM 구조(잘못된 업데이트)
<div>
<div class="first-node">
<div> 자식1 </div>
<div> 자식1 </div>
</div>
<div class="second-node">
<div> 업데이트 자식1 </div>
<div> 업데이트 자식2 </div>
</div>
</div>
second-node 아래에 first-node 자식들이 들어가 버리는 등의 비정상적인 구조가 되었습니다...!
오랫동안 updateElement 과정에서 헤메다가 혹시 렌더링시에 꼬이나 싶어 기존 노드 저장방법을 변경했습니다. WeakMap()인 currentNodeMap을 만들어 사용하니 해결되었습니다.
💡 궁금한 점 container._prevVNode처럼 단일 ref로 상태를 관리하는 방식은 왜 문제를 일으켰을까요?
반면 WeakMap을 이용하니 문제가 해결된 원인은 무엇일까요?
과제 피드백
안녕하세요 소연님! 과제 무척 잘 진행해주셨네요 ㅎㅎ 1주차보다 개인적으로 어려웠다는 말씀이 인상깊습니다 ㅋㅋ
질문해주신 내용을 토대로 커밋에서 변경된 내역을 확인해봤는데요,
이전 코드가 이렇게 되어있어요.
export function renderElement(vNode, container) {
const normalizedVNode = normalizeVNode(vNode);
// 최초 렌더링인지 확인 (이전 vNode가 없으면 최초)
if (!container._prevVNode) {
// 최초 렌더링: createElement로 DOM 생성
console.log("최초 렌더링");
const $el = createElement(normalizedVNode);
container.innerHTML = ""; // 기존 내용 제거
container.appendChild($el);
// 이전 vNode 저장
container._prevVNode = vNode;
// 렌더링 완료 후 이벤트 등록
setupEventListeners(container);
} else {
// 업데이트: updateElement로 기존 DOM 업데이트
console.log("업데이트 렌더링");
const prevNormalizedVNode = normalizeVNode(container._prevVNode);
// container.replaceChild(createElement(normalizedVNode), container.firstChild);
updateElement(container, normalizedVNode, prevNormalizedVNode);
// 새로운 vNode로 업데이트
container._prevVNode = vNode;
}
}
여기서 제가 주목한 부분은,
// 이전 vNode 저장 container._prevVNode = vNode;
여기인데요, 여기에 vNode가 아니라 normalizedVNode 를 저장해야 하는게 아닐까요!? 그래서 결과가 계속 달라지는게 아닌가.. 싶은 그런 생각이 들어요 ㅎㅎ
변경된 코드에서도 보면 normalizedVNode를 계속 저장하고 있답니다..!