chan9yu 님의 상세페이지[5팀 여찬규] Chapter 1-1. 프레임워크 없이 SPA 만들기

과제 체크포인트

배포 링크

https://chan9yu.github.io/front_6th_chapter1-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로 다시 구현한다.
  • 이 과정에서 직접 가공하는 것은 최대한 지양한다.

과제 셀프회고

기술적 성장

퓨어 JavaScript에서 클래스를 효과적으로 활용하는 방법에 대해 생각하고 사용해보면서 다양한 개념들을 학습할 수 있었습니다 특히 this 바인딩 문제는 개발 과정에서 크고 작은 이슈들을 많이 경험하게 해주었는데, 초기에 router 인스턴스 내부의 navigate 메소드만을 페이지 컴포넌트에 주입해서 사용하려다 보니 this 바인딩이 끊어지는 문제가 발생했습니다

이를 해결하기 위해 여러 가지 방안들을 찾고 적용해보았으며, 해당 경험과 해결책을 정리해서 팀원들과도 공유하고 관련해서 이야기도 할 수 있었어요 이러한 기본적인 JavaScript 문제를 직접 마주하고 해결하는 과정을 통해 자바스크립트에 대한 이해도가 크게 향상되었고, 이론으로만 알고 있던 개념들을 실제로 경험하니 훨씬 깊이 있게 이해할 수 있었습니다

(그때 당시 제가 공유했던 노션페이지 입니다!) https://www.notion.so/teamsparta/JavaScript-this-22a2dc3ef51480dca56fe47f1a12aa24?source=copy_link

자랑하고 싶은 코드

Router 클래스에서 해당 패스에 대응하는 등록된 컴포넌트를 렌더링하는 로직을 구현했습니다. 외부에서는 router 인스턴스의 register 메소드를 통해 라우터를 설정할 수 있는데, 이때 인자로 componentConstructor를 주입받도록 설계했습니다. componentConstructor는 추상 클래스인 Component를 상속받는 페이지 컴포넌트의 생성자 함수이며, 실제 컴포넌트 인스턴스 생성 시점에 라우터의 인스턴스를 props로 주입하여 유연하게 사용할 수 있도록 구성했습니다.

처음에는 외부에서 인스턴스를 미리 생성해서 전달받는 구조로 되어있었는데, 이 방식으로는 실제로 접근하지 않는 페이지의 컴포넌트 인스턴스까지 불필요하게 생성되는 문제가 있었습니다. 여러 번의 수정을 거쳐 나름 최적화한 코드입니다 ㅎㅎ

  #createComponent(componentConstructor) {
    try {
      // 컴포넌트 인스턴스 생성 시 라우터 의존성 주입
      const component = new componentConstructor({ router: this });

      if (!(component instanceof Component)) {
        throw new Error("컴포넌트는 추상클래스 Component를 상속해야 합니다!!");
      }

      return component;
    } catch (error) {
      if (error instanceof Error) {
        console.error("컴포넌트 인스턴스 생성 실패:", error.message);
        throw error;
      }
    }
  }

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

이 서비스의 가장 핵심적인 추상 클래스인 Component 라는 게 있는데 많이 아쉬운 코드라고 느껴집니다.

제가 의도한 대로 70~80% 정도는 잘 구현되었지만? 개선이 필요한 부분들은 많이 남아있는거 같아요... 특히 가장 큰 문제라고 생각하는 부분은 자식 컴포넌트의 렌더링입니다. 일단 구현은 해두었지만, 반복문을 돌려 몇십 개의 동일한 자식 컴포넌트를 렌더링하거나 props를 전달하는 부분에서 이슈가 많아서 기술 부채로 남겨져 있습니다 그래서 현재는 페이지를 제외한 나머지 컴포넌트들은 그냥 함수로 만들어서 HTML string을 리턴하는 방식으로 구현되어 있어요

또한 생각보다 직관적인 코드가 아닌 것 같다는 점도 아쉽습니다. 컨슈머 입장에서 사용했을 때 좀 더 간결하고 직관적인 API를 제공하고 싶은데, 현재는 사용법이 어렵다는 느낌이 듭니다. 저는 복잡하고 이것저것 많이 사용하는 코드보다는 누구나 이해하기 쉬운 코드를 선호하기 때문에, 제 코드를 받아 개발하는 컨슈머 입장에서 생각했을 때는 만족스럽지 않을 것 같다는 생각이 들어요. 이 부분은 좀 더 깊이 고민해보고 싶은 영역입니다!

학습 효과 분석

가장 큰 학습 효과가 있었던 부분은 테스트 영역이었습니다.

현재 회사에서는 테스트를 사용할 기회가 없었고, 이런 환경 자체가 저에게는 처음이었어요. 이론적으로만 알고 있었던 테스트를 실제 시나리오에 따라 통과시키기 위해 개발하다 보니, 훨씬 목적 있게 개발할 수 있었던 것 같습니다.

회사 제품 특성상 요구사항이 빠르게 변경되기 때문에 실무에서 사용하기는 다소 어려울 것 같다는 생각도 들지만, 테스트라는 것 자체가 어떻게 보면 제품의 히스토리와 경험을 쌓는 과정이기 때문에 시도해볼 만하다고 생각합니다. 빠르게 변경되는 요구사항이 문제라면 런타임이나 완전한 서비스 비즈니스 로직을 테스트하는 것이 아닌, 코어 레벨의 코드만 테스트해봐도 충분히 의미가 있을 것 같아요.

