최근 진행한 프로젝트에 있는 버튼을 이용한 무한 스크롤 기능을 스크롤 이벤트로 되도록 구현해봤다.
버튼을 이용하면 버튼 클릭시 다음 페이지에 해당하는 만큼의 데이터를 서버로부터 받아와서 표시하게 된다.
실제 우리가 사용하는 무한 스크롤기능이지만 버튼을 사용하게 되므로써 조금 더 구현이 쉬운 장점이 있다.
협업 프로젝트였고 마무리 기한이 정해져있다보니 같이 프론트 작업을 하던 팀원이 버튼으로 구현을 하고 마무리를 지었는데 이번에 프로젝트를 마무리하고 실제 스크롤을 이용한 버전으로 개선해봤다.
우선 내가 작성하지 않은 코드고 무한스크롤을 실제 구현하지 않아봐서 기존 코드을 이해하고 스크롤 event로 개선해야하는 부분을 찾는 것을 먼저했다.
아래코드는 무한스크롤 관련 부분만 남긴 코드이다.
const ReviewListContainer = () => {
... 다른 값들 생략
const reviews = useSelector((state: RootState) => state.review.reviews)
const [page, setPage] = useState(0)
const [pageCount, setPageCount] = useState(0)
// 총 리뷰 수
const reviewCount = selectedStore?.reviewCount ?? 0
// 서버에서 받아온 review 에서 중복 리뷰 제거
const newReviews = reviews.filter(중복 리뷰 sort 코드)
// 리뷰리셋 -> 처음 페이지에 들어올 때 redux에 있는 review 리셋
useEffect(() => {
if (reviews.length > 0) dispatch(initReviews())
}, [])
// 총 페이지수 계산 REVIEW_SIZE 는 10
useEffect(() => {
if (reviewCount) setPageCount(Math.ceil(reviewCount / REVIEW_SIZE - 1))
}, [reviewCount])
// page가 변하면 새로 page에 따른 review 받아오기
useEffect(() => {
if (storeId) dispatch(fetchAllReviews({ storeId, page }))
}, [page, storeId, dispatch])
return (
<ReviewListWrapper>
... 생략
<ListContainer>
{newReviews.map((review) => (
<ReviewList
key={review.reviewEntryNo}
... props 생략
/>
))}
// 전체 리뷰 수가 0보다 크고 전체 페이지수 보다 페이지가 작을 때
{page < pageCount && reviewCount > 0 && (
<FunButton
className="opposite"
onClick={() => {
// 버튼클릭시 페이지수 증가
setPage(page + 1)
}}
>
더보기
</FunButton>
)}
... 생략
</ListContainer>
</ReviewListWrapper>
)
}
export default ReviewListContainer
우선 서버에서 받아온 review들에서 중복 제거하는 식은 처음 서버에서 받아온 review가 저장되는 slice에서 진행하여 이미
reivew state에는 중복값이 없는 것이 좋다고 판단했다.
// review slice 안에서 리뷰 fetch 성공 case
builder.addCase(
fetchAllReviews.fulfilled,
(state, action: PayloadAction<ReviewType[]>) => {
// 받아온 review와 기존 state의 review에 중복을 제거한 새 리뷰
const newReviews = [...state.reviews, ...action.payload].filter(
(review, idx) => {
return (
[...state.reviews, ...action.payload].findIndex((review1) => {
return review.reviewEntryNo === review1.reviewEntryNo
}) === idx
)
}
)
state.loading = false
state.reviews = newReviews
}
)
pageCount state와 비슷하지만 더 이상 페이지가 존재하는지 여부는 hasMore state로 관리하도록 했다.
그래서 새로운 page 리뷰가 받아져와서 쌓일 때
전체 리뷰의 수 totalReviewCount 보다 작은 경우에만 true를 가지도록 하고 hasMore 값이 false가 되면 더이상 리뷰를 가져오는 action을 하지 않도록 했다.
const [hasMore, setHasMore] = useState(true)
useEffect(() => {
// 전체 리뷰수를 알게되면 review값이 갱신될때마다 작동
if (totalReviewCount > 0) {
setReivewLists(reviews)
setHasMore(totalReviewCount > reviews.length)
}
}, [reviews, totalReviewCount])
// page 수가 바뀔 때 hasMore가 true라면 fetch action
useEffect(() => {
if (storeId && hasMore) {
dispatch(fetchAllReviews({ storeId, page }))
}
}, [page, storeId, dispatch])
무한 스크롤을 만드려면 스크롤 이벤트와 관련된 값(scrollTop , offsetHeight 같은 거) 를 가지고 만들 수 있다.
하지만 위와 같은 값들은 scroll에 리스너를 달고 진행해야하고 Reflow를 일으킨다고 알고 있다.
또한 fetch를 다시 일으킬 조건에 맞는 스크롤 이벤트가 발생하면 조건이 맞는 범위내에서는 계속 불필요한 fetch 호출을 할 것이다.
불필요한 fetch 호출을 방지하기 위해서는 Throttle을 사용하면 된다.
하지만 나는보다 쉽게 스크롤 이벤트를 다룰 수 있는 intersection observer API 를 사용해서 구현하려 한다.
내가 아는 바로 이 api의 단점은 IE에서 지원하지 않는다는 점 뿐이라....
공부하고 사용하는데 좋다고 판단된다.
intersection observer는 다음 값들을 가질 수 있다.
interface IntersectionObserverInit {
root?: Element | Document | null;
rootMargin?: string;
threshold?: number | number[];
}
값에대한 MDN의 설명은 다음과 같다.
root
- 대상 객체의 가시성을 확인할 때 사용되는 뷰포트 요소입니다. 이는 대상 객체의 조상 요소여야 합니다. 기본값은 브라우저 뷰포트이며, root 값이 null 이거나 지정되지 않을 때 기본값으로 설정됩니다.
rootMargin
- root 가 가진 여백입니다. 이 속성의 값은 CSS의 margin 속성과 유사합니다. 이 값은 퍼센티지가 될 수 있습니다. 이것은 root 요소의 각 측면의 bounding box를 수축시키거나 증가시키며, 교차성을 계산하기 전에 적용됩니다. 기본값은 0입니다.
threshold
- observer의 콜백이 실행될 대상 요소의 가시성 퍼센티지를 나타내는 단일 숫자 혹은 숫자 배열입니다. 만일 50%만큼 요소가 보여졌을 때를 탐지하고 싶다면, 값을 0.5로 설정하면 됩니다. 기본값은 0이며 1.0은 요소의 모든 픽셀이 화면에 노출되기 전에는 콜백을 실행시키지 않음을 의미합니다.
내가 구현한 무한 스크롤은 맨 마지막 review에 intersection observer를 달고 이 review가 화면에 보여질 때 더 받아올 수 있는 값이 존재한다면
즉 hasMore 값이 true라면 fetch action을 하도록 구현하였다.
이를 위해서 review들 중 맨 마지막에 ref를 달았고 이 review 컴포넌트에도 forwardRef를 달았다.
{reivewLists.map((review, idx) => {
if (reivewLists.length === idx + 1) {
return (
<ReviewList
// 맨 마지막 review에만 ref를 넣음
ref={lastReviewRef}
... 생략
/>
)
} else {
return (
<ReviewList
... 생략
/>
)
}
})}
// reviewList.tsx
const ReviewList = forwardRef<HTMLDivElement, ReviewType>(
function ReviewList(props, ref) {
... 생략
export default ReviewList
위 lastReviewRef 말고도 useRef 훅으로 observer ref를 더 만들어 마지막 review를 관찰하여 연결하고 연결을 끊은 작업을 했다.
그리고 useCallback으로 감싸서 hasMore와 loading의 값에 따라서면 새로운 값이 되도록 해주었다.
const observer = useRef<IntersectionObserver | null>(null)
const lastReviewRef = useCallback(
(node: HTMLDivElement) => {
// 로딩중에는 리턴
if (loading) return
// observer ref가 이미 관찰중이면 연결 끊기
if (observer.current) observer.current.disconnect()
// 새로 intersectionObserver 달기
observer.current = new IntersectionObserver((entries) => {
// 단일 타겟을 관찰함으로 entries[0] & hasMore가 true일 때
if (entries[0].isIntersecting && hasMore) {
setPage((prevNum) => prevNum + 1)
}
})
// node가 있으면 -> div에 ref 달아놨으면 관찰하기
if (node) observer.current.observe(node)
},
[hasMore, loading]
스크롤 이벤트를 인식하고 fetch를 보내는 작업이 어려울 줄 알았는데 위 intersection observer를 다루는 부분이 어려웠다.
아직 더 공부를 해야 좀 알 것 같다.
https://tech.kakaoenterprise.com/149
https://youtu.be/NZKUirTtxcg