나의 문해력을 의심했던 한 주;;
1주차에 Vanilla JavaScript 기반의 SPA를 구현하며 웹 개발의 기초를 익혔다면, 2주차는 그보다 한 단계 더 나아가 가상 DOM 렌더링 시스템을 직접 만들어보는 시간이었습니다. 퍼블리셔로서 정적인 마크업과 스타일링에 익숙했던 만큼, 동적인 DOM 조작과 상태 관리, Virtual DOM의 필요성을 코드로 다뤄보며 조금씩 감을 잡을 수 있었습니다.
처음에는 "평탄화", "재귀적 표준화", "DocumentFragment" 같은 용어가 낯설고, createVNode, normalizeVNode 같은 함수들이 어떻게 연결되는지, 왜 이런 단계들이 필요한지 이해하기까지 시간이 꽤 걸렸습니다. 1주차에 세웠던 ‘AI는 참고만 하자’는 목표는 지키지 못했고, 결국 많은 부분에서 AI의 도움을 받아 구현에 집중하게 되었습니다. 그래도 과제를 마친 뒤에는 코드를 다시 들여다보며 흐름을 이해하려고 노력했고, 특히 처음부터 고민했던 부분은 기억에 남았습니다.
팀 코드 리뷰 시간은 특히 도움이 되었습니다. 다양한 코드들을 보며 "이렇게도 할 수 있구나", "이 방식이 더 깔끔하네"라는 생각이 들었고, 2-depth와 3-depth로 나뉘는 이벤트 위임 방식에 대해서도 알게 되었습니다. AI는 빠르게 답을 보여주지만, 팀원들과의 피드백은 왜 그렇게 구현했는지, 다른 방법은 없는지를 함께 고민하게 해주었습니다.
이번 경험은 프레임워크 없이 처음부터 만들어보며, 그동안 모르고 지나쳤던 부분들을 하나씩 알아가는 과정이었습니다. 어렵기도 했지만, 앞으로 개발을 계속하려면 꼭 필요한 기초를 차근차근 쌓아가는 시간이었다고 생각합니다.
createVNode
, normalizeVNode
, createElement
, updateElement
함수를 체계적으로 구현하면서 가상 DOM의 생성부터 업데이트까지 전체 생명주기를 이해할 수 있었습니다. 특히 updateElement
에서 다섯 가지 케이스를 처리하는 로직을 구성하며 DOM 조작이 얼마나 복잡하고 조건 분기가 중요한 작업인지 체감했습니다.
export function updateElement($el, oldVNode, newVNode) {
// 1. 같은 참조면 업데이트 불필요
if (oldVNode === newVNode) return;
// 2. 텍스트 노드 처리
if (typeof newVNode === "string" || typeof newVNode === "number") {
if ($el.nodeType === Node.TEXT_NODE) {
$el.textContent = String(newVNode);
} else {
const newTextNode = document.createTextNode(String(newVNode));
$el.parentNode.replaceChild(newTextNode, $el);
}
return;
}
// 3. 요소 타입이 바뀐 경우
if (oldVNode.type !== newVNode.type) {
const newElement = createElement(newVNode);
$el.parentNode.replaceChild(newElement, $el);
return;
}
// 4. 같은 타입이면 속성과 자식만 업데이트
updateAttributes($el, oldVNode?.props || {}, newVNode?.props || {});
updateChildren($el, oldVNode?.children || [], newVNode?.children || []);
}
createVNode
에서 children.flat(Infinity)
를 사용하는 이유를 처음에는 이해하지 못했지만, JSX에서 중첩 배열이 자주 생기기 때문에 이를 평탄화해 일관된 children 배열로 만드는 전처리 과정임을 깨달았습니다.
export function createVNode(type, props, ...children) {
// 중첩 배열을 평탄화하고 불필요한 값들 제거
const flatChildren = children
.flat(Infinity) // [1, [2, [3, 4]]] → [1, 2, 3, 4]
.filter((child) => child !== null && child !== undefined && child !== false && child !== true);
return { type, props, children: flatChildren };
}
normalizeVNode
함수 내부에서 자기 자신을 다시 호출하는 재귀 구조가 처음에는 낯설고 어려웠지만, 함수형 컴포넌트를 처리하는 과정에서 필요한 핵심 구조라는 것을 이해했습니다.
export function normalizeVNode(vNode) {
if (typeof vNode.type === "function") {
const result = vNode.type(nextProps);
return normalizeVNode(result); // 재귀 호출로 끝까지 파고들기
}
// ...
}
HTML에서는 여러 요소를 그냥 나열하면 되는데, JavaScript로는 DocumentFragment라는 걸 활용해 여러 DOM 요소를 한 번에 추가하는 방법을 배웠습니다. 단순히 반복문으로 처리하는 것보다, DOM 삽입 시 리플로우를 줄이고 성능을 개선할 수 있는 효율적인 기법임을 알게 되었습니다.
// DocumentFragment로 한 번에 DOM 추가
const fragment = document.createDocumentFragment();
children.forEach(child => {
fragment.appendChild(createElement(child));
});
$el.appendChild(fragment); // 리플로우 최소화
eventManager.js
에서 Map을 사용해 이벤트 저장소를 구현하면서, 객체보다 효율적인 키-값 저장 방식을 배웠습니다. 특히 DOM 요소를 키로 사용할 때 Map의 장점을 이해할 수 있었습니다.
const eventStore = new Map();
export function addEvent(element, eventType, handler) {
if (!eventStore.has(element)) {
eventStore.set(element, {});
}
const elementEvents = eventStore.get(element);
if (!elementEvents[eventType]) {
elementEvents[eventType] = [];
}
elementEvents[eventType].push(handler);
}
DOM 요소를 객체의 키로 쓰면 문자열로 변환되어 충돌이 날 수 있는데, Map은 객체 자체를 키로 사용할 수 있어서 더 정확했습니다. 그리고 WeakMap이라는 것도 힌트로 얻었는데, 키가 가비지 컬렉션되면 자동으로 해당 엔트리도 사라져서 메모리 누수를 방지할 수 있다고 하더라고요. 이번엔 시간이 부족해서 못 써봤지만, 나중에 메모리 관리 측면에서 꼭 찾아봐야겠습니다.
eventManager
를 통해 이벤트 위임 패턴을 직접 구현하는 과정을 지켜보면서 이벤트 위임의 원리를 이해할 수 있었습니다. 각 요소마다 개별적으로 이벤트를 등록하는 대신, 루트 요소에서 이벤트를 위임받아 처리하는 방식으로 성능을 최적화할 수 있다는 것을 배웠습니다.
export function setupEventListeners(root) {
// 루트에 한 번만 이벤트 등록
root.addEventListener("click", handleEvent);
root.addEventListener("change", handleEvent);
}
function handleEvent(event) {
const element = event.target;
const eventType = event.type;
// 해당 요소에 등록된 핸들러들 찾아서 실행
const handlers = eventStore.get(element);
if (handlers && handlers[eventType]) {
handlers[eventType].forEach((handler) => {
handler.call(element, event);
});
}
}
이런 개념들을 하나씩 알아가면서, 그동안 "가상 DOM이 빠르다", "Diff 알고리즘이 효율적이다"는 말들이 단순한 이론이 아니라 이런 복잡한 구현 과정을 통해 나오는 결과였구나 싶었습니다. 막상 직접 구현해보니까 왜 이런 복잡한 과정이 필요한지, 어떤 부분에서 성능 향상이 일어나는지 손으로 느낄 수 있었어요. 이론으로만 배웠다면 절대 이해할 수 없었을 부분들을 코드를 통해 체득할 수 있었던 소중한 시간이었습니다.
이번 과제에서는 이전보다 더 많은 부분에서 AI의 도움을 받았기에, “내가 정말 이걸 이해하고 있는 걸까?”, “AI에 너무 의존하고 있는 건 아닐까?” 하는 고민이 커졌습니다. 그래서팀원들과 코치님께 조언을 요청드렸습니다.
“AI를 잘 사용하고 감독해서 코드를 빠르게 작성하는 게 프론트엔드 개발자로 살아남는 방법이 아닐까”, “유용한 도구를 잘 활용하는 것도 능력이고, 주도권만 잃지 않으면 된다”는 말에 위로를 받았고, “GPT가 나오기 전에는 궁금한 점을 어떻게 찾아내셨나요?”라거나 “15분 규칙”이라는 구체적인 조언은 앞으로의 학습 방향에 힌트를 주었습니다. (진심 어린 조언 정말 감사합니다.ㅜㅜ)
코치님의 피드백도 인상 깊었습니다. “AI 의존적이라는 게 나쁜 건 아니지만, 본인 것이 아니라는 느낌이 더 크게 다가온다”는 말이 특히 와닿았고, “녹음기를 켜고 내가 작업한 결과를 말로 설명해보는 연습”을 통해 진짜 이해했는지를 스스로 점검해보라는 조언은 이후의 학습 방식에 대해 돌아보는 계기가 되었습니다.
무엇보다, 팀원들과 코치님이 나눠주신 조언처럼 도구에 휘둘리지 않고 내가 주도하는 학습을 이어가야겠다는 다짐을 하게 되었습니다.
*이 회고는 티스토리(https://soa-memo.tistory.com/63)에 먼저 작성한 글이며, 벨로그에도 함께 공유합니다.