Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[10팀 이용] [Chapter 1-2] 프레임워크 없이 SPA 만들기 #30

Open
wants to merge 31 commits into
base: main
Choose a base branch
from

Conversation

nogy21
Copy link

@nogy21 nogy21 commented Dec 26, 2024

과제 체크포인트

기본과제

가상돔을 기반으로 렌더링하기

  • createVNode 함수를 이용하여 vNode를 만든다.
  • normalizeVNode 함수를 이용하여 vNode를 정규화한다.
  • createElement 함수를 이용하여 vNode를 실제 DOM으로 만든다.
  • 결과적으로, JSX를 실제 DOM으로 변환할 수 있도록 만들었다.

이벤트 위임

  • 노드를 생성할 때 이벤트를 직접 등록하는게 아니라 이벤트 위임 방식으로 등록해야 한다
  • 동적으로 추가된 요소에도 이벤트가 정상적으로 작동해야 한다
  • 이벤트 핸들러가 제거되면 더 이상 호출되지 않아야 한다

심화 과제

1) Diff 알고리즘 구현

  • 초기 렌더링이 올바르게 수행되어야 한다
  • diff 알고리즘을 통해 변경된 부분만 업데이트해야 한다
  • 새로운 요소를 추가하고 불필요한 요소를 제거해야 한다
  • 요소의 속성만 변경되었을 때 요소를 재사용해야 한다
  • 요소의 타입이 변경되었을 때 새로운 요소를 생성해야 한다

2) 포스트 추가/좋아요 기능 구현

  • 비사용자는 포스트 작성 폼이 보이지 않는다
  • 비사용자는 포스트에 좋아요를 클릭할 경우, 경고 메세지가 발생한다.
  • 사용자는 포스트 작성 폼이 보인다.
  • 사용자는 포스트를 추가할 수 있다.
  • 사용자는 포스트에 좋아요를 클릭할 경우, 좋아요가 토글된다.

과제 셀프회고

1주차에 이어 React가 해결하고자 했던 문제를 조금씩 이해할 수 있는 시간이었습니다.

기존에는 리액트와 Next.js와 같은 프레임워크를 통해 추상화된 렌더링 방식을 사용하며 DOM 조작과 이벤트 관리에 대한 고민은 크게 하지 않았던 것 같습니다.

이번주 과제에서 가상돔을 만들고, Diffing 알고리즘을 구현하며 실제 DOM을 조작하기까지 기존에는 미처 생각해보지 못했던 이벤트 관리의 복잡성과 메모리 관리, 렌더링에 대한 고민을 해볼 수 있었습니다.

