Skip to content

Conversation

@Jihoon-Yoon96
Copy link

@Jihoon-Yoon96 Jihoon-Yoon96 commented Nov 11, 2025

과제 체크포인트

배포 링크

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

과제 셀프회고

이번 과제는 항해를 신청할 때부터 너무나도 기대하던 파트였다. 그래서, 7월부터 계획돼있던 여행일정이 껴있었지만 여행 전 밤샘 작업, 고속도로에서 코딩, 친구와 하루 종일 술을 먹고도 야밤에 과제를 진행하는 등 내가 생각해도 살인적인 스케줄을 스스로 계획하고 수행해나갔다. 과정은 진짜 너무나도 힘들고 괴로웠지만, 그만큼 가장 큰 기울기의 성장세를 느끼게 해준 과제라 생각한다.

압도적인 과제 양에 절망하면서도 "잘해보고 싶다." 라는 기대감을 안고 과제를 시작했다. 그래서 초반에 구조 설계에 엄청 많은 시간을 쏟아냈다. SPA에 대해 공부를 하면서 어떤 구조를 잡아볼지 구상하면서 "CSR이 아닌 SPA구조를 잡아보는 것"에 집중해보았다(SPA랑 CSR은 약간은 다른 관점인 것 같지만.. 초반에 왜 저렇게 생각던거지..?).

"공통 레이아웃을 잡고, 각 페이지별 섹션을 분리하여 렌더링 함수를 각각 실행시키고...", "라우터와 스토어는 전역관리 + 데이터변경 자동 감지..." 등 이러저러한 생각들을 많이 했고, 그 결과 기능 구현 단계에서 렌더링 프로세스가 일부 수정되기도 했지만 초반에 구상한 큰 틀에서 벗어나지 않았던 것 같다.
이 부분이 사실 가장 뿌듯하다 생각한다. 1-3주차 때 AI에 의존하면서 머리가 둔해지는 느낌이 들었는데, 오랜 시간이 걸렸음에도 탄탄한 구조를 잡고 구현해냈다는 사실이 뿌듯하고 회사경력이 물은 아니었구나(ㅋㅋ)하는 생각이 들었다.

지난 과제들도 정말 많이 고민하고 공부했지만, 이번 과제 만큼은 AI가 아닌 나의 생각이 정말 많이 반영돼있어 살아있음을 느꼈다. 감상은 이쯤하고, 아래에 상세 구현 내용들을 정리해봐야겠다. 정말 잘했다 느끼는 부분도 있고, 정말 아쉬운 부분도 있는데, 글로 잘 녹여낼 수 있을지 걱정이 된다.

기술적 성장

1. 애플리케이션 구조 + 렌더링 플로우 설계

결과물만 놓고는 100%의 만족도는 아니지만, 이번 주 나에게 주어진 시간을 고려한다면 정말 잘 만들었다 생각한다. 무엇보다, 구조에 대한 설명을 언제든 할 수 있다는 자신감이 있다. 아래는 내가 이번 과제의 최종 구조 및 렌더링 플로우이다.

