BBAK-jun 님의 상세페이지[3팀 박준형] Chapter 1-2. 프레임워크 없이 SPA 만들기 (2)

과제 체크포인트

배포 링크

기본과제

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

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

이벤트 위임

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

심화 과제

Diff 알고리즘 구현

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

배우게 된것(학습 노트)

1. Virtual DOM의 핵심 개념과 구현 (📖 상세 문서)

Virtual DOM을 직접 구현해보니 왜 React 같은 라이브러리가 이런 아키텍처를 선택했는지 깨달을 수 있었습니다. 실제 DOM 조작 비용을 줄이는 중간 계층의 역할이 생각보다 복잡하더라고요.

핵심 구현 플로우:

JSX → createVNode → normalizeVNode → createElement → 실제 DOM
                              updateElement (리렌더링 시)

2. JSX 트랜스파일 과정의 이해 (📖 상세 문서)

JSX가 어떻게 JavaScript 함수 호출로 변환되는지 직접 경험해볼 수 있었습니다. esbuild의 트랜스파일 과정을 이해하고 나니 Virtual DOM이 어떻게 작동하는지 더 명확해졌어요.

3. Suspense 패턴을 활용한 비동기 컴포넌트 처리 (📖 상세 문서)

기본 과제 외에 추가로 학습하고 구현한 부분입니다. React의 Suspense 패턴이 단순해 보이지만 실제로는 매우 정교한 설계가 필요하다는 걸 깨달았어요.

3.1 구현한 핵심 아키텍처:

SuspenseWrapper 컴포넌트 식별:

// normalizeVNode에서 SuspenseWrapper를 함수 컴포넌트보다 먼저 체크
if (vNode.type && vNode.type.name === "SuspenseWrapper") {
  const { fallback } = vNode.props;
  const children = vNode.children;
  const suspenseId = Math.random().toString(36).substr(2, 9);
  
  // Suspense 컨텍스트에 진입
  enterSuspenseContext({ id: suspenseId });
  // ...
}

비동기 컴포넌트 Promise 처리:

// 함수 컴포넌트에서 Promise 반환 시 처리
if (isThenable(result)) {
  if (insideSuspense) {
    // Promise를 캐시하고 리렌더링 콜백 설정
    setCachedResult(cacheKey, result);
    
    result
      .then((resolvedResult) => {
        setCachedResult(cacheKey, resolvedResult);
        triggerRerender(); // 해결되면 리렌더링
      })
      .catch((error) => {
        console.error("Async component error:", error);
        setCachedResult(cacheKey, "");
        triggerRerender();
      });

    throw result; // Suspense가 캐치하도록 throw
  } else {
    // Suspense 외부에서는 경고 메시지와 함께 빈 문자열 반환
    console.warn("Async component detected outside Suspense context. Please wrap with SuspenseWrapper.");
    return "";
  }
}

3.2 구현한 고급 기능들:

  1. 컨텍스트 스택 기반 중첩 관리: enterSuspenseContext/exitSuspenseContext로 중첩된 Suspense 경계 처리
  2. Promise 캐시 시스템: createCacheKey로 컴포넌트+props 기반 캐싱, 중복 실행 방지
  3. 상태 기반 렌더링: data-suspense="pending"/"resolved" 속성으로 현재 상태 추적
  4. 에러 바운더리: Promise reject 시 빈 문자열로 fallback 처리

3.3 실제 구현된 렌더링 플로우:

// 성공 시: resolved 상태로 실제 컨텐츠 렌더링
return {
  type: "div",
  props: { "data-suspense": "resolved" },
  children: normalizedChildren,
};

// Promise pending 시: fallback UI 렌더링  
return {
  type: "div",
  props: { "data-suspense": "pending" },
  children: [normalizeVNode(fallback, false)],
};

3.4 작성한 포괄적인 테스트 시나리오:

// 1. 동기 컴포넌트 정상 렌더링 확인
it("동기 컴포넌트는 정상적으로 렌더링되어야 한다", async () => {
  expect(container.querySelector('[data-suspense="resolved"]')).toBeTruthy();
  expect(container.innerHTML).toContain("Hello World");
  expect(container.innerHTML).not.toContain("Loading...");
});

