
저번 글에서는 좋아요 버튼에 VDOM 구조를 적용해봤어요.
과연 더 복잡한 상황에서도 diff가 제대로 동작할까요?
이번엔 게시글 목록에 적용을 해봤습니다!!
게시글 목록에서는 정렬과 검색에 따라 UI 구조가 다양하게 변경돼요.
- 게시글의 순서
- 표시되는 게시글의 개수
- 검색 결과가 없으면 게시글 목록 대신 안내 화면 표시
- 프로필 이미지 유무에 따라
span과img가 교체- 검색 상태에 따라 속성과 이벤트가 추가 또는 제거
과연 좋아요 버튼보다 복잡한 상황에서 직접 만든 Diff가 제대로 동작했을까요?
. . .

저의 코드에는 문제점이 정말 많았어요...
이번 글에서는 게시글 목록에 VDOM diff 적용을 하면서 겪었던 문제와 해결과정에 대해 적어봤어요.
h() 함수 내용은 이전 글에서 자세히 다뤘으니, 이번엔 diff 함수에 집중해볼게요!
기존에는 게시글 정렬 기준이 변경될 때마다 목록 전체를 새로 렌더링했어요.
그래서 정렬 버튼을 누를 때마다 게시글 목록 영역의 DOM 전체가 교체되는걸 볼 수 있죠?

이제 게시글 목록에도 diff 방식을 적용해볼게요.
newNode와 oldNode를 두고, updateElement에 넘기도록 했어요.
import { h, updateElement } from "../lib/vdom.js";
const postList = document.querySelector(".post-list");
let currentPostList;
function renderPosts(posts, { append = false } = {}) {
renderedPosts = append
? [...renderedPosts, ...posts]
: posts;
const newNode = PostList(getVisiblePosts());
updateElement(postList, newNode, currentPostList);
currentPostList = newNode;
}
}
정렬 버튼을 클릭했을 때도 render() 대신 renderPosts()를 호출했어요.
sortButtons.forEach((button) => {
button.addEventListener("click", () => {
currentSort = button.dataset.postSort;
sortButtons.forEach((item) => {
item.classList.toggle(
"pill-tab--active",
item === button
);
item.setAttribute(
"aria-pressed",
String(item === button)
);
});
renderPosts(renderedPosts);
});
});
이제 정렬 기준이 변경되면 새로운 게시글 목록 VNode를 만들고, 이전 목록과 비교해 실제 DOM을 갱신해요.

이렇게 섹션 전체가 아니라 바뀌는 부분의 dom만 변경되는걸 볼수있었어요.
하고 넘어갈 뻔 했지만... 이걸로 만족할 수 없었어요.
그래서 검색 기능을 붙여봤어요.
이번에는 잘 동작 했을까요?
.
.
.

검색 기능을 추가하자 단순한 구조에서는 가려져있던 문제들이 점점 드러났어요.
존재하지 않는 게시글을 검색하면 기존 카드가 모두 사라지고 검색 결과가 없다는 안내만 표시돼야해요.
하지만 일부 게시글 카드가 화면에 그대로 남아 있었어요.