1) main.js (MSW 활성화)
    │ 
    └──> 2) App.js (초기 렌더링 실행 및 기본 골격 생성)
            │
            └──> 3-1) /layouts (App.js의 "renderInitialLayout"에 의해 헤더/푸터/모달/토스트 렌더링) 
            │       │
            │       └──> Store 옵저버 패턴 구독 + 싱글턴 인스턴스 공유
            │               -> Header : Cart.js 구독 (장바구니 등록 현황)
            │               -> Modal : Cart.js 구독 (장바구니 모달 노출 제어)
            │               -> Toast : Toast.js 구독 (토스트 메세지 노출 제어)
            │
            │               -> /Store/product.js : 상품 목록/상세 전역 데이터 관리 + 상태 변경에 따른 비동기 API 데이터 호출 및 리렌더링
            │               -> /Store/cart.js : 장바구니 전역 데이터 관리 + 상태 변경 감지 시 localStorage에 반영 (데이터 영속성 보장) 
            │               -> /Store/toast.js : 토스트 메세지 노출 프로세스 관리
            │
            └──> 3-2) /Router/router.js (라우터에 페이지들(목록/상세/404) 등록 + 렌더링(handlePathChange)
                    │
                    └──> /pages (렌더링 시킬 html 스트링값 + onMount/unMount 콜백 리턴 / product 스토어 구독)
                            -> ProductListPage.js :
                                       └──> onMount 콜백 : 목록 페이지 이벤트리스너 + product 스토어 구독 + 무한스크롤(intersectionObserver)
                                       └──> unMount 콜백 : product 스토어 구독해제 + 무한스크롤(intersectionObserver) 해제 (이벤트리스너는 onMount단계에서 DOM을 새로 그리기 때문에 따로 해제 안했음 -> 할걸 그랬나..?)
                                       └──> product 스토어 구독 콜백 : handleStoreUpdate (product 스토어의 'product' state값을 기반으로 상품목록 화면 업데이트(스켈레톤 or productCard)
                                       
                            -> ProductDetailPage.js :
                                       └──> onMount 콜백 : 상세 페이지 이벤트리스너 + product 스토어 구독
                                       └──> unMount 콜백 : product 스토어 구독해제
                                       └──> product 스토어 구독 콜백 : render (product 스토어의 'productDetail' state값을 기반으로 상품상세 화면 업데이트(로딩중 or 상세정보 업데이트)

                            -> NoutFoundPage.js : 
                                       └──> onMount 콜백 : null
                                       └──> unMount 콜백 : null
                                

(1~3 까지의 흐름을 나름 잘 설명한 자료인 것 같다.)
한 줄 요약하자면, App.js에서 페이지별 레이아웃 생성 + 라우터 등록을 하고, 개별 페이지 컴포넌트에서 각각 필요한 이벤트리스너 + 스토어 구독을 하는 구조이다.

여기서 3-1)에서 /layouts 라는 경로를 따로 생성은 했지만, 소스를 확인해보면 사실 하나의 레이아웃 구조를 잡는 역할을 하는 것은 App.js이고,
저 /louyout 하위 소스들은 각 섹션별 렌더링 함수를 분리시켜놓았다 볼 수 있다.
초기에는 mainLayouts.js라는 공통의 레이아웃 관리용 스크립트 파일을 만들었는데,
스토어 데이터나 라우터 데이터가 변동을 감지할 때마다 mainLayouts.js 전체를 리렌더링하는 비효율적인 구조가 잡히게 되었다. 그래서, App.js에 전체 레이아웃 골격을 잡는 역할을 위임시키고, 섹션별 렌더링 함수를 구분지어 필요한 부분만 렌더링될 수 있게 수정해주었다.

2. 옵저버 패턴 + 싱글턴 패턴 (feat. SPA와 CSR, 데이터 불변성 등)

// /Store/product.js

/**
 * 옵저버 패턴 상세 내용
 *
 * 관찰 대상 (Subject): Product 클래스의 인스턴스 (최하단에 생성한 productStore)
 *      --> 싱글톤 패턴 활용 (하나의 product 스토어의 인스턴스를 모든 컴포넌트에서 공유하게 하기 위함)
 * 상태 (State): #state (Object)
 * 구독자 목록 (Observers): #observer (Set)
 * 구독 (Subscribe): subscribe() 메서드.
 * 알림 (Notify): #setState()가 호출되면 #notify() 메서드 호출.
 * */
const initialState = {...}

lass Product {
  // 캡슐화
  #state;
  #observer;

  constructor() {
    this.#state = initialState;
    this.#observer = new Set();
  }

  getState() {
    return structuredClone(this.#state);
  }

  #setState(val) {
    // TODO : 가능하다면 프록시 패턴 적용시켜보자!
    this.#state = { ...this.#state, ...val };
    this.#notify();
  }

  #notify() {
    this.#observer.forEach((callback) => callback());
  }

  subscribe(callback) {
    this.#observer.add(callback);
    return () => this.unsubscribe;
  }
  
  ...
}

/**
 * 싱긅톤 패턴 적용
 * --> 하나의 product 스토어 객체를 모든 컴포넌트에서 공유하게 하기 위함
 * */
const productStore = new Product();
export default productStore;
  

설계한 구조의 특징

Store 구조를 잡을 때 정말 많은 생각을 했다.

  • 옵저버 패턴만 적용시킬까 -> Redux, vuex같은 일반적인 스토어 구조를 따라해보는 것
  • 프록시 패턴도 적용시켜볼까 -> vue3의 Reactive같이 좀 더 유연하게 데이터 변경 가능
  • 싱글턴 패턴은? -> 컴포넌트마다 스토어의 인스턴스를 생성하면 컴포넌트마다 상태값 공유가 가능한가..?

크게는 위 세 가지 고민을 했고, 결론적으론 프록시 패턴을 제외한 옵저버+싱글턴 구조를 잡고자 했다. 가장 큰 이유는 디자인 패턴 적용 경험이 그리 많지 않기도 했고,
옵저버와 싱글턴 패턴은 SPA에서 "정말 필요한" 패턴이라 생각이 들었지만 프록시 패턴은 "굳이..?"의 영역이라 생각했다(적용 안해도 옵저버와 싱글턴 만으로도 잘 돌아갈거라 예상함).

싱글턴 패턴은 위에서의 설명처럼, 스토어의 state값들을 이곳저곳에서 공유되게 하기 위해 클래스 자체가 아닌 인스턴스를 export하게 하였다.

옵저버 패턴은 product, cart 등의 데이터들이 변화함에 따라 화면에 즉각적으로 반영시키기 위해 가장 먼저 적용시킨 패턴이다. 초반엔 낯설어서 "이렇게 하는게 맞나.." 싶었지만 걱정과는 다르게 금방 구현했던 것 같다.

설계한 구조의 문제점

image

다만, console창을 열고 애플리케이션에서 이런 저런 이벤드들을 실행시켜보면 "데이터 변화 감지!" 메세지가 수 없이 찍히는 점을 확인할 수 있다.
React처럼 setState가 batch로 돌지 않고 실행되는 만큼(notify 되는 만큼) 실행되기 때문이다. 그래서 debounce로직을 적용시킬까 하다가 일단 "동작은 하니..." 나머지 과제를 해결을 다 하면 돌아와서 해보자! 라고 결심했다. (결국 못하였지만 ㅠ)

...
#setState(val) {
    this.#state = { ...this.#state, ...val };
    this.#notify();
  }
...

그리고 setState내부 로직상, state값이 실제로 변경돼었는지 아닌지에 상관 없이 무조건 notify함수를 실행시키는데 (구독할 때 넘긴 콜백함수 실행),
이것도 컴포넌트 입장에선 불필요한 리렌더링이 강제된다 생각했다. 항해 사전스터디때 "데이터 불변성을 유지시켜 상태 변화를 감지시켜야 한다"에 매몰돼있다가 다시 구조를 분석해보면서 파악했다.
이 부분도

  • 스토어 setState 내부 로직에서 이전 state값과 값을 비교 후 조건부 notify 실행 (defineProperty를 써서 일일이 비교하면 되긴 할듯?)
  • 어차피 리렌더링의 주체는 컴포넌트이니 notify는 무조건 실행시키 돼, 상태값 변동 여부는 컴포넌트에서 진행

이런 고민을 거쳤다. 나의 최종 결정은 두 번째 안이었지만, 구현해보는 과정에서 너무도 다양한 state값들의 타입과 구조를 다 감내하기 힘들어 추후로 미뤄둬야만 했다.
(어차피 5주차 과제로 나올 것 같은 냄새가 지독하게 풍기기도 했다..)

자랑하고 싶은 코드

1. 싱글턴 패턴

// 캡슐화
#state;
#observer;

constructor() {
  this.#state = initialState;
  this.#observer = new Set();
}

...

const productStore = new Product();
export default productStore;

밑에 단 두 줄로 스토어 클래스에서 캡슐화 하여 관리 중인 상태값들을 전역으로 공유할 수 있게 한 것 같아 잘 설계한 코드라 생각한다.

2. 장바구니 데이터 영속성 유지 (Store + LocalStorage)

// /Store/cart.js

