과제 체크포인트
배포 링크
https://ldhldh07.github.io/front_6th_chapter1-2/
기본과제
가상돔을 기반으로 렌더링하기
- createVNode 함수를 이용하여 vNode를 만든다.
- normalizeVNode 함수를 이용하여 vNode를 정규화한다.
- createElement 함수를 이용하여 vNode를 실제 DOM으로 만든다.
- 결과적으로, JSX를 실제 DOM으로 변환할 수 있도록 만들었다.
이벤트 위임
- 노드를 생성할 때 이벤트를 직접 등록하는게 아니라 이벤트 위임 방식으로 등록해야 한다
- 동적으로 추가된 요소에도 이벤트가 정상적으로 작동해야 한다
- 이벤트 핸들러가 제거되면 더 이상 호출되지 않아야 한다
심화 과제
Diff 알고리즘 구현
- 초기 렌더링이 올바르게 수행되어야 한다
- diff 알고리즘을 통해 변경된 부분만 업데이트해야 한다
- 새로운 요소를 추가하고 불필요한 요소를 제거해야 한다
- 요소의 속성만 변경되었을 때 요소를 재사용해야 한다
- 요소의 타입이 변경되었을 때 새로운 요소를 생성해야 한다
과제 셀프회고
기술적 성장
2주차 과제는 1주차 과제와는 다른 해결과정을 요구했습니다. 1주차는 자유도가 높고 렌더링, 이벤트 위임과 관련된 동작을 구현해보는 과정에서 직접 로직을 구성하고 프로젝트 전체의 아키텍쳐를 짜는 경험을 제공했습니다.
반면 2주차는 어느정도 정해진 로직을 구현해야했습니다. 그래서 이번 과제에서 학습 포인트를 두가지로 잡았습니다.
- SPA의 로직을 이해한다
- 정해진 로직을 바닐라 JS 코드로 구현하는 능력을 기른다.
AI 활용 과정
해당 방향성에 맞게 AI를 활용하는 방식 또한 달랐습니다.
docs를 만들어서 현재 진행사항이나 rule 그리고 전체적인 계획을 기록해서 ai가 보다 큰 관점에서 대답할 수 있도록 하는 것은 유지했습니다. 추가적으로 아래와 같이 지침을 정했습니다.
- 직접 코드를 작성하거나 알려주지 않도록 했습니다. 그리고 단계별로 논리를 진행할 수 있도록 방향성을 말해달라 했습니다.
- 실제 기존 SPA 기술들이 작동하는 방식을 참고해서 적용해달라고 했습니다.
- 문제 해결 또한 원인을 분석하고 그 원인만 파악해달라고 했습니다.
- 추가적으로 이슈가 생겼을 경우 그 트러블슈팅 과정을 정리해서 문서로 남기도록 했습니다.
이는 이번 과제가 동작 로직을 이해하는 것에 중점이 있다 생각했고 이를 위해서는 어느정도 직접 고민하고 작성해야 생각했기 때문입니다.
최대한 직접 코딩을 하고자 하였고, 적당한 고민 후에는 고민한 내용을 공유하고 그에 맞게 제시된 방향성으로 새롭게 로직을 만들어 나가는 식으로 진행했습니다.
학습 효과 분석
SPA의 동작
직접 코드를 작성하고 동작을 시켜보니 가상돔이 필요한 이유를 알 수 있었습니다.
가상DOM diff 로직중에서 렌더링 최적화에 기여한다고 판단되는 로직들이 있었습니다.
- 속성만 변경될 경우 타입은 유지한채 속성만 변경(updateAttribute)
function updateAttributes(target, newProps, oldProps) {
const attributes = new Set([...Object.keys(newProps || {}), ...Object.keys(oldProps || {})]);
attributes.forEach((attribute) => {
const newValue = newProps?.[attribute];
const oldValue = oldProps?.[attribute];
if (newValue !== oldValue) {
// 값이 다를 때만 DOM 조작 수행
// 같으면 아무것도 하지 않아서 불필요한 setAttribute 방지
if (attribute.startsWith("on")) {
// 이벤트 핸들러 변경 처리
} else if (newValue == null) {
// 속성 제거
} else {
// 속성 설정
}
}
// newValue === oldValue인 경우 완전히 스킵
});
}
// updateElement에서 호출
if (newNode.type === oldNode.type) {
// 같은 타입이면 요소 재사용하고 속성만 업데이트
updateAttributes(targetElement, newNode.props, oldNode.props);
} else {
// 다른 타입이면 요소 전체 교체
parentElement.replaceChild(newChildElement, targetChildElement);
}
- 특수 속성 직접 접근 수정
const specialProperties = ["checked", "selected", "disabled", "readOnly"];
if (newValue == null) {
if (specialProperties.includes(attribute)) {
target[attribute] = false;
// DOM property에 직접 접근, setAttribute보다 빠름
} else {
target.removeAttribute(attribute);
// 일반 속성은 attribute 제거
}
} else {
if (specialProperties.includes(attribute)) {
target[attribute] = newValue;
// Boolean 속성은 property로 직접 설정
// target.checked = true가 target.setAttribute('checked', 'true')보다 빠름
} else {
target.setAttribute(attribute, newValue);
// 일반 속성은 setAttribute 사용
}
}
- 자식 노드 업데이트 순서로 인한 최적화
const newChildren = newNode.children || [];
const oldChildren = oldNode.children || [];
const commonLength = Math.min(newChildren.length, oldChildren.length);
// 1단계: 공통 길이만큼 기존 요소들 재사용
for (let i = 0; i < commonLength; i++) {
updateElement(targetElement, newChildren[i], oldChildren[i], i);
// 기존 DOM 요소를 최대한 재사용
}
// 2단계: 초과하는 기존 요소들 삭제 (역순)
if (oldChildren.length > newChildren.length) {
for (let i = oldChildren.length - 1; i >= newChildren.length; i--) {
targetElement.removeChild(targetElement.childNodes[i]);
// 뒤에서부터 삭제해야 인덱스가 안 꼬임
// 앞에서부터 삭제하면 removeChild 후 인덱스가 변경됨
}
}
// 3단계: 새로운 요소들 추가
if (newChildren.length > oldChildren.length) {
for (let i = oldChildren.length; i < newChildren.length; i++) {
targetElement.appendChild(createElement(newChildren[i]));
// 새로 생성이 필요한 요소들만 createElement 호출
}
}
- 이벤트 위임 방식
const prevContainers = new WeakSet();
setupEventListeners(root) {
if (prevContainers.has(root)) return;
// 같은 root에 중복으로 이벤트 등록하는 것 방지
prevContainers.add(root);
registeredEvents.forEach((eventType) => {
root.addEventListener(eventType, (event) => {
let currentTarget = event.target;
while (currentTarget && currentTarget !== root) {
const elementEvents = eventStore.get(currentTarget);
if (elementEvents && elementEvents.has(eventType)) {
// 해당 요소에 등록된 핸들러가 있으면 실행
const handlers = elementEvents.get(eventType);
handlers.forEach((handler) => handler(event));
}
currentTarget = currentTarget.parentElement;
// 이벤트 버블링을 활용해 부모로 올라가며 핸들러 찾기
}
});
});
}
addEvent(element, eventType, handler) {
registeredEvents.add(eventType);
// 실제 addEventListener는 하지 않고 Map에만 저장
// root에 위임된 리스너가 나중에 이 Map을 참조해서 핸들러 실행
if (eventStore.has(element)) {
// 기존 요소에 새 이벤트 추가
} else {
// 새 요소에 첫 이벤트 등록
}
}
실제 DOM을 전체 재렌더링한다면 element 생성 -> 속성 설정 -> 내용 설정 -> appendChild와 같은 단계를 거쳐 새롭게 element를 만들고 전체를 다시 갈아끼워야 합니다.
대신하여 이루어지는 가상 DOM에서는 어떤 식으로 최적화를 하는지 직접 작성함으로서 체감할 수 있었습니다.
vNode의 데이터 구조
이 최적화의 바탕이 되는것은 vNode의 객체 구조입니다.
// src/lib/createVNode.js
export function createVNode(type, props, ...children) {
return {
type: type, // 함수 혹은 타입
props: props, // 속성
children: children.flat(2).filter(...) // 평탄화된 자식요소
};
}
- 객체 구조로서 생성 및 수정이 빠름
- 메모리가 가벼움
이 객체 구조로 인해 diff로 비교를 빠르게 할 수 있고 실제로 바뀐 부분만 찾아내어 실제 DOM 조작을 할 수 있습니다. 과제를 수행하면서 초기에 이 vNode의 구조를 파악하는 것이 핵심이었습니다. 관련한 로직들을 개발할 때 이 구조를 계속 떠올리고 이 구조를 만들거나 혹은 조작하려면 어떤식으로 작동해야 할지 고민해야 했습니다. 특히 type에 함수가 들어가며 그 함수를 동작시키는 개념을 이해해야 했습니다.
과제 피드백
처음에 언급했던 두가지 중 spa동작에 대한 이해도는 높아졌습니다. 반면 자바스크립트 코드 작성에 대해서는 기본 자바스크립트 문법도 많이 잊어버리고 있었고, 아직 좀 더 발전해야겠다는 생각이 들었습니다.
가상DOM / 실제DOM 구분하기
과제에서 신경써야 할 부분은 가상DOM을 처리해야할 때와 실제DOM을 처리해야할 때였습니다.
- 타입, 속성, 내용 등의 객체 정보를 이용해서 비교하는 로직 -> 가상 DOM
- 해당 diff로 알아낸 조작을 실행할 때 -> 실제 DOM
간단한 분류지만 맥락을 잡기 전에는 어쩔때 실제 DOM을 호출하고, 조작하는 메소드를 써야할지, 어쩔때 가상 DOM에 해당하는 함수를 적용해서 비교를 해야할지 혼란스러운 경우가 있었습니다. 그 혼란을 해결하는 과정에서 SPA의 리렌더링 로직에 대한 이해도가 높아졌습니다.
사용하는 속성과 메소드들도 각자 달랐습니다.
- className / class
- children / childNOde
- on{이벤트명}/{이벤트명}
- 특수속성들
이 구분을 이해하고 적용하는 것에 시간이 들고 가장 학습이 많이 되었습니다.
자식 노드 반복 순서
diff 알고리즘을 구현하는 데 있어서 가장 이해하기 위해 노력한 부분은 자식 노드를 반복하는 순서였습니다. 인덱스를 제거하고 추가하는 복잡한 동작 속에서 순회방식을 어떻게 해야 모든 자식 노드들을 기존 인덱스에 맞게 재정렬할 수 있을 지는 흥미롭고 알아가는 재미가 있는 과정이었습니다.
처음에는 큰 고민없이 0부터 반복을 수행했지만 index를 참조를 못하는 오류가 발생했습니다. 참조 뿐 아니라 이 현상은 sort 변화로 인한 재배열이나 무한스크롤 시 예상치 못한 동작을 유발했습니다.
본래의 순서로 요소를 제거하고 순회할 시 오작동하는 현상은 알고리즘에서도 많이 접한 현상입니다. 그래서 두번째로는 역순으로 해서 보다 오류가 적었지만 마찬가지로 제대로 작동하지 않았습니다.
올바른 순서는 다음과 같았습니다:
- 공통 길이만큼 기존 요소 재사용 - diff 알고리즘으로 변경된 부분만 업데이트
- 초과하는 기존 요소들을 역순으로 삭제 - DOM 인덱스 변화를 방지
- 새로운 요소들을 순차적으로 추가 - 새로 생성이 필요한 요소만 처리
특히 2단계의 역순 삭제가 중요했는데, 순차 삭제 시 removeChild 후 배열 인덱스가 변경되어 잘못된 요소를 삭제하는 문제가 발생했습니다. 이런 세밀한 DOM 조작 로직을 직접 구현하면서 브라우저 API의 특성과 배열 인덱스 관리 또한 배울 수 있었습니다.
기존 프레임워크/라이브러리의 동작 이해
과제를 하면서 가상DOM에 대한 이해도가 높아지고 사용경험이 있는 기존 툴들의 동작 방식을 읽으니 이해도가 높아지고 재밌었습니다. 실제 코드를 작성하는 방식과 연결지어서 어떻게 동작하는지 파악하고자 했습니다.
렌더링 최적화
React
React의 Fiber 아키텍처: React는 Fiber를 통해 렌더링 작업을 작은 단위로 나누어 우선순위를 부여하고, 메인 스레드를 블로킹하지 않고 중단 가능한 렌더링을 제공합니다.
Vue
Vue의 반응형 시스템: Vue는 의존성 추적을 통해 컴포넌트가 실제로 사용하는 데이터만 관찰하고, 해당 데이터 변경 시에만 정확한 컴포넌트를 재렌더링합니다.
자식 노드 순회 알고리즘
React
React의 Key 기반 Reconciliation: React는 key prop을 통해 요소의 identity를 추적하여, 순서가 바뀌어도 같은 요소를 인식하고 재사용할 수 있습니다.
Vue
Vue의 양방향 diff 알고리즘: Vue는 양쪽 끝에서부터 비교를 시작하여 중간으로 좁혀가는 방식으로, 단순한 앞뒤 추가/삭제를 빠르게 처리합니다.
리뷰 받고 싶은 내용
updateElement는 케이스를 분리해서 동작을 처리하는 로직이 주가 되는 함수입니다. 이런 경우 함수의 가독성을 높이기 위해 헬퍼 함수를 따로 분리하여 정의하고 각 경우의 수마다 적용하는 방식을 선호합니다. 하지만 너무 이른 시점에 해당 작업을 수행하면 생산성이 떨어지고 , 과도하게 사용할 경우 오히려 복잡도가 올라가는 경험도 있었습니다.
단순히 가독성 복잡도뿐이 아니라, 테스트 가능성, 유지보수성, 팀 개발 관점에서 함수 분리의 기준과 시점을 어떻게 판단하시는지, 그리고 과도한 분리로 인한 코드 파편화를 방지하는 방법에 대해 의견을 듣고 싶습니다.
과제 피드백
두현님 한 주 고생 많으셨어요~ 작성해주신 회고 잘 읽었고 덕분에 저도 고민의 흔적들을 따라가볼수 있어서 좋았네요! 직접적으로 지침을 넣어서 명확한 학습방향을 만드시고 공부하셨던 것도 좋았던 것 같습니다.
PR에 남겨주셨던 궁금하셨던 내용 이야기를 이어서 해보면요.
단순히 가독성 복잡도뿐이 아니라, 테스트 가능성, 유지보수성, 팀 개발 관점에서 함수 분리의 기준과 시점을 어떻게 판단하시는지, 그리고 과도한 분리로 인한 코드 파편화를 방지하는 방법에 대해 의견을 듣고 싶습니다.
여러 원칙들과 선배들이 이야기 한 규칙들이 있겠지만, 사실 정답은 없는 것 같아요. 우리들은 깔끔한 코드를 명확한 규칙 내에 정리되게 작성하고 싶어하지만 현실에서는 그런 코드를 작성하는게 사실상 불가능하거든요. 그럼에도 개인적으로 이런 분리, 추상화 고민이 들때는 재사용이 실제로 발생하는 시점이 임박하거나 실제로 상상속에서 벌어지고만 있다면 구현하지 않는 편인것 같아요. 그리고 실제로 사용하게 되더라도 중복으로 구현되도록(복사를 아마 하겠죠?) 사용한 다음 주석으로 명확하게 개선이 필요하다고 남기고 추후에 별도 작업으로 분리해서 테스트와 함께 검증하면서 해결하는 편입니다. 내가 아무리 확실한 미래일지라도 상상하는 그 미래 상황에 맞춰 구현을 하고 코드를 나누다보면 늘 문제가 생기고 확장성이 사라졌던 것 같아요. 필요한 만큼 현재 시점에 맞게 딱 그정도만 구현을 한다음 필요해지면 더 구현해나가면 되지 않을 까 싶습니다 ㅎㅎ 모든 규칙에 있어서요.
고생하셨고 다음 주 과제도 화이팅입니다!