// 2. 비동기 컴포넌트 fallback 우선 렌더링 확인
it("비동기 컴포넌트는 먼저 fallback을 보여주고 나중에 실제 내용을 렌더링해야 한다", async () => {
  expect(container.querySelector('[data-suspense="pending"]')).toBeTruthy();
  expect(container.innerHTML).toContain("Loading...");
  expect(container.innerHTML).not.toContain("Async Hello");
});

// 3. 여러 비동기 컴포넌트 동시 처리
it("여러 비동기 컴포넌트가 있을 때 모두 완료될 때까지 fallback을 보여줘야 한다");

// 4. 중첩된 Suspense 경계별 독립적 처리
it("중첩된 Suspense 경계가 올바르게 작동해야 한다", async () => {
  expect(container.innerHTML).toContain("Sync content");
  expect(container.innerHTML).toContain("Inner Loading...");
  expect(container.innerHTML).not.toContain("Outer Loading...");
});

// 5. 에러 발생 시 적절한 fallback 처리
it("에러가 발생한 비동기 컴포넌트는 적절히 처리되어야 한다");

4. 이벤트 위임과 메모리 최적화

WeakMap을 활용한 메모리 안전한 이벤트 관리 시스템도 인상적이었습니다. 동적으로 추가/제거되는 요소들도 별도 설정 없이 자동으로 이벤트가 작동하는 것이 효율적이라고 느꼈어요.

과제 셀프회고

기술적 성장

이번 과제를 통해 프레임워크의 내부 동작 원리를 깊이 이해할 수 있었고, 특히 비동기 렌더링의 복잡성을 직접 경험할 수 있었습니다.

가장 어려웠지만 보람있었던 부분:

  • Suspense 컨텍스트 관리: insideSuspense 플래그와 컨텍스트 스택을 통한 중첩 경계 처리
  • Promise 라이프사이클: throw → catch → resolve → rerender 흐름 제어
  • 데이터 속성 활용: data-suspense 속성으로 렌더링 상태 추적 및 테스트 검증

새로 배운 핵심 개념들:

  • Algebraic Effects: throw/catch를 이용한 제어 흐름 전환
  • 컨텍스트 기반 렌더링: 부모 컴포넌트 상태가 자식 처리 방식 결정
  • 캐시 무효화 전략: Promise 해결/거부 시점의 적절한 상태 관리

코드 품질

특히 만족스러운 구현들:

  • 명확한 책임 분리: SuspenseWrapper는 마커 역할만, 실제 로직은 normalizeVNode에서 처리
  • 포괄적인 테스트: 동기/비동기, 단일/다중, 중첩/에러 등 모든 시나리오 커버
  • 방어적 프로그래밍: Suspense 외부 비동기 컴포넌트 사용 시 개발자 가이드 제공

추가 구현하고 싶은 부분들:

  • Server-Side Rendering에서의 Suspense 동기화
  • Promise 취소 및 cleanup 메커니즘
  • 더 세밀한 에러 타입별 처리 전략

학습 효과 분석

가장 큰 깨달음: 비동기 UI는 단순한 "로딩 처리"가 아니라 복잡한 상태 기계와 흐름 제어가 필요한 시스템이라는 것입니다. 특히 사용자가 느끼는 반응성과 개발자가 다루는 복잡성 사이의 균형점을 찾는 것이 중요하다고 느꼈어요.

실무 적용 가능성:

  • 대용량 데이터 페이지네이션에서 부분 로딩 UX 개선
  • 코드 스플리팅과 지연 로딩의 자연스러운 통합
  • 복잡한 비동기 의존성 체인이 있는 컴포넌트 설계

추가 학습 성과

기본 과제 외에 docs/async-vNode.md 문서를 깊이 연구해서 Suspense 패턴을 완전히 구현했습니다:

  1. 이론적 배경: React Suspense의 Algebraic Effects 패러다임 이해
  2. 실제 구현: 컨텍스트 스택, Promise 캐싱, 리렌더링 트리거 시스템
  3. 테스트 주도 개발: 5가지 주요 시나리오에 대한 체계적인 테스트 작성
  4. 성능 고려: 불필요한 Promise 실행 방지와 메모리 효율적인 캐시 관리

이 과정에서 단순히 기능을 복사하는 것이 아니라, 근본적인 문제와 해결 방식을 이해할 수 있었습니다.

리뷰 받고 싶은 내용