#setState(val) {
  this.#state = { ...this.#state, ...val };
  // 상태가 변경될 때마다 localStorage에 저장
  localStorage.setItem("cart", JSON.stringify(this.#state));
  // 구독자에게 변화 감지 + 리렌더링 함수 실행
  this.#notify();
}

장바구니 기능 개발을 할 때, "새로 고침"에 대한 이슈 사항은 제일 마지막에 발견해버려 구조 전체를 수정해야하나 고민을 많이 했다.
곰곰이 생각해보니, cart스토어의 상태값이 변경되고 구독자들에게 변경감지를 시키는 중간 단계에서 localStorage에 값을 저장시면, + cart 인스턴스가 생성될 때 localStorage에서 값을 받아온다면 "새로고침" 이슈가 해결 될 것 같았고, 다행히 예상대로 잘 적중했다.
(테스트 코드에서도 localStorage로 해결하고 있어서 정말 다행이라 생각했다.)

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

1. 이벤트 리스너 타겟(이라 쓰고 렌더링 범위 이슈?)

이 부분은 개선이 필요한지 의문이 드는 부분이다.
프로젝트 내에서 "addEventListener"를 검색하면 대부분 window/document에 걸려있다. 나의 의도는 "이벤트 리스너들은 윈도우나 도큐먼트에 1회만 등록시켜두면 컴포넌트들이 리렌더링되어도 리스너들은 유지될 것"이라는 생각이었다.
하지만, 의도와는 달리 이벤트 리스너들이 같이 초기화 되어서 결국 리렌더링되는 과정에서 이벤트리스너들을 새로 등록해주는 함수를 구현했다(ex. setupEventListeners)

그래서 아래와 같은 아쉬움을 남기게 되었다.

  • 어차피 리렌더링될 때 이벤트리스너를 다시 걸어줄 것이라면, 윈도우나 도큐먼트에 몰아넣는것이 아닌, 실제 이벤트가 걸릴 위치에 직접 설정해주는 방향으로 수정했어야 하지 않나..

2. 불필요한 리렌더링

위에서도 언급됐지만, Store에서 setState 매서드를 실행시키면 데이터 변화 여부와는 상관없이 무조건 구독자들에게 콜백함수를 강제하게 한다.

  • subscribe할 때 단순히 콜백함수 뿐 아니라 고유 식별자도 같이 넘기게하여, 컴포넌트에서 쓰이는 상태값이 변화했는지 아닌지를 스스로 판별하게 해주면, 모든 구독자들이 일일이 리렌더링 과정을 안거치지 않았을까?

위와 같은 생각을 작업 도중에 수없이 들었지만, 남아있는 명세 사항들이 많아 PR에만 남겨두게 되었다(아쉽..).

학습 효과 분석

  • 이론적으로만 알던 디자인 패턴들을 직접 적용시켜본 것이 가장 큰 수확인 것 같다. 이번 경험을 토대로 구조설계를 할 때 효율적인 방법들에 대한 선택지가 늘어난 것 같아 좋다.
  • 제공되는 기능들로만 구현했던 Store, Router, 라이프싸이클 등을 직접 구현해보면서 대략적인 원리를 파악할 수 있었고, 회사에서 작업 도중 의도대로 동작되지 않던 부분들도 이젠 이번에 깨우친 원리들을 바탕으로 디버깅할 수 있지 않을까 생각한다.

과제 피드백

AI 활용 경험 공유하기

AI없이 구현해보려 했지만... 컴포넌트 분리나 짜잘한 기능 오류 해결 과정에선 AI를 활용할 수 밖에 없었다.(인간적으로 과제 명세가 84개 + 테스트 코드 통과 + 깃허브 배포.. 너무.. 많아요.. ㅠ)

그 외에는 내가 생각한 설계의 방향성이 맞는 지에 대한 검토를 맡겨보았다.
한 번은, 레이아웃 구조상 전체가 리렌더링 돼버려 헤더의 장바구니 이벤트가 사라지는 현상이 이있었는데, 내가 생각한 방향성에 대해 검토해달라는 프롬프트를 날렸다.

장바구니 내부 이벤트 로직이 발생하면 장바구니 컴포넌트가 리렌더링 되는데, 
그 때마다 hidden 클래스가 붙어진 채로 리렌더링돼어서 모달창이 강제로 닫혀지는 문제가 있어.
아래는 내가 생각한 방향성인데, ProductListPage.js에 사이드 이펙트가 날 만한지 체크해줘
-->

1. `src/Store/cart.js` 수정:
       * initialState에 isCartOpen 추가 (초기값 : false)
       * toggleCartModal(isOpen) 메서드 추가 (isCartOpen 상태 변경 매서드)

   2. `src/utils/cartEventListeners.js` 수정:
       * cartModalControl 함수에서 DOM의 hidden 클래스를 직접 조작하는 대신, cartStore.toggleCartModal(true/false)를 호출하여 상태 변경

   3. `src/components/cart/CartModal.js` 수정:
       * cartStore 인스턴스에서 isCartOpen값 가져오기
       * isCartOpen 값에 따라 모달의 최상위 div에 hidden 클래스를 조건부로 추가하도록 분기처리

위와 같은 프롬프트를 날려, 내가 설계한 방향성이 맞는 지에 대한 판단을 위임하는 식으로 AI를 활용했던 것 같다. 가끔 좋다는 답변이 와도 해결이 안될 때가 있었지만, 대부분 잘 해결된걸로 보아 나름 잘 활용한게 아닐까 생각해본다.

리뷰 받고 싶은 내용

1. 옵저버 + 싱글턴 (자랑하고 싶은 코드 1번)

위에서는 "자랑하고 싶은 코드"항목에 올려두긴 했지만, 정말로 잘 구현한 것인가에 대한 의문점이 있습니다. 하나의 인스턴스를 export해서 여러 컴포넌트들이 공유하게 하는 구조가 적절했는지 피드백 부탁드립니다..!

2. 이벤트 리스너 타겟 (개선이 필요하다고 생각하는 코드 1번)

위에 적은 것 처럼, window나 document에 이벤트들이 몰려있으면 어떤 문제점이 있을지 상상이 잘 안갑니다.
대략 생각해보면, "등록된 이벤트들이 전부 실행되어 과부화(???)가 올 수 도 있다.." 인데... 어떤 문제 상황이 발생할 수 있는지 궁금합니다!

JunilHwang and others added 30 commits November 9, 2025 14:34
# Conflicts:
#	.github/pull_request_template.md
#	requirement.md
#	src/setupTests.js
#	vite.config.js
@Leehyunji0715
Copy link

우왓 정리 대박. PR보는것만으로도 공부됐어요👍

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)를 활용하여 자동으로 생성된 내용입니다.

전체 리뷰 요약

  • 전반적으로 Router/Store/Component 구조로 UI를 분리한 점과, 장바구니·토스트·상품 목록을 옵저버 기반 싱글턴으로 처리한 점은 SPA 아키텍처 측면에서 칭찬할 만합니다.
  • 다만 router의 베이스 경로 하드코딩, 상세 페이지 이벤트가 한 번만 등록되는 구조, productStore의 subscribe/queue 설계, 토스트·키보드 이벤트 등록 방식 등에서 재사용성과 확장성, 리소스 누수 문제가 발견됩니다.
  • 또한 사용자 입력(검색어/브레드크럼)을 바로 innerHTML에 넣는 부분은 보안 리스크가 있어서 반드시 escaping이 필요합니다.
  • E2E 테스트의 waitForTimeout 남발은 회귀에서 느린 피드백 루프를 만들기 때문에 상태 기반 API로 대체하는 것이 좋겠습니다.

