Chapter 1-2. 프레임워크 없이 SPA 만들기 :회고

한칙촉·2025년 7월 19일
post-thumbnail

1-2. 프레임워크 없이 SPA 만들기

2주차 과제는 1주차와 같이 프레임워크 없이 spa 페이지 구현하기지만, 더 자세하게는 가상 돔diff 알고리즘을 사용하여 이벤트 관리를 최적화하고, 불필요한 렌더링을 줄이는 것이었다.

실제 DOM의 복사본 - 변경된 것만 실제의 DOM에 반영하여 불필요한 렌더링을 줄임
과제 시작 직전, 내가 가상 돔에 대해 알고 있는 그대로 쓴 문장 하나이다. 기본 지식이라곤 이 한 문장이 전부였기 때문에 이번 과제는 또 어떻게 헤쳐나가야 할지 너무 막막했다.. 하지만 걱정과 달리 1주차 과제보다 훨씬 재밌게 느껴졌다. 저번주 과제는... 백틱에 둘러싸인 html 코드가 너무 보기가 싫었다...🥹 나가

우선 이번 회고에서는 과제를 하면서 막혔던 부분을 크게 3가지 정도 작성해보려 한다.

1. normalizeVNode() ?

createVNode나 createElement, updateElement와 같은 함수는 함수명만 봐도 어떤 역할인지 대강 알 수 있었는데, normalizeVNode는 정확히 어떤 역할의 함수인지 감이 잡히지 않았다. VNode를 정규화하는 건 어떤 과정일까..

renderElement 함수는 인자로 받은 vNode를 normalizeVNode 함수를 사용해 정규화하고, 그 반환값을 createElement 함수를 사용하여 요소로 만든다. 즉, VNode를 렌더링 가능한 일관된 형태로 변환해주는 것이다. createElement가 정제된 vNode를 사용해 요소를 만들 수 있도록 도움을 주는 함수이다!

추가로 normalizeVNode 함수의 테스트에는 null, undefined, boolean 값은 빈 문자열로 변환되어야 한다.Falsy 값 (null, undefined, false)은 자식 노드에서 제거되어야 한다.가 있는데, 처음에 이 둘의 차이를 정확히 이해하지 못했다.

  • null, undefined, boolean 값은 빈 문자열로 변환
    : 함수의 인자로 null, undefined, boolean 값이 들어왔을 때 함수의 결과 값이 빈 문자열 (ex. normalizeVNode(null) => "" )
if (typeof vNode === "boolean" || vNode === null || vNode === undefined) {
  vNode = "";
}
  • Falsy 값 (null, undefined, false)은 자식 노드에서 제거
    : 인자의 타입이 객체이면서, 그 자식 노드가 Falsy 값일 때 아예 그 값이 제거된 객체를 반환 (ex. <div>{null}</div> => <div></div>를 위한 과정)
if (typeof vNode === "object") {
  const children = vNode.children ?? [];
  const normalizedChildren = children
    .map(normalizeVNode)
    .filter((child) => child !== "" && child !== null && child !== undefined && typeof child !== "boolean");
  
  vNode = {
    ...vNode,
    children: normalizedChildren,
  };
}

2. updateAttribute() ?

위의 함수는 createElement 함수에서 새 노드에 속성을 세팅할 때, updateElement 함수에서 이전 노드와 새 노드를 비교하여 속성을 업데이트할 때 사용하는 함수이다.

// src/lib/updateElement.js

// props 업데이트 및 추가
Object.entries(originNewProps).forEach(([attr, value]) => {
  if (attr === "className") {
    target.setAttribute("class", value);
  } else if (typeof value === "boolean") {
    value && target.setAttribute(attr, ""); // ⚠️
  } else if (attr.startsWith("on")) {
    const eventType = attr.slice(2).toLowerCase();
    const oldHandler = originOldProps[attr];

    if (oldHandler) {
      // 이벤트를 제거하지 않으면 누적됨
      removeEvent(target, eventType, oldHandler);
    }
    addEvent(target, eventType, value);
  } else {
    target.setAttribute(attr, value);
  }
});

