구현한 모습은 아래와 같다.
스크롤이 교차 지점에 닿으면 그 다음 페이지를 불러온다. 그리고 다음 페이지를 불러올때마다 지도도 지역 중심으로 움직이는 걸 볼 수 있다.
카카오 API 를 사용해 음식점 리스트를 불러오는 코드를 구현했다.
keywordSearch 함수를 이용하면 콜백함수 인자로 result, status, pagination 정보를 내려준다.
const searchKeyword = (keyword : string) => {
const { kakao } = window;
if(!map || !kakao) return;
const ps = new kakao.maps.services.Places();
ps.keywordSearch(keyword, (result, status, pagination) => {
if(status === kakao.maps.Services.Status.OK) {
console.log(result)
console.log(pagination)
}
},
{
category_group_code : 'FD6',
useMapBounds : true
}
);
}
pagination 값을 살펴보면 아래와 같다.
한번에 15개씩 데이터를 받아오고, 데이터는 총 45개만 제공해준다.
current: 1
first: 1
gotoFirst: ƒ ()
gotoLast: ƒ ()
gotoPage: ƒ (a)
hasNextPage: true
hasPrevPage: false
last: 3
nextPage: ƒ ()
perPage: 15
prevPage: ƒ ()
totalCount: 45
무한스크롤을 구현하기 위해서 Intersection Observer API 를 사용했다.
Intersection Observer API 에 대해서 검색해보면 글이 많은데, 글만 보고서는 이해가 어려웠다.
그래서 일단 따라서 코드를 작성해봤는데 오히려 작성하면서 동작 원리를 이해하게 되었다.
1) IntersectionObserver 인스턴스 생성
const observer = new IntersectionObserver(handleObserver, { threshold: 0 });
const currentEl = observerEl.current;
if (currentEl) {
observer.observe(currentEl);
}
즉, currentEl 요소가 다른 요소나 브라우저 뷰포트와 교차하면 인수로 넣은 callback 함수가 실행된다.
마지막으로 useEffect 의 cleanup 함수를 이용해 요소에 대한 관찰을 중단하도록 했다.
useEffect(() => {
const observer = new IntersectionObserver(handleObserver, { threshold: 0 });
const currentEl = observerEl.current;
if (currentEl) {
observer.observe(currentEl);
}
return () => {
if (currentEl) {
observer.unobserve(currentEl);
}
};
}, [handleObserver]);
2) callback 함수 만들기
currentEl 요소가 다른 요소나 브라우저 뷰포트와 교차하면 인수로 넣은 callback 함수가 실행된다고 했다.
여기서 나는 handleObserver 함수를 생성해 콜백함수 인수로 넣어줬다.
const handleObserver = useCallback(
(entries: IntersectionObserverEntry[]) => {
const target = entries[0];
if (target.isIntersecting && pagination?.hasNextPage) {
pagination.gotoPage(pagination.current + 1);
}
},
[pagination]
);
IntersectionObserverEntry 배열을 받아서 첫번째 항목인 entries[0] 을 받아와 target 변수에 넣어준다.
해당 항목이 교차 상태이고 (target.isIntersecting 이 true 일때) , 그리고 다음 페이지가 존재하는 경우 gotoPage 함수를 통해 다음 페이지로 이동시킨다.
3) 전체 코드
const Card = () => {
const observerEl = useRef<HTMLDivElement>(null);
const { pagination, resData } = useMap();
const handleObserver = useCallback(
(entries: IntersectionObserverEntry[]) => {
const target = entries[0];
if (target.isIntersecting && pagination?.hasNextPage) {
pagination.gotoPage(pagination.current + 1);
}
},
[pagination]
);
useEffect(() => {
const observer = new IntersectionObserver(handleObserver, { threshold: 0 });
const currentEl = observerEl.current;
if (currentEl) {
observer.observe(currentEl);
}
return () => {
if (currentEl) {
observer.unobserve(currentEl);
}
};
}, [handleObserver]);
return (
<List>
{resData.map((res: any) => (
<ListBox
onClick={() => {
router.push(`/${res.id}`);
}}
key={res.id}
top={
<div className={styles.title}>
<Text typography="t4">{res.place_name}</Text>
<Text typography="st3">{res.road_address_name}</Text>
</div>
}
bottom={
<div>
<Text typography="st3">{res.phone}</Text>
</div>
}
/>
))}
<div ref={observerEl} />
</List>
);
};
export default Card;
마지막으로 useKeyword 훅을 수정해줬다.
if (resData.length > 0) { setResData([]); setPagination(null); }
setResData((prev) => [...prev, ...data]);
const searchKeyword = useCallback(
(keyword: string) => {
const { kakao } = window;
if (!map || !kakao) return;
const ps = new kakao.maps.services.Places();
// resData.length 배열 값이 있으면 초기화 작업을 해줘야한다.
if (resData.length > 0) {
setResData([]);
setPagination(null);
}
ps.keywordSearch(
keyword,
(data, status, pagination: any) => {
if (status === kakao.maps.services.Status.OK) {
const bounds = new kakao.maps.LatLngBounds();
let markers = [];
for (let i = 0; i < data.length; i++) {
// @ts-ignore
markers.push({
position: {
lat: data[i].y,
lng: data[i].x,
},
content: data[i].place_name,
});
// @ts-ignore
bounds.extend(new kakao.maps.LatLng(data[i].y, data[i].x));
}
setMarkers(markers);
setPagination(pagination);
// 페이지가 바뀌어도 기존 데이터를 모두 보여주기 위해 아래와 같이 작성
setResData((prev) => [...prev, ...data]);
map.setBounds(bounds);
}
},
{
category_group_code: 'FD6',
useMapBounds: true,
}
);
},
[setPagination, setResData, resData, setMarkers, map]
);
return { searchKeyword };
};
컴포넌트 안에 Intersection Observer API 로직이 함께 작성되어 이 로직을 훅으로 빼기로 했다.
콜백함수와 다음 페이지가 있는지 여부를 확인할 수 있는 hasNextPage 를 인자로 받는 함수로 관찰 요소를 설정할 수 있는 observeEl 과 isLoading 을 반환한다.
import { useState, useEffect, useRef, useCallback } from 'react';
type InfiniteScrollType = {
callbackFn: () => void;
hasNextPage: boolean;
};
export const useInfiniteScroll = ({ callbackFn, hasNextPage }: InfiniteScrollType) => {
const [isLoading, setIsLoading] = useState<boolean>(false);
const observerEl = useRef<HTMLDivElement>(null);
const handleObserver = useCallback(
(entries: IntersectionObserverEntry[]) => {
const target = entries[0];
if (target.isIntersecting && !isLoading && hasNextPage) {
setIsLoading(true);
callbackFn();
setIsLoading(false);
}
},
[callbackFn, isLoading, hasNextPage]
);
useEffect(() => {
const observer = new IntersectionObserver(handleObserver, { threshold: 0 });
const currentEl = observerEl.current;
if (currentEl) {
observer.observe(currentEl);
}
return () => {
if (currentEl) {
observer.unobserve(currentEl);
}
};
}, [handleObserver]);
return { observerEl, isLoading };
};
useInfiniteScroll 훅을 사용해 아래와 같이 수정했다.
const Card = () => {
const { pagination, resData } = useMap();
const fetchNextPage = () => {
pagination?.gotoPage(pagination.current + 1);
};
onst { observerEl } = useInfiniteScroll({ callbackFn: fetchNextPage, hasNextPage: pagination?.hasNextPage! });
return (
<List>
{resData.map((res: any) => (
<ListBox
onClick={() => {
router.push(`/${res.id}`);
}}
key={res.id}
top={
<div className={styles.title}>
<Text typography="t4">{res.place_name}</Text>
<Text typography="st3">{res.road_address_name}</Text>
</div>
}
bottom={
<div>
<Text typography="st3">{res.phone}</Text>
</div>
}
/>
))}
<div ref={observerEl} />
</List>
);
};
export default Card;
스크롤을 끝까지 내려서 교차 지점에 닿으면 gotoPage 함수가 실행되어 새로운 데이터가 불러와진다.
이렇게 kakao API 를 활용한 무한스크롤을 구현하는 방법에 대해 정리해봤다.