설계 관점 피드백

  • 옵저버/싱글턴 패턴은 현재처럼 전역 상태를 관리할 때 유용하지만, 콜백 제거가 제대로 이루어지도록 subscribe/unsubscribe를 정확히 구현하고, 이벤트 핸들러가 계속 누적되지 않도록 renderCartModal/cartModalControl 등에서 초기화 로직을 명확히 분리해야 합니다.
  • 깊은 상태 변화가 자주 일어나는 상세 페이지에서는 렌더 사이클을 최소화하고, 사용자 입력(수량 등)이 렌더링마다 초기화되지 않도록 별도의 상태 관리 전략(예: local state + effect)을 도입하는 것이 좋습니다。

질문에대한 답변

1. 옵저버 + 싱글턴 구조에 대한 피드백

옵저버 패턴과 싱글턴은 공유 상태를 여러 컴포넌트에서 일관되게 유지해야 할 때 아주 괜찮은 선택입니다. cartStore, productStore, toastStore처럼 렌더링 사이클과 독립적으로 동작하는 상태 변경이 필요한 경우에는 명확하고 구조적으로 관리할 수 있어 장점이 큽니다. 다만 아래를 꼭 고려하면 더 좋습니다.

  • 구독 해제 함수가 제대로 callback을 참조하지 않으면 누적되는 옵저버가 메모리·CPU를 잡아먹습니다. subscribe() => this.unsubscribe(callback)을 반환해야 합니다.
  • 모달이나 상세 페이지처럼 라이프사이클이 짧은 컴포넌트는 store의 리렌더링/알림을 받을 때마다 이벤트를 등록·해제하는 로직을 조심해야 합니다.
  • 싱글턴을 여전히 유지하면서도 테스트를 용이하게 하려면, DI처럼 createStore({ initialState }) 형태로 만들고 프로덕션에서는 하나만 쓰고 테스트에서는 새 인스턴스를 주입할 수 있도록 하면 좋습니다。

2. document/window 이벤트 관련 우려

window/document에 이벤트를 직접 붙이면 다음과 같은 문제가 발생합니다:

  • 중복 등록: 각 렌더링마다 cartModalControl처럼 핸들러를 다시 붙이면 ESC 누를 때마다 해당 핸들러가 여러 차례 실행되며 상태가 여러 번 토글됩니다.
  • 메모리 누수: 닫힌 컴포넌트가 있더라도 핸들러가 제거되지 않으면 계속 실행되고, GC가 해제하지 않기 때문에 누수가 생깁니다.
  • 이벤트 전파 충돌: 전체 body에 뭘 붙이면 다른 모듈이나 서드파티 라이브러리와 충돌하거나, 의도치 않게 다른 페이지에서도 실행됩니다.

이런 이유로 이벤트는 필요한 순간에 한 번만 등록하고, render 당마다 새로 만들지 않거나, removeEventListener로 정리하는 전략이 안전합니다. 예를 들어 ESC 핸들러는 한 번만 바디에 등록해 두고 document.querySelector(".cart-modal")을 조회해서 현재 열린 상태만 판단하도록 만들면, 누적이나 충돌 없이 동작합니다.必要하다면 이벤트를 컴포넌트가 마운트/언마운트할 때 명시적으로 정리하는 것도 고려해 주세요.


