Skip to content

Conversation

@milmilkim
Copy link

@milmilkim milmilkim commented Nov 8, 2025

과제 체크포인트

배포 링크

https://milmilkim.github.io/front_7th_chapter2-1/

기본과제

상품목록

상품 목록 로딩

  • 페이지 접속 시 로딩 상태가 표시된다
  • 데이터 로드 완료 후 상품 목록이 렌더링된다
  • 로딩 실패 시 에러 상태가 표시된다
  • 에러 발생 시 재시도 버튼이 제공된다

상품 목록 조회

  • 각 상품의 기본 정보(이미지, 상품명, 가격)가 카드 형태로 표시된다

한 페이지에 보여질 상품 수 선택

  • 드롭다운에서 10, 20, 50, 100개 중 선택할 수 있으며 기본 값은 20개 이다.
  • 선택 변경 시 즉시 목록에 반영된다

상품 정렬 기능

  • 상품을 가격순/이름순으로 오름차순/내림차순 정렬을 할 수 있다.
  • 드롭다운을 통해 정렬 기준을 선택할 수 있다
  • 정렬 변경 시 즉시 목록에 반영된다

무한 스크롤 페이지네이션

  • 페이지 하단 근처 도달 시 다음 페이지 데이터가 자동 로드된다
  • 스크롤에 따라 계속해서 새로운 상품들이 목록에 추가된다
  • 새 데이터 로드 중일 때 로딩 인디케이터와 스켈레톤 UI가 표시된다
  • 홈 페이지에서만 무한 스크롤이 활성화된다

상품을 장바구니에 담기

  • 각 상품에 장바구니 추가 버튼이 있다
  • 버튼 클릭 시 해당 상품이 장바구니에 추가된다
  • 추가 완료 시 사용자에게 알림이 표시된다

상품 검색

  • 상품명 기반 검색을 위한 텍스트 입력 필드가 있다
  • Enter 키로 검색이 수행된다
  • 검색어와 일치하는 상품들만 목록에 표시된다

카테고리 선택

  • 사용 가능한 카테고리들을 선택할 수 있는 UI가 제공된다
  • 선택된 카테고리에 해당하는 상품들만 표시된다
  • 전체 상품 보기로 돌아갈 수 있다
  • 2단계 카테고리 구조를 지원한다 (1depth, 2depth)

카테고리 네비게이션

  • 현재 선택된 카테고리 경로가 브레드크럼으로 표시된다
  • 브레드크럼의 각 단계를 클릭하여 상위 카테고리로 이동할 수 있다
  • "전체" > "1depth 카테고리" > "2depth 카테고리" 형태로 표시된다

현재 상품 수 표시

  • 현재 조건에서 조회된 총 상품 수가 화면에 표시된다
  • 검색이나 필터 적용 시 상품 수가 실시간으로 업데이트된다

장바구니

장바구니 모달

  • 장바구니 아이콘 클릭 시 모달 형태로 장바구니가 열린다
  • X 버튼이나 배경 클릭으로 모달을 닫을 수 있다
  • ESC 키로 모달을 닫을 수 있다
  • 모달에서 장바구니의 모든 기능을 사용할 수 있다

장바구니 수량 조절

  • 각 장바구니 상품의 수량을 증가할 수 있다
  • 각 장바구니 상품의 수량을 감소할 수 있다
  • 수량 변경 시 총 금액이 실시간으로 업데이트된다

장바구니 삭제

  • 각 상품에 삭제 버튼이 배치되어 있다
  • 삭제 버튼 클릭 시 해당 상품이 장바구니에서 제거된다

장바구니 선택 삭제

  • 각 상품에 선택을 위한 체크박스가 제공된다
  • 선택 삭제 버튼이 있다
  • 체크된 상품들만 일괄 삭제된다

장바구니 전체 선택

  • 모든 상품을 한 번에 선택할 수 있는 마스터 체크박스가 있다
  • 전체 선택 시 모든 상품의 체크박스가 선택된다
  • 전체 해제 시 모든 상품의 체크박스가 해제된다

장바구니 비우기

  • 장바구니에 있는 모든 상품을 한 번에 삭제할 수 있다

상품 상세

상품 클릭시 상세 페이지 이동

  • 상품 목록에서 상품 이미지나 상품 정보 클릭 시 상세 페이지로 이동한다
  • URL이 /product/{productId} 형태로 변경된다
  • 상품의 자세한 정보가 전용 페이지에서 표시된다

상품 상세 페이지 기능

  • 상품 이미지, 설명, 가격 등의 상세 정보가 표시된다
  • 전체 화면을 활용한 상세 정보 레이아웃이 제공된다

상품 상세 - 장바구니 담기

  • 상품 상세 페이지에서 해당 상품을 장바구니에 추가할 수 있다
  • 페이지 내에서 수량을 선택하여 장바구니에 추가할 수 있다
  • 수량 증가/감소 버튼이 제공된다

관련 상품 기능

  • 상품 상세 페이지에서 관련 상품들이 표시된다
  • 같은 카테고리(category2)의 다른 상품들이 관련 상품으로 표시된다
  • 관련 상품 클릭 시 해당 상품의 상세 페이지로 이동한다
  • 현재 보고 있는 상품은 관련 상품에서 제외된다

상품 상세 페이지 내 네비게이션

  • 상품 상세에서 상품 목록으로 돌아가는 버튼이 제공된다
  • 브레드크럼을 통해 카테고리별 상품 목록으로 이동할 수 있다
  • SPA 방식으로 페이지 간 이동이 부드럽게 처리된다

사용자 피드백 시스템

토스트 메시지

  • 장바구니 추가 시 성공 메시지가 토스트로 표시된다
  • 장바구니 삭제, 선택 삭제, 전체 삭제 시 알림 메시지가 표시된다
  • 토스트는 3초 후 자동으로 사라진다
  • 토스트에 닫기 버튼이 제공된다
  • 토스트 타입별로 다른 스타일이 적용된다 (success, info, error)

심화과제

SPA 네비게이션 및 URL 관리

페이지 이동

  • 어플리케이션 내의 모든 페이지 이동(뒤로가기/앞으로가기를 포함)은 하여 새로고침이 발생하지 않아야 한다.

상품 목록 - URL 쿼리 반영

  • 검색어가 URL 쿼리 파라미터에 저장된다
  • 카테고리 선택이 URL 쿼리 파라미터에 저장된다
  • 상품 옵션이 URL 쿼리 파라미터에 저장된다
  • 정렬 조건이 URL 쿼리 파라미터에 저장된다
  • 조건 변경 시 URL이 자동으로 업데이트된다
  • URL을 통해 현재 검색/필터 상태를 공유할 수 있다

