프로젝트 Github : https://github.com/boostcampwm-2022/Web02-XOXO
우리는 SWR
을 통해 무한 스크롤을 구현해 볼 것이다. 그러기 위해 꼭 알아야하는 useSWRInfinite
에 대해 우선 정리해보자.
import useSWRInfinite from 'swr/infinite'
// ...
const { data, error, isLoading, isValidating, mutate, size, setSize } = useSWRInfinite(
getKey, fetcher?, options?
)
각 페이지의 응답 값의 배열
useSWR과 동일
useSWR과 동일
useSWR과 동일
useSWR과 동일
가져올 페이지, 반환될 페이지의 수
가져와야 하는 페이지의 수를 설정
인덱스와 이전 페이지 데이터를 받고, 페이지 키를 반환하는 함수
만약 API 호출의 response가 아래의 형태라면
GET /users?cursor=123&limit=10
{
data: [
{ name: 'Alice' },
{ name: 'Bob' },
{ name: 'Cathy' },
...
],
nextCursor: 456
}
getKey함수는 이렇게 정의할 수 있다.
const getKey = (pageIndex, previousPageData) => {
// 끝에 도달
if (previousPageData && !previousPageData.data) return null
// 첫 페이지, `previousPageData`가 없음
if (pageIndex === 0) return `/users?limit=10`
// API의 엔드포인트에 커서를 추가
return `/users?cursor=${previousPageData.nextCursor}&limit=10`
}
useSWR과 동일
useSWR과 동일
흐름은 다음과 같다.
setSize
→ getKey
→ fetcher
즉,
내가 추가로 데이터를 받아왔으면 하는 곳에 이벤트로 () => setSize(size+1)
를 걸어주자.
그러면 size
가 바뀌면서 pageIndex
가 +1 된 채로 getKey
함수가 호출 된다. 그러면 새로운 pageIndex
로 데이터가 갱신되게 되는 것이다.
swr
이 보여주고 있는 예제 코드를 통해 자세히 살펴보자!
import React, { useState } from "react";
import useSWRInfinite from "swr/infinite";
const fetcher = (url) => fetch(url).then((res) => res.json());
const PAGE_SIZE = 6;
export default function App() {
const [repo, setRepo] = useState("reactjs/react-a11y");
const [val, setVal] = useState(repo);
const { data, error, mutate, size, setSize, isValidating } = useSWRInfinite(
(index) =>
`https://api.github.com/repos/${repo}/issues?per_page=${PAGE_SIZE}&page=${
index + 1
}`,
fetcher
);
const issues = data ? [].concat(...data) : [];
const isLoadingInitialData = !data && !error;
const isLoadingMore =
isLoadingInitialData ||
(size > 0 && data && typeof data[size - 1] === "undefined");
const isEmpty = data?.[0]?.length === 0;
const isReachingEnd =
isEmpty || (data && data[data.length - 1]?.length < PAGE_SIZE);
const isRefreshing = isValidating && data && data.length === size;
return (
<div style={{ fontFamily: "sans-serif" }}>
<input
value={val}
onChange={(e) => setVal(e.target.value)}
placeholder="reactjs/react-a11y"
/>
<button
onClick={() => {
setRepo(val);
setSize(1);
}}
>
load issues
</button>
<p>
showing {size} page(s) of {isLoadingMore ? "..." : issues.length}{" "}
issue(s){" "}
<button
disabled={isLoadingMore || isReachingEnd}
onClick={() => setSize(size + 1)}
>
{isLoadingMore
? "loading..."
: isReachingEnd
? "no more issues"
: "load more"}
</button>
<button disabled={isRefreshing} onClick={() => mutate()}>
{isRefreshing ? "refreshing..." : "refresh"}
</button>
<button disabled={!size} onClick={() => setSize(0)}>
clear
</button>
</p>
{isEmpty ? <p>Yay, no issues found.</p> : null}
{issues.map((issue) => {
return (
<p key={issue.id} style={{ margin: "6px 0" }}>
- {issue.title}
</p>
);
})}
</div>
);
}
여기서는 따로 변수로 선언해주지 않고, useSWRInfinite
의 파라미터에 넣어놓았다.
(index) => `https://api.github.com/repos/${repo}/issues?per_page=${PAGE_SIZE}&page=${index + 1}`
여기서 index
는 size
와 같다.
만약 어디선가 setSize
를 통해 size
가 변경되면 해당 함수가 호출이 돼서, 변경된 index에 맞는 페이지의 데이터를 요청하게 되는 것이다!
그렇다면 setSize
는 어디에서 하는 것일까…?
<button disabled={isLoadingMore || isReachingEnd} onClick={() => setSize(size + 1)}>
{isLoadingMore
? "loading..."
: isReachingEnd
? "no more issues"
: "load more"}
</button>
load more
버튼을 누르면, size
를 +1 시키고, 그에 따라 getKey
가 호출되는 형식이다.
여기서 보이는 isLoadingMore
, isReachingEnd
는 추가로 데이터를 불러오고 있는지(로딩중), 데이터를 끝까지 불러왔는지를 확인하는 변수이다.
const issues = data ? [].concat(...data) : [];
const isLoadingInitialData = !data && !error;
const isLoadingMore =
isLoadingInitialData ||
(size > 0 && data && typeof data[size - 1] === "undefined");
const isEmpty = data?.[0]?.length === 0;
const isReachingEnd =
isEmpty || (data && data[data.length - 1]?.length < PAGE_SIZE);
const isRefreshing = isValidating && data && data.length === size;
처음에는, 오로지 scroll
이벤트를 통해 무한 스크롤을 다루고자 했었다.
window.addEventListener('scroll', handleScroll)
를 통해 스크롤 이벤트를 걸어놓고, 해당 변수를 통해 확인해보는 것이다.
그러나 실제 이런 방식으로 구현했을 때, 사용자가 스크롤을 할때마다 계속 이벤트가 호출 되는 현상을 확인할 수 있었고, (물론 이는 디바운스나, 쓰로틀링을 통해 해결할 수 있기는 하다…) 다른 방식으로 구현할 수 있을까 찾아보게 되었다. 그래서 찾게된게 Intersection Observer
이다!
Intersection Observer
는, target Element가 화면에 노출되었는지 여부를 간단하게 구독할 수 있는 api이다.
let options = {
root: document.querySelector('#scrollArea'),
rootMargin: '0px',
threshold: 1.0
}
let observer = new IntersectionObserver(callback, options);
new
키워드를 통해 인스턴스를 생성하고, callback
, options
2개의 파라미터를 받는다.
callback
은 가시성의 변화가 생겼을 때 호출되는 콜백 로직이고, options
는 만들어질 인스턴스에서 콜백이 호출되는 상황을 정의한다.
root
값을 null
로 주었을 때 기본 값으로 브라우저 뷰포트가 설정된다.IntersectionObserverEntry의 인스턴스를 담은 배열이다. IntersectionObserverEntry는 루트 요소와 타겟 요소의 교차의 상황을 묘사한다.
entry는 여러가지 프로퍼티를 가지고 있으며 모두 읽기 전용이다. 여기서 우리가 주로 봐야할 것은
IntersectionObserverEntry.isIntersecting
이 변수이다.
해당 entry
에 타겟 요소가 루트 요소와 교차하는 지 여부를 Boolean 값으로 반환한다.
우리는 해당 변수를 통해 Intersection observer
를 Custom Hook
으로 만들어 재사용해보고자 한다!
import { useEffect, useRef } from 'react'
const useInfiniteScroll = (postings: any, callback: () => void) => {
const bottomElement = useRef(null)
useEffect(() => {
if (bottomElement?.current) {
const io = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
callback()
}
})
}, {
threshold: [1.0]
}
)
io.observe(bottomElement.current)
return () => io.disconnect()
}
}, [postings, bottomElement])
return bottomElement
}
export default useInfiniteScroll
useInfiniteScroll
커스텀 훅을 생성해, posting
이라는 deps
array와 callback
을 넘겨준다.
posting
을 deps
로 넘겨주는 이유는, 새로운 이미지를 받아왔을 때 bottom Element의 위치가 달라지게 되고, 그에 따른 observer를 다시 달아줘야하기 때문에 deps 로 넘겨주었다.
이후 앞서 말했던 isIntersecting
을 확인해, 교차했을 시 파라미터로 받은 callback
을 실행하는 식으로 구현하였다.
const SCROLL_SIZE = 15
const FeedPostingList = ({ isOwner, dueDate, isGroupFeed }: IProps) => {
const navigate = useNavigate()
const { feedId } = useParams<{ feedId: string }>()
const getKey = (pageIndex: number, previousPageData: Iposting[]) => {
if (previousPageData && !previousPageData.length) return null
if (pageIndex === 0) return `/feed/scroll/${feedId}?size=${isGroupFeed ? SCROLL_SIZE - 1 : (isOwner ? SCROLL_SIZE : SCROLL_SIZE - 1)}&index=${pageIndex}`
return `/feed/scroll/${feedId}?size=${SCROLL_SIZE}&index=${previousPageData[previousPageData.length - 1].id}`
}
const { data: postings, error, size, setSize } = useSWRInfinite(getKey, fetcher, { initialSize: 1 })
// 리스트 로딩중일 때
const isLoading = (!postings && !error) || (size > 0 && postings && typeof postings[size - 1] === 'undefined')
// 리스트를 정상적으로 받아왔지만 비어있을 경우 (게시글이 작성되지 않았을 경우)
const isEmpty = postings?.[0]?.length === 0
// 쓰기 버튼이 존재할때 리스트의 끝에 도달했는지 판단
const isExistWriteButton = (postings != null) && ((postings.length === 1 && postings[0].length < SCROLL_SIZE - 1) || (postings.length > 1 && postings[postings.length - 1].length < SCROLL_SIZE))
// 쓰기 버튼이 존재하지 않을 때 리스트의 끝에 도달했는지 판단
const isNotExistWriteButton = (postings != null) && (postings[postings.length - 1].length < SCROLL_SIZE)
// 그룹피드, 개인 피드의 주인, 개인 피드의 주인이 아닐 때 리스트의 끝에 도달했는지 판단
const isReachingEnd = (isGroupFeed && isExistWriteButton) || (!isGroupFeed && !isOwner && isExistWriteButton) || (!isGroupFeed && isOwner && isNotExistWriteButton)
const bottomElement = useInfiniteScroll(postings, () => {
!isReachingEnd && setSize(size => size + 1)
})
useEffect(() => {
if (isEmpty) toast('아직 작성된 포스팅이 없습니다')
}, [isEmpty])
const postingList = postings?.flat().map((posting: Iposting) => {
return (
<button key={posting.id} className="posting-container" onClick={() => handleClickPosting(posting.id)} >
<img key={posting.id}
className="posting"
src={getFeedThumbUrl(posting.thumbanil)}
/>
</button>
)
})
const writePostingButton =
<Link className="write-posting-container" to={`/write/${feedId}`}>
<div className='write-posting-button'>
<PlusIcon width={'5vw'}/>
</div>
</Link>
return (
<div className='posting-list-wrapper'>
<div>
<div className="posting-grid">
{isWritabble && writePostingButton}
{!isEmpty && postingList}
</div>
</div>
{isLoading
? <Loading />
: !isReachingEnd && <div className="bottom-element" ref={bottomElement}>
<ObserverElement />
</div>
}
<Toast />
</div>
)
}
export default FeedPostingList
그때 당시 나는 그냥 infinite scroll에 꽂혀있어서, 우선 이것을 구현하는 것에 급급해있었다. 이후 어찌저찌 구현을 완료하고 난 뒤, lazy loading을 시도해보려고 자세히 정보를 찾아보기 시작했을 때, 굳이 무한 스크롤이 적용되어 있는데 lazy loading 까지 도입을 해야하는 것에 대한 의문…? 이 들었다.
(물론 lazy loading 을 시도해보는 것이 나에게 있어서 엄청나게 좋은 경험이 될 것 같고, 해보고 싶었지만!!, lazy-loading은 모든 이미지를 저화질?(혹은 blur)로 이미 불러와 놓고, 사용자에게 보여질때 제대로 로딩하는 기법이라면, infinite scroll은 일부의 이미지만 불러와 놓고, 사용자에게 보여질때 또다시 요청을 하는 기법이다보니까, 둘이 상충된다는 느낌을 받았었던 것 같다.)
여튼 위의 이유로 접어뒀었는데, 아니나 다를까 발표시간에 해당 사항과 관련된 질문을 듣게 되었다. (infinite scroll만 적용했나요? lazy loading은요?)
이후 내가 잘못 생각한걸까? 두개 다 하는게 맞는걸까..? 아니면 애초부터 그냥 lazy loading 만을 도입했어야 했나…? 하는 생각이 들게 됐고, 다시 한번 자세히 살펴본 뒤 두개를 동시에 진행해보는 방식으로 로직을 조금 수정해보고 싶은 생각이 든다.
https://swimfm.tistory.com/entry/무한-스크롤-Infinite-Scroll-페이징-구현해보기-예제
https://dmdwn3979.tistory.com/9
https://dev.rase.blog/21-12-07-intersection-observer/
https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API
https://moon-ga.github.io/react/infinite-scroll-with-intersectionobserver/
https://velog.io/@minseok_yun/IntersectionObserver로-무한-스크롤-만들기
https://heropy.blog/2019/10/27/intersection-observer/
https://web.dev/rendering-performance/