const createRouter = () => {
const baseUrl = "/front_7th_chapter2-1";
let routes = [];
Copy link
Contributor

Choose a reason for hiding this comment

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

문제상황

router.js 안에서 baseUrl"/front_7th_chapter2-1"로 하드코딩되어 있어서, 배포 환경(예: 로컬 개발, preview build, 다른 브랜치에서의 GitHub Pages URL 등)이 이 주소와 다르면 라우터가 잘못된 경로로 탐색하고, 페이지가 깨지거나 404가 뜹니다.

현재 코드의 한계

  • /front_7th_chapter2-1 이외의 베이스 경로를 쓰는 환경에서는 router.navigate/updateQuery가 항상 틀린 URL에 pushState를 실행해서 SPA가 정상 동작하지 않습니다.
  • 테스트/배포 시마다 파일을 고쳐서 경로를 맞춰야 하므로 유지보수가 어렵습니다.
  • 확장성이 낮아서 향후 다른 경로를 가진 마이크로 프론트엔드나 포트폴리오 배포에 재사용하기 힘듭니다.

근본 원인

baseUrl을 런타임 환경에 따라 외부에서 주입하지 않고, 고정된 문자열로 고정했기 때문에 배포/로컬/프리뷰 등 환경 차이를 흡수하지 못하고 있습니다.

개선 구조

import.meta.env.BASE_URL이나 환경 변수로부터 baseUrl을 읽거나, createRouter에 인자를 전달해서 외부에서 베이스 경로를 주입하도록 바꿔야 합니다. 이렇게 하면 개발/배포 환경에 맞게 router가 자동으로 동작하고, 테스트에서도 다시 설정할 필요가 없습니다.

코드 비교

// ❌ 현재 방식
tconst baseUrl = "/front_7th_chapter2-1";
window.history.pushState({}, "", `${baseUrl}${path}`);

// ✅ 개선된 방식
const baseUrl = import.meta.env.BASE_URL || "/";
window.history.pushState({}, "", `${baseUrl}${path}`);

console.log("relatedProducts", relatedProducts);

return `
${DetailBreadcrumb(detail)}
Copy link
Contributor

Choose a reason for hiding this comment

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

문제상황

eventsInitialized 플래그 때문에 setupEventListeners는 첫 번째 상품을 렌더링한 뒤에 딱 한 번만 실행됩니다. 이후 다른 상품 상세로 넘어가도 다시 실행되지 않기 때문에, retry-fetch, 관련상품, 장바구니 등의 이벤트는 최초 상품 ID만 잡고 있게 됩니다.

현재 코드의 한계

  • 다른 상품으로 이동한 이후 retry-fetch 버튼을 눌러도 여전히 첫 번째 상품 ID로 fetchProductById를 호출합니다.
  • 관련 상품을 클릭해도 라우터에서 계속 첫 번째 상품으로 간단히, 사용자가 선택한 상품으로 바뀌지 않습니다.
  • 동일한 장바구니 버튼이 여전히 첫 번째 상품 ID만 가지고 있기에 새 상품을 담을 수 없습니다.

근본 원인

eventsInitializedtrue로 하고 나면 클로저 안의 productId는 처음 라우팅 때의 값으로 고정되고, 이후 라우트에서는 그 값을 갱신할 수 없습니다. 이벤트 자체는 한 번만 등록되므로 새 컨텍스트를 반영할 수 없습니다.

개선 구조

라우터로 다른 상품으로 이동할 때마다 setupEventListeners를 다시 실행하거나, 이벤트 핸들러 안에서 document.getElementById("product-detail-page")를 매번 조회해서 현재 data-product-id를 사용하는 식으로 수정합니다. eventsInitialized 플래그는 제거하거나, productId를 매번 최신 값으로 주입하는 방식으로 바꾸면 됩니다.

코드 비교

// ❌ 현재 방식
afunction setupEventListeners(productId) {
  if (eventsInitialized) return;
  document.body.addEventListener("click", (e) => {
    // productId는 최초 값
  });
  eventsInitialized = true;
}

// ✅ 개선된 방식
afunction setupEventListeners() {
  document.body.addEventListener("click", (e) => {
    const page = document.getElementById("product-detail-page");
    if (!page) return;
    const productId = page.dataset.productId;
    // 항상 최신 ID 사용
  });
}

/**
* 상품 상세 정보 렌더링
* @param {object} detail - 현재 상품의 상세 정보
* @param {Array} products - 관련 상품 후보 목록
Copy link
Contributor

Choose a reason for hiding this comment

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

문제상황

renderProductDetail가 렌더링이 시작될 때마다 quantity = 1로 초기화합니다. 그런데 관련 상품을 불러오거나 스토어의 products 배열이 갱신되어 render가 다시 실행될 때도 이 초기화가 일어나므로, 사용자가 수량을 2 이상으로 변경해 놓은 상태가 신경 쓰지 않고 사라져 버립니다.

현재 코드의 한계

  • 사용자가 수량을 조절한 뒤 스크롤하거나 related products가 로드되면 입력이 1로 돌아갑니다.
  • quantity 선택이 지속되지 않아서 장바구니에 담기 전에 의도한 수량을 놓치기 쉽습니다.
  • UX가 매끄럽지 않고 조절한 값이 유지된다는 믿음이 깨집니다.

근본 원인

quantity가 module-level 변수로 유지되면서 렌더링마다 항상 1로 재할당되고 있기 때문에, 스토어 변화를 따라가는 render가 실행될 때마다 기존 선택이 초기화됩니다.

개선 구조

상품이 바뀌었을 때만 수량을 1로 리셋하고, 그 외에는 DOM에 저장된 값을 유지하도록 해야 합니다. quantityProductDetailPage의 상태로 관리하고, renderProductDetail에서 현재 상태를 그대로 전달하거나, quantity를 input의 value로부터 읽는 방식으로 변경하세요.

코드 비교

// ❌ 현재 방식
function renderProductDetail(detail, products) {
  quantity = 1;
  return ProductInfo(detail, quantity);
}

// ✅ 개선된 방식
function renderProductDetail(detail, products) {
  const currentQuantity = detail.selectedQuantity ?? quantity;
  return ProductInfo(detail, currentQuantity);
}

// TODO : 가능하다면 프록시 패턴 적용시켜보자!
this.#state = { ...this.#state, ...val };
console.log("product.js - setState", this.#state);
// 구독자에게 변화 감지 + 리렌더링 함수 실행
Copy link
Contributor

Choose a reason for hiding this comment

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

문제상황

productStore.subscribereturn () => this.unsubscribe;를 반환하고 있어서, ProductListPageProductDetailPage에서 const unsubscribe = productStore.subscribe(render) 하고 나중에 unsubscribe();를 불러도 실제로 옵저버가 제거되지 않습니다.

현재 코드의 한계

  • 페이지를 여러 번 오갈수록 render가 누적되어 같은 상태 업데이트가 여러 번 실행되고 DOM이 중복으로 다시 그려집니다.
  • 옵저버가 살아남기 때문에 메모리 누수와 CPU 사용량 증가가 나타납니다.
  • unsubscribe가 기능하지 않으니 currentOnUnmount에서 cleanup이 전혀 이루어지지 않습니다.

근본 원인

subscribe가 실제 콜백을 인자로 잡아서 해당 콜백을 삭제하는 클로저를 반환하지 않고, this.unsubscribe 자체(메서드 레퍼런스)만 돌려 줍니다.

개선 구조

return () => this.unsubscribe(callback);와 같이 실제 callback을 참조하는 클로저를 만들면 정상적으로 구독을 해제할 수 있습니다.

코드 비교

// ❌ 현재 방식
return () => this.unsubscribe;

// ✅ 개선된 방식
return () => this.unsubscribe(callback);

/**
* 상품 목록을 가져오기
* page === 1 : 상품 목록 새로고침
* page > 1 : 기존 상품 목록에 추가 (무한 스크롤)
Copy link
Contributor

Choose a reason for hiding this comment

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

문제상황

fetchProducts의 첫 줄에서 if (this.#state.loading) return;로 중복 요청을 막는데, setParams에서 새로운 조건으로 다시 호출할 때도 loadingtrue이면 fetchProducts는 바로 return합니다. 즉, 사용자가 아직 로딩 중인 상태에서 정렬/검색을 바꾸면 새로운 요청이 실행되지 않고 버려집니다.

현재 코드의 한계

  • 빠른 필터링, 검색어 변경, pagination 변경이 반영되지 않고 UI가 이전 상태대로 머무릅니다.
  • API 호출이 무시되기 때문에 사용자는 아무 변화도 없다고 느낍니다.
  • 최신 조건이 서버에 전달되지 않으므로 상태와 URL이 엇갈립니다.

근본 원인

loading 플래그가 켜져 있으면 어떤 새로운 fetch도 실행되지 않도록 막기 때문에, 두 번째 setParams는 이후에 다시 수행되지 않습니다.

개선 구조

  • setParams가 실행될 때 loading이 true라면 새로운 파라미터를 큐에 저장해 두고, 현재 로딩이 끝난 후 자동으로 그 큐를 처리합니다.
  • 또는 fetchProducts에서 이전 요청을 취소하고 새 조건으로 즉시 요청합니다(AbortController 등).
  • 또는 setParams가 로딩 중일 때도 fetchProducts를 호출하고, loading 플래그는 요청 시작/종료로만 관리하며 조건을 관리하는 별도의 버퍼를 유지합니다.

코드 비교

// ❌ 현재 방식
if (this.#state.loading) {
  return;
}

// ✅ 개선된 방식 (간단한 큐 예시)
if (this.#state.loading) {
  this.#pendingParams = updatedParams;
  return;
}

(그리고 loading 완료 시 this.#pendingParams가 있으면 fetchProducts()를 다시 호출)

console.error("Toast container not found.");
return;
}

Copy link
Contributor

Choose a reason for hiding this comment

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

문제상황

renderToastisVisibletrue일 때마다 modalContainer.insertAdjacentHTML로 DOM을 추가만 하고, 기존에 있던 #toast-wrapper가 있다면 제거하지 않습니다. 연속해서 토스트를 띄우면 DOM 노드가 누적되고, 동일한 닫기 버튼이나 자동 사라짐도 여러 번 동작합니다.

현재 코드의 한계

  • 빠르게 두 번 이상 장바구니에 상품을 넣으면 토스트가 중첩되어 보이고, 닫기 버튼이 여러 개 생깁니다.
  • toastStore.showToast가 duration 중 다시 호출되면 기존 타이머는 여전히 살아있어 자동 닫힘 순서가 뒤엉킵니다.
  • DOM 노드가 삭제되지 않아 성능/메모리에도 부담이 커집니다.

근본 원인

isVisible true 상태에서 무조건 insertAdjacentHTML하는 방식이고, 기존 toast를 덮어 쓰거나 제거하는 로직이 없습니다.

개선 구조

#toast-wrapper가 이미 존재하면 innerHTML만 교체하거나, 렌더링 전에 기존 요소를 제거한 뒤 새로 생성합니다. 닫기 버튼 핸들러도 중복 생성하지 않도록 단일 요소만 유지하도록 리팩터링합니다.

코드 비교

// ❌ 현재 방식
modalContainer.insertAdjacentHTML("beforeend", `<div id="toast-wrapper">...</div>`);

// ✅ 개선된 방식
const existing = document.getElementById("toast-wrapper");
if (existing) {
  existing.innerHTML = toastUI({ message, type });
} else {
  modalContainer.insertAdjacentHTML("beforeend", `<div id="toast-wrapper">...</div>`);
}


if (cartModalCloseBtn) {
cartModalCloseBtn.addEventListener("click", closeCartModal);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

문제상황

cartModalControldocument.body.addEventListener("keydown", ...)을 호출하는데, 이 함수는 장바구니 상태가 바뀔 때마다(renderCartModal에서 구독되어) 다시 실행됩니다. 그 결과 ESC 핸들러가 누적되어 ESC 한 번만 눌러도 closeCartModal이 여러 번 호출되고 커스텀 이벤트가 중첩됩니다.

현재 코드의 한계

  • 산발적으로 ESC 키가 연속 트리거 되거나, ESC를 한 번 눌러도 닫힘/열림이 반복돼 UI가 깜빡입니다.
  • 이벤트 핸들러가 매번 새로 등록되면서 누적돼 메모리와 CPU를 낭비합니다.
  • 이전 DOM을 가리키고 있는 핸들러도 여전히 실행되므로 상태가 중복으로 토글됩니다.

근본 원인

키보드 이벤트를 cartModal이 렌더링될 때마다 다시 등록하고, 등록 해제를 하지 않기 때문에 브라우저는 매 렌더링마다 새로운 핸들러를 큐에 쌓습니다.

개선 구조

ESC 핸들러는 애플리케이션 전체에서 한 번만 등록하고, 내부에서 document.querySelector(".cart-modal")로 현재 DOM을 찾아서 상태를 확인하도록 만들면 됩니다. 또는 cartModalControl을 한 번만 초기화한 뒤에는 document.body.removeEventListener로 이전 핸들러를 제거한 뒤 새로 등록합니다.

코드 비교

// ❌ 현재 방식
document.body.addEventListener("keydown", (e) => {
  if (e.key === "Escape" && cartModal && !cartModal.classList.contains("hidden")) {
    closeCartModal();
  }
});

// ✅ 개선된 방식
const onEsc = (e) => {
  if (e.key !== "Escape") return;
  const modal = document.querySelector(".cart-modal");
  if (modal && !modal.classList.contains("hidden")) {
    closeCartModal();
  }
};
document.body.addEventListener("keydown", onEsc);

export default function SearchInput({ search = "" }) {
return `
<!-- 검색창 -->
<div class="mb-4">
Copy link
Contributor

Choose a reason for hiding this comment

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

문제상황

URL 쿼리(사용자 입력)에서 받은 search 문자열을 그대로 value="${search}"로 주입하고 있어서, 따옴표나 <script>가 섞인 검색어를 넣으면 DOM이 깨지거나 XSS 공격에 취약해집니다.

현재 코드의 한계

  • "<를 포함한 검색어가 들어오면 속성 값이 빠져나가고 브라우저가 HTML 파싱을 깨뜨릴 수 있습니다.
  • 악의적인 사용자라면 URL에 search=" onfocus=alert(1)를 넣어서 XSS를 유발할 수 있습니다.
  • search 값이 escaping 되지 않았기 때문에 보안 위험이 있습니다.

근본 원인

템플릿 리터럴로 value 속성을 빌드하면서 사용자 데이터를 escape 하지 않고 직접 주입하고 있습니다.

개선 구조

value에 넣기 전에 escape(또는 textContent) 처리를 하거나, DOM API(document.createElement, input.value = value)를 사용하여 자동으로 escaping되도록 하세요.

코드 비교

// ❌ 현재 방식
value="${search}"

// ✅ 개선된 방식
const safeSearch = search
  .replace(/&/g, "&amp;")
  .replace(/</g, "&lt;")
  .replace(/>/g, "&gt;")
  .replace(/"/g, "&quot;");
value="${safeSearch}"

<button data-breadcrumb="category1" data-category1="${category1}" class="text-xs hover:text-blue-800 hover:underline">${category1}</button>`;
}
if (category2) {
breadcrumbHTML += `<span class="text-xs text-gray-500">&gt;</span>
Copy link
Contributor

Choose a reason for hiding this comment

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

문제상황

Breadcrumbcategory1, category2를 URL에서 그대로 받아와 버튼 텍스트로 사용하고 있습니다. 두 값 모두 사용자가 조작 가능한 쿼리/네비게이션 지점인데, escape 없이 innerHTML에 삽입되기 때문에 DOM 기반 XSS가 가능합니다.

현재 코드의 한계

  • category1=<script>alert(1) 식으로 카테고리를 직접 URL에 넣으면 브라우저가 HTML 태그로 해석합니다.
  • API에서 전달된 값도 악의적으로 조작되면 자바스크립트 실행이 될 수 있습니다.
  • 템플릿에서 innerHTML 레벨로 삽입하므로 안전한 텍스트가 보장되지 않습니다.

근본 원인

사용자 입력을 escape 하지 않고 HTML 문자열 내에 직접 채워 넣고 있습니다.

개선 구조

버튼 텍스트는 DOM API(textContent)를 사용해서 넣거나, 렌더링 전에 </>/& 등을 escape 처리해서 안전하게 말풍선을 그립니다.

코드 비교

// ❌ 현재 방식
<button ...>${category1}</button>

// ✅ 개선된 방식
const safeCategory1 = category1.replace(/&/g, "&amp;").replace(/</g, "&lt;");
<button ...>${safeCategory1}</button>

test("검색어 입력 후 Enter 키로 검색하고 URL이 업데이트된다", async ({ page }) => {
const helpers = new E2EHelpers(page);
await helpers.waitForPageLoad();
await page.waitForTimeout(1000);
Copy link
Contributor

Choose a reason for hiding this comment

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

문제상황

테스트 전반에 await page.waitForTimeout(1000)이 곳곳에 들어가 있습니다. Playwright는 expect(...).toBeVisible()이나 page.waitForResponse() 같은 기다림 API를 제공하는데, 고정된 슬립을 쓴 결과 테스트가 불필요하게 느려지고, 네트워크/렌더링 속도의 변화에 취약해졌습니다.

현재 코드의 한계

  • 테스트가 각 단계마다 1초씩 기다리므로 전체가 지나치게 느려집니다.
  • 네트워크가 느릴 때는 기약 없는 슬립으로 인해 여전히 실패하거나, 너무 빠르면 보여지기 전에 expect가 실행되어 flake가 납니다.
  • 수동 슬립은 유지보수가 어렵고, 명시적으로 기다리려는 의도를 파악하기 어렵습니다.

근본 원인

UI 상태 변화 대신 시간 기준 waitForTimeout에 의존하기 때문에 Playwright의 waitFor/expect 등의 상태 기반 waiting을 활용하지 않고 있습니다.

개선 구조

각 작업(검색 결과, URL 업데이트, 브레드크럼 반영 등)에 대해 적절한 expect 또는 page.waitForResponse/locator.waitFor()를 사용해서 조건이 충족될 때까지 기다리면, 테스트는 더 빠르고 안정적으로 동작합니다. 불필요한 waitForTimeout은 제거하고, 필요한 경우 await expect(locator).toHaveText(..., { timeout: 5000 })처럼 조건을 기다리게 바꿔주세요.

Copy link

@devchaeyoung devchaeyoung left a comment

Choose a reason for hiding this comment

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

test

Copy link

@devchaeyoung devchaeyoung left a comment

Choose a reason for hiding this comment

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

test

Copy link

@devchaeyoung devchaeyoung 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은 옵저버 패턴과 싱글턴 패턴을 도입해 데이터 상태 관리를 명확히 구현하고, 라우터와 UI 컴포넌트가 잘 분리돼 유지보수와 확장성 측면에서 좋은 기반을 마련한 점이 인상적입니다. 특히 장바구니 영속성(localStorage 반영)과 무한 스크롤 구현, 이벤트 위임 처리 등 실무적 감각을 적용한 부분이 돋보입니다.👍

추가질문으로 옵저버+싱글턴 패턴의 적합성 및 이벤트 리스너 대상에 대해 고민하셨는데, 하나의 인스턴스를 모든 컴포넌트에 공유하는 싱글턴은 SPA 상태 공유에 적절한 방법입니다. 이벤트 리스너가 window나 document에 몰려있으면 의도치 않은 핸들러 실행이나 메모리 누수 우려가 있으니 등록과 해제를 신중하게 관리하는 게 필요합니다.

전체적으로 관심사 분리와 모듈화가 잘 되고, 렌더링 로직과 상태관리, 이벤트 처리를 적절히 분리한 점이 확장성과 유지보수에 긍정적입니다.

질문에대한 답변

1. 질문 요약

지훈님께서는 옵저버 패턴과 싱글턴 구조가 SPA 상태관리에 적합한지, 그리고 이벤트 리스너가 window 또는 document에 집중될 때 발생 가능한 문제점에 대해 궁금해 하셨습니다.

2. 현재 선택의 장단점

  • 옵저버 + 싱글턴 패턴

    • 장점: 전역 상태 공유와 변경감지, 컴포넌트 간 상태 일관성 유지가 용이하며, SPA에서 상태관리를 단순화할 수 있음
    • 단점: overly broad한 notify 호출로 인한 불필요한 리렌더링, unsubscribe 반환 함수 미흡 이슈, 상태 비교 로직 부재로 성능 저하 가능성
  • 이벤트 리스너 몰림 (window/document)

    • 장점: 이벤트 위임을 통한 메모리 절감, 한 곳에서 이벤트 일괄처리 가능
    • 단점: 이벤트가 광범위하게 전달되어 불필요한 이벤트 핸들러 실행 증가, 특정 컴포넌트별 이벤트 해제 어려움, 디버깅 난이도 증가

3. 실무에서라면 이렇게 설계할 것 같아요

  • 옵저버 패턴과 싱글턴은 SPA에서 상태 공유를 위해 기본적인 좋은 선택입니다. 하지만, 구독 해제 함수가 정확히 동작하도록 수정하고, 상태 변경 시 과도한 notify 호출을 줄이기 위해 변경 전후 상태 비교 또는 불변성 관리 라이브러리 도입을 고려할 수 있습니다.
  • 이벤트 리스너는 window/document에 등록하되, 이벤트 핸들링 시 위임 대상의 좁은 검사를 엄격히 해 불필요한 실행을 방지하고, 이벤트 중복 등록에 대비하여 등록·해제 로직을 명확히 관리하는 것이 좋습니다. 또한 컴포넌트별로 개별 이벤트 관리가 필요한 경우엔 각 컴포넌트 루트 노드에 이벤트를 제한적으로 부착하는 것도 좋은 방법이에요.

4. 앞으로 구조를 잡을 때 참고하면 좋은 포인트

  • 상태 변경 시 이전 상태와 비교하여 실제 변경이 있을 때만 구독자에게 알리는 로직 설계 (예: immer, Proxy, or shallow equality 비교 활용)
  • 이벤트 리스너 등록 위치 및 생명주기 관리 철저 (한 번만 등록 vs. 컴포넌트 마운트/언마운트에 따른 등록/해제)
  • 옵저버 패턴의 unsubscribe 함수 제대로 구현하여 메모리 누수 방지
  • 상태와 UI 렌더링의 관심사 분리를 명확히 하여 불필요한 리렌더링 최소화
  • SPA 라우팅 시 페이지별 이벤트 분리와 라우트 변경에 따른 초기화 적용

지훈님께서 셀프회고에서 고민하신 내용을 아주 잘 정리해두셨으며, 앞으로도 이번 경험을 바탕으로 점점 안정적이고 효율적인 SPA 아키텍처 설계 능력이 향상될 것 같습니다. 좋은 작업 수고 많으셨습니다!👍

search: "",
category1: "",
category2: "",
},

Choose a reason for hiding this comment

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

지훈님, 옵저버 패턴과 싱글턴 패턴을 활용하여 전역 상태관리를 구현한 부분은 SPA 구조에서 상태 공유를 위한 좋은 선택이에요👍. 특히 상태를 캡슐화하고, 구독/알림 구조를 명확히 한 점이 깔끔합니다.

다만 subscribe 메서드의 반환 함수가 return () => this.unsubscribe;로, 실제 unsubscribe 호출이 아니라 함수 참조만 반환하는 이슈가 있어요. 실수로 보이니 다음과 같이 수정해주면 구독 해제가 정상 동작합니다.

subscribe(callback) {
  this.#observer.add(callback);
  return () => this.unsubscribe(callback);
}

이 부분은 유지보수 시 중요한 포인트니 꼭 반영해보시면 좋을 것 같아요.

const initialState = {
items: [], // { productId, title, image, lprice, quantity, isChecked }
isCartOpen: false,
};

Choose a reason for hiding this comment

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

장바구니 스토어에서 로컬스토리지에 상태를 저장하여 새로고침 후에도 데이터가 유지되도록 설계하신 점, 실무 감안한 좋은 구조에요👍. 이 구조 덕분에 장바구니 영속성이 잘 보장됩니다.

다만 상태 변경 시 localStorage 저장과 notify 호출이 묶여 있어서, 상태 관리 로직을 좀 더 분리하거나 비동기 처리하는 방법도 존재함을 참고해보시면 좋습니다.

cartStore.toggleCartModal(true);
});
}

