이 글에서는 무한 스크롤을 구현하는 방법에 대해서 서술하고자 합니다.
구현 방법은 몇 가지 존재하지만 저는 Intersection Oberver API를 이용하여 구현하였습니다.
웹사이트나 앱에서 사용되는 스크롤링 기술로, 사용자가 페이지 하단에 도달했을 때, 콘텐츠가 계속 로드되는 사용자 경험(UX) 방식이다.
서버에서 데이터를 불러올 때, 모든 데이터를 한 번에 불러온다면 데이터가 많으면 많을수록 페이지 로딩 시간이 길어질 것이다.
원래는 이런 상황에서 페이지네이션을 주로 사용해왔지만 제목, 태그, 날짜 필터링 기능이 있는 상황이고, 추후 모바일 환경을 생각하니 무한 스크롤도 좋을 것 같다는 생각이 들어 선택하게되었다.
또한 사용자의 클릭을 최소화 하려는 목적도 가지고 있다.
Intersection Observer는 브라우저 뷰포트(Viewport)와 설정한 요소(Element)의 교차점을 관찰하며, 요소가 뷰포트에 포함되는지, 포함되지 않는지 구별하는 기능을 한다.
// useIntersectionObserver.ts
import { useRef } from "react";
export default function useIntersectionObserver(callback: () => void) {
const observer = useRef(
new IntersectionObserver(
(entries: IntersectionObserverEntry[]) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
callback();
}
});
},
{ threshold: 1 },
),
);
// Element는 DOM 요소를 나타내는 타입임
const observe = (element: Element) => {
observer.current.observe(element);
};
const unobserve = (element: Element) => {
observer.current.unobserve(element);
};
return [observe, unobserve];
}
new IntersectionObserver()를 통해 생성한 인스턴스(observer)로 관찰자를 초기화하고 관찰할 대상(element)을 지정한다.
콜백 함수는 IntersectionObserver의 entries 매개변수를 받아 각 엔트리를 순회하며 가시성 변화가 발생한 경우 콜백을 호출한다.
- observer를 useRef를 사용하여 생성한 이유는 상위 컴포넌트의 생애주기 동안 유지되는 값으로 하기위해 사용했다.
- threshold 옵션은 옵저버가 실행되기 위해 타켓의 가시성이 얼마나 필요한지 백분율로 표시한다. 여기서는 1로 설정하여 요소가 완전히 보일 때 가시성 변화를 감지하도록 하였다.
콜백함수 등록
const [observe, unobserve] = useIntersectionObserver(() => {
setPage((page) => page + 1);
});
가시성 변화가 발생한 경우 실행 될 콜백함수이며 , 새로운 데이터를 불러오기 위해 page + 1을 해준다.
교차 대상 지정
<>
<ListContainer>
...
</ListContainer>
{isLoading && <Loading />}
<div ref={target} style={{ width: "100%", height: 30 }}></div>
</>
div
태그가 교차 대상이며, 화면이 리스트의 끝에 왔을 때, 데이터를 추가로 불러와야 하기 때문에 해당 위치에 두었다.
기능 적용
useEffect(() => {
if (page === 1 && target?.current) {
observe(target.current);
}
const N = diaries.length;
const totalCount = pageInfo.totalElements;
if ((N === 0 || totalCount <= N) && target?.current) {
unobserve(target.current);
}
}, [diaries]);
diaries는 서버에서 Get요청으로 가져온 데이터 / totalCount는 해당 데이터가 총 몇 개 있는지 나타내는 변수이다.
해당 코드는 observe와 unobserve를 사용하였는데, 처음 데이터를 불러왔을 때, observe를 호출하며 / 모든 데이터를 불러왔으면 unobserve를 해준다.
마지막으로 page가 변화할 때 서버에서 데이터를 불러오는 useEffect문을 작성해준다.
// page 변경 감지에 따른 API호출
useEffect(() => {
fetchData().catch((error) => {
console.log(error);
});
}, [page]);
여기까지가 Intersection Oberver API를 이용한 무한스크롤 구현이였습니다.
추가로 쿼리를 이용한 태그, 제목, 날짜 필터링에서도 무한스크롤을 적용하였는데 나중에 참고할 부분이 있을 수도 있으니... 코드를 남겨두겠습니다.
const [searchVal, setSearchVal] = useState<string>("");
const debouncedSearchVal = useDebounce(searchVal, 300); // 300ms 딜레이로 디바운스 적용
const path = useLocation().pathname;
const search = useLocation().search;
// 이전 경로를 저장하기 위한 상태
const [previousPath, setPreviousPath] = useState(path);
const [previousSearch, setPreviousSearch] = useState(search);
... 관련 없는 코드 생략 ...
// page, path, search 변경 감지에 따른 API호출
useEffect(() => {
if (path !== previousPath || search !== previousSearch) {
setPage(1); // path 또는 search가 변경된 경우에만 page를 1로 초기화
}
// 경로 변화를 확인한 후에는 현재 경로로 상태 최신화
setPreviousPath(path);
setPreviousSearch(search);
fetchData().catch((error) => {
console.log(error);
});
}, [page, path, search]);
// API를 호출하는 부분
const fetchData = async () => {
setIsLoading(true);
try {
// 쿼리 없이 일기만 가져올 때
if (search === "") {
const API_URL = `${path}?page=${page}&size=10`;
const response = await api.get(API_URL);
// 페이지가 1 이상이면 기존 상태에 추가 / 1이라면 상태를 응답 데이터로 초기화
if (page > 1) {
setDiaries((prevData) => [...prevData, ...response.data.data]);
// setPageInfo(response.data.pageInfo);
} else {
setDiaries(response.data.data);
setPageInfo(response.data.pageInfo);
}
}
// 쿼리가 있을 때 && 쿼리에 ?title이 존재할 때 (제목 검색)
else if (search.includes("?title")) {
const API_URL = `${path}/search?page=1&size=10${search.replace(
"?",
"&"
)}`;
const response = await api.get(API_URL);
// 페이지가 1 이상이면 기존 상태에 추가 / 1이라면 상태를 응답 데이터로 초기화
if (page > 1) {
setDiaries((prevData) => [...prevData, ...response.data.data]);
// setPageInfo(response.data.pageInfo);
} else {
setDiaries(response.data.data);
setPageInfo(response.data.pageInfo);
}
}
// 쿼리가 있을 때(태그, 날짜)
else {
const API_URL = `${path}?page=${page}&size=10${search.replace(
"?",
"&"
)}`;
const response = await api.get(API_URL);
// 페이지가 1 이상이면 기존 상태에 추가 / 1이라면 상태를 응답 데이터로 초기화
if (page > 1) {
setDiaries((prevData) => [...prevData, ...response.data.data]);
// setPageInfo(response.data.pageInfo);
} else {
setDiaries(response.data.data);
setPageInfo(response.data.pageInfo);
}
}
} catch (error) {
console.log(error);
}
setIsLoading(false);
};
useEffect(() => {
if (debouncedSearchVal === "") {
navigate(`${path}`);
} else {
navigate(`${path}?title=${debouncedSearchVal}`);
}
}, [debouncedSearchVal]);
path와 search로 구별하는 이유는
useNavigate
를 사용하여 필터링 시 url을 변경시켜주기 때문