과제 체크포인트
배포 링크
기본과제
가상돔을 기반으로 렌더링하기
- 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 구현한 고급 기능들:
- 컨텍스트 스택 기반 중첩 관리:
enterSuspenseContext/exitSuspenseContext로 중첩된 Suspense 경계 처리 - Promise 캐시 시스템:
createCacheKey로 컴포넌트+props 기반 캐싱, 중복 실행 방지 - 상태 기반 렌더링:
data-suspense="pending"/"resolved"속성으로 현재 상태 추적 - 에러 바운더리: 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 패턴을 완전히 구현했습니다:
- 이론적 배경: React Suspense의 Algebraic Effects 패러다임 이해
- 실제 구현: 컨텍스트 스택, Promise 캐싱, 리렌더링 트리거 시스템
- 테스트 주도 개발: 5가지 주요 시나리오에 대한 체계적인 테스트 작성
- 성능 고려: 불필요한 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 전체적으로 작성한 글들을 보니 따로 피드백을 드릴만한게 크게 없는 것 같네요.
- Suspense 컨텍스트 스택의 메모리 안전성
넵. 구현도 적절하게 잘 해주신것 같은데요! 저도 조금 더 찾아봐야겠지만, 실제 키 기반으로 해당 캐시를 관리하다 보면 문제가 발생할 여지가 있어서 프로미스 객체 자체를 키로 활용해보는것도 좋지 않을까 라는 소소한 의견도 생각해봅니다. 호출부는 제가 못찾았을 수도 있는데, 각 테스트의 setup단계에서 작성해주셨던 것 같은데요. (제가 잘못이해한거면 추후에 메신저로 알려주세요!) 명시적으로 해당 부분을 언마운트 시점에 호출하는 부분이 있다면 적절할 것 같아요!
- 중첩 Suspense에서의 Promise 버블링 정확성
이 부분도 크게 현재 시점에서는 문제가 없을 것 같지만, suspense가 중첩되거나 재귀적으로 호출이 되다보면 충분히 문제가 발생할 여지는 있을 것 같아요. 콜 스택이 변한다거나.. 리액트에서는 이런 문제를 해결하기 위해서 fiber를 만들고 트리를 순회하는 형태로 개선했던 것 같은데 구현을 참조해보면 좋을것 같아요!
- Promise 캐시 키 생성 전략의 한계
요거는 위에서 슬쩍 제안 드려봤습니다 ㅎㅎ 좋은 방법있음 저도 공부할겸 아고라에 공유해주세요!
- 비동기 컴포넌트 에러 처리의 사용자 경험
요것도 실제 리액트 구현을 참고하면 좋을 것 같은데요. 저희들이 비동기, 동기적 여러 에러를 제어할 때 ErrorBoundary로 감싸서 각 객체 타입별로 에러를 제어하는 레이어를 명확하게 둬 합성해 관심사를 분리하는 형태로 구현하는게 일반적일 것 같은데요! 이런 구조를 그대로 따와서 각 에러에 따른 명확한 바운더리를 나누고 구역별 에러 메세지를 명확하게 나눠보면 어떨까요?
이 부분에 대해서 공부할만한 글이 있는데, 직접적이게 ErrorBoundary에 대해 막 다루는 글들은 아니지만 읽어보면 도움이 되실것 같아요.
https://tech.kakaoent.com/front-end/2022/221110-error-boundary/ https://www.youtube.com/watch?v=012IPbMX_y4
다음 주도 화이팅입니다!