프로젝트 소개
코드스테이츠 교육과정 중 솔로 프로젝트로 진행한 COZ Shopping 웹페이지로, 메인 페이지/상품리스트 페이지/북마크 페이지로 이뤄져 있어 상품 목록과 사용자가 지정한 북마크 목록들을 볼 수 있다.
배포 링크
https://jeannelee57.github.io/fe-sprint-coz-shopping/
링크 가신 후 흰 화면이 뜬다면 상단 왼쪽 로고를 한번 눌러주세요(다음 재배포 때 수정 예정)
깃허브 링크
https://github.com/JeanneLee57/fe-sprint-coz-shopping
구현한 기능
- 북마크 추가/제거(로컬스토리지 사용)
- 상품 이미지 클릭 시 모달창을 띄우고 모달창에서 북마크 추가/제거 가능
- 저장된 북마크가 없을 때 오류 컴포넌트 띄우기
- 햄버거 버튼을 눌러 메뉴 드롭다운 띄우기
- SPA 라우팅
- 상품리스트 페이지, 북마크 페이지에서 무한 스크롤 및 타입별 필터 기능
- 북마크 추가/제거 시 토스트 알림창
개발 과정에서 겪은 어려움
북마크 기능 구현
처음에는 로컬 스토리지만 이용할 생각을 해서 로컬 스토리지의 변화를 감지해서 재렌더링을 하도록 로직을 짜려 했다.
로컬 스토리지의 변화를 감지하는 이벤트 리스너를 생성하는 방법이 있었으나, 매번 로컬 스토리지 내에 새로운 키값이 생기는 것만 감지할 수 있기 때문에 밸류 값을 받아와서 변화가 있는지 확인하는 수밖에는 없었고 이런 과정이 비효율적이라는 것을 알게 됐다.
그래서 사용자가 북마크 추가/제거 행동을 할 때마다 로컬 스토리지에 아이템을 추가/제거하고, 그 내용을 다시 받아와서 상태로 저장하는 로직을 짰다.
이렇게 짜니 작동을 잘 하기는 했지만, 로컬 스토리지에 저장 후 다시 그 내용을 받아오는 작업이 불필요한 반복을 낳게 됐다.
그래서 최종적으로는 북마크 추가/제거 동작이 일어날 때 상태 먼저 변경을 해주고, 상태가 변경되면 useEffect로 상태 변화를 감지해서 업데이트된 상태를 로컬 스토리지에 저장해주도록 했다.
모달창 내 북마크 상호작용
메인 페이지의 북마크 리스트에서 모달창을 띄워 북마크 제거를 했을 때 북마크 상태가 변화하면서 바로 모달창이 사라져 버리는 문제가 있었다. 기능상의 치명적 문제가 있는 것은 아니지만 사용자가 잘못 눌러서 북마크 해제를 했을 때 그 작업을 취소하기가 어렵다는 단점이 있었다.
이를 해결하기 위해 모달 컴포넌트의 부모 컴포넌트인 Item 컴포넌트에서 관리하는 willBookmarked상태를 따로 둬서 모달창 내의 북마크 버튼을 눌렀을 때 상태 토글을 하고, 모달창을 닫았을 때 해당 상품이 기존에 북마크되어 있는지 여부를 나타내는 isBookmarked 상태와 비교하고 북마크를 업데이트하도록 했다.
event.stopPropagation()을 써서 같은 기능을 구현할 수 있다고 하나 구현해 보는 데는 실패했다.
리팩터링 이전 모달창 상호작용과 북마크 업데이트
const handleModalClose = () => {
if (isBookmarked && !willBookmarked) {
const bookmark = JSON.parse(localStorage.getItem("bookmark"));
const existingItemIndex = bookmark.findIndex((x) => x.id === item.id);
bookmark.splice(existingItemIndex, 1);
localStorage.setItem("bookmark", JSON.stringify(bookmark));
setBookmarkState(JSON.parse(localStorage.getItem("bookmark")));
notifyDeleteBookmark();
}
if (!isBookmarked && willBookmarked) {
const bookmark = JSON.parse(localStorage.getItem("bookmark")) || [];
bookmark.unshift(item);
localStorage.setItem("bookmark", JSON.stringify(bookmark));
setBookmarkState(JSON.parse(localStorage.getItem("bookmark")));
notifyBookmark();
}
setShowModal(false);
};
const handleBookmark = (e, item) => {
const bookmark = JSON.parse(localStorage.getItem("bookmark")) || [];
const existingItemIndex = bookmark.findIndex((x) => x.id === item.id);
const isExistingItem = existingItemIndex !== -1;
if (isExistingItem) {
bookmark.splice(existingItemIndex, 1);
} else {
bookmark.unshift(item);
}
localStorage.setItem("bookmark", JSON.stringify(bookmark));
setBookmarkState(JSON.parse(localStorage.getItem("bookmark")));
isBookmarked ? notifyDeleteBookmark() : notifyBookmark();
};
리팩터링 이후
const handleModalClose = () => {
if (isBookmarked && !willBookmarked) {
const updatedBookmarkState = updateBookmarkState();
setBookmarkState(updatedBookmarkState);
notifyDeleteBookmark();
}
if (!isBookmarked && willBookmarked) {
const updatedBookmarkState = updateBookmarkState();
setBookmarkState(updatedBookmarkState);
notifyBookmark();
}
setShowModal(false);
};
const updateBookmarkState = () => {
if (isBookmarked) {
const existingItemIndex = bookmarkState.findIndex(
(x) => x.id === item.id
);
const updatedBookmarkState = [...bookmarkState];
updatedBookmarkState.splice(existingItemIndex, 1);
return updatedBookmarkState;
} else {
const updatedBookmarkState = [item, ...bookmarkState];
return updatedBookmarkState;
}
};
const handleBookmark = () => {
const updatedBookmarkState = updateBookmarkState();
setBookmarkState(updatedBookmarkState);
isBookmarked ? notifyDeleteBookmark() : notifyBookmark();
};
라우팅
리액트 라우터 버전6의 createBrowserRouter와 data api를 사용하는 것을 고민했으나 여러 라우트 간에 서버를 통해 주고받아야 할 데이터가 있는 것 같지는 않아서 BrowserRouter만을 사용해 라우팅을 구현했다. 그렇지만 새로 개선된 기능에 익숙해지는 것이 좋으므로 다음에 프로젝트를 할 때는 createBrowserRouter를 써봐야겠다.
상품 페이지/북마크리스트 페이지
무한스크롤을 구현하기 위해서 Intersection api와 useInview를 둘 다 사용해 보았다.
useInview가 조금 더 간단하기는 하지만 최종 결과물은 Intersection api를 사용했다.
/* useInview 방식 */
const [page, setPage] = useState(1); //현재 페이지
const [load, setLoad] = useState(false); //로딩 스피너
const { ref, inView, entry } = useInView();
useEffect(() => {
setPage((prev) => prev + 1); //페이지 값 증가
getPost();
}, [inView]);
useEffect(() => {
getPost();
}, [page]);
const getPost = () => {
setLoad(true);
// 1초 후에 setShowData를 실행하는 setTimeout 예약
setTimeout(() => {
setShowData((prev) => [
...prev,
...data.slice((page - 1) * 12, page * 12),
]);
setLoad(false);
}, 1000);
};
/* Intersection observer 방식 */
const obsRef = useRef(null); //observer Element
useEffect(() => {
//옵저버 생성: threshold는 대상 요소가 얼마나 보여질 때 콜백이 호출될지를 설정(1.0이 100%)
const observer = new IntersectionObserver(obsHandler, { threshold: 1.0 });
observer.observe(obsRef.current);
return () => {
observer.disconnect();
};
}, []);
const obsHandler = (entries) => {
const target = entries[0];
if (!endRef.current && target.isIntersecting && preventRef.current) {
//옵저버 중복 실행 방지
preventRef.current = false; //옵저버 중복 실행 방지
setPage((prev) => prev + 1); //페이지 값 증가
}
};
useEffect(() => {
getPost();
}, [page]);
const getPost = () => {
setLoad(true);
// 1초 후에 setShowData를 실행하는 setTimeout 예약
setTimeout(() => {
setShowData((prev) => [
...prev,
...data.slice((page - 1) * 12, page * 12),
]);
setLoad(false);
}, 1000);
};
✅ 해결한 문제1. 처음에 렌더링할 때 옵저버 요소가 보이면서 한 번에 여러 페이지씩 올라간다.
그런데 페이지가 올라가면 그것을 감지해서 추가 데이터를 화면에 표시하도록 했는데 화면에는 첫 페이지의 12개만 표시되고, 스크롤을 끝까지 하더라도 데이터 100개가 전부 표시되지 않고 중간에 끊기는 문제가 있었다. 즉, 페이지가 올라갈 때 그것과 화면에 표시되는 데이터가 정상적으로 연동되지 않는 문제가 있었던 것.. 아마도 페이지 상태가 변하면 데이터를 추가로 받아오는 useEffect 콜백이 실행되기도 전에 다음 페이지 변화가 일어나서 그런 것 아닐까 추측해 본다. 코드랑 문제를 함께 남겼어야 했는데 그러질 못했다.
✅ 해결한 문제2. 처음에 데이터를 가져와서 페이지 단위로 끊은 다음에 필터를 적용하려고 하니 이미 끊어진 데이터 내에서 필터를 먹이게 되어서 다른 타입으로 넘어갔을 때 12개씩 데이터가 표시되는 것이 아니라 랜덤한 개수로 데이터가 받아졌다. 이 문제는 타입이 바뀌었을 때 보여줄 데이터 전체를 새로 세팅하고 페이지를 1로 설정하는 것으로 해결
✅ 해결한 문제3. 장황한 코드, 복잡한 로직
처음에는 타입 변화, 페이지 변화, 북마크 상태 변화를 감지해서 필요한 작업들을 해주는 useEffect들이 다 따로 있어서 코드가 장황하고 복잡했다. 그러다 보니 추가로 필요한 기능이 있으면 어디에 새로 로직을 추가해줘야 할지 알기가 너무 어려웠다. Q&A 멘토님과의 상담 후에 리팩터링을 해서 북마크 상태 변화와 타입 변화만을 감지해서 표시할 아이템 전체를 설정해 주고 리턴해 주는 부분에서 페이지 수만큼 끊어서 보여주도록 했다.
리팩터링 이전 무한스크롤 구현 방식
/* 북마크된 요소인지 확인(prop 전달용) */
const checkIsBookmarked = (item) => {
if (bookmarkState) {
return bookmarkState.some((x) => x.id === item.id);
}
return false;
};
/* 화면에 표시할 데이터를 업데이트 */
const updateShowData = (start, end) => {
setShowData(
bookmarkState
.filter((item) =>
currentType === "all" ? true : item.type === currentType
)
.slice(start, end)
);
};
/* 첫 렌더시 옵저버 생성 */
useEffect(() => {
const observer = new IntersectionObserver(obsHandler, {
threshold: 1.0,
});
if (obsRef.current) observer.observe(obsRef.current);
return () => {
observer.disconnect();
};
}, []);
/* 옵저버 콜백함수 */
const obsHandler = (entries) => {
const target = entries[0];
if (target.isIntersecting && preventRef.current) {
preventRef.current = false; //옵저버 중복 실행 방지
setPage((prev) => prev + 1); //페이지 값 증가
}
};
/* 데이터를 삭제하면 보여줄 데이터를 재설정 */
useEffect(() => {
updateShowData(0, page * 12);
}, [bookmarkState]);
/* 타입을 변경하면 보여줄 데이터를 재설정하고 페이지 초기화 */
useEffect(() => {
updateShowData(0, 12);
setPage(1);
}, [currentType]);
/* 페이지 변경시 보여줄 데이터를 재설정 */
useEffect(() => {
if (page !== 1) getPost();
}, [page]);
let timer = null;
const getPost = () => {
setLoad(true);
// 이전에 예약된 setTimeout이 있으면 취소
if (timer) {
clearTimeout(timeoutRef.current);
}
// 1초 후에 setShowData를 실행하는 setTimeout 예약
timer = setTimeout(() => {
setShowData((prev) => [
...prev,
...bookmarkState
.filter((item) =>
currentType === "all" ? true : item.type === currentType
)
.slice((page - 1) * 12, page * 12),
]);
preventRef.current = true;
setLoad(false);
}, 1000);
};
리팩터링 후 무한스크롤 구현 방식
/* 첫 렌더시 옵저버 생성 */
useEffect(() => {
const observer = new IntersectionObserver(obsHandler, {
threshold: 1.0,
});
if (obsRef.current) observer.observe(obsRef.current);
return () => {
observer.disconnect();
};
}, []);
/* 옵저버 콜백함수 */
const obsHandler = (entries) => {
setIsLoading(true);
setTimeout(() => {
const target = entries[0];
if (target.isIntersecting) {
setPage((prev) => prev + 1);
}
setIsLoading(false);
}, 500);
};
// useMemo
const filteredItem = useMemo(() => {
if (currentType === "all") {
return bookmarkState;
} else return bookmarkState.filter((item) => item.type === currentType);
}, [currentType, bookmarkState]);
❌ 해결 못한 문제 1. 마지막 페이지에 도달하면 데이터 추가 로드 방지
마지막 페이지에 도달하더라도 계속 로드스피너가 돌아가고 페이지 상태가 변하는 것이 마음에 들지 않아서 추가 로드를 방지하는 기능을 두려고 했다. endPage라는 상태를 따로 두어서 endPage에 도달하면 추가로 데이터를 가져오는 함수를 실행하지 못하도록 하려 했는데 일단 코드의 로직이 너무 복잡해서 endPage를 계산하는 로직을 어디다 둬야 할지 알기가 어려웠다.
리팩터링 후에는 로직이 많이 간단해져서 북마크페이지에서는 추가 로드 방지 기능을 구현할 수 있게 되었지만, 여전히 상품페이지 리스트 페이지에서는 기능을 구현하지 못했다. 핵심은 IntersectionObserver의 콜백함수 내에서 클로저가 생성돼 page의 최근값에 접근하기가 어렵다는 것이었다.
page의 최근값에 접근해야 disconnect()나 unobserve()를 통해서 옵저버를 꺼 줄 수가 있는데 page 상태 자체에 접근하지 못하니 그렇게 하기가 어렵다. 멘토님께서 추가 답변으로 useEffect를 새로 만들어 말씀주신 IntersectionObserver 관련 로직을 분리하여 작성한 뒤, 의존성 배열에 page를 넣는 방법과 setPage 와 같은 setState function의 인자에 기존 값이 들어오는 점을 활용하여 함수 호출시 콜백함수 내의 임시 변수에 저장하는 방법 두 가지를 추천해 주셨는데 둘 다 실제 구현에는 실패했다.. 시간을 두고 공부해서 다시 시도해봐야곘다.
/* 첫 렌더시 옵저버 생성 */
useEffect(() => {
observer = new IntersectionObserver(obsHandler, {
threshold: 1.0,
});
if (obsRef.current) observer.observe(obsRef.current);
return () => {
observer.disconnect();
};
}, []);
/* 옵저버 콜백함수 */
const obsHandler = (entries, observer) => {
if (filteredItem.length <= ITEMS_PER_PAGE * page) {
observer.unobserve(obsRef.current);
return;
}
setIsLoading(true);
setTimeout(() => {
const target = entries[0];
if (target.isIntersecting) {
setPage((prev) => prev + 1);
}
setIsLoading(false);
}, 500);
};
그밖의 돌아볼 점들
- html 작성: div, span만 사용하지 말고 웹 표준에 근접한 요소들을 사용하자.
- css 방법론에 관한 고민: tailwind 사용도 시도해보자.
- 아이템 카드 컴포넌트 재사용성: 재사용성이 떨어지는 것에 관한 고민이 있었으나 멘토님께서는 이 프로젝트 내에서는 재사용을 잘 하고 있으니 재사용성이 떨어지는 것은 아니나 여러 함수들이 섞여 있어 가독성이 떨어지니 컴포넌트를 분리해 보라고 하셨다.
- 네이밍과 컨벤션: 익숙하지 않은 분야이기도 하고 기능 구현에 급급해서 신경쓰지 못했다. 관련 자료들을 읽어 보고 앞으로 보편적인 컨벤션을 잘 지키는 네이밍을 해봐야겠다. import 정렬에도 컨벤션이 있다는 것을 이번에 처음 알게 되었다!
- 매직넘버 관리: 매직넘버 쓰지 말고 기호 상수로 작성하자.
- props drilling vs useContect vs 리덕스 vs 리코일: 상태 관리에 관한 구상을 프로젝트 계획 단계에서 꼼꼼히 하고 어떻게 상태를 관리할 것인지 잘 생각하자. 멘토님께서 리코일은 문법이 간단하니 사용해 보면 좋을 것이라고 추천해 주셨다. 상태 볼륨과 페이지 규모가 크지 않을 때는 useContext도 괜찮고, 페이지 규모가 커질 경우 리덕스는 필수라고 한다.
- 리드미, pr과 commit의 중요성: 나의 프로젝트가 어떻게 구성돼 있고 어떤 기능들을 어떤 과정을 거쳐서 만들었는지 잘 기록해 두자. 작은 기능별로 쪼개서 pr을 하자.
- 리액트 작동방식을 더 공부하자.
- 컴포넌트화, 코드 쪼개기: 두번 이상 반복되는 코드는 별도의 함수화/컴포넌트화하는 것이 바람직하다.
'프로젝트 > 미니 프로젝트 & 과제' 카테고리의 다른 글
[미니 프로젝트] 게시판 프로젝트 2일차 (0) | 2023.05.24 |
---|---|
[미니 프로젝트] 게시판 프로젝트 1일차 (0) | 2023.05.24 |
[과제] 피그마 클론 (0) | 2023.04.17 |
[과제] StatesAirline 서버 구현 (0) | 2023.04.05 |
[과제] Mini node server (2) | 2023.04.04 |