-
Notifications
You must be signed in to change notification settings - Fork 50
[3팀 김대현] Chapter2-1. 프레임워크 없이 SPA 만들기 #31
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
c1cdc92 to
9b09aa3
Compare
JunilHwang
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은 Vanilla JS SPA 쇼핑몰 구현으로, 상품목록 조회, 필터링, 무한 스크롤, 장바구니 모달, 상품 상세 페이지, 사용자 토스트 알림 등 핵심 요구사항을 충실히 구현했습니다. MSW를 이용한 API 모킹, Playwright 기반 E2E 테스트 구조까지 갖추며 실제 서비스 시나리오에 가까운 환경을 조성한 점도 훌륭합니다.
다만 아래와 같은 확장성 및 유지보수 측면 개선이 필요한 부분이 있습니다:
-
fetch 요청 및 상태 관리 로직 분리: 현재 상품 목록 fetch와 캐싱 로직이 Home 컴포넌트 내부에 집중되어 있어 필터 확장이나 페이징 방식 변경 시 복잡도가 증가합니다. 별도 커스텀 훅 혹은 서비스 단위로 분리해 재사용성과 테스트 용이성을 높일 수 있습니다.
-
상태 불변성 관리: 특히 Map 객체인 cart.list를 직접 mutate하는 부분이 있어 UI 갱신 명확성에 위험이 있습니다. 항상 새로운 Map을 복사해 할당하는 방식으로 불변성 유지가 필요합니다.
-
이벤트 핸들러 모듈화 및 이벤트 전파 제어: 전역 이벤트 핸들러가 한 곳에 몰려 있고, 이벤트 전파 제어가 완벽하지 않습니다. 기능별 모듈 분리와 명확한 이벤트 전파 제어가 유지보수성에 도움이 됩니다.
-
의존성 훅(useEffect) 개선: 커스텀 useEffect 내부 의존성 비교가 단순 참조 비교여서, 복잡한 객체 의존성 시 의도치 않은 재실행 가능성을 갖습니다.
-
UI 컴포넌트의 동적 상태 관리: 카테고리 및 토스트 등 UI 요소가 state와 100% 연동되어 있으나 카테고리 2단계 처리 및 토스트 문구 관리에 좀 더 유연한 패턴 적용이 가능해 보입니다.
-
IntersectionObserver 적용 위치 조정: 무한 스크롤 트리거가 footer에 걸려 UX 측면에서 미세 조정이 필요합니다.
향후 요구사항 확장, 유지보수 및 테스트 편의성 향상을 위해 위 개선 사항을 차근차근 적용하면 더욱 견고한 SPA가 될 것으로 기대됩니다.
현재 아키텍처 개요
- 컴포넌트 기반 렌더링: App.js가 루트 컴포넌트이며 Header, Footer, Modal, Toast, 콘텐츠 페이지 컴포넌트로 분리됨
- 상태관리: 전역 store (createStore)에서 상태를 관리하며, Map과 객체 조합으로 구성
- 라우터: 수동 구현 라우터(Router.js)로 URL 변화 감지 및 SPA 사례 대응
- API: productApi.js에서 fetch 추상화, MSW를 통한 API 모킹
- 이펙트: 커스텀 useEffect 훅으로 렌더링 후 실행 로직 관리
- 이벤트: eventHandler.js에 전역 이벤트 핸들러 집중
추가적으로 개선할 수 있는 사항
- 에러 처리 및 재시도 로직의 명확 분리 및 UI 개선
- 타입 안정성을 위한 JSDoc 및 타입 체크 강화
- 로컬스토리지 연동 시 동기화 로직 별도 모듈화
- 테스트 코드와 커버리지 보강으로 안정성 확보### 추가 질문에 대한 답변: React가 기본 라우팅 기능을 제공하지 않는 이유와 react-router-dom 같은 라우터 라이브러리에 의존하는 이유
1. React 자체는 UI 라이브러리입니다
- React는 UI 컴포넌트를 만들기 위한 라이브러리에 집중하고, 애플리케이션 내에서 어떻게 네비게이션을 처리할지까지 고민하지 않습니다.
- 이러한 책임 분리는 React 자체를 더 가볍고 특별한 목적에 집중된 도구로 만듭니다.
2. 라우팅은 다양한 요구사항과 환경 차이가 큽니다
- SPA 내에서도 라우팅 방식(히스토리 API, 해시 라우팅, 메모리 라우팅 등)과 URL 매칭 로직, 동적 라우팅, 라우트 가드, 중첩 라우트 등 다양한 기능이 필요합니다.
- 이러한 복잡성을 React 핵심에 넣으면 유지보수가 어렵고 기본 기능만 복잡해집니다.
3. react-router-dom 같은 전용 라우터 라이브러리의 가치
- 라우팅 로직과 컴포넌트 렌더링 간의 매핑, URL 변화 감지, 히스토리 관리, 라우트 중첩, 매개변수 처리 등 풍부한 기능을 제공합니다.
- 커뮤니티와 생태계가 잘 갖추어져 있어 다양한 요구사항에 대응 가능하며, React 버전별 호환성도 지속 관리합니다.
- 개발자들이 라우터에 대한 고민 없이 UI와 비즈니스 로직에 집중할 수 있게 돕습니다.
4. 결론
- 리액트는 UI에 집중하는 경량 라이브러리로, 애플리케이션 전체 아키텍처는 별도의 전용 라이브러리(react-router-dom 등) 조합으로 완성하는 모듈화 전략입니다.
- 따라서 SPA 앱에서 라우팅은 별도 라이브러리를 의존하는 것이 일반적이며, 이로 인해 라우팅 관련 복잡성을 많이 줄일 수 있습니다.
추가로 SPA 라우터 구현을 직접 할 경우 생기는 난점과 React Router 같은 라이브러리의 이점까지 고민하면, 현명한 도구 활용과 올바른 책임 분배가 중요함을 이해하는 데 도움이 될 것입니다.
| let isInitialized = false; // 초기화 플래그 | ||
| let lastPage = 1; // 마지막으로 로드한 페이지 | ||
| let isFetching = false; // 현재 fetch 중인지 확인 | ||
|
|
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.
1. 문제상황 제시
현재 Home.js에서 상품 목록을 무한 스크롤과 필터에 따라 fetch하는 기능이 구현되어 있는데, 만약 "검색어, 카테고리, 정렬 등 필터가 다양하게 확장"되고, "다양한 페이지네이션 전략"이 추가된다면 아래와 같은 한계가 있습니다.
- 검색어, 카테고리 필터가 3단계 이상 복잡해지거나 옵션 필터가 추가되면 fetch 파라미터 관리가 비대해진다.
- 현재 fetch 캐싱 비교(
lastFetchParams === paramsString)가 문자열 방식으로 돼 있어, 파라미터가 많아질 경우 유지보수가 어렵다. - 무한 스크롤과 일반 페이징 요청을 한 함수에서 구분 짓는데, 복잡한 UI 상태 변화 시 관리가 어려움.
2. 근본 원인
핵심 문제는 "fetch 요청 파라미터 로직이 Home 컴포넌트 내부에 하드코딩되어 있고, 상태 변화 및 캐싱 로직이 한 함수에 집중되어 있어 확장과 유지보수가 어렵다"는 점입니다.
이 구조는 새로운 필터가 추가되거나 다른 페이징 전략을 도입할 때마다 Home 컴포넌트 내 코드 수정이 늘어나고, 버그 발생 가능성도 커집니다.
3. 개선 구조
- 현재 구조:
Home 컴포넌트
└─ fetchProductsWithParams(filters, pagination, infiniteScroll)
├─ 파라미터 문자열 생성 및 캐싱 비교
├─ fetch 및 상태 업데이트
└─ 에러 처리
- 개선된 구조:
Home 컴포넌트
├─ useEffect에서 상태 변화 감지
├─ fetch 요청 파라미터는 별도 훅 또는 유틸 함수
├─ fetch 요청은 전용 fetchService 또는 커스텀 훅 사용
└─ 상태 업데이트 로직과 캐싱 로직 분리
개선 사항
- [1] 요청 파라미터 생성 및 변경 로직을 별도의 custom hook 혹은 서비스단으로 분리하여 관리
- [2] fetch 요청과 캐싱 로직을 분리하여, 요청 중복 방지나 응답 재활용을 깔끔히 처리
- [3] 무한 스크롤 여부 판단과 페이지네이션 로직도 독립된 훅 또는 모듈로 관리하여 코드 단순화
// ❌ 현재 방식 (Home.js 중 발췌)
if (lastFetchParams === paramsString && !isInfiniteScroll) { return; }
// fetch + 상태 관리 모두 한 함수에 있음
// ✅ 개선 아이디어
function useProductList(filters, pagination) {
const [state, setState] = useState({ products: [], isLoading: false, error: null });
useEffect(() => {
const params = createFilterParams(filters, pagination);
if (isSameParams(params, prevParams)) return;
setState({ ...state, isLoading: true });
fetchProducts(params)
.then(data => setState({ ...state, products: data.products, isLoading: false }))
.catch(err => setState({ ...state, error: err, isLoading: false }));
}, [filters, pagination]);
return state;
}이렇게 하면 Home 컴포넌트는 렌더링 로직에 집중하고, 비즈니스 로직과 데이터 요청 로직이 독립되어 확장과 유지보수가 쉬워집니다.
| lprice: "", | ||
| hprice: "", | ||
| mallName: "", | ||
| productId: "", |
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.
4. 상태 불변성을 명확히 관리하는 것이 중요합니다.
현재 store.js에서 상태가 바뀔 때마다 새로운 객체를 생성하고 있지만, cart.list는 Map 객체로 직접 수정(set) 후 상태에 포함시키고 있습니다.
- Map 객체는 내부적으로 상태 변경이 가능하고, 변경 여부를 쉽게 추적하지 못하므로 React 스타일 상태관리에서 권장하지 않습니다.
개선방법
- cart.list 변경 시 새로운 Map 복사본으로 만들어서 불변성을 유지하세요.
// ❌ 현재 방식
const newList = cart.list.set(targetId, item);
// 여기서 cart.list는 기존 Map 객체 자체가 변함
// ✅ 개선 방식
const newList = new Map(cart.list);
newList.set(targetId, item);
store.setState({ cart: { ...cart, list: newList } });이렇게 하면 상태 업데이트 감지에 안정성을 높이고, 추적 및 디버깅도 쉬워집니다.
| store.setState({ cart: { ...cart, quantity: cart.quantity - 1 } }); | ||
| } | ||
| }; | ||
|
|
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.
5. 이벤트 버블링과 위임에서 우선순위 조절
handleGlobalClick 내에서 .add-to-cart-btn 클릭 시 e.stopPropagation()만 호출하고, return이 없어서 click 이벤트가 뒤이어 다른 영역으로 번질 수 있습니다.
- 의도적으로 이벤트 전파를 막고 있다면 함수 종료(
return)도 추가하는 것이 좋아요.
// ❌ 현재
if (target.closest('.add-to-cart-btn') || target.closest('#add-to-cart-btn')) {
e.stopPropagation();
addCartItem(target.dataset.productId);
store.setState({ toast: { isOpen: true, type: 'success' } });
}
// ✅ 개선
if (target.closest('.add-to-cart-btn') || target.closest('#add-to-cart-btn')) {
e.stopPropagation();
addCartItem(target.dataset.productId);
store.setState({ toast: { isOpen: true, type: 'success' } });
return; // 이벤트 전파 중지 후 함수 종료
}이렇게 하면 불필요한 이벤트 중복 처리도 방지할 수 있습니다.
|
|
||
| const handleSearch = (value) => { | ||
| const filters = store.getState("filters"); | ||
| if (filters.search === value) 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.
6. 글로벌 상태 변경 시 깊은 상태 구조 주의
스토어 상태 구조가 깊거나 Map, Set 같은 컬렉션 객체를 포함하고 있습니다. store.setState 호출 시 깊은 복사 없이 부분적으로 병합하여 상태를 변경하는데, 이 점에서 불변성을 지키기 어려울 수 있어요.
- 깊은 상태를 부분적으로 변경할 때는 불변성 유지를 위해 반드시 객체를 새로 생성하거나 컬렉션 복사본을 생성해야 합니다.
예를 들어 카트의 상품 목록 관리 시 직접 mutate하지 않고 복사본으로 교체하세요.
|
|
||
| // 의존성 비교: 이전 값이 없거나, 하나라도 변경되었으면 실행 | ||
| const shouldRun = | ||
| !prevDeps || |
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.
7. useEffect 의존성 비교 개선 필요
현재 useEffect 훅 구현에서 이전 의존성과 현재 의존성을 비교하는 과정이 있습니다. 하지만 dependencies가 없거나 길이가 다르면 바로 실행하도록 되어 있습니다.
- 의존성 배열이
undefined또는null일 경우 무조건 실행하는데, 명확하게 배열 타입 검사를 추가하면 안전합니다. - 또한 참조 타입인 배열 내부의 객체가 변경되었는지 깊게 비교하지 않아, 내부 상태 변화 감지를 못할 수 있습니다.
개선점
- 의존성 배열을 배열인지 확인하고, 필요시 깊은 비교(또는 JSON 문자열 비교 등)로 개선 가능
const shouldRun =
!prevDeps ||
!dependencies ||
dependencies.length !== prevDeps.length ||
dependencies.some((dep, index) => dep !== prevDeps[index]);
// => 배열 타입 체크 추가
if (!Array.isArray(dependencies)) {
// 항상 실행
}이 부분은 간단하게 쓰기 쉽지만, 복잡한 의존성 변화 경우를 위해 별도의 라이브러리 사용도 고려할 수 있습니다.
| </div> | ||
| </div> | ||
| `; | ||
| }; |
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.
9. 토스트 메시지 닫기 버튼 이벤트 처리
토스트 컴포넌트에 닫기 버튼(#toast-close-btn)이 상수 HTML 내에 여러 번 렌더링되는데,
- 실제 이벤트 리스너 등록이 어떻게 처리되는지 코드상 확인되지 않아,
- 버튼 클릭 시 상태가 제대로 닫히는지, 또는 닫기 버튼 중복 렌더링의 부작용이 있는지 점검 필요합니다.
개선 제안
- 닫기 버튼 클릭 처리를 이벤트 위임으로 한 번만 처리하거나,
- 렌더링 시 중복 생성 방지 및 렌더링 최적화 검토 필요
- 자동 닫힘 시 useEffect 내 cleanup 함수도 적절히 구현되어 있어 좋은 구조입니다.
| <div class="p-3"> | ||
| <div class="cursor-pointer product-info mb-3"> | ||
| <h3 class="text-sm font-medium text-gray-900 line-clamp-2 mb-1"> | ||
| ${product.title} |
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.
10. 가격 표시 형식 일관성 및 지역화
ProductItem.js에서 가격을 Number(product.lprice).toLocaleString('ko-KR')로 변환해 렌더링하는 점이 좋으나,
Number()변환 실패 시 (예: 빈 값, 잘못된 문자열) NaN이 될 위험이 있습니다.- 안정적인 렌더링을 위해 변환 전 타입 검사나 기본 값 설정이 필요합니다.
개선 예
const priceNumber = Number(product.lprice) || 0;
const priceFormatted = priceNumber.toLocaleString('ko-KR');특히 외부 API 데이터가 가변적일 때는 안전한 데이터 핸들링이 중요합니다.
| if (!targetElement) { | ||
| 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.
11. 무한 스크롤 Intersection Observer의 범위 개선
Footer 컴포넌트에서 무한 스크롤용 IntersectionObserver를 바닥의 footer 요소에 걸었는데,
- footer 가 visible 되는 시점에 다음 페이지가 로드되므로 UX 상 불필요한 조기 로딩이 발생할 수 있습니다.
개선 제안
- 별도의 빈 div(예: #scroll-trigger)를 두어 스크롤 트리거 위치를 명확히 하고,
- rootMargin 혹은 threshold 값을 조정해서 의도한 시점에 API 호출이 트리거되도록 조절하면 더 자연스럽습니다.
| @@ -0,0 +1,210 @@ | |||
| import { router } from "./router/Router.js"; | |||
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.
12. 이벤트 핸들러 함수 분리와 역할 명확화
현재 eventHandler.js 파일에 클릭, 변경, 키다운, 인풋 등 전역 이벤트 핸들러가 한 파일에 모두 몰려 있어,
- 함수가 길고 기능별 책임이 혼재되어 읽기 어려움
- 버그 발생 시 영향 범위가 넓음
개선 방안
- 기능별로(검색, 장바구니, 라우팅 등) 이벤트 핸들러를 분리하여 모듈화
- 이벤트 리스너 별도로 등록하여 관심사 분리 및 코드 가독성 개선
- 테스트 시에도 모듈별로 유닛 테스트 진행 가능
|
|
||
| const queryString = params.toString(); | ||
| const basePath = router.basePath === "/" ? "" : router.basePath; | ||
| const currentPath = router.getPath(); |
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.
13. URL 상태 동기화 시 쿼리 파라미터 중복 방지
syncStateToURL 함수에서 history.replaceState를 호출하여 URL을 변경하는데,
- 현재 URL과 새 URL을 단순 비교해서 다를 경우만 update 하는 점은 유효하나,
- 쿼리 문자열 생성 시 정렬 순서, 값 존재 여부에 따라 변형 가능성에 주의해야 합니다.
개선 방안
- 쿼리 파라미터 생성 시 항상 일관된 순서로 키를 정렬하거나,
- 상태 복원 시에도 동일한 로직을 사용해 쿼리 비교 정확도를 높이세요.
과제 체크포인트
배포 링크
https://daehyunk1m.github.io/front_7th_chapter2-1/
기본과제
상품목록
상품 목록 로딩
상품 목록 조회
한 페이지에 보여질 상품 수 선택
상품 정렬 기능
무한 스크롤 페이지네이션
상품을 장바구니에 담기
상품 검색
카테고리 선택
카테고리 네비게이션
현재 상품 수 표시
장바구니
장바구니 모달
장바구니 수량 조절
장바구니 삭제
장바구니 선택 삭제
장바구니 전체 선택
장바구니 비우기
상품 상세
상품 클릭시 상세 페이지 이동
/product/{productId}형태로 변경된다상품 상세 페이지 기능
상품 상세 - 장바구니 담기
관련 상품 기능
상품 상세 페이지 내 네비게이션
사용자 피드백 시스템
토스트 메시지
심화과제
SPA 네비게이션 및 URL 관리
페이지 이동
상품 목록 - URL 쿼리 반영
상품 목록 - 새로고침 시 상태 유지
장바구니 - 새로고침 시 데이터 유지
상품 상세 - URL에 ID 반영
/product/{productId})상품 상세 - 새로고침시 유지
404 페이지
AI로 한 번 더 구현하기
과제 셀프회고
+11-10일
발제문서와 스터디문서 딥다이브...!
JS 관련 문서와
SPA 관련 문서는 내용이 많아서 과제 진행하면서 중간중간 계속 참고하기로..
컴포넌트를 나누고,
깃헙페이지를 액션 워크플로우에 물려서 배포하는방식을 적용했다.
페이지는 흰 화면이 나오지만, 타이틀은 '상품 쇼핑몰'로 제대로 나오는 것 같으니
우선 흰 화면만 뜨는건 과제좀 진행하고 해결하는걸로..
+11-11일
기술적 성장
자랑하고 싶은 코드
개선이 필요하다고 생각하는 코드
학습 효과 분석
과제 피드백
AI 활용 경험 공유하기
리뷰 받고 싶은 내용
궁금한 것.
제가 구현했을 때 생각보다 SPA의 구동 로직이 라우터를 통해 진행되는 것이 많은데요
SPA를 구성할 때 라우팅도 무시못할 중요한 축 중 하나라고 생각됩니다.
그런데 왜 리액트는 자체적인 라우팅이 없을까?
라우팅을 사용할 때 react-router-dom 같은 다른 라이브러리에 의존하게 될까??