위의 코드는 내가 처음 작성했던 코드이고, 문제가 됐던 부분은 value가 boolean 타입일 때의 처리였다. createElement와 같은 코드로 작성하면 정상 작동할 줄 알았는데, boolean type props가 property로 직접 업데이트되어야 한다.의 checked, disabled, selected 테스트를 모두 실패했다.

내가 쓴 코드에는 두 가지 문제가 있는데, 첫번째는 value가 false일 때 아무 처리도 하지 않는다는 것이고, 두번째는 이미 attr가 존재할 때 직접적으로 업데이트를 해주지 않는다는 것이다.

else if (typeof value === "boolean") {
  if (attr in target) {
    // target에 attr가 존재하는지 확인
    target[attr] = value; // 있으면 직접 업데이트
    if (!value) {
      // attr가 존재하는데 값이 false
      // 해당 attr 삭제
      target.removeAttribute(attr);
    }
  } else {
    // attr가 존재하지 않음
    // true이면 빈 문자열로 추가, false이면 제거
    value ? target.setAttribute(attr, "") : target.removeAttribute(attr);
  }
}

위와 같이 직접적으로 값을 업데이트하게 수정하여 테스트를 통과했다!
지금 보니까 target[attr] = value;만 작성해도 될 것 같은 느낌..


3. 이벤트 위임 문제 (이벤트 버블링)

함수 작동 테스트인 basic.test와 advanced.test를 모두 통과하고, e2e 테스트를 시작하기 전에 페이지를 열고 기본 동작들을 확인해보니 제일 기본적인 페이지 라우트가 동작하지 않았다. (테스트 다 통과했는데 대체 왜)

// src/components/ProductCard.jsx

<div className="cursor-pointer product-info mb-3" onClick={handleClick}>
  <h3 className="text-sm font-medium text-gray-900 line-clamp-2 mb-1">{title}</h3>
  <p className="text-xs text-gray-500 mb-2">{brand}</p>
  <p className="text-lg font-bold text-gray-900">{price.toLocaleString()}</p>
</div>

위 코드는 ProductCard 컴포넌트의 일부분인데, div 범위에는 포함되지만 h3, p 태그의 범위는 아닌 아주 애매한 부분을 클릭해야만 클릭 이벤트가 작동했다...
이딴 현상 처음봤다.

이는 setupEventListeners 함수에서의 문제였다.

// src/lib/eventManager.js (setupEventListeners())

const handleEvent = (event) => {
  const target = event.target;
  if (!handlerMap.has(target)) return;
  
  const handler = handlerMap.get(target);
  if (handler) handler(event);
};

내가 처음 작성했던 setupEventListeners 함수 코드의 일부분이다. 이 코드는 클릭한 요소에 등록되어있는 이벤트를 동작시키기 때문에, 부모 요소에 등록된 이벤트가 동작하지 않는다.

const handleEvent = (event) => {
  let target = event.target;

  // 클릭된 요소와 이벤트가 연결된 요소가 다름
  while (target && target !== event.currentTarget) {
    if (handlerMap.has(target)) {
      // 등록된 이벤트 함수
      const handler = handlerMap.get(target);
      // 실행시킨 후 종료
      if (handler) handler(event);
      break;
    }
    // 부모 요소로 올라가며 반복
    target = target.parentElement;
  }
};

바로 부모 요소를 event.currentTarget으로 확인하며 이벤트를 연결해주는 코드가 빠져있었기 때문이었다. 현재 클릭된 요소와 이벤트가 연결된 요소가 다를 경우에 부모 요소를 하나씩 검사하면서 이벤트 함수가 있으면 해당 이벤트를 실행하도록 코드를 수정하여 테스트에 통과했다. 💪


WIL

✅ DIFF 알고리즘

diff 알고리즘은 두 개의 데이터를 비교해서 어떤 부분이 다른지, 어떤 부분이 변경됐는지를 찾아내는 알고리즘이다. 처음 diff 알고리즘 테스트를 통과해야 한다는 말을 듣고 추가로 엄청난 어려운 과제가 남아있는 줄 알았는데 그냥 내가 구현한 모든 코드 자체가 diff 알고리즘이었다..😅💦

// src/lib/updateElement.js

const newChildren = newNode.children ?? [];
const oldChildren = oldNode.children ?? [];

