티스토리 뷰
[React] 무한스크롤 기능 구현( feat. react-query & react-intersection-observer)
blueprint-12 2022. 10. 23. 22:21무한 스크롤(Infinite Scroll)이란?
- 사용자가 특정 페이지 하단에 도달했을 때, API가 호출되며 컨텐츠가 끊기지 않고 계속 로드되는 사용자 경험 방식을 말합니다. 페이지를 클릭하면 다음 페이지 주소로 이동하는 페이지네이션(Pagination)과 달리, 한 페이지에서 스크롤만으로 새로운 컨텐츠를 보여주게 되므로, 많은 양의 콘텐츠를 스크롤하여 볼 수 있는 장점이 있습니다.
무한 스크롤을 구현하려면 가장 간단한 방법으로 스크롤 이벤트를 이용하여 구현할 수 있습니다. 하지만, 스크롤 이벤트를 사용하면 API 호출이 여러 번 갑니다. 이렇게 되면 documentElement.scrollTop과 documentElement.offsetHeight는 리플로우가 발생하기 때문에 개선하지 않으면 성능상 좋지 않게 됩니다.
🔥스크롤 이벤트는 짧은 시간에 수백번, 수천번 호출될 수 있고 '동기적'으로 실행되기 때문에 메인 스레드에 영향을 줍니다. 사용자가 스크롤할 때마다 이를 감지하는 이벤트가 끊임없이 호출되기 때문입니다.
이때 적용할 수 있는 방안으로는 디바운스와 쓰로틀이 있습니다. 둘 다 이벤트를 컨트롤할 때 사용하는 방법입니다. 여러 이벤트가 발생했을 때 하나의 이벤트만 발생하도록 하거나 일정 주기마다 이벤트를 모아 한번씩 이벤트가 발생하도록 제어할 수 있습니다.
Intersection Observer API
Intersection Observer(교차 관찰자 API)는 타겟 엘리먼트와 타겟의 부모 혹은 상위 엘리먼트의 뷰포트가 교차되는 부분을 '비동기적'으로 관찰하는 API이다.
🙋♂️viewport ? 현재 화면에 보여지고 있는 다각형의 영역을 말합니다.
웹 브라우저에서는 현재 창에서 문서를 볼 수 있는 부분을 말합니다. 뷰포트 바깥의 컨텐츠는 스크롤하기 전엔 보이지 않습니다.
스크롤 이벤트로 무한 스크롤을 구현하면 리플로우에 의해 좋지 않은 렌더링 성능과 상황에 따라 기대한대로 동작하지 않을 수 있는 문제점이 있었습니다. 최근에 이를 해결하기 위해 주로 사용하는 것이 바로 Intersection Observer API입니다.
기본적으로 브라우저 Viewport와 Target으로 설정한 요소의 교차점을 관찰하여 그 Target이 Viewport에 포함되는 지 구별하는 기능을 제공합니다. 이를 통해 성능적으로 더 나은 무한 스크롤을 구현할 수 있습니다.
- 동기적인 scroll이벤트와 달리 Intersetion Observer API는 요소를 비동기적으로 관찰하기 때문에 이벤트 컨트롤을 위해 debounce와 throttle을 사용하지 않아도 됩니다.
- reflow하지 않음 -> 스크롤 이벤트를 통해 무한 스크롤을 구현하면 항상 진짜 DOM에 접근하여 요소의 높이 값을 가져와야하는데 이렇게 되면 매번 새로운 layout을 새로 그리게 됩니다.
🐱👤layout을 새로 그린다는 것은 렌더 트리를 재생성한다는 의미이며, reflow라고도 불리우는 일련의 과정이 반복되면 브라우저의 성능이 저하되고 화면의 버벅거림이 생긴다.
*이 부분이 이해가 되지 않으신다면 브라우저 렌더링원리에 대해서 우선적으로 공부하는 것을 추천드립니다. 아주 중요한 개념입니다.
reflow & repaint 관련 추가 정보
🌷 DOM의 속성을 얻거나 갱신하는 것은 거의 같은 비용을 발생시킬 뿐 딱히 DOM객체가 느린 것은 아닙니다.
문제는 DOM을 건드리면 브라우저가 렌더링 트리를 재계산하고 그에 맞춰 렌더링을 일으킨다는 점입니다. 즉 느려진다는 것은 렌더링 부분입니다. 되도록 DOM의 속성을 건드리지않으려는 것은 렌더링이 느리기 때문입니다.
예를 들어, onClick의 이벤트리스너를 재설정하는 것은 렌더링에 큰 영향을 주지않기 때문에 굳이 가상돔을 경유할 이유가 없습니다.
🌷 일반적인 통념과 다르게 reflow보다 repaint의 부하가 더 큽니다. 단지 리플로우가 일어나면 많은 부분에 리페인트를 유발하기 때문에 리플로우를 최소화하려는 것입니다. 하지만 리플로우를 최소화했다고 해도 원래 부하가 큰 리페인트가 있다면 소용없습니다. (페인팅 비용이 가장 많이 비용이 많이드는 공정)
Q.이 기능을 언제 적용할 수 있나요?
- Lazy-loading : 페이지 스크롤 시 이미지를 레이지 로딩할 때
- 무한 스크롤을 통해 스크롤하며 새로운 콘텐츠를 불러올 때 ✔
- 광고의 수익 계산하기 위해 광고의 가시성을 참고할 때
- 사용자가 결과를 볼 것인지에 따라 애니메이션 동작 여부를 결정할 때
react-intersection-observer
리액트에서는 Intersection observer를 기반으로 만들어진 라이브러리가 있습니다.
이름부터 누가봐도 react-intersection-observer "리액트용 Intersection observer API..."
yarn add react-intersection-observer
를 통해 라이브러리를 추가해줍니다.
사용방식(기존 API의 entry의 isInterscting과 같은 역할이라고 합니다.)
const [ref, inView] = useInView();
ref는 관찰할 대상을 말하고, inView는 타겟이 화면에 보이지 않으면 false, 화면에 보이면 true를 갖습니다.
useEffect를 같이 사용하여 지정한 타겟이 화면에 보일 때마다 서버에 요청을 보내서 포스트를 받아올 수 있도록 합니다.
useEffect(() => {
if (inView && hasNextPage) {
fetch();
}
}, [fetch, hasNextPage, inView]);
react-query hooks
useInfiniteQuery()
- 기본 사용방법은 useQuery와 비슷합니다.
- fetchPage함수는 (기본 쿼리 함수가 정의되지 않은 경우에만) 필수 입니다. 쿼리가 데이터를 요청하는 데 사용할 비동기 함수입니다. 따로 만들어줘야 겠죠? resolve되거나 rejected된 프로미스 객체를 반환해야 합니다.
- getNextPageParam함수는 단일 변수를 리턴해야 합니다. 해당 단일 변수는 쿼리 함수의 마지막 인자로 전달됩니다. undefined를 리턴하게 되면 펫칭할 다음 페이지가 없다는 의미가 됩니다.
- 완전 처음 fetchPage가 시작되기 전에 {pageParam = 1} 으로 단일 변수를 넘겨주는 것을 볼 수 있습니다. 간단히 말하면 이게 초기값이라고 생각하면 될 거 같습니다. 페이지를 0부터 호출하고 싶다면 0으로 넘겨주면 됩니다.
기본 문법( 옵션들이 굉장히 많기 때문에 해당 문법을 암기한다 생각하시지 말고 우선 코드를 작성하시면서 필요한 기능 문법에 대해서 찾아가며 작성하시는 것이 좋을 거 같습니다 )
const {
fetchNextPage,
fetchPreviousPage,
hasNextPage,
hasPreviousPage,
isFetchingNextPage,
isFetchingPreviousPage,
...result
} = useInfiniteQuery(queryKey, ({ pageParam = 1 }) => fetchPage(pageParam), {
...options,
getNextPageParam: (lastPage, allPages) => lastPage.nextCursor,
getPreviousPageParam: (firstPage, allPages) => firstPage.prevCursor,
})
아래 코드는 제가 구현한 무한스크롤 기능입니다.
사용한 hooks
- useInfiniteQuery() // from react-query
- useInView() // from react-intersection-observer
src/components/InfiniteList.js
import React, { useEffect } from "react";
import { usePostA } from "../shared/api";
import { useInView } from "react-intersection-observer";
import Item from "./Item";
import Error from "./Error";
import Spinner from "./Spinner";
import NomoreContent from "./NomoreContent";
function InfiniteList() {
const { ref, inView } = useInView({
threshold: 0.5,
});
const { data, status, fetchNextPage, isFetchingNextPage, hasNextPage } =
usePostA();
useEffect(() => {
if (!data) return;
if (inView) fetchNextPage();
}, [inView]);
if (status === "error") return <Error>서버 에러</Error>;
return (
<>
{data?.pages.map((page, index) => (
<React.Fragment key={index}>
{page.itemListA.map((item) => (
<Item key={item.id} {...item}></Item>
))}
</React.Fragment>
))}
{isFetchingNextPage ? (
<Spinner />
) : hasNextPage ? (
<div ref={ref}></div>
) : (
<NomoreContent>no more contents</NomoreContent>
)}
</>
);
}
export default InfiniteList;
src/shared/api.js
import * as gvar from "./global_variables";
import { api } from "./axios";
import { useQuery, useInfiniteQuery } from "react-query";
// A포스트 가져오기
const getPostA = async (pageParam) => {
const res = await api.get(
`/recruit/${gvar.MY_TOKEN}/a-posts?page=${pageParam}`
);
const itemListA = res.data;
let isLast = pageParam >= 9 ? true : false;
return { itemListA, nextPage: pageParam + 1, isLast };
};
export const usePostA = () => {
return useInfiniteQuery(
"itemListA",
({ pageParam = 0 }) => getPostA(pageParam),
{
getNextPageParam: (lastPage) =>
!lastPage.isLast ? lastPage.nextPage : undefined,
}
);
};
👾관련 이슈
처음에 비동기 함수 getPostA만 따로 api 파일에 분리하고 리액트 쿼리로 데이터를 받아오는 함수 usePostA는 infiniteList.js파일에 두었습니다. infiniteList에서 getPostA를 임포트해와 usePostA에서 사용하였습니다.
문제상황: 마지막 페이지 리스트 이전까지는 정상적으로 스크롤이 마지막에 닿을 때마다 다음 리스트를 호출하여 가져왔으나 마지막 페이지를 펫칭할 때, 무한 로딩에 빠지게 되었습니다(브라우저 사망).
원인 예측
- 비동기 처리함수를 임포트해 불러와서 사용하기 때문에 해당 처리(비동기 요청을 끝내고 새로운 객체를 리턴함)가 완료되지 않은 시점에서 데이터를 뽑아와 렌더링하려 했기 때문에
- 원래 리액트 쿼리의 훅에 들어가는 쿼리함수(e.g. axios요청함수)는 같은 파일에 존재해야 한다.
- react query로직에 대해서 제대로 이해하지 못하고 사용했기 때문일 수도 있겠습니다. 이번에 처음 써보는 기능인지라 설명이 부족하겠지만 리액트 쿼리의 hooks들도 비동기적으로 실행되기 때문에 동기적으로 실행하려면 3번째 인자의 옵션 객체에 enabled: !!data 를 주면된다고 합니다. 제 코드에서 사용해보자면 아래와 같이 코드를 수정했다면 비동기 코드와 리액트 쿼리 훅이 다른 파일에 존재하더라도 위와 같은 에러가 해결되지 않을까 싶습니다.
3번 추가설명: react-query 종속 쿼리입니다. 이전 쿼리의 실행이 끝나지 않으면(끝나야 pageParam이 존재하기 때문에), 혹은 실행 완료값이 falsy한 값이라면 getNextPageParam을 실행시키지 않는다는 의미입니다.
enabled의 이러한 속성을 활용하여 쿼리를 원하는 때에만 호출할 수 있습니다.
export const usePostA = (pageParam = 0) => {
return useInfiniteQuery(
["itemListA" pageParam],
getPostA(pageParam),
{
getNextPageParam: (lastPage) =>
!lastPage.isLast ? lastPage.nextPage : undefined,
enabled: !!pageParam,
}
);
해결방법:
- 리액트 쿼리 훅에 들어가는 쿼리 함수를 같은 파일에 같이 두니 해결됐습니다. (2번 원인으로 생각하여 적용해봄)
평가:
- 결국 모든 원인들이 비동기와 관련되어 있고 리액트 쿼리의 동작 원리를 제대로 파악한다면 해결가능하지 않을까 싶습니다.
해당 이슈에 대해서 의견이나 해답을 알고 계신분이 있다면 댓글로 둥글게 남겨주시면 감사하겠습니다! 😉
'Frontend > react.js' 카테고리의 다른 글
[React | Issue] 리액트 컴포넌트의 DB 값 변동 시, 겪은 이슈(with json-server DELETE 메소드) (0) | 2022.12.08 |
---|---|
[React-Query] react-query 는 무엇이고 어떻게 사용하나요? + React.suspense (0) | 2022.10.24 |
[React] Portal (0) | 2022.10.22 |
[React | axios] axios interceptor 사용하기 (0) | 2022.10.22 |
[React] 에러 핸들링, 리액트 ErrorBoundary (0) | 2022.10.20 |
- Total
- Today
- Yesterday
- grid flex
- nvm경로 오류
- 타입스크립트 장점
- getStaticPaths
- text input pattern
- Prittier
- reactAPI
- fs모듈 넥스트
- 원티드 FE 프리온보딩 챌린지
- 프리렌더링확인법
- 항해99프론트후기
- is()
- float 레이아웃
- 프리온보딩 프론트엔드 챌린지 3월
- aspect-ratio
- && 셸 명령어
- D 플래그
- 항해99추천비추천
- nvm 설치순서
- 형제 요소 선택자
- getServerSideProps
- 원티드 3월 프론트엔드 챌린지
- tilde caret
- ~ ^
- 타입스크립트 DT
- 항해99프론트
- 원티드 프리온보딩 프론트엔드 챌린지 3일차
- 부트캠프항해
- 틸드와 캐럿
- 원티드 프리온보딩 FE 챌린지
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | ||||||
2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | 28 |