콘솔에서는 다음 오류가 발생했어요.
Failed to execute 'removeChild' on 'Node':
parameter 1 is not of type 'Node'.
removeChild...?
파라미터 1의 타입이 노드가 아니라고...?
에러 메시지를 확인한 후에 removeChild 코드를 뜯어봤어요...
자식 노드를 제거하는 코드였어요.😭
if (
newNode == null &&
oldNode != null
) {
return parent.removeChild(
parent.childNodes[index]
);
}
당시에는 이전 VNode와 새로운 VNode의 자식 중 더 긴 길이를 기준으로 앞에서부터 비교하고 있었어요.
const maxLength = Math.max(
newNode.children.length,
oldNode.children.length
);
for (let i = 0; i < maxLength; i++) {
updateElement(
parent.childNodes[index],
newNode.children[i],
oldNode.children[i],
i
);
}
문제는 parent.childNodes가 고정된 배열이 아니라는 점이었어요. DOM이 변경되면 그 결과가 childNodes에 즉시 반영돼요.
문제 상황을 재현해봤어요. 버튼을 클릭해보세요!
이렇게 일부 노드를 건너뛰게 되고, 마지막에는 존재하지 않는 위치를 조회하게 돼요.
Diff 작업도 중간에 중단되기 때문에 삭제되지 못한 게시글 카드가 화면에 남았던 거였어요.
처음에는 전체 순회를 뒤에서부터 수행하는 방법을 생각했어요.
뒤쪽 자식부터 삭제하면 앞에 있는 노드의 인덱스는 바뀌지 않으니까요!
[1, 2, 3, 4]
[1, 2, 3]
[1, 2]
[1]
[]
삭제만 생각하면 이 방식으로 해결할 수 있지만 updateElement()는 삭제뿐만 아니라 기존 자식의 갱신과 새로운 자식의 추가도 처리하고 있었어요.
새로운 자식 여러 개를 뒤에서부터 appendChild()로 추가하면 순서가 달라질 수 있어서 추가에는 적합하지 않다고 생각했어요.
기존: [A]
목표: [A, B, C]
C를 먼저 추가해요.
→ [A, C]
B를 추가해요.
→ [A, C, B]
그러다 문득 모든 자식 변경을 하나의 반복문에서 같은 방향으로 처리할 필요는 없다는 생각이 들었어요!
그래서 자식 변경을 세 가지로 분리했어요.
- 두 VNode에 모두 존재하는 자식은 앞에서부터 갱신
- 이전 VNode에만 존재하는 자식은 뒤에서부터 삭제
- 새로운 VNode에만 존재하는 자식은 앞에서부터 추가
먼저 공통으로 존재하는 자식의 개수를 구했어요.
const oldChildren = oldNode.children ?? [];
const newChildren = newNode.children ?? [];
const commonLength = Math.min(
oldChildren.length,
newChildren.length
);
공통 자식의 갱신은 DOM 노드의 개수를 변경하지 않으므로 앞에서부터 처리
for (let i = 0; i < commonLength; i++) {
updateElement(
parent.childNodes[index],
newChildren[i],
oldChildren[i],
i
);
}
이전 VNode에만 남아 있는 자식은 인덱스가 흔들리지 않도록 뒤에서부터 삭제
for (let i = oldChildren.length - 1; i >= newChildren.length; i--) {
updateElement(
parent.childNodes[index],
undefined,
oldChildren[i],
i
);
}
새로운 VNode에만 존재하는 자식은 순서를 유지하도록 앞에서부터 추가
for (let i = oldChildren.length; i < newChildren.length; i++) {
updateElement(
parent.childNodes[index],
newChildren[i],
undefined,
i
);
}
새로운 노드를 추가할 때는 항상 마지막에 추가하는 appendChild() 대신 지정된 위치에 삽입할 수 있는 insertBefore()를 사용했어요.
if (newNode != null && oldNode == null) {
const newElement = createElement(newNode);
const referenceNode = parent.childNodes[index] ?? null;
return parent.insertBefore(newElement, referenceNode);
}
수정 후에는 검색 결과가 없을 때 기존 게시글 카드가 모두 제거됐고, removeChild() 오류도 발생하지 않았어요.

위의 문제를 해결하니 또다른 문제점이 보였어요

검색어가 입력되면 게시글 목록의 현재 검색 상태를 확인할 수 있도록 data-query 속성이 추가되도록 했어요.
query?
{class: "post-list__items","data-query": query,}
: {class: "post-list__items",};
검색어로 5번을 입력하면 실제 DOM은 다음과 같이 만들어져요.
<div
class="post-list__items"
data-query="5번"
>
검색어를 지우면 새로운 VNode에는 data-query가 없으므로 실제 DOM에서도 제거되어야 해요.
<div class="post-list__items">
하지만 Elements 패널을 확인해보니 검색어를 지운 뒤에도 이전 data-query가 그대로 남아 있었어요.

이 문제는 속성 업데이트 로직이 의심돼서 꼼꼼히 확인해봤어요.
속성을 갱신하는 updateAttributes()를 확인해보니 당시 속성을 제거하는 반복문은 newProps를 순회하고 있었어요.
for (const [attribute] of Object.entries(newProps)) {
if (newProps[attribute] !== undefined) {
continue;
}
target.removeAttribute(attribute);
}
하지만 새로운 VNode에서 제거된 속성은 애초에 newProps에 존재하지 않아요.
const oldProps = {class: "post-list__items","data-query": "5번",};
const newProps = {class: "post-list__items",};
삭제 대상인 data-query는 oldProps에만 존재하기때문에
속성 제거 반복문이 oldProps를 기준으로 검사하도록 변경했어요.
for (const attribute of Object.keys(oldProps)) {
if (newProps[attribute] !== undefined) {
continue;
}
target.removeAttribute(attribute);
}
수정 후에는 검색어를 지우는 순간 data-query도 실제 DOM에서 제거됐어요.

