diff --git "a/content/blog/frontend/\355\216\200\354\236\207_\353\254\264\355\225\234\354\212\244\355\201\254\353\241\244.md" "b/content/blog/frontend/\355\216\200\354\236\207_\353\254\264\355\225\234\354\212\244\355\201\254\353\241\244.md" new file mode 100644 index 0000000..cf50d83 --- /dev/null +++ "b/content/blog/frontend/\355\216\200\354\236\207_\353\254\264\355\225\234\354\212\244\355\201\254\353\241\244.md" @@ -0,0 +1,204 @@ +--- +title: '펀잇팀의 무한스크롤 개발기' +date: 2023-10-15 15:40:00 +category: 'frontend' +draft: false +--- + +``` +📢 이 글은 무한스크롤을 구현하는 방법보다는 구현 과정에서 있었던 일에 대해 담고 있습니다. +``` + +펀잇 서비스에는 ‘상품 목록’을 보여주는 화면과 상품 상세 페이지에서 ‘리뷰 목록’을 보여주는 화면이 있다. 이러한 긴 목록들을 보여주는 방법에는 페이지네이션과 무한스크롤이 있다. 그 중에서 우리 팀은 무한스크롤 방식을 선택했고 왜 이 방식을 선택했는지, 그리고 어떤 방식으로 구현 했는지 글을 작성해보려고 한다. + +## 페이지네이션 + +스크린샷 2023-08-06 오후 2 00 16 + +페이지네이션은 화면 하단의 숫자를 클릭하여 선택한 페이지 넘버에 해당하는 페이지를 선택하는 방식이다. 대표적으로 구글이 이런 방식을 채택하고 있다. (요즘에는 더보기 버튼이 보이는 것 같기도..?) + +일정한 양을 매번 보여줌으로써 사용자들이 원하는 컨텐츠의 위치를 파악할 수 있도록 도와준다. 페이징이 일어날 때마다 사용자는 ‘클릭’이라는 행동을 취해야 한다는 특징이 있다. + +## 무한스크롤 + +![ezgif com-video-to-gif (4)](https://github.com/woowacourse-teams/2023-fun-eat/assets/55427367/e3c98015-497c-4bbc-8613-f450200dbc48) + +무한스크롤은 사용자가 스크롤을 내릴 때 페이징이 일어나는 방식이다. 사용자들이 직접 선택한 페이지로 이동하는 것은 불가능하지만, 스크롤만 해도 상품들이 더 노출되어 사용자의 행동을 최소화해준다는 장점이 있다. + +어떤 UX를 고를 것인지는 서비스의 의도, 환경 등에 따라 달라지게 된다고 생각한다. + +이런 무한스크롤도 구현 방법이 나뉘는데 scroll event를 사용하는 방법과 intersection observer을 사용하는 방법으로 나뉜다. + +scroll event는 매번 스크롤 이벤트가 일어날 때마다 높이를 감지하는 방식이고 intersection observer는 교차하는 지점을 감지하여 무한스크롤을 하는 방식이다. + +최종적으로 우리 팀에서는 intersection observer를 사용한 무한스크롤 방식을 채택하게 되었는데 그 이유는 아래와 같다. + +1. 펀잇은 모바일 기준으로 제작되어 클릭 액션을 최소화한 무한스크롤이 더 좋은 사용자 경험을 제공할 것임 +2. 펀잇에는 수천개의 상품이 존재하는데 이를 내릴 때마다 스크롤 이벤트가 발생하게 된다면 페이지 부하가 심해질 것임 + +## 구현 방법 + +### useIntersectionObserver hook 작성 + +1. options + +```tsx +const defaultOptions = { + root: null, + rootMargin: '0px', + threshold: 0.3, +} +``` + +- `root`: 타겟 요소의 가시성을 검사할 때 사용하는 루트 요소 (`null` 일 때 브라우저 뷰포트로 설정) +- `rootMargin`: 루트요소의 범위를 확장하여 확장된 영역에 들어가면 가시성 변화 발생하게 한다. 단위를 필수적으로 적어줘야 한다. +- `threshold`: 요소가 어느정도로 보여졌을 때 콜백을 실행할 것인지 결정 + +1. observer 생성 + +```tsx +const observer = useRef( + new IntersectionObserver(entries => { + entries.forEach(entry => { + if (entry.isIntersecting) { + callback() + } + }) + }, defaultOptions) +) +``` + +1. observe, unobserve 함수 작성 + +```tsx +const observe = (element: T) => { + observer.current.observe(element); +}; + +const unobserve = (element: T) => { + observer.current.unobserve(element); +}; + +useEffect(() => { + if (!targetRef.current) { + return; + } + + if (isLastPage) { + unobserve(targetRef.current); + return; + } + + observe(targetRef.current); + + return () => { + observer.current.disconnect(); + }; + }, [targetRef.current]); +}; +``` + +### 완성본 + +```tsx +import type { RefObject } from 'react'; +import { useRef, useEffect } from 'react'; + +const defaultOptions = { + root: null, + rootMargin: '0px', + threshold: 1.0, +}; + +const useIntersectionObserver = ( + callback: () => void, + targetRef: RefObject, + isLastPage: boolean | undefined +) => { + let isInitial = true; + + const observer = useRef( + new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + if (isInitial) { + isInitial = false; + } else { + callback(); + } + } + }); + }, defaultOptions) + ); + + const observe = (element: T) => { + observer.current.observe(element); + }; + + const unobserve = (element: T) => { + observer.current.unobserve(element); + }; + + useEffect(() => { + if (!targetRef.current) { + return; + } + + if (isLastPage) { + unobserve(targetRef.current); + return; + } + + observe(targetRef.current); + + return () => { + observer.current.disconnect(); + }; + }, [targetRef.current]); +}; +``` + +## 트러블슈팅 + +펀잇에는 여러개의 카테고리가 존재한다. 일반 상품 5가지, PB 상품 4가지로 총 9가지의 탭에서 모두 무한스크롤이 일어나게 된다. 따라서 사용자가 카테고리 탭을 바꿀 때마다 `page = 0` 으로 초기화해서 요청을 보내야했다. + +이 때, page 값 초기화와 비동기 요청의 순서를 보장할 수 없는 문제가 있었다. 예를 들어 간편식사 탭에서 `page = 4` 까지의 요청을 보낸 뒤 과자류 탭으로 이동했을 때 `page = 4` 의 요청이 먼저 간 뒤 `page = 0` 요청이 일어나는 것이었다. + +도대체 왜 이런 현상이 일어나는 것일까 고민해봤는데 내가 생각한 문제점은 다음과 같았다. + +무한스크롤을 할 때 두 가지의 작업을 하게 된다. + +1. `setPage` 를 통해 page를 초기화하는 과정 + +2. 팀 내에서 만든 `useGet` 을 통해 data를 가져오는 과정 → 이 때 의존성 배열 `[categoryId, page]` 가 존재한다. + +이때 page 상태가 0으로 초기화되기 전에 API 요청이 완료되어 상품 목록을 응답하게 되면 두 과정 사이의 race condition이 발생하는 것으로 판단했습니다. setState를 하는 함수는 렌더링 동일성이 보장되지만 비동기 요청은 순서를 보장하지 않기 때문이다. + +**🤔 해결방안** + +문제를 해결하기 위해 생각했던 방법은 카테고리값이 변경된 것을 확인하고 요청을 보내는 것이었다. 공식 문서에서 ref는 어떠한 정보를 저장하고 싶지만 렌더는 일으키고 싶지는 않을 때도 사용한다는 내용을 참고해 해당 방법으로 접근했다. + +`previousCategoryId`를 ref로 값을 저장 + +그 후 `categoryId`와 비교해서 값이 바뀌었다면 `page`를 초기화 한 후 비동기 요청을 보내게끔 구현 + +```tsx +const prevCategoryId = useRef(categoryId) +const nextPage = prevCategoryId.current !== categoryId ? 0 : page + +useEffect(() => { + setPage(0) //초기화 + prevCategoryId.current = categoryId +}, [categoryId, sort]) + +// useCategoryProducts에 전달해주는 값들이 refetch 조건임 +const { data: productListResponse } = useCategoryProducts( + categoryId, + nextPage, + sort +) //비동기 요청 +``` + +물론 이 문제는 [Suspense](https://react.dev/blog/2022/03/29/react-v18#new-suspense-features)를 사용하면 해결이 가능하다. + +이에 대해서는 2탄으로 이어적어보도록 하겠다!