기술적 성장

  • JSX가 실제 DOM으로 그려지는 과정에 대한 이해

    1. JSX -> Babel 등을 이용한 트랜스파일링
    2. Virtual DOM 생성
    3. 실제 DOM 노드 생성 및 렌더링
    4. DOM 업데이트 (Reconciliation)
    • 상태나 Props 변경 시 새로운 Virtual DOM을 생성하고 이전 Virtual DOM과 비교 (Diffing Algorithm)하여 변경된 부분만 실제 DOM에 반영
    • Fiber 아키텍처를 통한 효율적인 업데이트 => 추가 학습 필요
  • 가상돔이 실제 성능 향상에 미치는 영향과 오해
    : 대규모 DOM 조작이 필요한 복잡한 애플리케이션에서는 확실한 이점이 있지만, 작은 규모의 앱에서는 오히려 오버헤드가 될 수 있다.

    • 대규모 리스트나 복잡한 UI 업데이트 시 VirtualDOM이 효과적

    • 간단한 UI 변경에는 직접적인 DOM 조작이 더 빠를 수 있음

    • 메모리 사용량과 초기 로딩 시간에 대한 고려 필요

    • 특정 유형의 애니메이션에서는 최적의 성능을 내기 어려울 수 있음
      (애니메이션처럼 미세하고 연속적인 DOM 변경이 필요한 경우, 이 과정이 비효율적일 수 있기 때문)

      <summary>애니메이션의 특징</summary>
      
      <div markdown="1">
      
      1. 빈번한 프레임 업데이트
      : 초당 60프레임(60fps)을 유지하려면, 매 프레임마다 DOM을 업데이트해야 함
      2. 미세한 DOM 변경
      : CSS 스타일 변경, 트랜스폼(translate, scale, rotate 등) 등이 포함됨
      
      </div>
      
      <summary>Virtual DOM 렌더링 프로세스의 성능 저하 요인</summary>
      
      <div markdown="1">
      
      1. **Virtual DOM diffing 오버헤드**
      : 리액트는 상태 변경이 발생할 때마다 Virtual DOM을 갱신하고, diffing을 수행하여 실제 DOM을 업데이트한다. 애니메이션에서는 매 프레임마다 이런 작업이 발생할 수 있어 오버헤드가 크다.
      2. 실제 DOM 업데이트 속도와 충돌
      : 애니메이션은 GPU 가속이 가능한 CSS 속성(예: transform, opacity)을 활용하는 것이 빠르다. 그러나 리액트는 Virtual DOM을 통해 DOM 업데이트를 처리하므로, 최적화된 CSS 애니메이션보다 느릴 수 있다.
      3. 스케줄링 지연
      : 리액트의 렌더링 스케줄러는 UI 업데이트를 효율적으로 관리하지만, 빈번한 애니메이션 프레임 업데이트와 충돌할 수 있다.
      
  • 이벤트 위임의 목적과 사용 방법에 대한 이해

    • 많은 DOM 요소에 개별 이벤트 리스너를 등록하는 대신, 상위 요소에 이벤트를 위임하여 메모리 사용량을 줄이고 성능을 최적화하는 기법
    • 동적인 이벤트 관리에도 유리함
  • 이벤트 위임과 WeakMap을 활용해 이벤트 리스너 관리와 메모리 누수 문제를 효과적으로 해결했습니다. (해당 커밋, 슬랙에 올라온 동기분들의 지식 공유를 통해 인사이트를 얻었습니다.)

    • 특정 이벤트 유형에 대해 동일한 DOM 요소에 이벤트가 중복 등록되지 않도록 WeakMap을 활용했습니다.
      (WeakMap은 객체를 키로 사용하며, 참조가 없어지면 자동으로 제거되어 메모리 누수 방지에 유리함)
    • WeakMap은 키를 직접 순회할 수 없기에 이벤트 유형을 기반으로 관리하기 위해 전체 이벤트(eventListeners)를 Map을 사용해 관리했습니다.
    • 이벤트 유형(eventType)을 키로 갖는 WeakMap을 생성하고, 각 DOM 요소와 핸들러를 연결하여 이벤트를 관리했습니다.
    const eventListeners = new Map();
    
    if (!eventListeners.has(eventType)) {
      eventListeners.set(eventType, new WeakMap());
    }
    
    const listeners = eventListeners.get(eventType);
    listeners.set(element, handler);
  • 메모리 누수 방지
    • DOM 요소를 키로 사용하는 WeakMap은 참조가 사라지면 자동으로 메모리를 해제하므로, 수동으로 메모리를 관리할 필요가 없습니다.
    • 이를 통해 DOM 요소가 제거된 경우에도 메모리 누수가 발생하지 않도록 설계되었습니다.

  • 효율적인 이벤트 위임
    • 이벤트 위임을 활용하여 상위 요소(root)에 이벤트 리스너를 등록하고, 하위 요소에서 발생하는 이벤트를 처리했습니다.
    • 이벤트를 setupEventListeners 함수에서 일괄적으로 관리하며, 기존 리스너를 제거한 후 다시 등록하여 불필요한 중복 리스너 등록을 방지했습니다.
    • 이벤트 위임 처리 로직

    eventListeners.forEach((_, eventType) => {
      root.removeEventListener(eventType, handleEvent);
      root.addEventListener(eventType, handleEvent);
    });
    
    const handleEvent = (e) => {
      const { target, type } = e;
      const listeners = eventListeners.get(type);
    
      if (!listeners) {
        return;
      }
    
      const handler = listeners.get(target);
      if (typeof handler === "function") {
        handler(e);
      }
    };

학습 효과 분석

  • 큰 배움이 있었던 부분으로는 이벤트 위임과 메모리 관리, 그리고 가상 DOM의 개념과 구현 방식에 대해 이해할 수 있었습니다.
  • 추가 학습이 필요한 영역으로 React의 컴포넌트 추상화와 상태 관리에 대해 보다 깊은 이해가 필요한 것 같습니다.
    • 컴포넌트 내부의 상태가 Virtual DOM과 어떻게 연결되고 업데이트되는지, 지역 상태 관리(useState)나 React의 라이프사이클 메서드는 어떻게 구현되는지 등
    • Fiber 아키텍처를 도입하며 렌더링 작업을 우선순위에 따라 작은 단위로 나누어 실행해 성능을 최적화한다고 하는데 정확한 과정에 대한 이해

과제 피드백

  • 추상화된 기술들을 사용하며 평소 깊이 고민하지 않았던 부분들을 돌아보고, 자세히 생각해볼 수 있어서 좋았고, 그동안 DOM 조작에 대한 지식이 부족했다는 점도 느낄 수 있어서 좋았습니다.

