과제 체크포인트
배포 링크
https://jun17183.github.io/front_6th_chapter1-2/
기본과제
가상돔을 기반으로 렌더링하기
- createVNode 함수를 이용하여 vNode를 만든다.
- normalizeVNode 함수를 이용하여 vNode를 정규화한다.
- createElement 함수를 이용하여 vNode를 실제 DOM으로 만든다.
- 결과적으로, JSX를 실제 DOM으로 변환할 수 있도록 만들었다.
이벤트 위임
- 노드를 생성할 때 이벤트를 직접 등록하는게 아니라 이벤트 위임 방식으로 등록해야 한다
- 동적으로 추가된 요소에도 이벤트가 정상적으로 작동해야 한다
- 이벤트 핸들러가 제거되면 더 이상 호출되지 않아야 한다
심화 과제
Diff 알고리즘 구현
- 초기 렌더링이 올바르게 수행되어야 한다
- diff 알고리즘을 통해 변경된 부분만 업데이트해야 한다
- 새로운 요소를 추가하고 불필요한 요소를 제거해야 한다
- 요소의 속성만 변경되었을 때 요소를 재사용해야 한다
- 요소의 타입이 변경되었을 때 새로운 요소를 생성해야 한다
과제 셀프회고
이번 과제는 테스트 코드를 요구사항 명세서처럼 참고하며 순차적으로 진행하면 수월하게 통과할 수 있는 과제였습니다. 난이도가 상대적으로 이전 주차보다 쉽고 진행방향이 명확하기에 1주차보다 좀 더 체계적이면서 개념을 잘 익힐 수 있도록 문서로 정리해 가며 진행하기로 했습니다.
우선 AI에게 과제의 주제와 목표에 대해 설명했습니다. 가상 돔을 구현하는 과제로, UI나 서비스 등은 미리 구현이 되어 있는 템플릿이 주어지며 내용이 비어있는 함수를 구현하면 된다고 알려주었습니다. 목표는 basic 테스트를 우선으로 advanced를 거쳐 e2e 테스트까지 성공하는 것이며, 하나의 테스트 케이스씩 통과하기 위해 코드를 추가하는 방향으로 진행해 달라고 하였습니다. 또한 각 함수 구현에 앞서 중요한 개념 학습을 먼저 거친 후에 코드를 작성하였습니다.
예시) Q. createVNode를 구현하기 전에 이해가 부족한 거 같아. 가상 돔을 만들기 위한 객체를 만드는 역할 정도로만 이해하고 있어. 하지만 왜 평탄화가 필요한지, 언제 호출되는지 등은 모르겠어. A. (평탄화가 필요한 이유...) (JSX (설계도) → createVNode (부품 정리) → createElement (실제 조립)...)
Q. 평탄화를 하지 않은 채로 createElement를 호출한다면? A. 매번 타입을 체크할 필요 없이 일관되게 처리 가능. 성능에 큰 차이는 없지만 불변 조건을 만들어 단순성을 보장하고 버그를 방지
테스트 코드) null, undefined, boolean 값은 빈 문자열로 변환되어야 한다. 코드 추가)
if (vNode === null || vNode === undefined || typeof vNode === 'boolean') {
return "";
}
// 여기까지 추가하고 다음 테스트 진행
코드 작성 진행 전 아래와 같이 문서를 작성하였고, 테스트 코드를 하나씩 통과하며 문서를 업데이트 하였습니다.
createVNode
createVNode는 파싱된 jsx로부터 노드 정보를 받아 가상 노드 객체로 바꾸어 주는 함수구성 요소
- type: text나 div와 같은 태그명
- props: 태그 속성
- children: 자식 node
평탄화
평탄화는 중첩된 배열을 하나의 평평한 배열로 만드는 과정을 말한다.
const items = ['apple', 'banana'];
<ul>
{items.map(item => <li key={item}>{item}</li>)}
</ul>
위와 같은 jsx가 있을 때 평탄화 하기 전 객체는 아래와 같다.
{
"type": "ul",
"props": null,
"children": [
[
{
"type": "li",
"props": null,
"children": [
"apple"
]
},
{
"type": "li",
"props": null,
"children": [
"banana"
]
}
]
]
}
이 객체를 평탄화하지 않은 채로 실제 DOM에 적용하려면 아래와 같이 코드가 복잡해진다.
// 1. 평탄화 없는 createVNode
function createVNodeWithoutFlatten(type, props, ...children) {
return { type, props, children };
}
// 3. createElement 함수 (평탄화 X)
function createElement(vNode) {
console.log('createElement 호출:', vNode);
if (typeof vNode === 'string' || typeof vNode === 'number') {
return document.createTextNode(vNode);
}
if (Array.isArray(vNode)) {
// 복잡한 로직 필요
const fragment = document.createDocumentFragment();
vNode.forEach(child => {
fragment.appendChild(createElement(child));
});
return fragment;
}
const element = document.createElement(vNode.type);
// 속성 처리
if (vNode.props) {
Object.entries(vNode.props).forEach(([key, value]) => {
if (key === 'className') {
element.className = value;
} else {
element.setAttribute(key, value);
}
});
}
// 자식 처리
vNode.children.forEach(child => {
try {
element.appendChild(createElement(child));
} catch (error) {
console.error('createElement 에러:', error.message);
element.appendChild(document.createTextNode(`[ERROR:${error.message}]`));
}
});
return element;
}
하지만 평탄화가 되어 있다면 아래와 같이 코드가 간단해진다.
// 평탄화
function flattenArray(arr) {
const result = [];
for (const item of arr) {
if (Array.isArray(item)) {
result.push(...flattenArray(item));
} else {
result.push(item);
}
}
return result;
}
// vNode 생성
function createVNodeWithFlatten(type, props, ...children) {
return {
type,
props,
children: flattenArray(children)
};
}
// DOM 생성이 단순해짐
function createElement(vNode) {
if (typeof vNode === 'string') {
return document.createTextNode(vNode);
}
const element = document.createElement(vNode.type);
// 배열 중첩 걱정 없이 단순하게 처리
vNode.children.forEach(child => {
element.appendChild(createElement(child));
});
return element;
}
구현 코드
function flattenArray(arr) {
const result = [];
for (const item of arr) {
if (Array.isArray(item)) {
result.push(...flattenArray(item));
}
// jsx 파싱 결과 null, undefined, false가 arr에 들어갈 수도 있기에
// 이를 걸러준다.
else if (item !== null && item !== undefined && item !== false) {
result.push(item);
}
}
return result;
}
export function createVNode(type, props, ...children) {
return {
type,
props,
children: flattenArray(children),
};
}
normalizeVNode
createVNode가 jsx를 가상 노드(vNode)로 바꾸어 주는 함수라면 normalizeVNode는 vNode를 렌더링 가능한 형태로 변환하는 역할이다.
createVNode 호출 시점은 컴파일 타임으로 배열 평탄화만을 담당하지만, normalizeVNode는 런타임(렌더링 직전) 시 호출하여 함수 컴포넌트 실행, 타입 변환, 재귀 정규화를 담당한다. 이때 함수 컴포넌트는 HTML 태그로 변환된다.
JSX → createVNode → normalizeVNode → createElement → DOM
JSX와 함수형 컴포넌트
normalizeVNode 시 주의해야 할 점 첫 번째, 함수형 컴포넌트는 JSX의 children을 props로 받는다. 상세한 변환 과정은 아래와 같다.
// JSX 원본
<UnorderedList>
<li>Item 1</li>
<li>Item 2</li>
</UnorderedList>
// 함수 컴포넌트 정의
const UnorderedList = ({ children }) => {
// children = [<li>Item 1</li>, <li>Item 2</li>]
return <ul>{children}</ul>;
};
// 1. JSX → createVNode 변환
createVNode(UnorderedList, null,
createVNode('li', null, 'Item 1'),
createVNode('li', null, 'Item 2')
);
// 2. 결과 vNode 구조
{
type: UnorderedList, // 함수
props: null,
children: [
{ type: 'li', children: ['Item 1'] },
{ type: 'li', children: ['Item 2'] }
]
}
// 3. 함수 호출 시 children을 props로 전달
UnorderedList({
children: [ // props.children으로 전달
{ type: 'li', children: ['Item 1'] },
{ type: 'li', children: ['Item 2'] }
]
});
두 번째, 함수형 컴포넌트에서 children이 1개라면 배열 그대로 props에 넘겨줄 필요 없이 children 1개만 넘겨주면 처리를 줄일 수 있다.
그래서 만약 vNode가 함수라면 아래와 같은 조건을 작성할 수 있다.
if (vNode && typeof vNode === 'object' && typeof vNode.type === 'function') {
const props = vNode.props || {};
// children을 props로 전달해야 함
if (vNode.children && vNode.children.length > 0) {
// children이 있으면 props.children으로 전달
props.children = vNode.children.length === 1
? vNode.children[0]
: vNode.children;
}
const result = vNode.type(props);
return normalizeVNode(result);
}
구현 코드
export function normalizeVNode(vNode) {
// 1. null, undefined, boolean → ""
if (vNode === null || vNode === undefined || typeof vNode === 'boolean') {
return "";
}
// 2. 문자열, 숫자 → 문자열
if (typeof vNode === 'string' || typeof vNode === 'number') {
return String(vNode);
}
// 3. 함수형 컴포넌트 처리
if (vNode && typeof vNode === 'object' && typeof vNode.type === 'function') {
const props = vNode.props || {};
// children을 props로 전달해야 함
if (vNode.children && vNode.children.length > 0) {
// children이 있으면 props.children으로 전달
props.children = vNode.children.length === 1
? vNode.children[0]
: vNode.children;
}
const result = vNode.type(props);
return normalizeVNode(result);
}
// 4. 일반 vNode 객체 처리 (children 정규화)
if (vNode && typeof vNode === 'object' && vNode.children) {
return {
...vNode,
children: vNode.children
.map(child => normalizeVNode(child))
.filter(child => child !== "") // 빈 문자열 제거
};
}
return vNode;
}
createElement
normalizeVNode를 통해 변환된 정보를 가지고 DOM 요소로 변환하는 함수
```js
// vNode (메모리) → DOM 요소 (브라우저)
{ type: "div", props: {...}, children: [...] }
→ <div class="...">...</div>
document.createElement()- HTML 요소 생성document.createTextNode()- 텍스트 노드 생성document.createDocumentFragment()- 여러 요소 컨테이너element.appendChild()- 자식 요소 추가element.setAttribute()- 속성 설정
DocumentFragment
실제 DOM에 연결되지 않은 메모리 상의 노드로 임시 컨테이너 역할을 한다. DOM에 추가될 때 자신은 사라지고 내용만 남긴다.
왜 사용하는가?
- 성능 최적화
// ❌ 비효율 (3번의 리플로우)
container.appendChild(div1);
container.appendChild(div2);
container.appendChild(div3);
// ✅ 효율 (1번의 리플로우)
fragment.appendChild(div1);
fragment.appendChild(div2);
fragment.appendChild(div3);
container.appendChild(fragment);
- 배열 처리
// 가상 DOM에서 배열을 어떻게 DOM 노드로 변환할까?
[vNode1, vNode2, vNode3] // ← 이걸 뭘로 변환?
// ❌ div로 감싸면 불필요한 태그 추가
// ✅ Fragment 사용하면 깔끔하게 여러 요소 반환
- 자기 소멸 특성
fragment.appendChild(div1);
fragment.appendChild(div2);
console.log(fragment.childNodes.length); // 2
document.body.appendChild(fragment);
console.log(fragment.childNodes.length); // 0 (비워짐!)
// div1, div2만 body에 남음, fragment는 사라짐
구현 코드
import { addEvent } from "./eventManager";
function updateAttributes($el, props) {
if (!props) return;
Object.entries(props).forEach(([key, value]) => {
if (key.startsWith('on') && typeof value === 'function') {
// 이벤트는 addEvent 함수 사용
const eventType = key.slice(2).toLowerCase(); // onClick → click
addEvent($el, eventType, value);
} else if (key === 'className') {
$el.className = value;
} else if (key === 'style' && typeof value === 'object') {
Object.entries(value).forEach(([styleKey, styleValue]) => {
$el.style[styleKey] = styleValue;
});
} else if (typeof value === 'boolean') {
if (value) {
$el.setAttribute(key, '');
}
} else if (value != null) {
$el.setAttribute(key, String(value));
}
});
}
export function createElement(vNode) {
// 1. Falsy 값 처리
if (vNode == null || typeof vNode === 'boolean') {
return document.createTextNode("");
}
// 2. 원시값 처리
if (typeof vNode === 'string' || typeof vNode === 'number') {
return document.createTextNode(String(vNode));
}
// 3. 배열 처리 - DocumentFragment 생성
if (Array.isArray(vNode)) {
const fragment = document.createDocumentFragment();
vNode.forEach(child => {
fragment.appendChild(createElement(child)); // 재귀 호출
});
return fragment;
}
// 4. 함수 컴포넌트 오류
if (vNode && typeof vNode === 'object' && typeof vNode.type === 'function') {
throw new Error('함수 컴포넌트는 normalizeVNode로 먼저 변환해야 합니다.');
}
// 5. 일반 vNode
if (vNode && typeof vNode === 'object' && typeof vNode.type === 'string') {
const $el = document.createElement(vNode.type);
// 기존 updateAttributes 함수 사용
updateAttributes($el, vNode.props);
// children 처리
if (vNode.children) {
vNode.children.forEach(child => {
$el.appendChild(createElement(child));
});
}
return $el;
}
return document.createTextNode("");
}
eventManager
eventManager는 이벤트 위임을 관리하는 함수
addEvent: 핸들러를 eventStore에 저장setupEventListeners: 루트에 위임 리스너 등록- 클릭 발생 → 버블링으로 루트까지 전파
- 위임 리스너가 e.target 확인 → eventStore에서 핸들러 찾아 실행
eventStore조회: 해당 요소의 핸들러 찾기- 핸들러 실행: 모든 등록된 핸들러 호출
이벤트 위임이란?
개별 요소마나 이벤트 리스너를 등록하지 않고 공통 조상에 하나의 리스너만 등록하는 방식
// ❌ 기존 방식 (개별 등록)
<div id="container">
<button id="btn1">Button 1</button>
<button id="btn2">Button 2</button>
<button id="btn3">Button 3</button>
</div>
// 각각 개별 등록
btn1.addEventListener('click', handler1);
btn2.addEventListener('click', handler2);
btn3.addEventListener('click', handler3);
// ✅ 이벤트 위임
<div id="container">
<button id="btn1">Button 1</button>
<button id="btn2">Button 2</button>
<button id="btn3">Button 3</button>
</div>
// 조상에 하나만 등록
container.addEventListener('click', (e) => {
if (e.target.id === 'btn1') handler1(e);
if (e.target.id === 'btn2') handler2(e);
if (e.target.id === 'btn3') handler3(e);
});
<!-- 이벤트 전파 과정 -->
<div id="root"> <!-- 여기서 이벤트 감지 -->
<section>
<article>
<button>클릭!</button> <!-- 실제 클릭 -->
</article>
</section>
</div>
<!-- 이벤트 전파: button → article → section → div#root -->
구현 코드
// 전역 저장소들
const eventStore = new WeakMap();
const registeredRoots = new WeakSet();
export function setupEventListeners(root) {
// 이미 등록된 루트면 종료
if (registeredRoots.has(root)) return;
registeredRoots.add(root);
const eventTypes = [
'click',
'input',
'change',
'keydown',
'keyup',
'submit',
'mouseover',
'mouseout',
'mousedown',
'mouseup',
'focus',
'blur'
];
eventTypes.forEach(eventType => {
// 이벤트 위임 리스너 등록
root.addEventListener(eventType, (e) => {
let currentElement = e.target;
// 타겟에서 루트까지 거슬러 올라가며 핸들러 찾기
while (currentElement && currentElement !== root.parentNode) {
const elementEvents = eventStore.get(currentElement);
if (elementEvents) {
const handlers = elementEvents.get(eventType);
if (handlers && handlers.size > 0) {
// 등록된 모든 핸들러 실행
handlers.forEach(handler => handler(e));
return; // 핸들러 발견시 즉시 중단
}
}
currentElement = currentElement.parentNode;
}
}, false); // 버블링 단계에서 처리
});
}
export function addEvent(element, eventType, handler) {
// 요소별 이벤트 맵 초기화
if (!eventStore.has(element)) {
eventStore.set(element, new Map());
}
const elementEvents = eventStore.get(element);
// 이벤트 타입별 핸들러 Set 초기화
if (!elementEvents.has(eventType)) {
elementEvents.set(eventType, new Set());
}
// 핸들러 추가 (Set이 자동으로 중복 방지)
elementEvents.get(eventType).add(handler);
}
export function removeEvent(element, eventType, handler) {
const elementEvents = eventStore.get(element);
if (!elementEvents) return;
const handlers = elementEvents.get(eventType);
if (!handlers) return;
// 핸들러 제거
handlers.delete(handler);
// 메모리 정리: 빈 구조 제거
if (handlers.size === 0) {
elementEvents.delete(eventType);
if (elementEvents.size === 0) {
eventStore.delete(element);
}
}
}
renderElement
renderElement는 지금까지의 가상 DOM 시스템의 최종 실행자. 모든 것을 통합하여 실제 화면을 만드는 핵심 함수
지금까지의 흐름
JSX → babel로 파싱 → createVNode(자동) → renderElement(App 렌더링 시 개발자가 호출) → 내부에서 normalizeVNode → createElement → setupEventListeners
구현 코드
import { setupEventListeners } from "./eventManager";
import { createElement } from "./createElement";
import { normalizeVNode } from "./normalizeVNode";
import { updateElement } from "./updateElement";
export function renderElement(vNode, container) {
// 1. vNode 정규화
const normalizedVNode = normalizeVNode(vNode);
// 2. 초기 렌더링 여부 확인
const isInitialRender = !container._vNode;
if (isInitialRender) {
// 3. 최초 렌더링
container.innerHTML = '';
const domElement = createElement(normalizedVNode);
container.appendChild(domElement);
} else {
// 4. 업데이트 렌더링: updateElement 사용
updateElement(container, normalizedVNode, container._vNode, 0);
}
// 5. 이전 vNode 저장
container._vNode = normalizedVNode;
// 6. 이벤트 위임 설정
setupEventListeners(container);
}
updateAttributes
DOM 요소의 속성들을 이전 상태와 새로운 상태를 비교해서 필요한 부분만 업데이트 하는 함수
- 일반 속성
// 예시: id, data-*, aria-* 등
oldProps: { id: "old-id" }
newProps: { id: "new-id" }
→ element.setAttribute('id', 'new-id')
- className 속성
// 특별 처리 필요
oldProps: { className: "old-class" }
newProps: { className: "new-class" }
→ element.className = "new-class"
// 제거 시
newProps: {} // className 없음
→ element.removeAttribute('class')
- checked, selected
oldProps: { checked: false }
newProps: { checked: true }
→ element.checked = true // property 설정
- disabled, readonly
oldProps: { disabled: false }
newProps: { disabled: true }
→ element.disabled= true // property 설정
→ element.setAttribute('disabled', '') // attribute 설정
- 이벤트 핸들러
// onClick, onMouseOver 등
oldProps: { onClick: oldHandler }
newProps: { onClick: newHandler }
→ removeEvent(element, 'click', oldHandler)
→ addEvent(element, 'click', newHandler)
- 변경 감지
const allKeys = new Set([
...Object.keys(oldProps || {}),
...Object.keys(newProps || {})
]);
allKeys.forEach(key => {
const newValue = newProps?.[key];
const oldValue = oldProps?.[key];
if (newValue === oldValue) return; // 변경 없음 - 건너뛰기
// 변경된 속성만 처리
});
- 속성 제거
// 이전에 있었지만 새로운 props에는 없는 경우
if (oldValue !== undefined && newValue === undefined) {
element.removeAttribute(key);
}
구현 코드
// 속성 업데이트
function updateAttributes(target, newProps, oldProps) {
const allKeys = new Set([
...Object.keys(oldProps || {}),
...Object.keys(newProps || {})
]);
allKeys.forEach(key => {
const newValue = newProps?.[key];
const oldValue = oldProps?.[key];
if (newValue === oldValue) return;
// 이벤트 핸들러 처리
if (key.startsWith('on') && typeof oldValue === 'function') {
const eventType = key.slice(2).toLowerCase();
removeEvent(target, eventType, oldValue);
}
if (key.startsWith('on') && typeof newValue === 'function') {
const eventType = key.slice(2).toLowerCase();
addEvent(target, eventType, newValue);
return;
}
// className 처리
if (key === 'className') {
if (newValue) {
target.className = newValue;
} else {
target.removeAttribute('class');
}
return;
}
// checked와 selected는 property만 설정 (attribute 건드리지 않음)
if (key === 'checked' || key === 'selected') {
target[key] = !!newValue;
return;
}
// 다른 Boolean 속성들 (disabled, readOnly)
const otherBooleanProps = ['disabled', 'readOnly'];
if (otherBooleanProps.includes(key)) {
target[key] = !!newValue;
if (newValue) {
target.setAttribute(key, '');
} else {
target.removeAttribute(key);
}
return;
}
// 일반 속성 처리
if (newValue == null || newValue === false) {
target.removeAttribute(key);
} else {
target.setAttribute(key, String(newValue));
}
});
}
updateElement
이전 가상 DOM과 새로운 가상 DOM을 비교하여 실제 DOM을 최소한으로 변경하는 함수
parentElement: 부모 DOM 요소newNode: 새로운 가상 DOM 노드oldNode: 이전 가상 DOM 노드index: 부모 내에서의 위치
비교 전략
- 노드 존재 여부 체크
// 새 노드가 없으면 기존 노드 제거
if (!newNode && oldNode) {
const childToRemove = parentElement.childNodes[index];
if (childToRemove) {
parentElement.removeChild(childToRemove);
}
return;
}
// 기존 노드가 없으면 새 노드 추가
if (newNode && !oldNode) {
const newElement = createElement(newNode);
parentElement.appendChild(newElement);
return;
}
- 텍스트 노드 처리
// 둘 다 텍스트 노드면서 다를 경우 업데이트
if (typeof newNode === 'string' && typeof oldNode === 'string') {
if (newNode !== oldNode) {
parentElement.childNodes[index].textContent = newNode;
}
return;
}
- 요소 타입 비교
// 노드 타입이 다르면 교체
if (newNode.type !== oldNode.type) {
const newElement = createElement(newNode);
const oldElement = parentElement.childNodes[index];
parentElement.replaceChild(newElement, oldElement);
return;
}
- 같은 타입 요소 업데이트
// 같은 타입이면 속성과 자식만 업데이트
const currentElement = parentElement.childNodes[index];
updateAttributes(currentElement, newNode.props, oldNode.props);
- 자식 노드
// 같은 타입이면 속성과 자식만 업데이트
const currentElement = parentElement.childNodes[index];
updateAttributes(currentElement, newNode.props, oldNode.props);
// 자식 노드들 업데이트
const newChildren = newNode.children || [];
const oldChildren = oldNode.children || [];
const maxLength = Math.max(newChildren.length, oldChildren.length);
// 먼저 기존 자식들을 새로운 자식들과 비교/업데이트
for (let i = 0; i < Math.min(newChildren.length, oldChildren.length); i++) {
updateElement(currentElement, newChildren[i], oldChildren[i], i);
}
// 새로운 자식이 더 많으면 추가
for (let i = oldChildren.length; i < newChildren.length; i++) {
const newElement = createElement(newChildren[i]);
currentElement.appendChild(newElement);
}
// 기존 자식이 더 많으면 제거
// 인덱스 관리를 위해 역순으로 처리
for (let i = oldChildren.length - 1; i >= newChildren.length; i--) {
const childToRemove = currentElement.childNodes[i];
if (childToRemove) {
currentElement.removeChild(childToRemove);
}
}
구현 코드
// 엘리먼트 업데이트
export function updateElement(parentElement, newNode, oldNode, index = 0) {
// 1. 새 노드가 없으면 기존 노드 제거
if (!newNode && oldNode) {
const childToRemove = parentElement.childNodes[index];
if (childToRemove) {
parentElement.removeChild(childToRemove);
}
return;
}
// 2. 기존 노드가 없으면 새 노드 추가
if (newNode && !oldNode) {
const newElement = createElement(newNode);
parentElement.appendChild(newElement);
return;
}
// 3. 둘 다 텍스트 노드면서 다를 경우 업데이트
if (typeof newNode === 'string' && typeof oldNode === 'string') {
if (newNode !== oldNode) {
parentElement.childNodes[index].textContent = newNode;
}
return;
}
// 4. 노드 타입이 다르면 교체
if (newNode.type !== oldNode.type) {
const newElement = createElement(newNode);
const oldElement = parentElement.childNodes[index];
parentElement.replaceChild(newElement, oldElement);
return;
}
// 5. 같은 타입이면 속성과 자식만 업데이트
const currentElement = parentElement.childNodes[index];
updateAttributes(currentElement, newNode.props, oldNode.props);
// 6. 자식 노드들 업데이트
const newChildren = newNode.children || [];
const oldChildren = oldNode.children || [];
const maxLength = Math.max(newChildren.length, oldChildren.length);
// 7. 먼저 기존 자식들을 새로운 자식들과 비교/업데이트
for (let i = 0; i < Math.min(newChildren.length, oldChildren.length); i++) {
updateElement(currentElement, newChildren[i], oldChildren[i], i);
}
// 8. 새로운 자식이 더 많으면 추가
for (let i = oldChildren.length; i < newChildren.length; i++) {
const newElement = createElement(newChildren[i]);
currentElement.appendChild(newElement);
}
// 9. 기존 자식이 더 많으면 제거
// 인덱스 관리를 위해 역순으로 처리
for (let i = oldChildren.length - 1; i >= newChildren.length; i--) {
const childToRemove = currentElement.childNodes[i];
if (childToRemove) {
currentElement.removeChild(childToRemove);
}
}
}
이전 주차보다 AI 사용이 훨씬 원활하긴 했지만 작은 문제가 하나 생겼었습니다. 채팅을 이어가던 중 채팅 길이가 너무 길어져 새 채팅을 열어야 하는 상황. 문제는 이전 채팅의 정보를 가져갈 수가 없었습니다.
다행히 문서를 남기며 작업 중이었기에 너무 어렵지 않게 다음 채팅을 이어나갈 수는 있었지만 그럼에도 테스트 코드나 지금까지 작성한 코드를 다시 붙여 넣는 과정이 필요했습니다.
그래서 두 번째 채팅부터는 채팅 길이를 지속적으로 감지하여 일정 길이가 되면 지금까지의 과정, 진행사항, 다음 진행 단계, 그리고 다음 채팅에서 어떤 문장과 함께 시작하면 되는지를 정리한 문서를 달라고 했습니다.
진행사항 정리 문서 예시
# 가상 DOM 프로젝트 진행 상황 및 컨텍스트📋 프로젝트 개요
가상 DOM을 직접 구현하며 테스트를 통과하는 과제를 수행 중
주요 구현 함수들
createVNode- 가상 노드 생성normalizeVNode- 가상 노드 정규화 (컴포넌트 → HTML 요소 변환)createElement- 가상 노드 → 실제 DOM 요소 변환addEvent,removeEvent,setupEventListeners- 이벤트 위임 시스템renderElement- 최종 렌더링 (정규화 + DOM 생성 + 이벤트 설정)
🎯 현재 진행 상황
✅ 완료된 단계
- Basic 테스트 통과 ✅ - 기본적인 가상 DOM 생성 및 렌더링 구현
- Advanced 테스트 통과 ✅ - Diff 알고리즘 및 최적화된 업데이트 로직 구현
- E2E 테스트 통과 ✅ - UI 테스트 완료
- 필수 과제 완료 🎉
🔄 현재 단계
- 추가 기능 구현 계획 중 - React와 비교하여 업그레이드 방향 검토
📅 향후 계획
- React와 비교 분석 - 현재 구현과의 차이점 파악
- 상태 관리 시스템 구현 - useState, useEffect 등
- 성능 최적화 - 더 정교한 reconciliation 알고리즘
- 컴포넌트 라이프사이클 - mount, update, unmount 처리
🧪 Advanced 테스트 요구사항
핵심 기능들
- Diff 알고리즘
- 변경된 부분만 업데이트
- DOM 요소 재사용 (같은 타입의 요소는 새로 생성하지 않음)
- 효율적인 자식 노드 관리
- 특수 속성 처리
className→class속성 매핑- Boolean 속성들:
checked,disabled,selected,readOnly - 속성 제거 시 DOM에서도 완전 제거
- 컴포넌트 업데이트
- 함수형 컴포넌트의 효율적인 리렌더링
- 중첩된 컴포넌트에서 깊은 레벨 변경사항만 업데이트
- 엣지케이스 처리
- 자식 배열 크기 변경 (많은 자식 → 적은 자식)
- 빈 배열로 모든 자식 제거
- 복잡한 중첩 구조에서의 업데이트
주요 테스트 케이스
diff 알고리즘을 통해 변경된 부분만 업데이트해야 한다새로운 요소를 추가하고 불필요한 요소를 제거해야 한다요소의 속성만 변경되었을 때 요소를 재사용해야 한다요소의 타입이 변경되었을 때 새로운 요소를 생성해야 한다className이 props에서 제거될 때 class 속성이 올바르게 제거되어야 한다boolean type props가 property로 직접 업데이트되어야 한다
🛠️ 완성된 핵심 구현
1. createVNode - 가상 노드 생성
javascript
function createVNode(type, props, ...children) {
return {
type,
props,
children: flattenArray(children), // 중첩 배열 평탄화 + falsy 값 필터링
};
}
2. normalizeVNode - 함수형 컴포넌트 정규화
javascript
function normalizeVNode(vNode) {
// 함수형 컴포넌트 → HTML 요소로 변환
// children을 props로 전달하여 컴포넌트 호출
}
3. createElement - Virtual DOM → Real DOM
javascript
function createElement(vNode) {
// 배열 → DocumentFragment
// 함수 컴포넌트 처리 (에러 발생)
// 일반 vNode → DOM 요소 + 속성 + 자식
}
4. updateElement - Diff 알고리즘 핵심
javascript
function updateElement(parentElement, newNode, oldNode, index = 0) {
// 1. 노드 추가/제거 처리
// 2. 타입 변경 시 교체
// 3. 같은 타입 시 속성/자식 업데이트
// 4. 자식 노드 재귀적 처리 (역순 제거)
}
5. updateAttributes - 효율적 속성 업데이트
javascript
function updateAttributes(target, newProps, oldProps) {
// className 특별 처리
// checked, selected → property만 설정
// disabled, readOnly → property + attribute 설정
// 이벤트 핸들러 교체
}
6. 이벤트 위임 시스템
javascript
// WeakMap + WeakSet으로 메모리 효율적 관리
// 부모에서 위임 처리로 성능 최적화
🐛 발생한 에러와 해결 과정
1. Boolean 속성 처리 에러
🚨 문제: checked 속성 테스트 실패
expect(checkbox.getAttribute("checked")).toBe(null);
실제: [빈값] != null
❌ 문제가 있던 코드
javascript
// 모든 boolean 속성을 동일하게 처리
const booleanProps = ['checked', 'disabled', 'selected', 'readOnly'];
if (booleanProps.includes(key)) {
target[key] = !!newValue;
if (newValue) {
target.setAttribute(key, ''); // ← checked에서는 문제!
} else {
target.removeAttribute(key);
}
}
✅ 해결된 코드
javascript
// checked와 selected는 property만 설정
if (key === 'checked' || key === 'selected') {
target[key] = !!newValue;
// attribute는 건드리지 않음!
return;
}
// 다른 Boolean 속성들은 property + attribute 설정
const otherBooleanProps = ['disabled', 'readOnly'];
if (otherBooleanProps.includes(key)) {
target[key] = !!newValue;
if (newValue) {
target.setAttribute(key, '');
} else {
target.removeAttribute(key);
}
return;
}
해결 핵심: checked와 selected는 React에서 property로만 제어하며 attribute는 건드리지 않음
2. 자식 노드 제거 에러
🚨 문제: 초과 자식 제거 시 개수 불일치
expect(container.firstChild.children.length).toBe(2);
실제: 3 != 2
❌ 문제가 있던 코드
javascript
// 순서대로 제거하면 인덱스가 변경됨
const maxLength = Math.max(newChildren.length, oldChildren.length);
for (let i = 0; i < maxLength; i++) {
updateElement(currentElement, newChildren[i], oldChildren[i], i);
}
✅ 해결된 코드
javascript
// 1. 공통 부분 업데이트
for (let i = 0; i < Math.min(newChildren.length, oldChildren.length); i++) {
updateElement(currentElement, newChildren[i], oldChildren[i], i);
}
// 2. 새로운 자식 추가
for (let i = oldChildren.length; i < newChildren.length; i++) {
const newElement = createElement(newChildren[i]);
currentElement.appendChild(newElement);
}
// 3. 초과하는 기존 자식 제거 (역순으로!)
for (let i = oldChildren.length - 1; i >= newChildren.length; i--) {
const childToRemove = currentElement.childNodes[i];
if (childToRemove) {
currentElement.removeChild(childToRemove);
}
}
해결 핵심: 자식 제거는 반드시 역순으로 처리하여 인덱스 안정성 유지
3. 이벤트 핸들러 미동작 에러
🚨 문제: 전체 테스트 실행 시 이벤트 핸들러 0번 호출
expect(clickHandler).toHaveBeenCalledTimes(1);
실제: 0번 호출됨
❌ 문제가 있던 코드
javascript
// 문자열 변환으로 인한 중복 키 문제
const registeredEvents = new Set();
export function setupEventListeners(root) {
eventTypes.forEach(eventType => {
const eventKey = `${root}_${eventType}`; // "[object HTMLDivElement]_click"
if (registeredEvents.has(eventKey)) return; // 모든 div가 같은 키!
registeredEvents.add(eventKey);
// ... 이벤트 등록
});
}
✅ 해결된 코드
javascript
// WeakSet으로 객체 참조 직접 비교
const registeredRoots = new WeakSet();
export function setupEventListeners(root) {
if (registeredRoots.has(root)) return; // 객체 참조로 정확히 비교
registeredRoots.add(root);
eventTypes.forEach(eventType => {
root.addEventListener(eventType, (e) => {
// ... 이벤트 위임 로직
});
});
}
해결 핵심: WeakSet으로 DOM 요소 객체 자체를 추적하여 각 컨테이너를 정확히 구분
4. ReferenceError 에러
🚨 문제: 변수 선언 누락
ReferenceError: registeredRoots is not defined
❌ 문제가 있던 코드
javascript
// 변수 선언 없이 사용
export function setupEventListeners(root) {
if (registeredRoots.has(root)) return; // ← registeredRoots 선언 안됨!
}
✅ 해결된 코드
javascript
// 파일 상단에 변수 선언 추가
const eventStore = new WeakMap();
const registeredRoots = new WeakSet(); // ← 이 줄 추가!
export function setupEventListeners(root) {
if (registeredRoots.has(root)) return;
registeredRoots.add(root);
// ...
}
해결 핵심: 전역 변수는 사용 전에 반드시 선언
🔧 해결한 주요 기술적 도전과제
1. Diff 알고리즘 구현
- DOM 요소 재사용: 같은 타입 요소는 새로 생성하지 않고 업데이트
- 효율적 자식 관리: 역순 제거로 인덱스 안정성 유지
- 메모리 최적화: 불필요한 DOM 조작 최소화
2. 특수 속성 처리
- Boolean 속성 분류:
checked/selectedvsdisabled/readOnly - className 매핑:
props.className→element.className - 이벤트 핸들러: 동적 추가/제거
3. 이벤트 관리 시스템
- 위임 패턴: 성능 최적화
- 메모리 누수 방지: WeakMap/WeakSet 활용
- 테스트 격리: 각 컨테이너별 독립적 관리
4. 컴포넌트 시스템
- 함수형 컴포넌트: props + children 전달
- 정규화 과정: 컴포넌트 → HTML 요소 변환
- 중첩 처리: 깊은 컴포넌트 트리 지원
🚀 다음 단계: React 수준으로 업그레이드
1. 상태 관리 시스템
javascript
// useState 훅 구현
function useState(initialValue) {
// 상태 저장소
// setter 함수
// 리렌더링 트리거
}
// useEffect 훅 구현
function useEffect(effect, dependencies) {
// 의존성 배열 비교
// 마운트/언마운트/업데이트 처리
// 클린업 함수 관리
}
2. 컴포넌트 라이프사이클
javascript
// 마운트/언마운트 감지
// 의존성 변화 추적
// 효과(effect) 실행 시점 관리
3. 성능 최적화
javascript
// React.memo 같은 메모이제이션
// useMemo, useCallback 구현
// 더 정교한 reconciliation
4. 추가 기능들
- Context API: 프롭 드릴링 해결
- Ref 시스템: DOM 직접 접근
- 조건부 렌더링: Suspense, ErrorBoundary
- Key 기반 리스트: 더 효율적인 배열 렌더링
💡 분석할 React와의 차이점
현재 구현의 한계
- 상태 없음: 함수형 컴포넌트에 상태 저장 불가
- 라이프사이클 없음: 마운트/언마운트 감지 불가
- 단순한 Diff: key 기반 최적화 없음
- 효과 시스템 없음: 사이드 이펙트 관리 불가
React의 고급 기능들
- Fiber 아키텍처: 작업 분할 및 우선순위
- Concurrent Rendering: 논블로킹 렌더링
- Time Slicing: 큰 작업을 작은 단위로 분할
- Suspense: 비동기 컴포넌트 로딩
💡 참고사항
중요한 구현 포인트
- DOM 요소 참조 유지 (같은 요소는 재사용)
- 이벤트 리스너 유지/업데이트
- 메모리 누수 방지 (제거된 요소의 이벤트 정리)
- 성능 최적화 (불필요한 DOM 조작 최소화)
React와의 차이점 분석 예정
- 상태 관리 (useState, useEffect 등)
- 컴포넌트 라이프사이클
- 더 정교한 reconciliation 알고리즘
- 가상 DOM 트리 비교 최적화
다음 대화 시작 시: 이 문서를 첨부하고 "필수 과제를 완료했으니 React 수준의 고급 기능 구현을 시작하겠습니다" 라고 말하면 됩니다.
우선순위 추천 순서:
- useState 훅 구현 (상태 관리의 기초)
- useEffect 훅 구현 (라이프사이클 관리)
- 컴포넌트 재렌더링 최적화
- Context API 또는 추가 훅들
현재 성취: 기본적인 Virtual DOM과 Diff 알고리즘을 성공적으로 구현하여 모든 테스트를 통과했습니다! 🎉
기술적 성장
과제 특성상 코드 품질이 많이 다르진 않겠지만 다른 것보다 주석을 잘 단 것 같습니다. 또한 normalizeVNode에서 아래와 같이 과정을 조금이라도 줄여보려 한 부분이 좋지 않았나 생각합니다. (children이 하나만 있다면 배열로 넘기지 않기 등등...)
// 3. 함수형 컴포넌트 처리
if (vNode && typeof vNode === 'object' && typeof vNode.type === 'function') {
const props = vNode.props || {};
// children을 props로 전달
if (vNode.children && vNode.children.length > 0) {
// children이 있으면 props.children으로 전달
props.children = vNode.children.length === 1 ? vNode.children[0] : vNode.children;
}
const result = vNode.type(props);
return normalizeVNode(result);
}
가장 만족스러운 부분은 코드 구현은 아니지만 AI 사용입니다. 지난 주차에서 배운 AI 사용 경험을 토대로 문서 정리부터 개념 학습까지 아주 AI를 알차게 쏙쏙 빼먹지 않았나 생각합니다. (AI에게 화낸 적이 한 번도 없다니)
특히 현재 채팅의 사용량을 감지하고 새 채팅을 이어나갈 수 있도록 문서로 정리하는 것이 직접 깨우친 쏠쏠한 꿀팁이지 않나 생각합니다.
학습 효과 분석
말로만 듣던 가상 돔에 대한 이해도가 가장 큰 수확이었습니다. 가상 돔이 어떻게 구현되어 있는지, 어떤 식으로 흘러가는지 알 수 있었습니다.
추가로 과제에 상태 관리 코드를 적용한다면 어떻게 적용할 수 있을지, 다른 프레임워크들은 어떤 식으로 구성되어 있는지 학습을 해볼까 합니다.
과제 피드백
1주차에 이어서 이번 과제도 PT하듯 밀린 공부를 하게 된 느낌이라 큰 도움이 되었습니다. 바닐라 JS로 SPA, 라우터, 가상 돔 등을 직접 만들어 보는 것이 이해도를 높이는 가장 효율적인 방법이라고 피부로 느꼈습니다.
(과제 특성상 테스트코드가 지난 주차보다 깔끔해서 좋았습니다... ㅎ)
리뷰 받고 싶은 내용
-
eventManager.js의 addEvent, removeEvent를 지금처럼 createElement나 updateElement에서 사용하는 것이 아니라 직접 사용할 일이 있을까요? 저는 어떤 데이터를 비동기로 조회 후 이 데이터로 state를 업데이트 하여 리렌더링이 발생하는 것이 아닌, 비동기로 조회한 데이터 결과에 따라 바로 어떤 이벤트를 적용하거나 지워야 하는 경우 직접 addEvent, removeEvent 함수를 호출할 수도 있겠다 생각했습니다.
-
기술적인 내용이 아니라 적절치 못한 질문일 수도 있을 것 같습니다. 1, 2주차를 거치며 AI 사용이 점점 익숙해지고 있습니다. 특히 이번 주차는 질문의 단위도 작게 나누어 코드 이해도를 높였고, 개념 정리도 틈틈히 진행했습니다. 하지만 결국 구현능력은 조금 떨어지는 느낌이 듭니다. 내가 이미 알고 있는 것이지만 귀찮은 작업이기에 맡기는 그림이 맞는지, AI의 도움을 적극적으로 받아 큰 맥락, 주요 개념 위주로 익히며 진행해도 되는지 궁금합니다. (+ "AI 없이 코딩하기 수련"이 필요할까요?)
과제 피드백
홍준님 고생하셨습니다. 회고를 읽어보니 아주 많은 것을 스스로 학습하신 것 같아서 좋네요! 명확하게 각 함수들에 대해 정리해보고, 목적을 작성해보신것도 너무 좋았습니다. 큰 것은 아니기도 하고 갠취이긴 하지만 이런 주석을 남기는게 AI에게 명확한 구현 지침이 될 수 있어서 좋다고 생각하는데요! 다만 전통적인 관점에서 이런 주석들이 코드 자체로 명확하게 드러날 수 있다고 생각해 구현이 마무리되면 명확한 부분들만 남겨보는건 어떨까 싶기도하네요!
리뷰 받고 싶어하셨던 내용으로 넘어가보면요!
eventManager.js의 addEvent, removeEvent를 지금처럼 createElement나 updateElement에서 사용하는 것이 아니라 직접 사용할 일이 있을까요?
지금 딱 떠오르는 시나리오는 없는 것 같은데요. 이미 시스템을 만들었다는 점에서 캡슐화 관점에서 결국에는 이 엘리먼트 내에서 추상화를 해서 제어를 하는 편이 이상적이고, 관리가 명확해지면서 편하다고 생각이 드는 것 같아요!
기술적인 내용이 아니라 적절치 못한 질문일 수도 있을 것 같습니다. 1, 2주차를 거치며 AI 사용이 점점 익숙해지고 있습니다. 특히 이번 주차는 질문의 단위도 작게 나누어 코드 이해도를 높였고, 개념 정리도 틈틈히 진행했습니다. 하지만 결국 구현능력은 조금 떨어지는 느낌이 듭니다. 내가 이미 알고 있는 것이지만 귀찮은 작업이기에 맡기는 그림이 맞는지, AI의 도움을 적극적으로 받아 큰 맥락, 주요 개념 위주로 익히며 진행해도 되는지 궁금합니다. (+ "AI 없이 코딩하기 수련"이 필요할까요?)
음...! 저도 이 부분에 대해 고민이 굉장히 많은데요. 이전에 저희가 공부를 하는데 있어서는 각 API를 빠르고 적절하게 사용하는 것이 매우 중요했었기 때문에 코드를 작성하는 연습을 하는게 굉장히 중요했었던 것 같아요. 지금의 시점에서는 그때만큼 그런 역량이 중요해지지 않은 것은 사실이지만 그럼에도 AI를 활용해 모든 코드를 완벽하게 작성할 수 없고, 복잡하고 비즈니스에 가까운 복잡한 함수들을 작성하게 되면 결국 제가 다시 작성하게 되더라구요. 그런 부분에서 과거만큼은 아니지만 여전히 중요하지 않을까라고 생각하는 것 같습니다. 미래에는 어떻게 될지 모르죠! 저희가 코드를 작성하지 않게 될 수 있겠지만, 지금의 시점에서 저희는 관점을 갖고 연습하고 준비해야 하기 때문에 이 부분도 같이 공부해보면 어떨까 싶네요!
고생하셨고 다음주도 화이팅입니다~~