-
Notifications
You must be signed in to change notification settings - Fork 50
[4팀 안소은] Chapter2-1. 프레임워크 없이 SPA 만들기 #36
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
base: main
Are you sure you want to change the base?
Conversation
- JSX 도입 - 노드유형 별 렌더링 - TODO: 재조정 고려 필요
- 컴포넌트가 리렌더링 되면 하위 요소를 모두 삭제했다가 다시 렌더링 구현 - TODO: 실제 변경이 발생한 부분만 부분적으로 리렌더링 및 재조정 하는 로직 구현 필요
- createRouter 함수를 통해 라우트 정의 및 Router 및 useRouter 생성 - 생성된 useRouter는 전역 변수로 등록하여 여러 컴포넌트에서 사용할 수 있도록 구성 (전역 상태 흉내내기) - TODO: Provider를 구현하여 정상적인 형태로 useRouter 제공하기
- DOM 자식 요소 렌더링 시 key를 전달하지 않아서 중복키가 발생, 이로 인해 무한 리로드 현상이 발생하고 있던 것 수정
- v1에서 tagged template 형태로 작성하던 컴포넌트 코드들 마이그레이션
- 장바구니 추가 제외하고 상품목록 기능 구현
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
이 피드백은 n8n + ai (gpt-5-mini)를 활용하여 자동으로 생성된 내용입니다.
종합 피드백
- 라우터/상태 관리:
Router와 글로벌 상태가window전역과 전체 트리 리렌더 방식으로 구성돼 있어 확장/테스트/서버 환경에서 구조적 결함을 갖고 있습니다. Context 기반으로 라우터를 제공하고,useGlobalState/useState가 실제 변화 구간만 다시 렌더하도록 부분 업데이트 전략으로 변경하면 적용 범위가 넓은 기능(예: 여러 페이지, 모달)에서도 유지보수가 수월해집니다. - 렌더링 엔진:
render→useState→useMemo가 각각 전체 node를 다시 그리거나 콜백을 두 번 호출하는 구조이므로, 향후 페이징·필터·장바구니 알림이 많아질 때 성능 병목이 발생할 수 있습니다. 계산을 격리하고, 의존성을 명확하게 하며, state tree를 병합하는 방식을 도입하는 것이 필요합니다. - 목록/모달/토스트 UI:
CartModal의 data attribute,overlay닫기 로직,Toast연산 방식처럼 UI를 제어하는 지점들에 반복되는 버그가 있습니다. E2E 테스트나 실제 사용자 플로우가 많아질 경우 정확한 선택자와 close 콜백이 필수적이며, 이들부터 정리해야 새 기능이 위태롭지 않습니다.
설계적 제안
- 라우터를 Context로 추상화하여
useRouter를 어디서든 안전하게 구독하도록 개선 useGlobalState/useState/useMemo가 각각 트리 전체가 아닌 컴포넌트 단위로 갱신하도록renderTree와searchCurrentNode를 재설계- Overlay/Toast 등 공통 UI를 키값/Map으로 관리하면서 이벤트 리스너가 누적되지 않도록
- ProductState(무한 스크롤, 필터, 장바구니) 데이터를
immutable하게 관리하고, 변화를 감지할 수 있도록setProducts,getProducts등을 숫자/문자열로 리턴해서 새로운 참조를 만드는 방향으로 리팩토링 - 추가 요구사항(예: 더 많은 상품 필터링, 토스트 피드백, 관련 상품 클릭) 전에는 현재의 데이터 흐름을 명확하게 단위화하고, 그 위에서 커스텀 훅이나 helper를 만들어 두는 것이 다음 단계 확장성을 보장합니다.
질문에대한 답변
JSX, Fiber Tree, 이벤트 기반 구조에 대한 답변
-
Fiber Tree의 목적: React Fiber는 렌더 트리를 더 잘 다루기 위해 나온 개념으로, '비동기 렌더링', '중단과 재시작' 같은 요구사항을 해결합니다. 여러분이 만든 프레임워크에서는
render함수가 트리 전체를 순회해서 한 번에 DOM을 갱신하기 때문에, 다시 그린 부분을 알고리즘적으로 재사용해주는 구조가 없습니다. Fiber는 "어떤 노드를 언제 다시 그릴지", "컴포넌트의 우선순위"를 트래킹할 수 있도록 노드들을 이어주는 연결 구조인데, 내부에서state,pendingProps,alternate같은 필드를 통해beginWork/completeWork를 분리해서 업데이트를 chunk단위로 처리합니다. 구현이 어렵다면, 먼저 "컴포넌트 마다 고유한 key와 상태 버전을 붙여서 재사용"하는 방식(현재renderTree랑searchCurrentNode조합)과 같은 전략을 통해 Fiber가 해결하려는 ‘부분 업데이트’ 문제를 단계적으로 분리해보세요. -
JSX → 함수 흐름을 이해하기 힘든 이유: JSX는 실제로
JSXFactory(현재h함수)로 컴파일이 됩니다. 그러다 보니 JSX 문법이h('div', { … }, child)형태로 변환된다는 점과, 렌더 트리/상태 트래킹 로직이 분리되어 있어서 흐름이 직관적이지 않을 수 있습니다. 도움이 되는 방법은 생성된DomNode구조를 콘솔/디버거로 찍어보는 겁니다. 예를 들어, JSX가CompnentElementNode인지, 어떤props를 갖고 있는지 점검하면 렌더 흐름을 상상할 수 있습니다. 또,render가 호출될 때currentRenderingNode가 어떤 값을 가지는지를 찍어보면 어떤 순서로 훅이 실행되는지 명확해집니다. -
Microtask/Delay 최적화 팁: 타이밍 조절을 위해
queueMicrotask나delay(1)을 많이 쓰고 있다면, 그 이유를 물어보는 것이 첫 단계입니다.useState나useEffect가 비동기적으로 실행되는 상황이라면,render의 내부에서currentRenderingNode를 업데이트한 뒤setTimeout/queueMicrotask없이 바로 다음 단계로 넘어가는 구조를 짜면 대부분 해결됩니다.delay는 주로renderTree.raw를 클론한 뒤 DOM에 붙이기 전에 메인 루프를 한 쪽으로 넘기기 위해 쓰는 방식인데, 이는render를 예약하는 queue (pendingWork)를 만들고, batch로 DOM 작업을 수행하도록 하는 식으로 정리하면 더 명확합니다. 짧은 팁은queueMicrotask나delay(1)을 사용하는 곳마다 **"이걸 쓰지 않으면 어떤 문제가 생기나"**를 주석으로 남기고, 그 문제를 처리할 수 있는 구조적 대안을 설계하는 것입니다. -
Context API 구현 방향:
Router와 같은 전역 상태가window.__router로 노출된 지금 구조에서는 Context가 이미 고민했던 문제와 같은 맥락입니다. Context는Provider→useContext조합을 사용해value를 트리 내부로 전파하고,window전역 접근을 제거합니다. 구현은 간단합니다: 첫째const RouterContext = createContext<ReturnType<typeof useInternalRouter> | undefined>(undefined);둘째function RouterProvider({ children }) { const router = useInternalRouter(); return <RouterContext.Provider value={router}>{children}</RouterContext.Provider>; }마지막으로function useRouter() { const router = useContext(RouterContext); if (!router) throw new Error("RouterProvider가 없습니다"); return router; }. 지금처럼 전역에서window.__router로 접근하는 모든useRouter를 이 context 버전으로 바꾸면,CartButton,ProductFilter,Topbar가 모두 동일한 router 인스턴스를 안정적으로 참조합니다. -
이벤트/옵저버블 vs. 현재 구조: 여러분이 느낀 것처럼
setCount를 호출하면 내부에render가 다시 호출되면서 렌더링이 다시 수행되므로, 별도의 옵저버가 없어도 상태가 흐릅니다. 그러나 옵저버블/시그널 패턴은 다음과 같은 장점을 줍니다: (1) 상태가 바뀔 때마다 수동으로render를 호출할 필요 없이, 상태를 구독한 컴포넌트가 자동 갱신됩니다. (2) 시그널은 상태를 원자적으로 취급하며 의존성이 끊긴 상태에서도 최소한의 컴포넌트만 갱신합니다. (3) 나중에state를 콘솔에서 추적하거나 페이지의 일부만 re-render 해야 하는 경우, 옵저버블은 “무엇이 구독했는가”를 알려주기 때문에 더 정밀한 control이 가능합니다.preact/qwik같은 프레임워크가 사용하는 시그널은 내부적으로 옵저버 패턴 형태이며, 현재 여러분이 만든useGlobalState처럼 전체를 리렌더하지 않고도 특정 영역만 재랜더하도록 확장하기 위한 좋은 단서가 됩니다. 지금 구조에서는stateMap의 각 키에 구독자 리스트를 두고setState시점에 관련 컴포넌트만 다시 렌더링하는 식으로 옵저버를 흉내 내면 비슷한 이점을 얻을 수 있습니다.
| path: "/product/:id", | ||
| component: ProductDetailPage, | ||
| }, | ||
| } as const; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
문제상황 제시
현재 useRouter가 window.__router에 의존하고 있어서 전역 변수로 라우터 인스턴스를 펴두고 있습니다. SPA를 더 확장해서 Topbar, ProductFilter, ProductDetail처럼 여러 컴포넌트에서 라우터 정보를 참조해야 할 때, 전역 변수는 테스트하기 어렵고 SSR/mock 환경에서는 생명주기 문제를 만들 수 있습니다.
현재 코드의 한계
[한계1]전역 변수에 의존하다 보니 라우터를 한 번만 초기화하거나 여러 개의 인스턴스를 적용하기가 어렵습니다.[한계2]라우터 정보를 읽는 컴포넌트가 라우터가 아직 초기화되지 않았을 때undefined를 받게 되면 안전하지 않습니다.[한계3]Context나 Provider 없이 특정 컴포넌트에서 라우터를 띄워야 하는 경우,window.__router를 테스트 더블로 교체하는 게 까다롭습니다.
근본 원인
핵심 문제: 라우터 컨텍스트를 React-style로 전파하지 않고 window 전역을 통해 공유하고 있기 때문입니다.
왜 문제인가: 이 구조에서는 라우터 의존성이 명확하게 분리되지 않아 컴포넌트를 다른 앱이나 테스트로 옮길 때 라우터 설정을 다 던져줘야 하며, 향후 Router를 중첩하거나 SSR에서 공유 상태를 만들기가 사실상 불가능합니다.
개선 구조
현재 구조:
window.__router = useInternalRouter();
export function useRouter() { return window.__router; }
개선된 구조 (예시):
const RouterContext = createContext(undefined);
function RouterProvider({ children }) {
const router = useInternalRouter();
return <RouterContext.Provider value={router}>{children}</RouterContext.Provider>;
}
function useRouter() {
const context = useContext(RouterContext);
if (!context) throw new Error("Router context is missing");
return context;
}
개선 사항:
- 라우터 정보를 React-style Context/Provider로 감쌈
Topbar나CartButton이 Context만 구독하면window조작 없이 라우터 정보를 얻도록 구성- 테스트가 필요할 때 Provider만 래핑하면 되니 외부 환경에서도 안정적으로 작동
이렇게 개선하면 라우터를 전역 변수처럼 흩어 쓰지 않고도, Router 컴포넌트만 Provider로 감싸면 모든 자식은 Context를 통해 라우터를 참조하게 되어 구조적 응집도를 확보할 수 있습니다.
| stateMap.set(key, value); | ||
| } | ||
|
|
||
| queueMicrotask(() => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
문제상황 제시
setValue가 호출될 때마다 root 요소를 통째로 innerHTML = ""로 비우고, render(cloneDeep(renderTree.raw!))를 통해 전체 앱을 다시 렌더링합니다. 정적인 데이터를 조금 더 많이 추가하거나 여러 페이지/레이어가 생기는 상황에서 전체 트리를 매번 다시 그리면 렌더 비용과 메모리 사용이 급격히 늘어나며, 상태 복원 전략도 더 복잡해집니다.
현재 코드의 한계
[한계1]업데이트가 전역적이어서 상태를 변경한 컴포넌트의 자식만 다시 렌더링할 수 있는 구조를 만들기 어렵습니다.[한계2]renderTree.raw를cloneDeep해서 다시 그리기 때문에 ref가 모두 달라져 테스트나 DOM 이벤트 위임이 깨질 수 있습니다.[한계3]규모가 커지면 사용자 입력/무한 스크롤 등 작은 인터랙션마다 전체 앱을 그리므로 레이턴시/성능 문제가 커집니다.
근본 원인
핵심 문제: 리액트처럼 변경 대상 부분만 갱신하는 변경 감지를 하지 않고, 전역 상태 변경 시 전체를 다시 런칭한다는 구조적 접근을 취하고 있습니다.
왜 문제인가: 민첩하게 화면을 확장해야 하는 추가 요구사항(예: 장바구니 상태가 여러 위치에서 읽힐 때)에서는 변화량이 늘어나기 때문에 전체 트리 리렌더 방식은 성능 병목을 낳고, 메모리/이벤트 손실을 만들 수 있습니다.
개선 구조
개선된 구조 예시:
- 각 상태 키마다 옵저버를 등록하고,
setValue가 호출될 때 이key를 구독하는 컴포넌트만 재렌더링 - 렌더 루프가 전역
render를 부르지 않고currentRenderingNode아래 구간만 다시 생성
개선 사항:
stateMap을Map<string, Set<() => void>>처럼 구독자 리스트로 바꿔서setValue가 호출되면 해당 구독자만 다시 렌더링render에 “root”를 통째로 비우는 대신,currentRenderingNode를 찾아 해당 subtree만 diff/replacecloneDeep(renderTree.raw!)를 지우고, 변경이 필요한 노드만 얕은 복사 후render호출
이렇게 하면 향후 상품 리스트/상세/모달이 많아져도 cart 상태 변경이 전체 DOM을 다시 그리지 않고 일부분만 업데이트하며, 고용량 페이징에도 확장성이 확보됩니다.
|
|
||
| if (typeof state[stateCursor] === "object") { | ||
| Object.freeze(state[stateCursor]); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
문제상황 제시
setValue가 특정 컴포넌트를 호출한 뒤 render(parentNode, parentNode.parent, "")로 부모부터 다시 렌더링합니다. 이 방식은 현재 컴포넌트에만 영향을 줘야 할 상태 업데이트가 부모 범위까지 전체 DOM을 다시 그리는 부작용을 만들고, stateCursor 같은 내부 커서를 render 호출과 공유하다 보니 렌더 순서가 달라지면 상태가 꼬일 수 있습니다. 예를 들어, 무한스크롤로 데이터를 여러 페이지 받아오는 사이에 개별 ProductItem의 상태를 변경하면 리렌더 범위가 너무 커집니다.
현재 코드의 한계
[한계1]부모 노드를 다시 렌더링하는 순간 그 부모의 모든useState가stateCursor기준으로 다시 쌓이면서 의도치 않은 상태 혼선 발생[한계2]queueMicrotask를 통해 비동기로render를 호출하지만, 여러 상태 업데이트가 동시에 들어오면 순서 보장이 어려워집니다.[한계3]컴포넌트 내부 깊은 부분만 변화해도 루트까지render가 호출돼 비효율적
근본 원인
핵심 문제: 상태를 갱신할 때 DOM의 어디가 달라졌는지를 모르기 때문에 부모부터 다시 그리는 강제로 전체 트리를 다시 구성하는 구조입니다.
왜 문제인가: 이런 구조는 ProductList 같은 무한 스크롤 리스트에서 각 ProductItem의 버튼을 누를 때마다 전체 목록을 재생성하게 하고, 디버깅도 어렵습니다. 더 큰 규모의 기능(예: 장바구니를 여러 위치에서 보이게 하는 Sticky UI)을 추가하면 렌더 타이밍을 민감하게 맞추기 힘듭니다.
개선 구조
개선 사항:
setValue가 호출될 때searchCurrentNode(key)로 찾은 컴포넌트만 재렌더링하도록 제한- 렌더 트리를
renderTree에 트래킹하여 해당 노드만 교체하는diff/replace로직 구현 stateCursor를currentRenderingNode가 유지하게 두되,setValue직후에render보다 먼저stateCursor를 0으로 리셋하는 등 안정 상태를 확보
이렇게 하면 setValue는 “현재 컴포넌트” 수준에서만 rerender를 트리거하므로 나중에 트랜지션/비동기 상태도 고르게 관리할 수 있습니다.
| isNil(currentRenderingNode.sideEffectsCursor) | ||
| ) { | ||
| throw new Error( | ||
| "parentNode.sideEffects or parentNode.sideEffectsCursor is not set", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
문제상황 제시
useMemo에서 callback을 한 번 결과 저장을 위해 호출한 다음 다시 returnValue를 얻기 위해 또 호출하고 있습니다. 그래야 의존성이 바뀌었을 때 값을 재계산한다고 하셨겠지만, 현재 구현에서는 항상 callback이 2번 실행되고 dependencies도 매번 cloneDeep으로 복제되기 때문에, 의도치 않게 callback에서 네트워크 호출이나 사이드 이펙트를 두 번 실행할 수 있는 위험이 큽니다.
현재 코드의 한계
[한계1]callback이 의도와 상관없이 두 번 실행되어 부작용(토스트, fetch 등)이 두 번 발생할 수 있습니다.[한계2]cloneDeep을 매번 호출하면 종속성 배열이 클수록 GC 부담이 커집니다.[한계3]stateCursor와sideEffectsCursor를 함께 올리지만,sideEffects에cleanup이 없이callback두 번 호출로 이전 값을 덮어쓰면서 메모리가 누수될 수 있습니다.
근본 원인
핵심 문제: memoized value를 계산하면서 캐시 여부를 판단하는 시점에 callback을 바로 두 번 호출하고, 디펜던시 클론도 불필요하게 반복합니다.
왜 문제인가: 사용자가 상품 목록을 필터링할 때 useMemo로 총합을 계산하고 있을 경우, 화면마다 두 번씩 계산/토스트가 발생하여 성능 저하 및 비동기 호출이 중복됩니다.
개선 구조
개선 사항:
callback을 오직 한 번 실행하고, 그 결과를state[stateCursor]에 저장한 뒤 의존성이 변경된 경우에만callback을 재호출cloneDeep사용을JSON.stringify/structuredClone처럼 경량화하거나.every비교 등으로 대체sideEffects[sideEffectsCursor]가 없다면dependencies만 저장하고, 이후hasChanged여부를 판단
이렇게 하면 useMemo는 callback을 연산 집약적으로 사용하는 경우에도 성능을 유지하면서, 이전 값 대비 실제로 필요한 시점에서만 memo를 갱신하게 됩니다.
| setOverlays((prev) => | ||
| prev.filter((overlay) => overlay.id !== overlay.id), | ||
| ) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
문제상황 제시
close 콜백을 넘겼을 때 내부에서는 setOverlays를 쓰면서 이렇게 작성되어 있습니다:
setOverlays((prev) =>
prev.filter((overlay) => overlay.id !== overlay.id),
);이렇게 되면 overlay.id !== overlay.id는 항상 false가 되어서 filter가 언제나 빈 배열을 리턴하지 못합니다. 결국 사용자가 모달을 닫을 수 없는 상태가 됩니다.
현재 코드의 한계
[한계1]close를 호출해도 overlay를 제거하지 않으므로 ESC 키/배경 클릭 시간이 무한히 남음[한계2]모달을 여러개 띄우고 닫으려고 하면 첫 번째도 닫히지 않아 UX가 금방 망가집니다[한계3]id비교에 잘못된 변수 이름이 쓰여 테스트에서overlay가 계속 쌓입니다
근본 원인
핵심 문제: filter 내부에서 현재 overlay 변수를 다시 쓰면서 자기 자신과 항상 비교해서 빠르게 일치하는 항목만 필터링합니다.
왜 문제인가: 결과적으로 close 핸들러가 아무런 효과가 없으니 장바구니 모달을 닫을 수 없고, 반복적인 모달을 닫는 기능(예: ESC, 취소 버튼)이 모두 무시됩니다.
개선 구조
개선 사항:
close에서id를 외부에서 받아서 식별할 수 있도록 작성
const close = () => {
setOverlays((prev) => prev.filter((item) => item.id !== id));
};Controller를 렌더링할 때onClose콜백을 인자로 넘기고,openOverlay에서 생성한id를 기억
이렇게 하면 ESC 키, 배경 클릭, 닫기 버튼이 동일한 id를 갖는 overlay만 제거하므로 modal stack이 정상적으로 관리됩니다.
| setIsLoading(false); | ||
| } | ||
| }; | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
문제상황 제시
setProducts에서 아래처럼 prev를 그대로 수정하고 있습니다:
setProducts((prev) => {
prev[page - 1] = response.products;
return prev;
});이 코드는 기존 배열을 공유한 채로 내용을 바꾸고 있기 때문에 React-style 상태 시스템과 마찬가지로 참조가 변하지 않아 렌더링이 트리거되지 않을 수 있습니다. 무한 스크롤로 새 페이지가 추가될 때 DOM이 갱신되지 않거나, 이후 필터를 적용했을 때 products.flat()이 이전값을 계속 유지하게 됩니다.
현재 코드의 한계
[한계1]products상태가 같은 객체를 반환해서 렌더러가 변화를 감지하지 못합니다.[한계2]이후router.push로 상태를 바꿔도 배열 참조는 그대로여서 DOM에 반영되지 않을 수 있습니다.[한계3]추가 요구사항(예: 마지막 페이지에서ImpressionArea가 반복적으로setPage를 호출)과 결합하면 데이터 누락이 생깁니다.
근본 원인
핵심 문제: useState의 setter는 참조 변화로 렌더링을 판단하는데, prev를 직접 변조하면서 새로운 참조를 만들지 않습니다.
왜 문제인가: 무한 스크롤을 확장해서 page가 여러 개 쌓이거나 filtering/refresh 기능을 추가할 때, products 상태가 업데이트되지 않아 UI에 반영되지 않고, ImpressionArea가 더 이상 새 페이지를 붙이지 않을 위험이 큽니다.
개선 구조
개선 사항:
setProducts((prev) => {
const next = [...prev];
next[page - 1] = response.products;
return next;
});next배열을 새로 만들고, 그 안에서 해당 인덱스를 바꾸기- 리렌더링이 필요한 시점에
prev를 mutate하지 않고next를 반환
이렇게 하면 infinite scroll에서 products.flat()이 항상 최신 배열을 반영하고, 추가 조건(예: 새 필터 적용, totalProducts 변경)에도 안정적으로 동작합니다.
| </svg> | ||
| </button> | ||
| </div> | ||
| {cart.length === 0 ? ( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
문제상황 제시
CartModal에서 cart 항목을 렌더링할 때 data-product-id 속성들이 모두 하드코딩된 값(85067212996)으로 설정되어 있습니다. 나중에 테스트 코드나 트래픽 분석을 위해 다양한 상품에 동일한 data attribute를 기대하는 E2E 테스트들을 작성하면, 두 번째 상품부터 id가 모두 첫 번째 상품과 같아져서 선택/삭제/체크 등 요소 선택이 정확히 안 됩니다.
현재 코드의 한계
[한계1]Playwright/E2E 테스트가data-product-id로 해당 상품을 찾아야 하는데, 중복되면 잘못된 엘리먼트를 선택합니다.[한계2]클라이언트 로직(수량 변경, 체크박스 등)도data-product-id를 신뢰하고 있는데, 중복되면 의도치 않게 첫 번째 상품만 조작됩니다.[한계3]이후 카트 데이터가item.product.productId에 따라 관리되지만 DOM이 그와 다르면 디버깅이 어려워집니다.
근본 원인
핵심 문제: 렌더링 중 data-product-id는 상품별로 item.product.productId를 사용해야 하는데, 템플릿 복붙으로 고정값이 들어가 있습니다.
왜 문제인가: 사용자 조작과 자동화된 테스트 모두 productId를 키로 삼아서 동작하는데, DOM이 서로 다른 id를 가지지 않으면 delete/select 이벤트가 특정 상품에만 영향을 주고 다른 상품이 무시됩니다.
개선 구조
개선 사항:
- 각 반복문에서
data-product-id={item.product.productId}를 사용 <button>이나<div>에도 동일한 식별자를 적용하여 이벤트 핸들링과 테스트 선택자를 모두 일치
이렇게 하면 카트 내 어떤 상품을 클릭하거나 조작하더라도 실제 productId에 대응해서 동작하고, Playwright 검증 스윗도 안정적으로 작동합니다.
| export function ProductDetailPage() { | ||
| const router = useRouter(); | ||
| const { id } = router.pathParams; | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
문제상황 제시
ProductDetail 컴포넌트의 useEffect 훅이 fetchProduct 함수를 빈 배열 의존성으로 실행하고 있어, 라우터에서 id가 바뀌어도 effect가 다시 실행되지 않습니다. SPA 내에서 같은 ProductDetail 컴포넌트를 재사용하면서 productId를 바꾸는 전환(예: 관련 상품 클릭)에서는 새 상품 데이터를 불러올 수 없습니다.
현재 코드의 한계
[한계1]관련 상품 클릭 시id가 바뀌어도 effect가 다시 실행되지 않기 때문에 페이지가 이전 상품 정보를 계속 보여줍니다.[한계2]브라우저 뒤로가기/앞으로가기를 통해 상세 페이지를 재접근해도 상태가 갱신되지 않습니다.[한계3]추가 요구사항(예: 같은 상세 페이지에서 다른 상품 정보를 띄우는 탭)에서 상태를 다시 읽을 수 없습니다.
근본 원인
핵심 문제: effect가 id에 의존하지 않고 빈 배열을 전달하고 있어서, 리렌더링이 일어나도 fetch가 트리거되지 않는 구조입니다.
왜 문제인가: 사용자가 Related 섹션에서 다른 상품을 클릭하거나 브라우저 history를 이동할 때 id가 바뀌는 것이 명확하므로 그때마다 데이터를 다시 요청해야 하는데, effect가 그걸 감지하지 못합니다.
개선 구조
개선 사항:
useEffect(() => {
fetchProduct();
}, [id]);
id를 effect 의존성에 넣어서 라우터 파라미터 변경마다 새 요청 실행- 관련 상품 섹션에서도
router.push로id만 바꾸면 effect가 트리거
이렇게 하면 SPA 조건을 만족하면서도 URL 기준으로 상품 상세가 새로고침/공유되는 기대를 충족합니다.
| > | ||
| <div className="flex-shrink-0">{icon}</div> | ||
| <p className="text-sm font-medium">{title}</p> | ||
| <button |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
문제상황 제시
ToastContainer 내부에서 window.addEventListener("toast", ...)만 붙여놓고 있지만, 토스트를 자동으로 제거할 때 onDestroy에서 setToasts로 상태를 갱신할 때 배열이 점점 커질 수 있습니다. 특히 Toast 컴포넌트가 3초 후 onDestroy를 호출하되 ToastContainer는 setToasts((prev) => prev.filter((t) => t.id !== toast.id))로 항상 새 배열을 만들기 때문에, 지속적으로 여러 개의 토스트가 띄워지는 시나리오에서 매 렌더마다 새로운 배열을 만들고 필터링하는 비용이 증가합니다.
현재 코드의 한계
[한계1]토스트를 연속으로 열고 닫을 때마다setToasts가prev.filter를 실행해 O(n) 동작합니다.[한계2]전역 이벤트 방식이라 테스트 중에도 토스트가 계속 쌓이면 GC 부담[한계3]자동 제거 시setToasts가 여러 번 호출되면 배경에서 불필요한 렌더링이 발생
근본 원인
핵심 문제: 토스트 리스트를 배열로 유지하면서 매번 filter를 돌려 DOM을 최신화하는 구조는 스파이크 트래픽에 취약합니다.
왜 문제인가: 장바구니/검색에서 토스트가 빈번히 뜨는 경우, filter 작업이 전체 toast 개수만큼 반복되고 렌더링이 수행되며, 애니메이션이 많은 UI에서는 버벅임이 체감될 수 있습니다.
개선 구조
개선 사항:
- 토스트들을
Map<id, Toast>구조로 유지하고setToasts는 Map 기준으로delete만 수행 - 혹은
setToasts를setToasts((prev) => { const next = prev.filter(...); if (next.length === prev.length) return prev; return next; })처럼 실제로 변화가 있으면 새 배열을 리턴
이렇게 하면 토스트가 많아져도 Map 기반으로 O(1) 삭제를 구현하거나, 변화가 없을 때 상태를 그대로 유지하여 렌더링 횟수를 줄일 수 있습니다.
devchaeyoung
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
이 피드백은 n8n + ai (gpt-5-mini)를 활용하여 자동으로 생성된 내용입니다.
이번 PR은 v1(Tagged Template Literal)에서 v2(JSX와 Virtual DOM)로 전환하며 더 직관적이고 타입 안정적인 프레임워크를 새로 구현한 점이 돋보입니다.👍 핵심적으로 useState, useEffect, useMemo를 직접 만든 점과 SPA 라우터 구현, 상품 필터링, 무한스크롤, 장바구니 모달 구현 등 요구사항을 충실히 따라 안정적 사용자 경험을 제공하도록 설계하셨습니다.
<추가질문>에서는 JSX 프레임워크 설계 시 fiber tree 개념 이해, 렌더링 흐름 파악 난점, 렌더링 타이밍 최적화, useRouter 및 Context API 설계 고충, 그리고 이벤트 기반 옵저버블 구현 방향 등 구조적 고민이었습니다. 이에 대해 멘토링 차원에서 JSX 내부 구현 원리, Context API 기본 구상, 상태 최적화 방법, 그리고 이벤트 기반과 옵저버블 설계의 트레이드오프를 설명드렸습니다.
전체적으로 관심사가 잘 분리되어 있고 컴포넌트별 책임이 명확하나, 전역 상태 리렌더링 범위, Context API 구현, 성능 최적화, 그리고 테스트 커버리지 보완 부분에서 구조 개선 여지가 보입니다. 장바구니, 상품 상세, 라우터 등 주요 도메인 로직은 재사용과 확장용으로 잘 분리된 점도 긍정적입니다.
질문에대한 답변
1. 질문 요약
소은님은 이번 과제에서 v1과 v2 두 가지 방식으로 직접 프레임워크를 구현하며 JSX, Virtual DOM, 훅, SPA 라우터, 전역 상태 관리 등 여러 핵심 개념을 경험했습니다. 특히, fiber tree와 렌더링 흐름 이해 및 Context API/Router 상태 공유 문제, 렌더링 순서나 비동기 타이밍 제어 고민, 이벤트 기반 구현과 옵저버블 방식 구현의 차이점에 대한 궁금증이 있었습니다.
2. 현재 선택의 장단점
- v1의 Tagged Template Literal 방식은 타입 안정성, 컴포넌트 합성, 디버깅, 성능 최적화 측면에서 한계가 있었습니다.
- v2의 JSX + Virtual DOM 방식은 타입 체크, 효율적인 업데이트, 익숙한 API를 활용해 장점이 크지만, 렌더링 내부 로직과 훅 상태 동기화가 복잡해졌고 초기 설계 미흡 시 관리가 어려워질 수 있습니다.
- 현재 리렌더링 시 해당 컴포넌트 전체와 하위 DOM을 다시 렌더링하는 단점이 있으나, 최초 학습용으로 구현 단계에서는 충분히 이해 가능한 접근입니다.
- useRouter의 window.__router 전역 변수 사용은 프로덕션 용으로는 한계가 있고, Context API를 통한 구현이 더 바람직합니다.
- 이벤트 기반 또는 옵저버블 방식 구현은 상태 관리 접근 방법의 차이로, 이벤트 기반은 간단하고 직관적인 반면 옵저버블은 fine-grained 업데이트 제어에 유리합니다.
3. 실무에서라면 이렇게 설계할 것 같아요
- Fiber Tree 이해: React Fiber는 UI 업데이트 중단과 재개, 우선순위 조정, 부분적 업데이트 제어를 위해 가상 DOM 트리를 쪼개어 관리합니다. 즉, 렌더링 작업을 세분화해서 유연하고 성능 높은 업데이트를 가능하게 하는 내부 렌더링 아키텍처입니다.
- 컨텍스트 API 설계 조언: Context는 전역 상태를 계층적으로 전달하는 방법으로, 이벤트 없이도 필요한 컴포넌트에서 직접 상태를 읽고 구독할 수 있습니다. 현재 전역 window.__router 방식을 대체하기 위해 Context Provider 컴포넌트를 만들어 내부 상태를 useState, useEffect 등과 연동하면 자연스러운 라우터 접근이 가능합니다.
- 렌더링 타이밍 최적화: useEffect나 useMemo 내 비동기 큐 사용은 렌더링 루프를 중단시키지 않고, 순차적 상태 업데이트와 부수효과 실행을 위해 필요합니다. 빈번한 비동기 동기화 코드를 줄이려면 의존성 배열 관리와 상태 변경 최소화 전략을 동시에 고민하는 것이 좋습니다.
- 이벤트 기반 vs 옵저버블: 이벤트 기반은 명시적 상태 변경과 렌더 트리 재실행이 용이하고 디버깅이 편리합니다. 옵저버블은 상태 변경을 구독(subscribe)하는 방식으로 더 미세한 변경 추적과 최적화를 가능케 하지만, 구현 난도가 높고 러닝 커브가 있습니다.
4. 앞으로 구조를 잡을 때 참고하면 좋은 포인트
- 초기 구현 단계에서 안정성 있는 단일 트리 렌더링과 명확한 상태 분리를 우선하세요.
- 중복 코드 제거 및 관심사 분리(비즈니스 로직 ↔ UI)도 꾸준히 고민하세요.
- Context API는 전역 상태 및 라우터 상태 관리에 적극 활용해보세요. 예를 들어 RouterContext 선언 후 Provider 컴포넌트 제작, useRouter 훅에서 Context 사용하기.
- 성능 문제를 인식했다면 컴포넌트 재사용 및 memoization, 부분 렌더링 전략도 가능성을 열어두세요.
- JSX 파싱과 렌더링 내부를 직접 관찰하고, Virtual DOM 비교 알고리즘(특히 key 활용)을 더 학습해보시면 도움이 클 겁니다.
- 마지막으로, 테스트 커버리지 확보를 꾸준히 병행하면 안정성과 리팩토링 용이성도 자연스럽게 확보됩니다.
소은님, 궁금한 부분이 계속 있을 때마다 질문해주시면 차근차근 해결해 나가도록 돕겠습니다!
|
|
||
| state[stateCursor] = state[stateCursor] ?? initialValue; | ||
| currentRenderingNode.stateCursor++; | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
소은님, 현재 useState 훅에서 상태 관리가 state 배열과 stateCursor로 이루어져 있는데, setter 내부에서 부모 컴포넌트를 searchCurrentNode로 찾아서 상태를 업데이트하는 구조가 깔끔합니다.👍 이렇게 구현하면 상태가 컴포넌트별로 분리되어 유지된다는 점이 장점이에요.
한 가지 참고하면 좋을 포인트는 Object.freeze를 상태 값에 사용하는 부분입니다. 이 방식은 상태 불변성을 잡는 데 도움되지만, 값이 객체가 아닐 경우(원시 타입)엔 무의미할 수 있으니 해당 부분은 typeof state[stateCursor] === 'object' && state[stateCursor] !== null 처럼 방어적으로 검사해주시면 안정적일 거에요.
| const dispatcher = valueOrDispatcher as (value: T) => T; | ||
| stateMap.set(key, dispatcher(cloneDeep(state))); | ||
| } else { | ||
| const value = valueOrDispatcher as T; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
전역 상태를 Map을 사용해 관리하고, 상태 변경 시 root를 초기화하고 렌더링하는 방식이 간단하면서도 기능적으로 충분한 것을 확인할 수 있었습니다.👍 이 구조는 소은님이 말씀하신 Context API나 Redux같은 복잡한 전역 상태 도입을 하지 않고도 전역 상태 관리를 어느 정도 깔끔하게 할 수 있는 좋은 포인트입니다.
하지만 현재 구현은 전역 상태가 바뀔 때마다 root 전체를 새롭게 렌더링해서, 부분적 리렌더링이 어려운 단점이 있습니다. 나중에 전역 상태가 많아지거나 복잡해질 때 최적화를 고려하시면 좋겠습니다.
| public sideEffectsCursor?: number, | ||
| public parent?: HTMLElement | DocumentFragment, | ||
| public nodes?: HTMLElement[], | ||
| public nestedComponenets?: (CompnentElementNode | FragmentNode)[], |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
JSX factory 함수 내부에서 children에 키를 자동 부여하는 mapKeyToChildren 함수가 잘 구현되어 있습니다.👍 key 관리가 되어야 Virtual DOM 비교 시 재사용이 올바르게 진행되어 리렌더링 성능 최적화에 크게 기여합니다.
다만, 현재 키 할당 로직이 자식이 객체일 때만 key를 할당하고, primitive 타입은 그대로 두는데, 이 부분도 필요에 따라 자동으로 프리미티브 타입도 인덱스 키로 감싸는 구조로 확장 가능하니 참고해보시면 좋겠습니다.
|
|
||
| function useRouter(): ReturnType<typeof useInternalRouter> { | ||
| return (window as any).__router; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
useInternalRouter 훅에서 라우트 변경 시 히스토리 API를 사용하고 상태를 관리하는 구조가 깔끔하게 보입니다.👍 에러 및 404 페이지 처리도 적절히 되어있어 안정적인 SPA 라우터 기본 구조에 부합하는 구현입니다.
다만, 소은님 셀프회고에서 언급하셨듯 useRouter의 글로벌 접근법으로 window.__router를 사용하는 것은 테스트 및 유지보수 측면에서 제한적일 수 있습니다. Context API처럼 컴포넌트 계층 내에 상태를 전달하는 구조를 도입하면 보다 자연스럽고 안정적인 라우터 사용 경험을 제공할 수 있습니다.
| id="search-input" | ||
| placeholder="상품명을 검색해보세요..." | ||
| value={search ?? ""} | ||
| onKeyDown={(e) => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ProductFilter 컴포넌트에서 URL 쿼리 파라미터 상태를 라우터에서 직접 받아 쓰는 점이 흥미롭습니다.👍 이렇게 하면 URL 상태와 UI 상태가 일관되게 유지되어 SPA 네비게이션 요구사항에 적합한 구조입니다.
하지만 필터 변경 시 직접 콜백으로 라우터에 push를 호출하는 쪽으로 로직이 섞여 있는데, 이를 별도의 훅이나 상태 관리 계층으로 추상화하면 관심사 분리에 더욱 도움이 될 것 같아요.
| /> | ||
| </label> | ||
| {/* 상품 이미지 */} | ||
| <div className="w-16 h-16 bg-gray-100 rounded-lg overflow-hidden mr-3 flex-shrink-0"> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
CartModal 컴포넌트가 선택된 아이템 관리를 useState로 별도 관리하는 구조가 적절합니다.👍 또한 장바구니 전체선택 체크박스 로직도 깔끔하고 UI에 잘 반영하고 있네요.
다만 setCart가 직접 cart 배열을 복사 및 수정하는 부분이 다소 로직이 반복적으로 구현되어 있는데, 이를 별도 유틸리티 함수(예: addToCart, removeFromCart 등)로 분리해보면 코드 재사용성과 가독성이 향상될 것 같습니다.
| src={product.images[0]} | ||
| alt="PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장" | ||
| className="w-full h-full object-cover product-detail-image" | ||
| /> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ProductDetail 컴포넌트 내에서 상품 상세 API를 두 번 호출하는 부분이 있습니다: fetchProduct 와 fetchRelatedProducts. 관련 상품 조회가 상품 정보가 로드된 이후에 자동으로 실행되는 점이 좋으며 useEffect 의존성 배열 관리도 적절해 보입니다.👍
하지만 상품 상세 데이터 의존성을 기준으로 관련 상품을 다시 fetch하는 방식은 괜찮지만, 만약 카테고리가 바뀔 수도 있는 경우에는 확장성 차원에서 별도의 상태/이펙트 관리가 필요할 수 있으니 참고해보세요.
| return Array.from({ length: limit }).map(() => ( | ||
| <ProductSkeleton /> | ||
| )); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
상품 목록에서 로딩 상태에 따른 스켈레톤 UI 렌더링과 실제 상품 데이터를 매끄럽게 분리하는 구조가 잘 되어 있습니다.👍
로딩 상태에 따라 보여주고자 하는 UI가 명확하고, 비동기 상태 핸들링 구조도 깔끔해서 유저 경험을 잘 고려했다고 생각됩니다.
| let triggered = false; | ||
| const root = document.querySelector("#root"); | ||
|
|
||
| if (isNil(root)) return; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Infinite Scroll 트리거를 위해 IntersectionObserver와 MutationObserver를 조합한 방식이 안정적이며, debounceTime을 두어 호출을 제한하는 등 성능을 고려한 점이 아주 좋습니다.👍
다만 트리거 영역(#impression-area)이 동적으로 변경될 수도 있으므로 MutationObserver를 적절히 사용하고 있는데, 이 점도 잘 처리하신 부분입니다. 앞으로도 비슷한 경우에는 IntersectionObserver 활용을 추천합니다.
| setOverlays([]); | ||
| }} | ||
| > | ||
| {overlays.map(({ id, Controller }) => ( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OverlayContainer에서 여러 오버레이를 상태 배열로 관리하고, Escape 키 및 배경 클릭으로 닫기 처리를 하는 점이 좋습니다.👍
단, 현재 여러 오버레이를 렌더링할 때 z-index 관리나 포커스 트래핑(접근성) 부분을 강화한다면 실제 서비스에서 더 견고한 UX 제공이 가능합니다. 이 부분은 앞으로 단계적으로 고려해보세요.
과제 체크포인트
배포 링크
https://ahnsummer.github.io/front_7th_chapter2-1/
기본과제
상품목록
상품 목록 로딩
상품 목록 조회
한 페이지에 보여질 상품 수 선택
상품 정렬 기능
무한 스크롤 페이지네이션
상품을 장바구니에 담기
상품 검색
카테고리 선택
카테고리 네비게이션
현재 상품 수 표시
장바구니
장바구니 모달
장바구니 수량 조절
장바구니 삭제
장바구니 선택 삭제
장바구니 전체 선택
장바구니 비우기
상품 상세
상품 클릭시 상세 페이지 이동
/product/{productId}형태로 변경된다상품 상세 페이지 기능
상품 상세 - 장바구니 담기
관련 상품 기능
상품 상세 페이지 내 네비게이션
사용자 피드백 시스템
토스트 메시지
삽질기
컴포넌트 인스턴스와 상태 추적 문제
리렌더링 시 DOM 위치 복원
GitHub Pages 서브 디렉토리 배포 시 경로 처리
무한 리렌더링 지옥
심화과제
SPA 네비게이션 및 URL 관리
페이지 이동
상품 목록 - URL 쿼리 반영
상품 목록 - 새로고침 시 상태 유지
장바구니 - 새로고침 시 데이터 유지
상품 상세 - URL에 ID 반영
/product/{productId})상품 상세 - 새로고침시 유지
404 페이지
AI로 한 번 더 구현하기
과제 셀프회고
v1에서 v2로: 두 번의 구현을 통한 깊은 학습
v1 브랜치 링크
이번 과제는 두 번에 걸쳐 완전히 다른 방식으로 구현했습니다. 첫 번째 시도(v1)에서는 Tagged Template Literal 방식으로, 두 번째(현재 버전)에서는 JSX와 Virtual DOM 방식으로 프레임워크를 만들었습니다.
v1: Tagged Template Literal 방식의 시도와 한계
v1에서 구현했던 방식
처음에는 Lit이나 hyperHTML에서 영감을 받아 Tagged Template Literal 기반으로 프레임워크를 만들었습니다:
v1에서 마주친 문제들
타입 안정성 부족
컴포넌트 합성의 어려움
Card({ title: 'Title' }).html...`` 같은 이상한 문법이 필요했습니다디버깅의 어려움
성능 최적화의 한계
기존에 사용하던 방식과의 차이
v2: JSX와 Virtual DOM으로의 전환
왜 다시 작성하기로 결심했는가
v1으로 기본 기능은 동작했지만, 위의 한계점들이 명확했고 특히 동작의 흐름을 예측하기가 어렵다는 게 결정적이었습니다. "지금 되돌리지 않으면 큰일이 날 것 같다"는 직감이 들었고, 아예 처음부터 다시 설계하기로 결심했습니다.
v2에서 달라진 점
두 번 구현하며 얻은 것
솔직히 처음부터 다시 만드는 게 시간이 더 걸렸지만, 덕분에 "왜 React가 이렇게 설계되었는지"를 몸소 깨달았습니다. 특히 훅들을 구현하면서 왜 훅을 조건부로 호출되는 것을 리액트 측에서 금지하는 지도 알 것 같았습니다. Tagged Template Literal의 간결함도 좋지만, 복잡한 UI를 다루기에는 JSX와 Virtual DOM이 필수적이라는 걸 체감했습니다.
기술적 성장
커스텀 리액트 프레임워크 구현
두 가지 다른 방식(Template Literal vs JSX)으로 프레임워크를 구현하면서 각 접근법의 장단점을 직접 경험했습니다. useState, useEffect, useMemo 같은 핵심 훅들을 직접 만들어보니, "아, 그래서 React가 이런 규칙을 만들었구나" 하는 순간들이 많았습니다. 현재는 key를 사용한 재조정과 같은 알고리즘은 구현하지 못하고 특정 컴포넌트가 리렌더링 되면 하위 DOM을 통째로 리렌더링 하는 방식으로 구현했는데, 디버깅 과정에서 엄청나게 많은 리렌더링이 발생하는 것을 보면서 왜 React가 key prop을 요구하는지 뼈저리게 느꼈습니다.
SPA 라우팅 시스템 구현
History API를 처음 제대로 써봤는데, 생각보다 까다로웠습니다. 특히 뒤로가기/앞으로가기 버튼을 눌렀을 때 상태를 올바르게 복원하는 부분이 어려웠습니다. URL 쿼리 파라미터를 파싱하고 관리하는 시스템도 직접 만들어보니, Next.js나 React Router 같은 라이브러리가 얼마나 많은 엣지 케이스를 처리하고 있는지 실감했습니다.
E2E 테스트 주도 개발
Playwright로 E2E 테스트를 처음 작성해봤는데, 테스트 코드가 곧 명세서라는 말이 실감났습니다. 테스트가 "장바구니 아이콘은
#cart-icon-btnid를 가져야 한다"고 명시하니까, 마크업을 대충 짤 수가 없더라고요. 덕분에 테스트 가능한 코드를 작성하는 습관이 생겼습니다.자랑하고 싶은 코드
반응형 상태 관리 시스템
아주 잘 동작하고 있는 게 맞는 지는 조금 의심스럽지만 리액트와 매우 유사한 문법의 코드를 만들어 냈다는 게 그래도 뿌듯합니다. 특히 멀티패러다임 프로그래밍을 적용하여 함수가 유리한 부분은 함수로, 클래스가 유리한 부분은 클래스로 구현하고자 하였던 부분이 초반에 방향을 잡는데에 도움이 많이 되었던 것 같습니다.
가독성을 챙길 수 있는 유틸리티들
토스의 useOverlay나 Sonner의 toast와 같은 문법들을 유사하게 구현해내어 관련된 코드들을 작성할 때 가독성을 챙길 수 있었던 것 같아 좋았습니다.
개선이 필요하다고 생각하는 코드
타입 안정성
솔직히 급하게 작성하다 보니
any타입을 몇 군데 써버렸습니다. 특히 이벤트 핸들러 부분에서 타입을 제대로 추론하지 못해as캐스팅을 남발한 게 아쉽습니다. 제네릭을 좀 더 활용했으면 타입 안정성을 높일 수 있었을 텐데, 시간에 쫓겨 타협한 부분이 많습니다.에러 바운더리
현재는 Router 안에서 에러를 처리하고 있어서 컴포넌트에서 에러가 발생하면 앱 전체가 뻗어버립니다. React의 Error Boundary 같은 걸 구현하려다가 시간이 부족해서 못했는데, 실제 서비스라면 필수였을 것 같습니다.
Context API
현재 useRouter라던지, 장바구니 데이터 등을 전역 상태를 사용하여 구현하였고, 그 과정에서 전역 상태가 업데이트 되면 앱 전체가 리렌더링 되는 형태인데, Context API를 구현했다면 훨씬 더 효율적인 앱을 구현할 수 있었을 것 같습니다.
성능 최적화
Virtual DOM을 구현하긴 했지만, 모든 컴포넌트가 부모가 리렌더링되면 같이 리렌더링됩니다. 또한, 리렌더링 시 업데이트 대상 노드를 찾는 로직이 너무 비효율적이라고 생각합니다. React의
memo나useMemo같은 최적화 기능을 추가하고 싶었는데 시간이 부족했습니다. 특히 상품 목록 페이지에서 필터를 바꿀 때마다 전체가 다시 그려지는 게 눈에 보여서 찝찝합니다.직관적이지 않은 렌더링 로직
처음에 단순히 상태 없이 JSX를 DOM으로 렌더링 하는 것은 너무 쉽게 잘 되어서 조금... 자만했습니다. 그런데 상태나, useEffect 같은 것들이 들어가기 시작하니까 갑자기 복잡도가 확 올라갔고, 중간부터는 스스로도 로직을 제대로 파악하지 못한 채로 개발을 이어나가게 되었습니다. 초반에 설계를 좀 더 탄탄하게 했다면 더 직관적인 렌더링 로직을 구현할 수 있었을 것 같습니다.
테스트 커버리지
E2E 테스트만 있고 단위 테스트가 전혀 없습니다. 특히 라우터나 상태 관리 로직 같은 핵심 기능은 단위 테스트가 필요한데, 시간 관계상 건너뛰었습니다. 나중에 리팩토링할 때 테스트가 없으면 불안할 것 같습니다.
학습 효과 분석
가장 큰 배움
"React 쓰면 되는데 왜 직접 만들어야 하나" 싶었는데, 만들어보니 완전히 다른 세상이 보였습니다. 왜 React 훅에 규칙이 있는지, 왜 key가 필요한지, 왜 불변성을 지켜야 하는지... 이런 것들이 이제야 이해가 됩니다. 특히 상태가 바뀔 때 어떤 컴포넌트만 다시 그려야 하는지 판단하는 게 얼마나 복잡한지 깨달았습니다.
추가 학습 필요 영역
아직도 모르는 게 산더미입니다. Context API처럼 props drilling을 피하는 패턴도 구현해보고 싶고, Reducer 패턴으로 복잡한 상태를 관리하는 것도 공부해야 할 것 같습니다.
성능 프로파일링도 제대로 해보고 싶습니다. 지금은 "느린 것 같은데?" 하는 감으로만 판단하는데, 실제로 어디가 병목인지 측정하고 개선하는 경험이 필요합니다.
접근성(a11y)은 완전히 간과했습니다. 키보드만으로 조작할 수 있는지, 스크린 리더 사용자는 어떻게 쓸지 전혀 고려하지 못했는데, 실무에서는 필수라고 들어서 꼭 공부하려고 합니다.
과제 피드백
좋았던 부분
E2E 테스트 코드를 미리 주신 게 정말 좋았습니다. "이런 기능이 필요하다"는 추상적인 설명보다 실제 테스트 코드를 보니 "아, 이렇게 동작해야 하는구나"가 명확했습니다. 특히 헤맸을 때 테스트 코드를 다시 읽으면 힌트가 있어서 큰 도움이 됐습니다.
위에서 언급한 것과 같이 엉성하긴 하지만 그래도 처음 다뤄보는 JSX를 사용해서 끝까지 과제를 완수해서 뿌듯합니다. 또한 클래스 형태의 컴포넌트를 구현했다면 좀 더 수월했을 것 같은데, 챌린지 한 목표임을 알면서 함수형 컴포넌트를 구현한 것도 좋았습니다.
과제를 통해 프론트엔트 프레임워크들이 어떤 과정을 통해 동작하는 지, 개발되면서 어떤 고민들을 거쳤을 지 더 잘 알게 된 것 같아서 좋았습니다.
아쉬웠던 부분
테스트 코드도 작성하지 못 하고 검증하지 못 한 부분들이 너무 많아서 아직 불안정한 코드 상태인 것이 아쉽습니다. 중간까지는 그래도 나름 여유를 가지고 작업하였는데 점점 분량에 쫒기면서 날림 코드가 된 것 같아서 너무 아쉽습니다. 또한 직접 구현하기는 하였지만 뭔가 하나 터지면 땜빵하고 또 터지면 메꾸고 하는 식으로 작업을 진행해서 코드의 흐름이라던지 어떻게 구현했었는지가 머리속에 잘 안 남아서 아쉬웠습니다.
AI 활용 경험 공유하기
AI를 헤비하게 사용하지는 못했는데, 그래도 한번씩 무한 리렌더링 같은 현상이 발생할 때 같이 디버깅 하면서 도움이 되긴 한 것 같았습니다. 또한 v1 작업을 할 때에는 레퍼런스가 없는 코드라 잘 못 할 줄 알았는데 생각보다 코드를 잘 파악하고 코드를 잘 작성해줘서 의외였습니다.
다만 JSX 버전을 작업할 때에는 자꾸만 React 코드를 사용하려고 하는 경향이 있었어서, 역시 AI는 데이터베이스를 학습한 것이라는 한계가 있다는 것을 다시 한번 느낀 것 같습니다.
리뷰 받고 싶은 내용
4팀 코드리뷰
setCount가 호출되면 내부적으로render를 호출해서 리렌더링이 발생하니까 굳이 이벤트나 옵저버블이 필요하다고 느끼지 못했거든요. preact나 qwik처럼 시그널 패턴을 사용하는 프레임워크들도 결국 이벤트 기반과 비슷한 구조라고 알고있는데 이런 이벤트 기반 구현이 갖는 실질적인 장점이나 필요성은 무엇인지 궁금합니다!