리뷰 받고 싶은 내용

  • WeakMap을 활용한 코드에서 현재의 이벤트 관리 구조가 확장성 측면에서 보완할 부분 많다고 생각이 됩니다.

    • 현재 코드는 기본 브라우저 이벤트를 처리하는 데 초점을 맞췄습니다.

    • 그래서 이벤트 흐름 제어(stopPropagation, preventDefault)나 캡처링/버블링 동작을 세밀하게 제어할 수 없습니다.

    • React의 합성 이벤트(Synthetic Event 객체 활용)와 같은 추상화된 인터페이스를 도입하면 브라우저 호환성이 높을 뿐만 아니라 현재 제 코드와 같은 이벤트 흐름에 대한 조건부 처리, 그리고 유지보수성이나 확장성에 장점이 있을 것 같은데 구현 방식이 떠오르지 않아서 생각에만 그친 상황입니다.

    • 현재의 이벤트 위임 방식(root 요소에 리스너를 등록)에서도 Synthetic Event를 활용하여 캡처링/버블링 제어가 가능할까요?

    • 도입한다면 handleEvent 함수 내에서 Synthetic Event를 생성하는 방식이 옳은 방향일지 고민입니다.

      const handleEvent = (e) => {
        const syntheticEvent = createSyntheticEvent(e); // Synthetic Event 생성
        const { target, type } = syntheticEvent;
        const listeners = eventListeners.get(type);
      
        if (!listeners) {
          return;
        }
      
        const handler = listeners.get(target);
        if (typeof handler === "function") {
          handler(syntheticEvent);
        }
      };
    • 위와 같은 방식으로 처리한다면 이벤트 흐름 제어(stopPropagation, preventDefault)나 캡처링/버블링 처리도 해당 로직에서 처리하는게 맞을까요?

  • requestAnimationFrame 도입에 대해서도 문의 드립니다. 메인 스레드를 차단하지 않고, 다음 repaint가 이벤트 루프에서 스케줄링되기 직전에 실행되기 때문에 해당 콜백 내에서 DOM 작업을 수행하면 더 효율적이라고 학습을 했는데, 현재 과제에서 적용하면 테스트 코드에서 실패가 발생해서 적용하지 못하였습니다. 시점의 불일치 때문으로 의심을 하고 있지만, 코드 자체에 문제가 있을지도 모르겠어서 문의 드립니다. 브라우저에서 동작은 정상적으로 수행되고 있습니다.

/** @jsx createVNode */
// 초기화 함수
import { ForbiddenError, UnauthorizedError } from "./errors";
import { createVNode, renderElement } from "./lib";
import { NotFoundPage } from "./pages";
import { router } from "./router";

let renderScheduled = false; // 중복 렌더링 방지

export function render() {
  if (renderScheduled) return;

  renderScheduled = true;

  window.requestAnimationFrame(() => {
    renderScheduled = false;

    try {
      const Page = router.get().getTarget() ?? NotFoundPage;
      const $root = document.querySelector("#root");

      renderElement(<Page />, $root);
    } catch (error) {
      if (error instanceof ForbiddenError) {
        router.get().push("/");
        return;
      }
      if (error instanceof UnauthorizedError) {
        router.get().push("/login");
        return;
      }
      console.error(error);
    }
  });
}

- 올바른 구조의 vNode를 생성
- 자식 요소 검증: 숫자 0을 제외한 falsy 값 제거
- null, undefined, boolean일 경우 빈 문자열 반환
- 숫자 또는 문자열일 경우 문자열로 반환
- 유효성 로직 수정 및 검증 함수 추가
- 문자열 반환 함수 추가
- type이 function일 경우 해당 함수 실행하여 type, props, children 반환
- normalizeChildren 함수 구현
  - children 요소의 경우 자식 요소가 모두 텍스트 노드일 경우 합치는 로직 추가
- 배열 입력 시 fragment로 감싸서 반환
- 상위 스코프의 eventListeners 사용하여 이벤트 추가 및 등록 관리
- eventListeners의 손쉬운 관리를 위해 배열로 자료구조 변경
  - 중복 제거 없이 특정 인덱스에 접근/수정/제거를 쉽게 하기 위함
  - 추후 최적화를 한다면 다른 자료구조 사용