Choose a reason for hiding this comment

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

장바구니 모달의 열림/닫힘 상태를 store 상태로 제어하신 점, UI 상태 관리를 전역 상태에 포함시킨 좋은 설계입니다.

다만, 이벤트 리스너를 모달이나 헤더가 다시 렌더링 될 때마다 중복 등록하는 부분이 보이는데, 이로 인해 이벤트 핸들러가 중첩 실행될 가능성이 있어요. 이 부분은 이벤트 등록 전에 기존 이벤트를 제거하거나, once 옵션 등으로 핸들링하는 개선이 필요해보입니다.


const queryParams = Object.fromEntries(new URLSearchParams(window.location.search));
let pageComponent;

Choose a reason for hiding this comment

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

라우터에서 baseUrl을 분리하여 라우팅 경로 처리를 명확하게 하고, query 파라미터를 관리하는 구조가 현명해요👍. SPA 네비게이션 구현의 기본 틀을 잘 잡으셨습니다.

다만 URL 업데이트 시 pushState를 호출하면서 바로 handlePathChange()를 실행시켜 중복 호출될 수 있으므로 이 부분 성능도 주의하며 추후 필요시 디바운스 처리를 고려해보시면 좋겠습니다.

${Breadcrumb({ category1, category2 })}
${CategoryButtons({ categories, category1, category2 })}
</div>
${FilterOptions({ limit, sort })}