상품 목록 - 새로고침 시 상태 유지

  • 새로고침 후 URL 쿼리에서 검색어가 복원된다
  • 새로고침 후 URL 쿼리에서 카테고리가 복원된다
  • 새로고침 후 URL 쿼리에서 옵션 설정이 복원된다
  • 새로고침 후 URL 쿼리에서 정렬 조건이 복원된다
  • 복원된 조건에 맞는 상품 데이터가 다시 로드된다

장바구니 - 새로고침 시 데이터 유지

  • 장바구니 내용이 브라우저에 저장된다
  • 새로고침 후에도 이전 장바구니 내용이 유지된다
  • 장바구니의 선택 상태도 함께 유지된다

상품 상세 - URL에 ID 반영

  • 상품 상세 페이지 이동 시 상품 ID가 URL 경로에 포함된다 (/product/{productId})
  • URL로 직접 접근 시 해당 상품의 상세 페이지가 자동으로 로드된다

상품 상세 - 새로고침시 유지

  • 새로고침 후에도 URL의 상품 ID를 읽어서 해당 상품 상세 페이지가 유지된다

404 페이지

  • 존재하지 않는 경로 접근 시 404 에러 페이지가 표시된다
  • 홈으로 돌아가기 버튼이 제공된다

AI로 한 번 더 구현하기

  • 기존에 구현한 기능을 AI로 다시 구현한다.
  • 이 과정에서 직접 가공하는 것은 최대한 지양한다.

과제 셀프회고

image ㅠㅁㅠ

바닐라 자바스크립트를 할 줄 안다고 생각했는데 잘 모른다는 것을 깨달았다. 농담으로 '리액트 개발자'라는 말을 쓰기도 하는데 나는 리액트 개발자도 아니다. 뷰를 더 많이 썼기 때문이다. 그럼 뷰 개발자? 라기에도 뷰를 그렇게까지 깊이 아는 건 아니다. ㅜㅜ

  1. 라우터 만들기
    먼저 간단하게 라우터를 구현하기로 했다. 왜냐하면, GitHub Pages에 배포를 하면 라우터나 경로 문제가 생길 것이므로 이걸 먼저 해결해놓고 개발에 집중하는 게 편하기 때문이다. 처음에는 라우터에 대해 별다른 깊은 고민을 하지 않고 만들었다. routes 배열과 renderPage함수를 우선 만들어 렌더링이 되는 걸 확인하고 추가로 Router 함수를 붙였다. 즉시실행 함수를 이용한 싱글턴 패턴이다. 이 함수를 추가할 당시에는 아무 생각 없이 노션의 예제를 베이스로 만들었기 때문에 싱글턴까지 생각을 못 했다가 나중에 깨달았다.
import HomePage from "./pages/HomePage";
import DetailPage from "./pages/DetailPage";

const routes = [
  {
    path: "/",
    component: HomePage,
  },
  {
    path: "/detail",
    component: DetailPage,
  },
];

export const Router = (() => {
  let currentPath = window.location.pathname;

  return () => ({
    getPath: () => currentPath,
    push: (path) => {
      if (currentPath === path) return;
      currentPath = path;
      window.history.pushState({}, "", path);
      renderPage();
    },
  });
})();

export const renderPage = (routerId = "router-view") => {
  const path = window.location.pathname;
  const route = routes.find((r) => r.path === path);
  const routerRoot = document.getElementById(routerId);

  if (!routerRoot) return;

  if (route) {
    routerRoot.innerHTML = route.component();
  } else {
    routerRoot.innerHTML = "<h1>not found😂</h1>";
  }
};

let initialized = false;

export const initRouter = () => {
  if (initialized) return;
  initialized = true;

  const router = Router();

  renderPage();

  // 뒤로가기
  window.addEventListener("popstate", () => renderPage());

  document.addEventListener("click", (e) => {
    const { target } = e;
    if (target.dataset.routerLink) {
      router.push(target.dataset.routerLink);
    }
  });
};

SPA를 구현할 때 이벤트 위임 방식이 더 좋을 수 있다는 것도 이번 발제 때 처음 깨달았다.

 <div id="router-view"></div>

템플릿 안에서 라우터 영역을 만들어준다. 처음에는 이것도 레이아웃 컴포넌트 안에서 엘리먼트를 찾아서 innerHTML안에 템플릿을 넣으려 했는다. 그러나... DOM에 아직 존재하지 않는 엘리먼트를 찾을 수 없다. 😳 순수하게 템플릿 문자열만 리턴하는 함수로 구성되어 있기 때문이다. 그래서 queueMicrotask(() => {})를 사용해서 한 틱 미룬 뒤에 엘리먼트를 찾은 후에 페이지를 렌더링 하는 시도를 했었다. 실행 타이밍이 정확히 언제인지 확신이 없어서 쓰지 않았다. 대신 initRouter로 main 함수 안에서 한번 초기화 하도록 했다.

  1. 배포
    워크플로우를 추가하여 main에 푸쉬 하면 빌드하여 github-pages에 배포되도록 했다. 이렇게만 올리면.. 안 된다. 기본 경로(/front_7th_chapter2-1/)가 자동으로 붙기 때문에, Vite의 base 옵션을 해당 경로로 설정해주어야 한다.
// vite.config.js
base: env.VITE_BASE_PATH || "/",

사실 환경변수를 굳이 추가하지 않아도 분기 처리는 가능하지만, 그냥 명시적으로 설정해두었다.

이렇게 하면 에셋의 경로는 잘 잡아주지만 라우터의 베이스도 다시 잡아야 한다. 라우터에서 BASE_PATH가 존재하면 잘 잘라내도록 설정을 해주어야 한다.

그렇게 하고 배포를 했을 때 잘 나올 줄 알았는데 아무것도 화면에 나오지 않았다. msw 경고는 무시하고 라우터 부터 보려고 했었는데, msw 설정에 실패하면 뒤의 함수가 실행되지 않게 되어있었다.