// 공통 길이까지 자식 요소를 비교하며 요소 업데이트
for (let i = 0; i < Math.min(newChildren.length, oldChildren.length); i++) {
  updateElement(target, newChildren[i], oldChildren[i], i);
}

// newChildren가 더 많으면 추가
for (let i = oldChildren.length; i < newChildren.length; i++) {
  target.appendChild(createElement(newChildren[i]));
}

// oldChildren가 더 많으면 역순으로 삭제
for (let i = oldChildren.length - 1; i >= newChildren.length; i--) {
  const child = target.childNodes[i];
  if (child) target.removeChild(child);
}

자식 노드끼리 비교하며 재귀 업데이트를 하는 부분이 조금 헷갈렸었다.

우선 현재 자식 노드와 이전 자식 노드의 공통 길이를 Math.min으로 계산한 뒤에 순서대로 updateElement 함수를 통해 업데이트해주었다.
그 이후 추가될 자식 노드가 있는 경우엔 appendChild로 노드를 요소로 추가해주었고, 이전 자식 노드가 더 많은 경우엔 순서(인덱스)의 영향을 받지 않기 위해 역순으로 삭제해주었다!


✅ renderElement()

처음에 과제를 하면서 테스트 코드를 보며 각 함수를 작성하면서 근데 이런 함수들이 어떻게 연결되는거지...? 라는 생각이 들었다. 각 함수의 기능도 알겠고 역할도 알겠는데 결국 이 함수들이 어느 순서를 거쳐 페이지에 컴포넌트를 보여주는 건지 감이 잡히지 않았다.

// src/lib/renderElement.js

// 최초 렌더링시에는 createElement로 DOM을 생성하고
// 이후에는 updateElement로 기존 DOM을 업데이트한다.
// 렌더링이 완료되면 container에 이벤트를 등록한다.

const prevVNode = new WeakMap();

export function renderElement(vNode, container) {
  // 이전 VNode
  const currentNode = prevVNode.get(container);
  // 새로운 VNode를 정규화
  const normalizedVNode = normalizeVNode(vNode);

  if (!currentNode) {
    // 최초 렌더링 시 DOM 생성
    const element = createElement(normalizedVNode);
    container.appendChild(element);
  } else {
    // 기존 DOM 업데이트
    updateElement(container, normalizedVNode, currentNode);
  }

  // 렌더링한 VNode 저장 후 이벤트 위임
  prevVNode.set(container, normalizedVNode);
  setupEventListeners(container);
}
  1. 먼저 최초 렌더링 시 createElement를 통해 정규화된 vNode를 DOM으로 변환하여 container에 넣음
  2. 그 이후엔 이전 vNode와 현재의 vNode를 비교하여 diff 알고리즘을 통해 변경된 부분만 업데이트
  3. 렌더링된 vNode는 이후 업데이트의 기준이 되어야 하기 때문에 저장하고, 이벤트 위임을 등록

친절한 주석 덕분에 위의 순서 그대로 구현할 수 있었다👍


KPT

Keep

이번 과제는 시작하기 전 학습 자료를 정독하는 시간을 가졌다.
테스트 코드도 미리 보면서 어떤 기능을 구현해야 하는지 알고 시작하니 저번 주보다 훨씬 수월하게 진행된 느낌이었다...

Problem

테스트를 통과해서 코드가 지저분해보이는데도 그냥 넘어간 부분이 몇 있다.
(updateElement나 setupEventListener 함수 부분)
테스트 통과한건 좋지만, 시간이 남으면 리팩토링 미루지 말고 꼭 해보기.

Try

정확히 모르겠는 개념은 질문하기, 다른 팀원의 코드도 살펴보기!!
저번 주부터 지금까지 계속 모든 걸 너무 나 혼자 하는 것 같다.
물론 개인 과제라 혼자 해야 하지만 부트캠프 내에서 할 수 있는 일을 안하고 있는 느낌..? 훨씬 어려워보이는 3주차 과제부턴 꼭 질문 많이하기.. 😶

profile
빙글빙글돌아가는..

2개의 댓글

comment-user-thumbnail
2025년 7월 20일

아름님 2주차 고생 많으셨어요! 저도 자꾸 혼자 하려는 습관이 있어서 반성되네욧.. 😢 같은 팀끼리 으쌰으쌰 해봐요!

1개의 답글