메인 페이지에는 수많은 게시글이 출력될 것이다. 따라서, 화면을 내릴 수록 게시글이 계속 보일 수 있도록 무한 스크롤 기능을 적용해보자.
SWR
은 페이지 매김 및 무한 로딩과 같은 일반적인 UI 패턴을 지원하기 위해서 전용 API인 useSWRInfinite
을 제공한다.
useSWRInfinite
는 하나의 Hook으로 여러 요청을 트리거(어느 특정한 동작에 반응해 자동으로 필요한 동작을 실행하는 것을 뜻)할 수 있는 기능을 제공합니다.
import useSWRInfinite from 'swr/infinite'
// ...
const { data, error, isValidating, mutate, size, setSize } = useSWRInfinite(
getKey, fetcher?, options?
)
위와 같이 useSWR
이 새로운 Hook은 요청 키, 가져오기 기능 및 옵션을 반환하는 함수를 허용한다.
무한 로딩에서 한 페이지에 하나의 요청이며, 여러 페이지를 가져와서 렌더링하는 것이다.
참고하자! 👉 useSWRInfinite
✅ 게시글들이 무한스크롤되어 출력될 곳인 메인페이지(index.tsx)
에 useSWRInfinite
을 사용하여 api 요청을 해주자.
1️⃣ getKey
: 각 페이지의 SWR 키를 얻기 위한 함수로, fetcher에 의해 혀용된 값을 반환한다. null이 반환되면 페이지의 요청은 시작되지 않는다.
2️⃣ 화면에 즉시 보이는 게시글 데이터들은 previousPageData
에 저장된다.
3️⃣ 만약 previousPageData
의 length
가 존재하지 않는다면 이는 더 이상의 데에터가 저장될 것이 없다는 것으로 화면의 끝이 도달했다는 뜻이다.
4️⃣ 만약 그렇지 않고 스크롤할 게시글이 남았다면 api 요청
을 한다.
5️⃣
data
: 각 페이지의 가져오기 응답 값의 배열erroruseSWR
: useSWR의 error와 동일isValidatinguseSWR
: useSWR의 isValidating와 동일mutate
: useSWR의 바인딩된 mutate 함수와 동일하지만 데이터 배열을 조작size
: 가져와서 반환 할 페이지 수setSize
: 가져와야 하는 페이지 수 설정 // index.tsx
// 1️⃣ 번
const getKey = (pageIndex: number, previousPageData: Post[]) => { // 2️⃣ 번
if (previousPageData && !previousPageData.length) return null; // 3️⃣ 번
return `/posts?page=${pageIndex}`; // 4️⃣ 번
};
const { // 5️⃣ 번
data,
error,
size: page,
setSize: setPage,
isValidating,
mutate,
} = useSWRInfinite<Post[]>(getKey);
✅ 게시글 나열하기(UI 작성)
1️⃣ 현재 페이지를 요청해온 페이지 값
또는 그렇지 않다면 첫번째 페이지인 0
으로 저장.
2️⃣ 페이지당 요청해온 값만큼
또는 그렇지 않다면 8개씩 보이게
저장.
3️⃣ 게시글 데이터를 가져올건데 다음과 같은 조건으로 가져온다.
order
: 작성일자를 기준으로 내림차순으로 가져오기relations
: sub(커뮤니티), votes(투표), comments(댓글) 정보도 join하여 가져오기skip
: 8개의 데이터를 제외한 다음의 데이터를 제공take
: 8개의 데이터를 가져온다.4️⃣ 투표에 대해 항상 반영되어야하는 유저정보 저장.
const getPosts = async (req: Request, res: Response) => {
const currentPage: number = (req.query.page || 0) as number; // 1️⃣ 번
const perPage: number = (req.query.page || 8) as number; // 2️⃣ 번
try {
const posts = await Post.find({ // 3️⃣ 번
order: { createdAt: 'DESC' },
relations: ['sub', 'votes', 'comments'],
skip: currentPage * perPage,
take: perPage,
});
if (res.locals.user) { // 4️⃣ 번
posts.forEach(p => p.setUserVote(res.locals.user));
}
return res.json(posts);
} catch (error) {
console.log(error);
return res.status(500).json({ error: '문제가 발생했습니다.' });
}
};
router.get('/', userMiddleware, getPosts);
이제는 앞서 설정한 8개의 게시글이 존재하는 화면의 끝에 도달하면, 다음 게시글이 보여지는 액션을 구현해야 한다. 이때 생각할 수 있는 것은 addEvenListner()
, scroll 이벤트
이다.
둘의 이벤트를 사용한다는 것은 document 스크롤 이벤트를 등록하고, 특정지점을 관찰하여 앨리먼트가 위치에 도달했을 때 실행할 콜백함수를 등록하는 것이다.
✅ 하지만, 이 동작은 단점을 갖는다.
scroll 이벤드는 수백번 호출될 수 있고, 동기적으로 실행하여 메인 스레드에 영향을 준다.
각 엘리먼트마다 이벤트가 등록되어 있기 때문에 이벤트가 끊임없이 호출되어 리플로우 현상이 발생한다.
리플로우(reflow)
: 리플로우는 브라우저가 웹 페이지의 일부 또는 전체를 다시 그려야하는 경우 발생한다.✅ Intersection Observer API를 사용하면 위와 같은 문제를 해결할 수 있다.
Intersection Observer
는 비동기적으로 실행되기 때문에 메인 스레드에 영향을 주지 않으면서 변경 사항을 관찰할 수 있다.
Intersection observer
는 기본적으로 브라우저 뷰포트(Viewport)
와 설정한 요소(Element)
의 교차점을 관찰하며, 요소가 뷰포트에 포함되는지 포함되지 않는지, 더 쉽게는 사용자 화면에 지금 보이는 요소인지 아닌지를 구별하는 기능을 제공한다.
이 기능은 비동기적으로 실행되기 때문에, scroll 같은 이벤트 기반의 요소 관찰에서 발생하는 렌더링 성능이나 이벤트 연속 호출 같은 문제 없이 사용할 수 있다.
✅ element가 교차(intersection)
될 때 이를 관찰하여 알려주는 관찰자(observer)
가 필요!
참고하자!
👉 Intersection Observer API
👉 Intersection Observer - 요소의 가시성 관찰
👉 The Intersection Observer API
✅ 기능 생성
1️⃣ 스크롤을 내려서 observedPost
에 닿으면 다음 페이지 게시글들을 가져오기 위한 id state
2️⃣ Infinite Scroll
기능 구현
3️⃣ 게시글이 없으면 return
하여 종료
4️⃣ 게시글이 담긴 배열안의 마지막 게시글의 id
를 저장.
5️⃣ posts(게시글 배열)
안에 새로운 게시글이 추가되어 마지막 게시글이 변경된다면 observedPost
에 마지막 게시글 id
를 저장.
6️⃣ 인자로 document.getElementById(id)
를 담고 observeElement
호출
getElementById()
: 태그에 있는 id 속성을 사용하여 해당 태그에 접근하여 하고 싶은 작업을 할 때 쓰는 함수. 해당 id가 없는 경우 null 에러가 발생7️⃣ 브라우저 viewport
와 설정한 element
의 교차점을 관찰
new IntersectionObserver
: 생성자를 호출하고 임계값이 한 방향 또는 다른 방향으로 교차할 때마다 실행되는 콜백 함수를 전달하여 교차 관찰자를 만든다.entries
: IntersectionObserverEntry 인스턴스의 배열8️⃣ 교차상태 true
isIntersecting
: 관찰 대상의 교차 상태(Boolean)observer.unobserve
: 대상 요소의 관찰을 중지한다.9️⃣ observer
가 실행되기 위해 대상의 가시성 비율이 얼마나 필요한지 백분율로 표시한 단일 숫자 또는 숫자 배열
0.5
를 사용할 수 있다.0
.(단 하나의 픽셀이라도 표시되는 즉시 콜백이 실행됨을 의미). 1.0
값은 모든 픽셀이 표시될 때까지 임계값이 통과된 것으로 간주되지 않음을 의미.🔟 observer.observe
: 대상 요소의 관찰을 시작
// index.tsx
const [observedPost, setObserverPost] = useState(''); // 1️⃣ 번
// 2️⃣ 번
useEffect(() => {
if (!posts || posts.length === 0) return; // 3️⃣ 번
const id = posts[posts.length - 1].identifier; // 4️⃣ 번
if (id !== observedPost) { // 5️⃣ 번
setObserverPost(id);
observeElement(document.getElementById(id)); // 6️⃣ 번
}
}, [posts]);
const observeElement = (element: HTMLElement | null) => {
if (!element) return;
const observer = new IntersectionObserver( // 7️⃣ 번
entries => {
if (entries[0].isIntersecting === true) { // 8️⃣ 번
console.log('Reached bottom of post');
setPage(page + 1);
observer.unobserve(element);
}
},
{ threshold: 1 } // 9️⃣ 번
);
observer.observe(element); // 🔟 번
};
✅ 다음 페이지의 게시글이 잘 들어온다!