Choose a reason for hiding this comment

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

상품 목록 페이지에서 IntersectionObserver를 활용해 무한 스크롤을 구현한 점이 실무 흐름과 잘 맞으며👍 상당히 효과적입니다.

이벤트 리스너를 document.body에 위임하여 한 번만 등록하고 이벤트를 처리하는 점도 메모리 낭비를 줄이는 좋은 패턴이에요.

다만, 이벤트 리스너가 모든 클릭 이벤트마다 실행되므로, 이벤트 타겟 필터링을 보다 엄격히 하여 불필요한 처리 방지 및 성능 최적화 시도를 추천드립니다.

</div>
<!-- 상품 정보 -->
<div class="p-3">
<div class="product-info mb-3">

Choose a reason for hiding this comment

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

상품 카드 컴포넌트를 재사용 가능한 함수로 구현한 점이 컴포넌트 재사용 측면에서 깔끔하네요👍.

다만, 이 함수는 순수 HTML 문자열 생성만 하므로 렌더링 타이밍에 따라 DOM 이벤트 등과 분리 관리가 필요합니다. 이런 분리 덕분에 UI 재사용성뿐 아니라 테스트 편리성도 향상됩니다.

const addToCartBtn = e.target.closest("#add-to-cart-btn");
if (addToCartBtn) {
const { productId: btnProductId } = addToCartBtn.dataset;
const { data } = productStore.getState().productDetail;

Choose a reason for hiding this comment

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

상세 페이지에서 이벤트 리스너를 한 번만 등록하기 위해 eventsInitialized를 활용한 점은 재발생 방지에 좋았습니다.

다만, 관련 상품 링크 클릭 후 라우팅을 할 때 기존에 등록된 이벤트와 상태가 올바르게 갱신되고 있는지 꼼꼼히 확인해보시면 좋겠습니다.

또한 수량 조절 UI가 비동기 상태 변화에 따라 정확히 반영되는지 테스트를 권장드립니다.

import { ProductDetailPage } from "./pages/ProductDetailPage.js";
import { NotFoundPage } from "./pages/NotFoundPage.js";
import { renderHeader } from "./layouts/headerRenderer.js";
import { renderFooter } from "./layouts/footerRenderer.js";

Choose a reason for hiding this comment

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

초기 레이아웃을 App.js에서 한 번만 렌더링하고 이후에는 상태 변화에 따라 필요한 영역 단위로 다시 렌더링하는 구조는 SPA 설계의 좋은 기본이에요👍.

헤더, 푸터, 모달, 토스트 등 공통 UI 레이아웃을 분리시키고 상태 관리 스토어 구독을 통해 리렌더링하는 방식은 유지보수에 유리합니다.

</h1>
`;
}

Choose a reason for hiding this comment

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

헤더에서 경로에 따라 타이틀과 뒤로가기 버튼을 조건부 렌더링한 점이 UX 측면에서 좋습니다.

장바구니 아이템 수를 렌더링하여 사용자가 현재 장바구니 상태를 쉽게 인지하도록 한 점도 명확해요.

그러나 헤더가 다시 렌더링 될 때마다 이벤트 리스너를 재등록하는 패턴은 중복 등록 문제를 일으킬 수 있으므로 주의가 필요합니다.

categoryButtonsHTML = Object.keys(currentCategories)
.map((cat1) => {
return `<button data-category1="${cat1}" class="category1-filter-btn text-left px-3 py-2 text-sm rounded-md border transition-colors
bg-white border-gray-300 text-gray-700 hover:bg-gray-50">

Choose a reason for hiding this comment

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

카테고리 버튼 UI를 1뎁스, 2뎁스 조건에 따라 동적으로 구성한 점이 유연하고 보기 좋습니다.

현재 선택된 카테고리에는 별도의 CSS 클래스를 추가해 시각적 구분을 제공하는 것도 좋은 UI 접근 방식입니다.

혹시나 카테고리 데이터가 없거나 로딩 중일 때에 대한 사용자 피드백도 잘 표현되어 있어 좋네요.

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