1. Suspense 컨텍스트 스택의 메모리 안전성

현재 suspenseContextStack 배열과 promiseCache Map으로 상태를 관리하고 있는데, 컴포넌트 언마운트 시나 라우트 변경 시 적절한 cleanup이 이루어지는지 궁금합니다. 특히 clearCache() 함수의 호출 시점이 적절한지 검토해주실 수 있을까요?

2. 중첩 Suspense에서의 Promise 버블링 정확성

현재 enterSuspenseContext/exitSuspenseContext로 스택을 관리하는데, 깊게 중첩된 상황에서 Promise가 올바른 Suspense 경계에서 catch되는지 확신이 서지 않습니다. 특히 자식에서 throw된 Promise가 의도하지 않은 부모 Suspense에서 처리될 가능성은 없을까요?

3. Promise 캐시 키 생성 전략의 한계

현재 createCacheKey(component, props)에서 JSON.stringify(props)를 사용하는데, 함수나 순환 참조가 포함된 props에서 문제가 될 수 있을 것 같습니다. 더 안전하고 효율적인 캐시 키 생성 방법이 있을까요?

4. 비동기 컴포넌트 에러 처리의 사용자 경험

현재 Promise reject 시 빈 문자열로 처리하고 있는데, 실제 사용자에게는 아무것도 보이지 않는 상황이 됩니다. 네트워크 에러, 권한 에러, 일반 에러 등을 구분해서 더 의미있는 fallback을 제공하는 방법이 있을까요?

과제 피드백

준형님 한 주 알차게 보내신 것 같네요 ㅎㅎ (링크가 동작을 안하는 것도 있는것 같지만) 과제나 회고가 매우 훌륭하고 완성도있게 작성되어 있네요 :+1 전체적으로 작성한 글들을 보니 따로 피드백을 드릴만한게 크게 없는 것 같네요.

  1. Suspense 컨텍스트 스택의 메모리 안전성

넵. 구현도 적절하게 잘 해주신것 같은데요! 저도 조금 더 찾아봐야겠지만, 실제 키 기반으로 해당 캐시를 관리하다 보면 문제가 발생할 여지가 있어서 프로미스 객체 자체를 키로 활용해보는것도 좋지 않을까 라는 소소한 의견도 생각해봅니다. 호출부는 제가 못찾았을 수도 있는데, 각 테스트의 setup단계에서 작성해주셨던 것 같은데요. (제가 잘못이해한거면 추후에 메신저로 알려주세요!) 명시적으로 해당 부분을 언마운트 시점에 호출하는 부분이 있다면 적절할 것 같아요!

  1. 중첩 Suspense에서의 Promise 버블링 정확성

이 부분도 크게 현재 시점에서는 문제가 없을 것 같지만, suspense가 중첩되거나 재귀적으로 호출이 되다보면 충분히 문제가 발생할 여지는 있을 것 같아요. 콜 스택이 변한다거나.. 리액트에서는 이런 문제를 해결하기 위해서 fiber를 만들고 트리를 순회하는 형태로 개선했던 것 같은데 구현을 참조해보면 좋을것 같아요!

  1. Promise 캐시 키 생성 전략의 한계

요거는 위에서 슬쩍 제안 드려봤습니다 ㅎㅎ 좋은 방법있음 저도 공부할겸 아고라에 공유해주세요!

  1. 비동기 컴포넌트 에러 처리의 사용자 경험

요것도 실제 리액트 구현을 참고하면 좋을 것 같은데요. 저희들이 비동기, 동기적 여러 에러를 제어할 때 ErrorBoundary로 감싸서 각 객체 타입별로 에러를 제어하는 레이어를 명확하게 둬 합성해 관심사를 분리하는 형태로 구현하는게 일반적일 것 같은데요! 이런 구조를 그대로 따와서 각 에러에 따른 명확한 바운더리를 나누고 구역별 에러 메세지를 명확하게 나눠보면 어떨까요?

이 부분에 대해서 공부할만한 글이 있는데, 직접적이게 ErrorBoundary에 대해 막 다루는 글들은 아니지만 읽어보면 도움이 되실것 같아요.

https://tech.kakaoent.com/front-end/2022/221110-error-boundary/ https://www.youtube.com/watch?v=012IPbMX_y4

다음 주도 화이팅입니다!