// main.js
import("./mocks/browser.js").then(({ worker }) =>
    worker.start({
      onUnhandledRequest: "bypass",
      serviceWorker: {
        url: `${import.meta.env.VITE_BASE_PATH}mockServiceWorker.js`,
      },
    }),

이와 같이 수정한다. 기본적인 라우터가 작동되는 것을 확인할 수 있다.

그럼 이제 새로고침이 안 될 것을 예상할 수 있다. SPA라 페이지를 못 찾기 때문이다. github-pages는 이럴 경우 404.html로 폴백 처리 하는데 그냥 간단하게 해결했다.
package.json에서 빌드 후 index.html을 404.html으로 복사해준다.

 "build": "vite build && cp dist/index.html dist/404.html",

여기까지 하면 배포 문제가 해결된다.

  1. 컴포넌트
    가장 고민을 많이 한 부분이다.

우선 컴포넌트를 함수와 클래스 중에 고민 했는데 요즘엔 함수형 프로그래밍을 선호하기도 하고 팀원의 함수형을 지향한다는 의견을 따라서 아무 생각 없이 함수형 컴포넌트로 시작을 했다. 함수형 프로그래밍이 객체지향과 비교했을 때 어떤 이점이 있는 걸까? 하는 의문이 들었다. 아무래도 그래도 객체지향이 조금 더 익숙하기 때문이다. 그 부분에 대해 멘토링 시간에 질문을 했다. 결론을 요약하면 취향 차이라고 한다.

단순하게 문자열을 리턴하는 함수형 컴포넌트를 사용하면 컴포넌트 내에서 비동기 작업을 할 수 없고, 단순한 프레젠테이션 컴포넌트에 가깝다. 따라서 컴포넌트 안에 render 와 같은 함수가 필요했고 그러려면 부모를 기반으로 렌더링을 해야 한다.

컴포넌트 안에서 컨테이너를 만들고 아이디를 고정하여 리렌더링하는 방식도 시도해 보았지만 이 방식도 렌더링 시점을 정확히 파악하기 어려운 것 같아서 폐기했다. 유니크한 ID를 만들어볼까도 했지만 이 단계에서는 최대한 간단한 구조로 먼저 구현하는 게 나을 거라 판단했다.

이러한 렌더 함수가 있는 컴포넌트는 데이터를 fetch하는 페이지 단위에서만 우선 적용하면 될 것 같아 일단 페이지 안에서 작업을 했다. 그리고 라우터 초기화 시점에서 받은 라우터 컨테이너의 id를, renderPage 함수에서 에서 항상 전달하면 된다.

// router.js
 ...
    route.component({
      root: routerRoot,
    });
...
...
// 페이지 내
  render();

  const response = await fetch("/api/products");
  const data = await response.json();
  products = data.products;
  pagination = data.pagination;
  isLoading = false;

...

  render();

이렇게 하면 상태 관리도 귀찮고 이벤트 바인딩을 하면 항상 초기화 된다. 어느 정도 공통화를 하고 싶었다.
하지만 생명 주기를 어떻게 관리해야 할지는 고민이 되었다. 우선 넘어가서 상세 페이지 작업을 마저 했다. 그 뒤 AI의 도움을 (많이) 받았다.

export const createPage = (root) => {
  let state = {};
  const template = '...'

  const render = () => {
     root.innerHTML = template;
  }
}

나는 이 정도로 작성한 뒤에 AI로 개선했다.

전부 객체로 리턴시키는 식으로 컴포넌트를 작성할 수도 있었는데, 모양이 vue의 options api와 비슷해졌다. 나는 별로 좋아하지 않는 방식이고 훅을 사용하고 싶었기에 몇 번 질의를 하여 컴포넌트의 초안은 이렇게 잡았다. (이후에도 여러 번 수정을 했다.)

export const createPage = (root, setup) => {
  let state = {};
  let view = () => "";
  const binders = [];
  let cleanups = [];

  const getState = () => ({ ...state });
  const setState = (patch) => {
    state = typeof patch === "function" ? patch(state) : { ...state, ...patch };
    render();
  };

  const template = (fn) => {
    view = fn;
  };
  const afterRender = (fn) => {
    console.log("afterRender");
    binders.push(fn);
  };

  const render = () => {
    // 이전 바인딩 제거
    cleanups.forEach((fn) => fn && fn());
    cleanups = [];

    // 그리기
    root.innerHTML = view(state);

    // 새 바인딩
    binders.forEach((fn) => {
      const c = fn({ root, getState, setState });
      if (typeof c === "function") cleanups.push(c);
    });
  };

  setup({ getState, setState, template, afterRender });
  render();

  return {
    unmount() {
      console.log("unmount");
      cleanups.forEach((fn) => fn && fn());
      cleanups = [];
      root.replaceChildren();
    },
  };
};

페이지 위주로 컴포넌트를 작업하고 단순히 뷰만 가지고 있는 컴포넌트는 순수 템플릿 함수를 유지하기로 했다.

  1. 이벤트 버스
    라우터 영역에서 페이지 단위로 컴포넌트를 만들어놨는데 헤더도 동적으로 변경되는 내용이 있었다. 대충 처음엔 라우터 안에서 헤더를 업데이트했다가 그러면 컴포넌트를 나눈 의미가 없는 것 같았다. 그래서 라우터의 개선이 필요했다. 단순히 라우터가 컴포넌트를 렌더링하고 끝나는 게 아니라, 컴포넌트 내부에서 라우트의 변화를 감지 할 수 있어야 한다. 어떤 방식으로 할 수 있을까 하다가, 처음엔 커스텀 이벤트를 만들어서 핑퐁하려 했다. 그러면서 장바구니도 다 이벤트로 처리했다. 이렇게 목록, 상세, 카트 추가를 기능 구현만 완료했다.
  window.dispatchEvent(new CustomEvent("route:changed", { detail: { path } }));
 window.dispatchEvent(new CustomEvent("cart:add", { detail: { productId, quantity: state.quantity } }));

그런데 이러면 유지보수도 힘들 거 같고 쓸 때마다 이벤트 리스너를 추가해야 할 수도 있고 어디에 이벤트 리스너 추가했는지 까먹을 수 있어서 메모리 누수도 발생할 수 있을 것 같다.

노션의 지식 뭉치를 읽어다가 옵저버 패턴이 있었고, 옵저버 패턴을 GPT에게 물어보다가 이벤트 버스를 사용하라는 방식을 들었다. 그래서 구현해달라고 했다. 😄
milmilkim@b6b2bfa
싱글턴 클래스는 아니지만 모듈 캐시에 의한 싱글턴이다.
라우터가 즉시 실행 함수로 구현된 싱글턴이라면,이벤트 버스는 new EventBus()를 한 번만 생성해서 export하기 때문에 모든 모듈이 동일한 인스턴스를 공유하는 형태의 싱글턴이 된다.

그러다보니 무한 루프에 걸렸다. 스테이트가 변경될 때마다 렌더를 했고 렌더 안에서 이벤트 등록을 했기 때문이다.
상태 변경 → 렌더 → 렌더 안에서 이벤트 on → emit → 다시 상태 변경 → 다시 렌더…
이 패턴이 반복되면서 구독이 렌더 횟수만큼 중첩되고, 결국 무한 루프가 발생했다.

해서 render()함수와 별도로 onMount, onUpdated, (onUnmount)를 추가했다. 어쩌다보니 Vue를 따라하고 있었다. 그리고 최대한 간단하게 가려 했지만 라이프 사이클에 대해 고민하느라 머리가 아파지기 시작했다.

  1. 옵저버 패턴 상태 관리
    https://github.com/milmilkim/front_7th_chapter2-1/blob/7b2607733a732734fb2d9f9ea9532821e5acfef8/src/stores/cartStore.js
    장바구니의 경우 옵저버 패턴으로 상태를 관리할 수 있도록 했다. 노션 내용을 참고하여 구현은 AI에게 맡겼다.
    cartStore 뿐 아니라 다른 Store도 추가될 수 있도록 처음엔 베이스 클래스를 추상화 하려 했지만, 함수형으로 만드려는 처음의 생각이 있었기에 함수로 바꾸었다. 최근 나는 zustand를 썼기 때문에 그런 모양으로 만들었다.

  2. 리팩토링
    milmilkim@f4ee764
    불필요하다고 생각되는 부분을 정리하고 컴포넌트 형식을 바꾸고 useAsync 훅을 만드는 시도를 했었다.
    그런데 useAsync를 쓰려면 또 구독을 해야 하고 더 복잡한 거 같아서 이것은 일단 없애고, 기능 구현을 마저 하기로 했다.

  3. 무한 스크롤
    intersection observer API를 통해 구현한다.

 // 무한 스크롤 옵저버 생성
    observer = new IntersectionObserver((entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          console.log("무한 스크롤 옵저버 트리거");
          const { isLoading, data } = getState();
          const pagination = data?.pagination || {};

          // 로딩 중이거나 더 이상 데이터가 없으면 요청하지 않음
          if (isLoading) return;
          if (!pagination.hasNext) return;

          // 다음 페이지 로드
          const { filter } = getState();
          setState({ filter: { ...filter, page: filter.page + 1 } });
          fetchProducts();
        }
      });
    });