- addEventListener와 removeEventListener에 동일한 이벤트핸들러 참조를 위해 boundHandler 구현 및 eventListeners에 갱신
- 정규화된 가상 노드를 DOM 요소로 생성하고 컨테이너에 추가한 뒤 이벤트 등록
- on<event> 핸들러 등록을 위해 createElement의 속성 처리 로직에 조건 추가
- 새로운 노드를 등록할 경우(container에 이미 childNodes 요소가 있는 경우) 기존 노드와 교체
  - 상세 비교하여 변경분에 대한 업데이트가 아닌 노드를 통으로 교체함. 추후 updateElement 사용하여 변경분에 대한 업데이트 예정
…도 함수로 추출

- createVNode 불필요한 조건문 및 반환문 제거
- normalizeChildren 함수의 응집도를 높이기 위해 문자열 병합 로직을 mergeStrings 함수로 추출 (역할 분리)
…Event 사용

- 속석 업데이트 로직 updateAttributes 함수로 분리 및 조건문 가독성 개선
- DOM 요소에 이벤트 직접 등록하지 않고 addEvent 함수로 이벤트 리스터 저장소에 저장
- 모듈 스코프 가상 노드인 globalVNode 선언
  - 해당 변수로 가상 DOM 트리 상태 관리
  - container에 자식 요소 없을 시 null 초기화
  - globalVNode가 유효하지 않을 경우 DOM 노드 생성
  - 유효할 경우 updateElement 함수를 이용한 DOM 노드 업데이트
- updateElement 함수 구현
  - 가상 DOM 비교하여 DOM 조작
  - 가상 DOM 속성 변경 시 updateAttributes 함수 호출
  - 가상 DOM 자식 요소 비교 시 재귀적 호출
- updateAttributes 함수 구현
  - on<event> 등록 및 제거 시 전역 관리를 위한 addEvent, removeEvent 호출
  - 속성 비교 후 DOM 반영
- 가독성 개선을 위해 모듈 스코프의 가상 노드인 globalVNode 초기화 로직을 initGlobalVNode 함수로 추출
- container에 자식 요소가 없을 경우 globalVNode 초기화하는 로직
- 해당 로직은 globalVNode 변수의 메모리 관리를 위해 renderElement 실행 시 수행
- createElement, createVNode, eventManager, updateElement, renderElement 함수에 jsdoc 형태 주석 추가
- 함수 타입 처리 및 자식 요소 처리 로직 수정
  - 함수 타입일 경우 함수를 호출하며 재귀적으로 함수 타입 확인한 후 vNode 반환
  - 자식 요소를 가지고 있는 경우 유효성 검사 후 문자열 또는 숫자일 경우 문자열 값, 그외의 경우 normalizeVNode 함수로 정규화하여 children 반환
- updateElement 함수에서 기존 노드 제거 시 removeOnEvent 호출, 신규 노드 추가 시 addOnEvent 호출하도록 변경
- eventManager
  - eventListeners 변수를 배열에서 Map으로 자료구조 변경
  - 이벤트 타입에 따라 WeakMap을 등록하여 이벤트 리스너 관리
  - setupEventListeners 함수에서 중복 방지를 막기 위해 제거 후 등록하도록 개선

- renderElement: container와 연결된 가상돔을 WeakMap으로 관리하도록 개선
- 문제: jsx 컴포넌트 렌더링이 안되는 이슈
- 상황: 함수형 컴포넌트에 대한 호출이 안됨
- 분석: 노드를 정규화하는 과정에서 type이 함수형일 경우 재귀적 호출에 문제가 있는 것으로 파악. 또한, 자식 요소 정규화 과정에서 내부 구조가 적절하게 처리되지 않은 것으로 보임
- 해결: 속성 및 자식 요소 처리 로직 단순화, 재귀 호출 로직 개선
- 전역 정보의 로그인 한 사용자, 현재 사용자 정보를 이용해 권한에 따른 게시물 조작 기능 처리
  - 로그인하지 않은 사용자는 게시물 등록 폼을 노출하지 않음
  - 로그인하지 않은 사용자가 게시물 좋아요 누를 시 alert 노출
  - 로그인한 사용자는 게시물 추가 및 좋아요 클릭 가능
- 문제: 이벤트 핸들러 교체 로직에서 기존 이벤트 제거가 새로운 이벤트가 없는 경우에만 진행됨. 등록 역시 기존 속성이 없는 경우에만 진행되어 잘못된 로직으로 작성됨

- 해결: 두 이벤트가 다를 경우 기존 이벤트가 있다면 지우고 새로운 이벤트가 존재한다면 지워지도록 조건 로직 개선

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

과제를 진행하며 반복되는 코드들이 많아서 비효율적인 것 같다 생각했는데 utils에 반복되는 코드를 따로 작성하고 사용한 것이 인상적이었습니다!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants