[3팀 김대현] Chapter2-1. 프레임워크 없이 SPA 만들기#31
[3팀 김대현] Chapter2-1. 프레임워크 없이 SPA 만들기#31daehyunk1m wants to merge 12 commits intohanghae-plus:mainfrom
Conversation
c1cdc92 to
9b09aa3
Compare
JunilHwang
left a comment
There was a problem hiding this comment.
이 피드백은 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.
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.
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.
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.
6. 글로벌 상태 변경 시 깊은 상태 구조 주의
스토어 상태 구조가 깊거나 Map, Set 같은 컬렉션 객체를 포함하고 있습니다. store.setState 호출 시 깊은 복사 없이 부분적으로 병합하여 상태를 변경하는데, 이 점에서 불변성을 지키기 어려울 수 있어요.
- 깊은 상태를 부분적으로 변경할 때는 불변성 유지를 위해 반드시 객체를 새로 생성하거나 컬렉션 복사본을 생성해야 합니다.
예를 들어 카트의 상품 목록 관리 시 직접 mutate하지 않고 복사본으로 교체하세요.
|
|
||
| // 의존성 비교: 이전 값이 없거나, 하나라도 변경되었으면 실행 | ||
| const shouldRun = | ||
| !prevDeps || |
There was a problem hiding this comment.
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.
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.
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.
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.
12. 이벤트 핸들러 함수 분리와 역할 명확화
현재 eventHandler.js 파일에 클릭, 변경, 키다운, 인풋 등 전역 이벤트 핸들러가 한 파일에 모두 몰려 있어,
- 함수가 길고 기능별 책임이 혼재되어 읽기 어려움
- 버그 발생 시 영향 범위가 넓음
개선 방안
- 기능별로(검색, 장바구니, 라우팅 등) 이벤트 핸들러를 분리하여 모듈화
- 이벤트 리스너 별도로 등록하여 관심사 분리 및 코드 가독성 개선
- 테스트 시에도 모듈별로 유닛 테스트 진행 가능
|
|
||
| const queryString = params.toString(); | ||
| const basePath = router.basePath === "/" ? "" : router.basePath; | ||
| const currentPath = router.getPath(); |
There was a problem hiding this comment.
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 같은 다른 라이브러리에 의존하게 될까??