span이 img로 바뀌지 않아요여러가지 상황을 테스트해보다가 최신순에서 인기순으로 변경했을때 프로필 이미지가 나타나지 않는 문제가 생겼어요.
Elements 패널을 확인하니 기존 span이 그대로 유지된 상태에서 이미지의 src와 alt 속성만 추가되어 있었어요.

게시글 카드의 프로필 영역은 작성자의 프로필 이미지 유무에 따라 서로 다른 태그를 만들어요.
프로필 이미지가 있으면 img
const avatar = profileImage
? h("img", {
class: "avatar",
src: profileImage,
alt: `${nickname} 프로필 이미지`,
})
프로필 이미지가 없으면 닉네임의 첫 글자를 표시하는 span을 만들었어요.
: h(
"span",{
class: "avatar",
"aria-hidden": "true",
},
nickname.slice(0, 1)
);
최신순 첫 번째 게시글에는 프로필 이미지가 없고, 인기순 첫 번째 게시글에는 프로필 이미지가 있는 상황이었어요.
정렬을 변경하면 첫 번째 위치의 VNode의 태그가 span에서 img로 바뀌어야 하는데 어쩐지 바뀌지 않는 상황이었어요.
이미 여러 문제를 해결해온 덕인지 이번에는 문제점을 나름 빨리 찾을 수 있었어요!
updateElement()에서 두 노드의 JavaScript 자료형만 비교하고 있었던게 문제였어요 😱
if (typeof newNode !== typeof oldNode) {
return parent.replaceChild(
createElement(newNode),
parent.childNodes[index]
);
}
span과 img를 나타내는 VNode는 모두 객체이기때문에 두 값이 모두 객체라는 사실만으로는 어떤 DOM 태그를 나타내는지 구분할 수 없었던 거예요...
그래서 VNode의 type도 함께 비교하도록 조건을 수정했어요.
if (
typeof newNode !== typeof oldNode ||
newNode.type !== oldNode.type
) {
return parent.replaceChild(
createElement(newNode),
parent.childNodes[index]
);
}
이제 두 VNode가 모두 객체여도 태그가 다르면 기존 DOM을 재사용하지 않아요.
span → span
기존 DOM의 속성과 자식을 갱신
span → img
새로운 img를 만들고 기존 span을 교체
수정 후에는 정렬 기준에 따라 span과 img가 정상적으로 교체됐어요.

문제들을 모두 해결했으니 이번에는 검색 초기화 버튼을 추가해봤어요
그리고 PostList()를 호출할 때 검색을 초기화하는 화살표 함수를 전달했어요.
const newNode = PostList(
getVisiblePosts(),
currentQuery.trim(),
() => clearSearch()
);
버튼이 처음 생성됐을 때는 정상적으로 동작했어요.
그런데 버튼을 바로 누르지 않고 검색어를 한 글자 더 입력한 뒤 다시 눌러보니 아무런 반응이 없었어요.
왜 한 글자 더 입력한다고 버튼이 동작하지 않는거죠?ㅜㅜ

검색어를 입력할 때마다 새로운 VNode가 만들어지고 이때 전달한 화살표 함수도 매번 새롭게 생성돼요.
const firstHandler =
() => clearSearch();
const secondHandler =
() => clearSearch();
firstHandler === secondHandler; // false
함수의 내용은 같아 보여도 각각 다른 함수 객체이기때문에 이전 VNode와 새로운 VNode의 onClick을 비교하면 서로 다른 값으로 판단해요.
최초 렌더링에서는 createElement()가 이벤트를 별도로 처리하고 있었어요.
if (name.startsWith("on") && typeof value === "function") {
element[name.toLowerCase()] = value;
return;
}
따라서 onClick은 실제 DOM의 onclick 프로퍼티에 함수로 연결됐어요.
element.onclick = onClearSearch;
반면 Diff 과정에서 실행되는 updateAttributes()는 이벤트를 일반 HTML 속성처럼 처리하고 있었어요.
target.setAttribute("onClick",onClearSearch);
setAttribute()는 함수 객체를 HTML 속성에 맞게 문자열로 변환해요. 실제 이벤트 프로퍼티에 함수를 연결하는 것과는 다르게 동작해요.
최초 생성
→ element.onclick = 함수
→ 버튼이 정상적으로 동작해요.
Diff 갱신
→ setAttribute("onClick", 함수)
→ 함수가 일반 속성으로 처리돼요.
→ 버튼이 동작하지 않아요.
updateAttributes()에서도 이벤트 Props를 별도로 처리하도록 변경했어요.
function updateAttributes(
target,
newProps,
oldProps
) {
for (const [attribute, value] of Object.entries(newProps)) {
if (oldProps[attribute] === value) {
continue;
}
if (attribute.startsWith("on") && typeof value === "function") {
target[attribute.toLowerCase()] = value;
continue;
}
target.setAttribute(attribute,value);
}
for (const attribute of Object.keys(oldProps)) {
if (newProps[attribute] !== undefined) {
continue;
}
if (attribute.startsWith("on")) {
target[attribute.toLowerCase()] = null;
continue;
}
target.removeAttribute(attribute);
}
}
새로운 이벤트 핸들러가 전달되면 이벤트 프로퍼티에 직접 할당했어요.
target.onclick = value;
새로운 VNode에서 이벤트가 사라졌다면 기존 핸들러를 제거했어요.
target.onclick = null;
이벤트를 처리한 뒤에는 return이 아니라 continue를 사용했어요.
return을 사용하면 이벤트 하나를 처리한 시점에 updateAttributes() 전체가 종료돼요. 이벤트 뒤에 있는 다른 속성들이 갱신되지 않을 수 있어요.
수정 후에는 검색어를 여러 번 변경해 Diff가 반복 실행된 뒤에도 검색 초기화 버튼이 정상적으로 동작했어요.

현재 구현은 같은 인덱스에 있는 두 자식을 비교해요.
oldNode의 0번 자식 ↔ newNode의 0번 자식
oldNode의 1번 자식 ↔ newNode의 1번 자식
목록 마지막에 새로운 요소가 추가되는 경우에는 자연스럽게 동작해요.
<!-- 이전 -->
<ul>
<li>first</li>
<li>second</li>
</ul>
<!-- 새로운 결과 -->
<ul>
<li>first</li>
<li>second</li>
<li>third</li>
</ul>
앞의 두 요소가 같다는 것을 확인하고 마지막에 third만 추가하면 돼요.
하지만 목록 앞에 새로운 요소가 추가되면 불필요한 변경이 발생해요.
<!-- 이전 -->
<ul>
<li>Duke</li>
<li>Villanova</li>
</ul>
<!-- 새로운 결과 -->
<ul>
<li>Connecticut</li>
<li>Duke</li>
<li>Villanova</li>
</ul>
위치만 기준으로 비교하면 다음과 같이 판단해요.
1. Duke → Connecticut으로 변경해요.
2. Villanova → Duke로 변경해요.
3. 마지막에 Villanova를 추가해요.
맨 앞에 Connecticut만 추가하면 될걸, 불필요한 교체가 있어요.
여기서는 3개지만, 100개의 항목이면 100개의 노드가 교체되니 굉장히 비효율적이죠
key가 있어요React에서는 목록의 각 요소에 key를 지정할 수 있어요.
그래서 100번 비교하지않고, 새로생긴 하나의 항목을 key를 통해 식별하고, 그 항목만 추가하는 작업을 거쳐요.
다음 글에서는 VDOM에 key까지 적용해보고, 적용 전과 비교까지 해볼 예정이에요.
좋아요 버튼에서는 정상적으로 보였던 Diff가 게시글 목록과 검색 기능을 만나자 여러 문제를 드러냈어요. 자식 삭제 순서, 사라진 속성, 태그 변경, 이벤트 갱신처럼 처음에는 생각하지 못했던 조건들이 필요했어요.
문제를 해결하는데 몇 시간씩 걸리고 힘들기도 했지만
이 문제들을 하나씩 마주치면서 직접 디버깅하고, 해결해나가는 과정이 뿌듯했어요.
그리고 추상적으로만 알았던 VDOM의 동작 과정에 대해 직접 구현해보며 깨달을 수 있었던 좋은 경험이었어요!
다음 글도 기대해주세용 😋
글이 살아있는거 같아요 ㅋㅋㅋ