과제 피드백

전반적으로 굉장히 만족스러운 과제였습니다. 주제가 너무 재미있었고 실제로 즐겁게 진행할 수 있었어요.

다만 조금 불편했던 점을 꼽자면, 제공된 HTML 템플릿이 보기 어려운 느낌이 들었습니다. 물론 이렇게 제공해주시는 것이 최선의 방법일 수도 있다고 생각하지만, DOM 구조가 변경되면 테스트가 실패하는 환경이었기 때문에 개발하면서 관련코드들을 찾는데 조금 힘들었던 것 같습니다.

그럼에도 불구하고 굉장히 재미있고 많은 것을 얻어갈 수 있었던 과제였던거 같아요!

AI 활용 경험 공유하기

Claude를 주로 사용했는데, 설계 단계에서 어떤 것을 구현하는 게 좋을지부터 시작해서 제 아이디어를 마구 던지면서 AI와 함께 설계를 했던 경험이 있었습니다. 당연히 처음에는 잘 안됐지만, 제 의도와 AI의 힘을 합치니 꽤나 만족스러운 코드 설계를 할 수 있었어요. 이 과정에서 몰랐던 지식들도 자연스럽게 얻어가고 생각하는 힘이 좀 늘어난 것 같습니다.

AI를 설계 레벨에서 활용하다 보니 그냥 저와 비슷한 한 명과 계속 토론했던 느낌이 났었네요 ㅎㅎㅎㅎ

리뷰 받고 싶은 내용

컴포넌트에서 이벤트 위임을 통해 이벤트를 정의하고 있는데, 현재 bindEvents 메서드 안에서 클릭, 변경, 키 입력 등 모든 이벤트 처리 로직이 하나의 메서드에 뒤섞여 있어서 복잡하게 구현된 것 같습니다. 특히 클릭 이벤트 내에서 라우팅, 모달 제어, 장바구니 추가, 수량 조절 등 서로 다른 기능들이 if문과 switch문으로 얽혀있어 코드가 복잡해 보입니다.

DOM 이벤트 관련 로직만 따로 분리해서 EventManager 같은 별도 클래스로 관리하는 방법도 고려해봤지만, 오히려 코드가 더 복잡해지고 직관성이 떨어질 것 같다? 라는 생각이 들어서요 (시간문제로 시도는 안해봤지만..) 또한 향후 새로운 이벤트 처리 요구사항이 추가되면서 코드량이 늘어날 때 현재 구조로는 가독성과 유지보수성이 크게 떨어질 것 같아서 어떤 방향으로 리팩토링해야 할지 고민되는 상황입니다.

export class ProductListPage extends Component {
  // ...

  bindEvents(element) {
    element.addEventListener("click", (e) => {
      const targetElement = e.target.closest("[data-route]");
      if (targetElement) {
        const route = targetElement.dataset.route;
        this.props.router.navigate(route);
        return;
      }

      if (e.target.classList.contains("cart-modal-overlay")) {
        this.#handleCloseCartModal();
        return;
      }

      if (e.target.classList.contains("add-to-cart-btn")) {
        const productId = e.target.dataset.productId;
        const product = this.state.products.find((item) => item.productId === productId);

        cartService.addItem({
          id: productId,
          image: product.image,
          price: product.lprice,
          selected: false,
          title: product.title,
        });

        this.setState({
          cartItemCount: cartService.itemCount,
          cartItems: cartService.items,
        });

        this.#showToast("장바구니에 추가되었습니다", "success");

        return;
      }

      if (
        e.target.classList.contains("quantity-decrease-btn") ||
        e.target.classList.contains("quantity-increase-btn")
      ) {
        const targetElement = e.target.closest("[data-product-id]");
        if (!targetElement) return;

        const productId = targetElement.dataset.productId;
        const isIncrease = e.target.classList.contains("quantity-increase-btn");

        // 수량 업데이트
        isIncrease ? cartService.increaseQuantity(productId) : cartService.decreaseQuantity(productId);

        const input = document.querySelector(`.quantity-input[data-product-id="${productId}"]`);
        if (!input) return;

        const current = Number(input.value);
        const delta = isIncrease ? 1 : -1;
        const next = current + delta;

        const min = Number(input.min) || 1;
        const max = Number(input.max) || Infinity;
        input.value = Math.max(min, Math.min(max, next));

        // this.setState({ totalPrice: cartService.totalPrice });

        return;
      }

      switch (e.target.id) {
        case "cart-icon-btn":
          this.setState({ isOpenCartModal: true });
          break;
        case "cart-modal-close-btn":
          this.#handleCloseCartModal();
          break;
      }
    });

    element.addEventListener("change", (e) => {
      switch (e.target.id) {
        case "limit-select":
          this.#handleLimitChange(e.target.value);
          break;
        case "sort-select":
          this.#handleSortChange(e.target.value);
          break;
      }
    });

    element.addEventListener("keypress", (e) => {
      switch (e.target.id) {
        case "search-input":
          if (e.key === "Enter") {
            const searchTerm = e.target.value.trim();
            this.#handleSearchChange(searchTerm);
          }
          break;
      }
    });

    document.addEventListener("keydown", (e) => {
      if (e.key === "Escape") {
        if (this.state.isOpenCartModal) {
          this.#handleCloseCartModal();
        }
      }
    });
  }

  // ...
}