잘 안 된다! isLoading이 true일 때 그대로 끝나서 아무 일도 일어나지 않았던 것이다.

  // 매 렌더링마다 - sentinel 다시 관찰
  onUpdated(() => {
    if (!observer) return;

    const sentinel = document.querySelector("#sentinel");
    if (sentinel) {
      observer.observe(sentinel);
    }
  });

onUpdated마다 다시 확인하도록 했다.

  1. 모달
    모달의 경우 이벤트 버스를 사용한다. 구독중인 장바구니 모달이 이벤트를 받으면 상태를 변경한다. 모달 자체는 컴포넌트이며 스토어의 값을 사용한다. 스토어는 로컬스토리지에서 값을 초기화하고 업데이트 한다.

  2. 쿼리스트링
    앞에서 라우터 이벤트를 다 만들어 놓았기에 이벤트를 구독하고 거기에 맞춰 검색 필터의 값을 넣어준다. 초반에 만든 라우터에서 계속 분기를 추가하여 만들었는데, 지저분해졌다. 그래서 나중에 정리하기로 했다.

  3. 리팩토링
    여기까지 하면 CI에 초록불이 들어온다. E2E 테스트를 통과한다. 하지만 우선 구현을 한다가 목표였기에 제대로 검수하지 않았기 때문에 이제 테스트가 깨지지 않는 상태를 유지하며 리팩토링 진행한다. 이 과정에서 e2e 테스트의 소중함을 느낄 수 있다. 테스트 코드가 존재하기 때문에 맘 놓고 이것저것 테스트 해볼 수 있다. 하지만 이미 작성된 E2E 테스트만으로는 잡히지 않는 문제가 있을 수 있기 때문에 눈으로도 봐가며 진행했다.

   // 장바구니 Store 구독
    unsubscribe = cartStore.subscribe((cartState) => {
      setState({
        items: cartState.items || [],
        selectedItems: new Set(Array.from(cartState.items.filter((item) => item.selected)).map((item) => item.id)),
      });

우선 이미 개발된 부분의 스토어 구독 부분을 살펴본다. cartStore를 구독하여 상태가 변경되면 setState를 호출하여 state의 값을 바꾸고 그래서 컴포넌트가 업데이트된다. 좀 별로인 것 같았다.

 const useStore = (store) => {
      const unsubscribe = store.subscribe(() => {
        // 스토어 변경 시 리렌더 트리거
        render();
      });

      mountCleanups.push(unsubscribe);
    };

컴포넌트 셋업 함수에 useStore를 추가하여 스토어 변경시 컴포넌트가 업데이트하도록 했다.

   const items = cartStore.getState().items;

그리고 컴포넌트를 마운트 하는 방식이 통일되어야 했다. 기존엔 컴포넌트 팩토리 함수를 호출하면 바로 render()함수를 실행했는데 이러면 언제 렌더링을 하겠다는 건지 알기 어렵다. 또, 부모 컴포넌트 안에 자식 컴포넌트가 있을 경우 부모 컴포넌트의 DOM이 렌더링 된 시점에서 루트를 찾아야 자식 컴포넌트를 렌더링 할 수 있으므로 이 컴포넌트의 뷰가 한번 마운트 된 시점을 알 필요가 있다. 그래서 onMounted를 추가했다. onMount는 삭제하고 onBeforeMount를 추가했다. render함수는 리턴했다.

export const createComponent = (setup) => {
  return ({ root, props = {}, options }) => {
    let state = {};
    let view = () => "";

    /**
     * lifecycle callbacks
     */
    const beforeMountCallbacks = [];
    const mountedCallbacks = [];
    const updatedCallbacks = [];
    const unmountCallbacks = [];

    let isMounted = false;

    const mountedCleanups = [];

    const getState = () => ({ ...state });
    const setState = (patch) => {
      state = typeof patch === "function" ? patch(state) : { ...state, ...patch };
      render();
    };

    const template = (fn) => {
      view = fn;
    };

    // DOM 이벤트 헬퍼 (자동 cleanup)
    const on = (target, event, handler) => {
      if (!target) return;
      target.addEventListener(event, handler);
      unmountCallbacks.push(() => target.removeEventListener(event, handler));
    };

    const onBeforeMount = (fn) => {
      beforeMountCallbacks.push(fn);
    };

    const onMounted = (fn) => {
      mountedCallbacks.push(fn);
    };

    const onUpdated = (fn) => {
      updatedCallbacks.push(fn);
    };

    const onUnmount = (fn) => {
      unmountCallbacks.push(fn);
    };

    // 스토어 구독 헬퍼 - subscribe만 처리
    const useStore = (store) => {
      const unsubscribe = store.subscribe(() => {
        render();
      });

      mountedCleanups.push(unsubscribe);
    };

    const render = () => {
      root.innerHTML = view(state);

      // 최초 렌더링일 경우 실행
      if (!isMounted) {
        console.log(`✅ onMounted: ${options?.name || "component"}`);
        mountedCallbacks.forEach((fn) => fn && fn());
        isMounted = true;
      }

      updatedCallbacks.forEach((fn) => {
        fn();
      });
    };

    setup({ root, props, getState, setState, template, onBeforeMount, onMounted, onUnmount, onUpdated, on, useStore });

    beforeMountCallbacks.forEach((fn) => {
      fn();
    });

    const unmount = () => {
      console.log(`🧨 unmount: ${options?.name || "component"}`);
      unmountCallbacks.forEach((fn) => fn());
      mountedCleanups.forEach((fn) => fn());

      root.replaceChildren();

      unmountCallbacks.length = 0;
      mountedCleanups.length = 0;
    };

    return {
      getState,
      setState,
      unmount,
      render,
    };
  };
};

컴포넌트의 언마운트는... 우선 라우터에서 페이지가 바뀔 때는 언마운트를 시킨다.
이외의 경우에는 수동으로 언마운트를 해주어야 한다.

const App = createComponent(({ template, onMounted, onUnmount }) => {
  template(() => /*html*/ `<div id="main-layout"></div>`);

  let mainLayout = null;
  onMounted(() => {
    mainLayout = MainLayout({ root: document.getElementById("main-layout"), options: { name: "mainLayout" } });
    mainLayout.render();
  });

  onUnmount(() => {
    mainLayout?.unmount();
  });
});

이런 식으로 컴포넌트를 쌓으려 했는데, 부모 컴포넌트 안에 여러 개의 자식 컴포넌트가 있을 때는 어떻게 처리해야 할지 고민이 되었다. 그럼 자식 컴포넌트의 인스턴스를 부모가 다 가지고 있어야 하고 상태도 복원해야 하겠고 모조리 다 리렌더링 되어서 성능도 별로일 것 같다.

그정도까진 고민하지 않아도 되겠지? 하는 마음을 가지고 있었는데 다음주의 과제가 그런 쪽의 알고리즘 구현이라고 하니... 다음주에 더 깊게 고민하기로 했다.

  1. 라우터 구조 변경
    돌아가게는 만들어 놓고 Q&A 세션에서 라우터에 대한 부분을 보며 아차 하는 생각이 들었다. 또한 이벤트 버스도 전역적이라는 것에서 본질은 똑같았다. 어차피 정리도 해야 했으므로 기존 내용을 옵저버 패턴으로 변경하였다. 그랬더니 전체적으로 여기저기 수정해야 했고 리팩토링 하면서 e2e테스트가 여러 번 깨졌다.

milmilkim@2415438#diff-3210ca9ed615e6f0fc1743fc09be3c86bc38178dcc050e5a41ff7982d15875b7
새 페이지를 렌더링 할 때 이전 페이지를 언마운트 하는 게 누락되어서 페이지가 언마운트 되지 않고 이벤트 리스너를 삭제하지 않아 여러 이벤트가 중첩되는 문제가 있어서 수정하였다.
그리고 상품 목록도 쿼리 스트링 기반으로 모두 처리하게 변경했다.

기술적 성장

설계의 중요성을 느끼게 되었다. 그리고 AI를 이전보다 좀 더 잘 다루게 된 것 같다.

자랑하고 싶은 코드

클로저 기반의 라이프 사이클을 가지고 있는 컴포넌트의 구현을 시도했다. 고민 자체에 의의가 있다는 생각이 든다.
전체적으로 함수형 프로그래밍을 유지해보려는 노력을 했다.
컴포넌트 내에 헬퍼 함수를 통해 이벤트를 추가하면 자동으로 컴포넌트가 언마운트 되는 시점에 해제되도록 했다.

개선이 필요하다고 생각하는 코드

부모가 여러 자식 컴포넌트를 가지고 있는 구조는 구현하지 못했다. 렌더링시 innerHTML의 모든 내용을 교체하는 방식은 성능에 한계가 있다.
전체를 렌더링 하기 때문에 input의 포커스가 날아가거나 모바일에서 이미지가 깜빡이는 등의 문제가 있었다. 라이프사이클은 뷰를 모방했지만 불안정하다. 수동적으로 언마운트를 해야 한다. 전역 스토어에서 바뀐 것만 부분적으로 구독하는 식으로 최적화를 하지 못했다.

학습 효과 분석

이전에 클로저를 사용 자체는 많이 해보았지만 좀 더 깊이 있게 이해하게 되었다. 옵저버와 같은 디자인 패턴은 사실 전혀 몰랐던 부분이어서 새로운 걸 알게 되어 좋았다. 막연하게 생각하던 생명주기, 훅, 클린업 함수에 대해서도 어떤 맥락인지 이해하게 된 것 같다. (어쩐지 리액트보다는 뷰에 대한 이해가 더 잘 된 것 같긴하다.)

리액트를 사용하면 선언적 프로그래밍을 지향하게 되는데... 그런데 정말 선언적이라는 게 뭔가? 하는 생각이 있었는데 명령형 방식과 비교해보고 멘토링 시간에 설명을 듣다 보니 이제 좀 알 것 같은 기분이다.

과제 피드백

몇가지 예시는 있지만 어떤 방향과 목적을 가지고 작업해야 하는지는 스스로 찾아가야 한다는 점이 재미있었다. 높은 자유도에 처음에는 막막했지만 코어 근육을 단련한 것 같다.

AI 활용 경험 공유하기

  • 설계에 대한 질문 하기
    render(), update() 등의 생명 주기를 가지고 있는 컴포넌트를 구현할 수 있는 아이디어에 대해 요청한 후 코드를 살펴본다. setup 함수를 가지고 있는 클로저로 컴포넌트를 만들고 되도록 선언적으로 만들고 싶다고 했다. 마음에 드는 결과가 나올 때까지 초기화하고 질문을 바꿔본다. 이번 과제는 AI가 프로젝트의 콘텍스트를 다 알 필요는 없다고 생각하고 토큰도 없어서 컴포넌트에 대한 부분은 chatGPT에 거의 맡겼다.

  • 단순 작업을 위임하기
    어느 정도 설계가 갖춰진 후에는 누가 해도 결과가 비슷할 단순한 장바구니 같은 작업은 cursor에 맡긴 후 검수하고 버그를 수정한다. 라우터의 이런 저런 예외 케이스나 정규식 같은 부분도 AI에게 구현을 맡긴다.

  • 리팩토링 하기
    라우터나 컴포넌트의 설계를 변경하였을 때 연동된 기능들에 대한 리팩토링을 AI에게 시킨 후 변경 내역을 확인한다. 분석하고 다시 내가 마음에 드는 방향으로 정리한다. 이 부분은 커서와, 저번 과제 하느라 결제한 클로드 코드를 활용했다.

리뷰 받고 싶은 내용

  1. render() 함수에서 isMounted 체크를 통해 최초 렌더링과 업데이트를 구분하는 방식이 적절할까요?
  2. useStore 훅에서 구독만 하고 상태를 직접 읽지 않는 구조입니다. 제공하는 훅이 값까지 함께 리턴하는 형태가 좋은지, 아니면 지금처럼 구독만 하고 UI에서 getState로 직접 읽는 패턴도 괜찮은지 궁금합니다.
  3. 인풋 이벤트나 체인지 이벤트가 발생할 때마다 렌더링을 시키면 인풋에서 포커스 아웃이 되기 때문에 uncontrolled 방식을 사용하였습니다. 이 경우 인풋의 값을 변경해놓고 다른 필터를 변경하여 다른 필터 값 변경 시 인풋 값이 날아가는 문제가 있는데 개선 방법이 있을까요?
  4. 컴포넌트 안에 컴포넌트가 여러 개 존재하는데 마운트/언마운트를 제어하는 방법은 무엇일까요? 전역적으로 컴포넌트를 등록하고 관리해야 할까요?

@milmilkim milmilkim changed the title 과제제출용 [6팀 김소리] Chapter2-1. 프레임워크 없이 SPA 만들기 Nov 8, 2025
@ckdwns9121
Copy link
Member

쩐다... 사운드킴 1빠따

Copy link
Contributor

@JunilHwang JunilHwang left a 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에서 전체적으로 SPA 구조를 직접 구현하여 라우터, 컴포넌트, 상태관리, 그리고 UI 컴포넌트들을 잘 분리하고 구성한 점이 인상적입니다. createComponent를 통한 라이프사이클 관리, 옵저버 패턴 기반 이벤트버스, 그리고 상태 저장 및 복원 기능이 실현되어 점진적인 기능 확장에 좋은 기반을 마련하였습니다.

하지만 확장성 및 유지보수 관점에서 다음과 같은 개선 사항이 있습니다.

1. 컴포넌트 라이프사이클 개선

  • render() 내부의 isMounted 플래그만으로 구분하는 현재 구조는 단순하지만, 향후 복잡한 상태 변화와 최적화 요구를 처리하기 어렵습니다.
  • 이에 shouldUpdate 등 조건부 렌더링 로직과 더 상세한 라이프사이클 훅 분리를 추천합니다.

2. 상태관리 및 구독패턴

  • useStore 훅에서 상태값 반환을 하지 않고 구독만 하는 패턴은 명확하지만, 상태 접근 편의성 면에서는 조금 더 일원화된 API가 유리할 수 있습니다.
  • 섞이지 않도록 설계하되, 유연한 API 확장을 고려할 수 있습니다.

3. 입력 필드 동기화 문제

  • 검색 인풋이 uncontrolled 요소로 설정되어, 상태 변동 시 입력값이 초기화되는 UX 이슈가 있습니다.
  • controlled 컴포넌트 패턴으로 전환하거나 두 상태 간 동기화를 명확히 하여 문제를 완화할 수 있습니다.

4. 컴포넌트 중첩 마운트/언마운트 관리

  • 자식 컴포넌트들을 별도로 직접 생성 후 관리하는 구조로, 대규모 앱에서 여러 컴포넌트 트리 관리가 어렵습니다.
  • 전역 혹은 상위 컴포넌트에서 상태 기반으로 중첩 컴포넌트들을 제어하는 방식 추천합니다.

5. 기타 가독성 및 안정성

  • Rating 컴포넌트의 입력 타입 검증 및 최대 별점 상수화
  • 라우터 이벤트 중복 등록 방지
  • URL 쿼리 파라미터 명명 일관성 유지
  • 이벤트 핸들러 내부 책임 분리 및 중복 제거
  • 데이터 타입 및 예외처리 강화

현재 컴포넌트 구조 예시

App
└─ MainLayout
   ├─ Header
   ├─ CartModal
   └─ <router-view>
       ├─ HomePage
       └─ DetailPage

각 컴포넌트는 createComponent를 통해 상태, 이벤트, 렌더링, 언마운트를 관리하며, eventBuscartStore 등 전역 상태관리 및 이벤트를 활용합니다.

이 구조는 SPA에서 비교적 경량형 프레임워크를 직접 구현한 예로, 앞으로 기능 증가시 라이프사이클 및 상태관리의 확장성과 디커플링을 고려하는 방향이 좋겠습니다.추가질문에 대한 답변을 드립니다:

  1. render() 함수에서 isMounted 체크를 통해 최초 렌더링과 업데이트를 구분하는 방식에 대해

    • 이 방식은 초기 간단한 컴포넌트에서는 충분히 효과적이고 직관적입니다.

    • 하지만 복잡한 컴포넌트 상태나 조건부 렌더링 로직이 늘어나면, 모든 렌더 호출 시 onUpdated()를 무조건 실행하는 것이 비효율적이고 불필요한 작업을 만들 수 있습니다.

    • 개선하려면 shouldUpdate 함수 같은 조건 체크를 도입해 실제 상태 변화가 있을 때만 렌더 업데이트와 onUpdated 호출을 하도록 하는 게 좋습니다.

    • 또한 onBeforeUpdate, onAfterUpdate 식의 세분화된 라이프사이클 훅 추가도 검토해보세요.

  2. useStore 훅에서 구독만 하고 상태를 직접 읽지 않는 구조에 대해

    • 현재 구독만 하고 상태는 외부 getState()로 직접 읽는 패턴도 충분히 합리적입니다. 이 분리는 구독과 데이터 읽기를 명확히 구분하는 장점이 있습니다.

    • 다만, React와 유사한 환경에서는 상태와 구독을 동시에 제공하는 useStore 형태가 개발자 경험이 좋고 코드가 간결해 집니다.

    • 두 방식 모두 장단점이 있으니, 팀 내 컨벤션이나 용도에 맞게 결정하면 됩니다.

  3. input 이벤트나 change 이벤트 시 렌더링으로 인한 입력값 초기화 문제 개선 방안

    • Controlled 컴포넌트 형태로 전환하면 걱정 없는 값 동기화가 가능합니다. 즉, 모든 입력값을 상태로 관리하고 변경 시 setState를 통해 반영합니다.

    • 만약 렌더링 비용 때문에 uncontrolled 방식을 유지한다면, 입력 필드가 포커스 중인 때는 상태 업데이트를 제한하거나 별도의 임시 상태 변수로 입력 중 데이터를 유지하는 방법을 사용할 수 있습니다.

    • 또는 requestAnimationFramedebounce를 사용해 너무 잦은 렌더 호출을 제어하는 것도 도움이 됩니다.

  4. 컴포넌트 내부 다수 컴포넌트 마운트/언마운트 제어법

    • 일반적으로 상위 컴포넌트에서 자식 컴포넌트 인스턴스를 생성하고, 필요 시 마운트(render()) 및 언마운트(unmount())를 호출 관리하는 방식이 보편적입니다.

    • 전역적으로 모든 컴포넌트를 등록하고 관리하는 방법은 오버헤드가 크므로, 보통은 컴포넌트 트리 구조에 따라 계층적으로 관리합니다.

    • 상태 변화에 따라 조건부 렌더링을 하고, 각 컴포넌트가 자신의 라이프사이클을 책임지도록 구조화하는 것이 유지보수에 유리합니다.

추가 문의 있다면 언제든 편하게 질문해 주세요!

// 스토어 구독 헬퍼 - subscribe만 처리
const useStore = (store) => {
const unsubscribe = store.subscribe(() => {
render();
Copy link
Contributor

Choose a reason for hiding this comment

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

1. 문제상황 제시

구체적인 문제 상황:
createComponent 함수 내 render() 함수가 isMounted 플래그를 사용해 최초 마운트와 업데이트를 구분하여 각각 onMountedonUpdated 라이프사이클 훅을 호출합니다. 이 방식은 간단하지만 확장성 측면에서 한계가 있습니다.

현재 코드의 한계:

  • onUpdated 훅이 매 렌더마다 무조건 호출되어 내부에서 상태 변화 확인을 중복 수행할 수 있음
  • 추가적인 라이프사이클(예: shouldUpdate 같은 최적화)이 없어서 리렌더 트리거를 세밀하게 조절하기 어렵다
  • 마운트 후 상태 유지를 위한 플래그가 컴포넌트 내부에 숨겨져 있어 외부 제어가 불가능함

2. 근본 원인

핵심 문제:
render() 함수가 내부 상태 플래그(isMounted)로 최초 렌더와 업데이트를 구분하여 수동으로 라이프사이클을 호출한다.

왜 문제인가:
이 방식은 단순하지만 복잡한 컴포넌트 상태 관리 및 최적화를 어렵게 하며, 확장성/유지보수 측면에서 제약이 존재합니다.

3. 개선 구조

현재 구조:

render() {
  root.innerHTML = view(state);
  if (!isMounted) {
    onMounted.forEach(fn => fn());
    isMounted = true;
  }
  onUpdated.forEach(fn => fn());
}

개선된 구조:

render() {
  const shouldUpdate = /* 커스텀 조건으로 결정 */;
  if (!isMounted) {
    root.innerHTML = view(state);
    onMounted.forEach(fn => fn());
    isMounted = true;
  } else if (shouldUpdate) {
    root.innerHTML = view(state);
    onUpdated.forEach(fn => fn());
  }
}

개선 사항:

  • shouldUpdate 조건을 만들어 불필요한 렌더링을 방지한다
  • 라이프사이클 훅을 좀 더 세분화(예: beforeUpdate, afterUpdate)한다
  • 플래그를 외부에서 제어하거나 메타데이터로 확장 가능하도록 설계

console.log(`🧨 unmount: ${options?.name || "component"}`);
unmountCallbacks.forEach((fn) => fn());
mountedCleanups.forEach((fn) => fn());

Copy link
Contributor

Choose a reason for hiding this comment

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

2. useStore 훅 사용과 상태 관리 패턴 관련

구체적인 문제 상황:
useStore 훅은 컴포넌트 내에서 스토어 구독만 수행하며, 실제 상태 데이터 읽기는 컴포넌트 내부 getState() 호출로 하는 패턴입니다. 이는 구독과 상태 접근이 분리되어 있지만, 일부에서는 한번에 반환하는 방식을 더 선호합니다.

현재 코드의 한계:

  • 상태와 구독이 분리되어 있어 호출하는 쪽에서 상태 일관성을 직접 관리해야 함
  • useStore가 상태 반환하지 않으면 상태 구조 파악이 컴포넌트마다 개별적임

근본 원인

핵심 문제:
구독과 상태 제공을 분리하여 상태 접근에 일관성이 떨어지고, 컴포넌트 코드가 분산 관리됨

왜 문제인가:
상태 사용처에서 상태 변경을 자동 감지하지만, 실제 상태 접근 방법이 일정하지 않아 가독성과 유지보수성이 다소 낮아짐

개선 구조

현재 구조:

useStore(store); // 구독
const state = getState(); // 실제 상태 접근

개선된 구조:

const [state, unsubscribe] = useStore(store);
// 상태와 구독 해제 함수를 같이 받음

또는

const state = useStore(store);
// 내부적으로 구독과 상태 수신을 포함

개선 사항:

  • useStore에서 상태값까지 포함하여 전달하도록 개선
  • 자동 구독 해제 및 상태 관리 일원화
  • React 등 주요 프레임워크 관례와 유사하여 학습 및 유지보수 좋음

});

// 최초 1번만 - DOM 이벤트 위임
onBeforeMount(() => {
Copy link
Contributor

Choose a reason for hiding this comment

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

3. 입력 필드 값 날아가는 문제와 상태 동기화 개선

구체적인 문제 상황:
#search-input 인풋 값을 uncontrolled 방식으로 관리하여, 필터 변경 시 컴포넌트 리렌더링으로 입력 값이 초기화되어 UX 불편이 발생합니다.

현재 코드의 한계:

  • 인풋 값과 필터 상태가 완전히 분리되어, 리렌더링 시 상태 반영이 이뤄지지 않는다.
  • 사용자 입력을 잃지 않기 위해 상태 동기화가 필요하지만, 현재는 렌더 시 고정값으로 덮어씌운다.

근본 원인

핵심 문제:
컴포넌트 상태와 인풋 값의 양방향 바인딩 부재로 상태 변경 시 입력 필드가 초기화된다.

왜 문제인가:
사용자 입력과 상태가 일치하지 않아 입력 도중 값이 날아가고 이로 인해 UX가 떨어진다.

개선 구조

현재 구조:

<input id="search-input" value="" />
// 인풋 이벤트에서 상태를 업데이트하지 않고 사용자가 직접 입력함

개선된 구조:

const [searchValue, setSearchValue] = useState("");
return `<input id="search-input" value="${searchValue}" />`;

// input 이벤트에서
const onInput = (e) => {
  setSearchValue(e.target.value);
};

또는

  • 컴포넌트 상태에 searchValue 저장 및 업데이트
  • 렌더 시 상태로 인해 인풋 값이 유지됨
  • React, Vue 등 프레임워크의 controlled component 개념과 유사

header.render();

const cartModal = CartModal({
root: document.getElementById("cart-modal-container"),
Copy link
Contributor

Choose a reason for hiding this comment

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

4. 컴포넌트 라이프사이클 관리 및 중첩 컴포넌트 마운트/언마운트

구체적인 문제 상황:
MainLayout 컴포넌트가 여러 자식 컴포넌트(Header, CartModal 등)를 생성하고 렌더링하는데, 현재 개별 컴포넌트 관리가 컴포넌트 내부에서 한 번만 마운트 하게 설계되어 확장성에서 제한적입니다.

현재 코드의 한계:

  • 컴포넌트 인스턴스를 전역 및 최상위 컴포넌트에서 관리하지 않아 중첩 구조 확장에 제약이 있음
  • 자식 컴포넌트가 동적으로 변경되거나 조건부 렌더링이 어려움
  • 명시적 언마운트 호출이 분산되어 복잡성 증가

근본 원인

핵심 문제:
전역 혹은 상위에서 컴포넌트 인스턴스 관리 및 라이프사이클 제어가 통합적으로 이루어지지 않아 컴포넌트 계층 관리가 어려움

왜 문제인가:
대규모 애플리케이션에서 컴포넌트 트리 변경 및 동적 UI에 적절히 대응하기 어렵고, 유지보수가 복잡해짐

개선 구조

현재 구조:

const MainLayout = () => {
  const header = Header(...);
  header.render();
  const cartModal = CartModal(...);
  cartModal.render();
}

개선된 구조 예시:

  • 루트에서 하위 컴포넌트 트리 관리
  • 각 컴포넌트 컴포지션 및 라이프사이클 책임 분리
  • 상태변경에 따른 조건부 컴포넌트 마운트/언마운트 제어

예:

const MainLayout = () => {
  const childComponents = [];
  // 트리 구조로 관리

  return {
    mount() {
      childComponents.forEach(c => c.render());
    },
    unmount() {
      childComponents.forEach(c => c.unmount());
    }
  };
}
  • 또는 전역 컴포넌트 등록 및 관리 함수 도입
  • 상태 기반 UI 표현 및 선언적 렌더링 도입 고려

return /*html*/ `
<div class="flex items-center">
${filledStar.repeat(rating)}
${emptyStar.repeat(5 - rating)}
Copy link
Contributor

Choose a reason for hiding this comment

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

5. 별점 렌더링 숫자 값 타입 체크와 범위 안전성

별점 렌더링 시 rating.repeat(rating) 구문을 사용합니다. 그러나 rating이 숫자가 아닐 경우 에러가 발생할 수 있으며, 0~5 사이 값 제한도 필요합니다.

  • repeat 함수 인자는 문자열이지만 숫자 타입으로 통상맞음, 안전하게 숫자 변환 체크 필요
  • 최대 별 개수 5를 넘지 않도록 내부 검증 권장

예:

const validRating = Math.min(5, Math.max(0, Math.floor(Number(rating)) || 0));
return `${filledStar.repeat(validRating)}${emptyStar.repeat(5 - validRating)}`;

이렇게 하면 예상치 못한 타입 이슈 방지 및 견고한 UI로 개선할 수 있습니다.

// 경로 변경 감지 훅 (쿼리스트링 변경은 무시)
export const useRouteChange = (callback) => {
const router = useRouter();
if (!router) return () => {};
Copy link
Contributor

Choose a reason for hiding this comment

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

6. 이벤트 중복 등록 가능성 제거를 위한 개선 필요

document.addEventListener를 통한 전역 이벤트 등록 시, 라우터 초기화가 여러 번 호출되면 이벤트가 중복 등록될 수 있습니다.

  • 현재 initRouter에서 popstate, urlchange 이벤트에 직접 핸들러 등록 부분이 중복 호출 위험이 있음
  • 중복 방지를 위해 플래그를 사용하거나 외부에서 한번만 호출 보장 필요

예:

let initialized = false;
export const initRouter = () => {
  if (initialized) return;
  initialized = true;
  // 기존 초기화 코드...
}

이 방법으로 이벤트 중복 실행에 따른 예기치 않은 버그 예방이 가능합니다.

showToast("상품 정보를 찾을 수 없습니다", "error");
}
};

Copy link
Contributor

Choose a reason for hiding this comment

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

7. URL 쿼리 파라미터 current 사용 혼란 및 통일성 문제

현재 쿼리 파라미터에서 페이지 번호를 나타내는 쿼리명이 current로 정의되어 있습니다. 하지만 일반적으로 page 혹은 pageNumber와 같은 명칭을 많이 사용합니다.

  • 사용자/개발자 입장에서 이해하기 쉬운 명칭 선택 권장
  • HomePage 컴포넌트 내 필터 상태 page 명칭과도 혼동 소지 있음
  • URL 파라미터와 내부 상태의 명칭 일치화 및 주석 추가 필요

예시 개선:

// URL에서
? page=2 & ...
// 내부 필터 객체도 page 사용

또한, 쿼리 파라미터에서 삭제/설정 시 로직이 다소 반복적이므로 유틸 함수로 추출해 중복 제거 권장합니다.

d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
</div>
</div>
Copy link
Contributor

Choose a reason for hiding this comment

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

8. 카테고리 데이터 타입 가정과 안정성 향상

getCategoryTree 함수가 카테고리 데이터가 배열 혹은 객체인 두 가지 타입으로 오는 경우를 핸들링합니다.

  • 다만, category.children이 배열이 아닐 경우 에러 발생 위험이 있음
  • 각 단계마다 타입 체크와 기본값 할당 등 안정성 강화 필요

예:

if (!Array.isArray(category.children)) {
  return { name: category.name, children: [] };
}
  • 데이터 소스 변화에 대비해 견고한 방어적 코딩이 권장됩니다.

document.addEventListener("keydown", handleEscape);

// 모달 닫기 핸들러
const handleClose = (e) => {
Copy link
Contributor

Choose a reason for hiding this comment

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

9. 상태 동기화 및 이벤트 핸들러 최적화

장바구니 모달 내에서 여러 이벤트 핸들러(click, input 등)를 체계적으로 관리하고 있지만, 동일한 이벤트 내에서 여러 요소를 처리하는 부분이 있어 가독성과 유지보수성이 떨어집니다.

  • 이벤트 위임 시 이벤트 종류별로 핸들러 함수 분리하고, 명확한 책임 분리가 필요
  • 상태 변경 시점과 저장 로직이 중복될 수 있으므로 핸들러에서 상태 업데이트 일원화 권장
  • 예외 케이스나 입력값 검증 로직 추가 시 코드 비대화 방지

예시:

const handleCheckboxChange = (e) => {
  const target = e.target;
  if (target.matches('.cart-item-checkbox')) {
    // 개별 아이템 선택 처리
  } else if (target.matches('#cart-modal-select-all-checkbox')) {
    // 전체 선택 처리
  }
};

이처럼 핸들러를 세분화하면서 공통 부분은 함수 분리해 재사용성과 안정성 확보 가능

`;
};

export default Rating;
Copy link
Contributor

Choose a reason for hiding this comment

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

10. magic number 하드코딩 제거 및 구성 상수화

별점 개수 및 최대값 5가 코드 내 하드코딩되어 있어 변경 시 어렵습니다.

  • 하드코딩된 숫자 5를 상수로 분리해 관리
  • 테마 및 설정 변경 시 편리함

예:

const MAX_RATING = 5;
return /*html*/`
  <div class="flex items-center">
    ${filledStar.repeat(rating)}
    ${emptyStar.repeat(MAX_RATING - rating)}
  </div>
`;

이러한 상수 분리는 유지보수와 가독성, 코드 품질에 긍정적인 영향을 미칩니다.

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.

4 participants