프로젝트 중 무한 스크롤을 구현하게 되었다. useRef를 사용하여 컨테이너 요소의 scrollTop, scrollHeight, clientHeight 값을 받아서 구현했다.

브라우저 호환성 문제, 요소의 높이를 부적절하게 구하는 등에 의해서 무한 스크롤이 멈추거나 작동하지 않는 경우가 있었고 단시간에 수백번 호출이 되며 동기적으로 실행되었다. 또한, 각 엘리먼트 마다 이벤트가 등록되어 있는 경우, 사용자가 스크롤할 때마다 이벤트가 끊임없이 호출되기 때문에 몇배로 성능 문제가 발생한다는 것을 알 수 있었다.
일단 기존에 작성한 코드를 한번 살펴보자.
useEffect(() => {
setItems([...places]);
}, [places]);
const handleScroll = () => {
if (listRef.current) {
const { scrollTop, scrollHeight, clientHeight } = listRef?.current;
if (
Math.ceil(scrollTop) + clientHeight >= scrollHeight &&
hasNext &&
!loading
) {
setLoading(true);
setPage((prevPage) => prevPage + 1);
} else {
// TODO: 해야 함
}
}
};
useEffect(() => {
if (listRef.current) {
listRef.current.addEventListener("scroll", handleScroll);
}
return () => {
if (listRef.current) {
listRef.current.removeEventListener("scroll", handleScroll);
}
};
}, [handleScroll]);
props로 받은 places 배열 객체를 places가 변경될때마다 Spread 연산자로 얕은 복사를 수행하여 state에 저장한다.
handleScroll 함수는 Ref 객체로 받은 스크롤 값들로, 아래의 조건일 때 로딩 상태로 만들고 page 1 증가시킨다.
Math.ceil(scrollTop) + clientHeight >= scrollHeight && hasNext && !loading
cleanup 함수를 사용하여 이벤트 리스너를 제거함으로써 메모리 누수를 방지하고 예기치 않은 동작을 방지했다.
useEffect(() => {
if (page === 0) return;
const lastItemIndex = items ? items.length - 1 : -1;
const lastid = items[lastItemIndex]?.placeId;
const lastRating = items[lastItemIndex]?.rating;
async function fetchItems() {
try {
const placeSearchRes = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/course/placeSearch?keyword=${debouncedSearch}&lastId=${lastid}&lastRating=${lastRating}`
);
if (!placeSearchRes.ok) return;
const placeSearchResult = await placeSearchRes.json();
setItems((prevItems) => [
...prevItems,
...placeSearchResult?.data.places,
]);
// placeSearchResult?.data 가 undefined 일 때 기본값 false 를 할당
setHasNext(placeSearchResult?.data?.hasNext || false);
setLoading(false);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
}
fetchItems();
}, [page]);
page가 0일 경우는 Effect를 끝냈다. 만약 앞선 조건이 옳았다면 page가 1이 되면서 해당 Effect가 동작하였을 것이다.
lastItemIndex는 items의 마지막 index 값
lastid는 마지막 item의 placeId 값
lastRating는 마지막 item의 rating값이다.
해당 내용을 담아서 API 호출하였고 response로 hasNext와, 5개의 장소 데이터(places)를 반환해주었다. 그렇게 기존 items에 포함시켜주고, 백엔드로부터 받은 hasNext를 컴포넌트 state에 저장해주었다.
기존에 구현했던 방식을 잠깐 살펴보았다. 그렇다면 intersection observer를 이용하여 무한 스크롤을 구현해보자.
✅Intersection Observer API란 무엇인가?
Intersection Observer API는 대상 요소와 상위 요소 또는 최상위 문서의 뷰포트 사이의 교차점 변화를 비동기적으로 관찰하는 방법을 제공한다.
이 API는 웹 개발에서 요소가 화면에 나타나거나 사라지는 등의 교차점 변화를 감지하고 이를 처리할 수 있는 기능을 제공한다. 기존에는 스크롤 이벤트나 타이머를 사용하여 요소의 가시성을 감지했지만, 이는 비효율적이고 성능에도 문제가 있을 수 있다. Intersection Observer API는 이러한 문제를 해결하기 위해 등장한 API다.
Intersection Observer API는 감시하고자 하는 요소가 다른 요소(viewport)에 들어가거나 나갈때 또는 요청한 부분만큼 두 요소의 교차부분이 변경될 때 마다 실행될 콜백 함수를 등록할 수 있게 한다. 즉, 사이트는 요소의 교차를 지켜보기 위해 메인 스레드를 사용할 필요가 없어지고 브라우저는 원하는 대로 교차 영역 관리를 최적화 할 수 있다.
Intersection Observer의 필요성
- 페이지가 스크롤 되는 도중에 발생하는 이미지나 다른 컨텐츠의 지연 로딩.
- 스크롤 시에, 더 많은 컨텐츠가 로드 및 렌더링되어 사용자가 페이지를 이동하지 않아도 되게 하는 infinite-scroll 을 구현.
- 광고 수익을 계산하기 위한 용도로 광고의 가시성 보고.
- 사용자에게 결과가 표시되는 여부에 따라 작업이나 애니메이션을 수행할 지 여부를 결정.
✅ Intersection Observer 생성하기
let options = {
root: document.querySelector('#scrollArea'),
rootMargin: '0px',
threshold: 1.0
}
let observer = new IntersectionObserver(callback, options);
// 옵저버 생성 뒤 관찰할 대상 요소를 지정한다.
let target = document.querySelector('#listItem');
observer.observe(target);
// the callback we setup for the observer will be executed now for the first time
// it waits until we assign a target to our observer (even if the target is currently not visible)
IntersectionObserver() 생성자에 전달되는 options 객체는 observer 콜백이 호출되는 상황을 조작할 수 있다.
🟡 root
대상 객체의 가시성을 확인할 때 사용되는 뷰포트 요소다. 이는 대상 객체의 조상 요소여야 한다. 기본값은 브라우저 뷰포트이며, root 값이 null 이거나 지정되지 않을 때 기본값으로 설정된다.
🟡rootMargin
CSS의 margin 속성과 유사한 root가 가진 여백이다. 이것은 root 요소의 각 측면의 bounding box를 수축시키거나 증가시키며, 교차성을 계산하기 전에 적용된다.(기본값 0)
🟡threshold
observer의 콜백이 실행될 대상 요소의 가시성 퍼센티지를 나타내는 단일 숫자 혹은 숫자 배열로 만일 50%만큼 요소가 보여졌을 때를 탐지하고 싶다면 0.5로, 25% 단위로 요소의 가시성이 변경될 때마다 콜백이 실행되게 하고 싶다면 [0, 0.25, 0.5, 0.75, 1] 과 같은 배열을 설정한다. 기본값은 0(이는 요소가 1픽셀이라도 보이자 마자 콜백이 실행됨을 의미함). 1.0은 요소의 모든 픽셀이 화면에 노출되기 전에는 콜백을 실행시키지 않음을 의미한다.
🟢 callback
콜백은 관찰할 대상 (target)이 등록되거나, 가시성(visibility: 해당 요소가 뷰포트 혹은 특정 요소에서 보이거나 보이지 않을 때)에 변화가 생기면 실행된다.
콜백은 2개의 인수(entries, observer)를 갖는다.
let observer = new IntersectionObserver((entries, observer) => {}, options);
entries
entries는 IntersectionObserverEntry의 배열을 뜻한다.
IntersectionObserverEntry는 읽기 전용의 여러가지 속성들을 포함한다.
let callback = (entries, observer) => {
entries.forEach(entry => {
// Each entry describes an intersection change for one observed
// target element:
// entry.boundingClientRect
// entry.intersectionRatio
// entry.intersectionRect
// entry.isIntersecting
// entry.rootBounds
// entry.target
// entry.time
});
};
- boundingClientRect: 관찰 대상의 경계 사각형 정보를 나타낸다.
- intersectionRect: 교차되는 영역의 정보를 나타낸다.
- intersectionRatio: 요소가 교차되는 영역의 비율을 0.0과 1.0 사이의 숫자로 나타다.(intersectionRect 영역에서 boundingClientRect 영역까지 비율, Number)
- isIntersecting: 요소가 교차되는지 여부를 나타낸다.
- rootBounds: 뷰포트 또는 루트 요소의 경계 상자 정보를 나타낸다.
- target: 교차 관찰 대상 요소(Element)를 나타낸다.
- time: 변경이 발생한 시간 정보(DOMHighResTimeStamp) 반환한다.



개념에 대한 추가적인 내용은 heropy님의 블로그를 참고하는 게 좋을 것 같다. 아주 잘 정리되어 있다.
✅ 구현하기
// useInfiniteScroll
export const useInfiniteScroll = (
posts: Places[],
hasNext: boolean,
keyword: string | null
): UseInfiniteScroll => {
const [dynamicPosts, setDynamicPosts] = useState<Places[]>(posts);
const [isInfiniteScrolling, setIsInfiniteScrolling] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isLastPage, setIsLastPage] = useState(false);
const [lastItemId, setLastItemId] = useState<number | undefined>(undefined);
const [lastItemRating, setLastItemRating] = useState<string | undefined>(
undefined
);
const observerRef = useRef<IntersectionObserver>();
const loadMoreTimeout: NodeJS.Timeout = setTimeout(() => null, 500);
const loadMoreTimeoutRef = useRef<NodeJS.Timeout>(loadMoreTimeout);
useEffect(() => {
const lastIndex = posts.length - 1;
// post가 변경되면 dynamicPosts에 저장하고 마지막 item의 id와 rating을 저장함.
setDynamicPosts(posts);
setLastItemId(posts[lastIndex]?.placeId);
setLastItemRating(posts[lastIndex]?.rating);
setIsLastPage(!hasNext);
}, [posts]);
/** Intersection Observer의 콜백 함수 */
const handleObserver = useCallback(
(entries: IntersectionObserverEntry[]) => {
// 감지된 요소와 관련된 정보를 포함하고 있는 첫 번째 요소 사용.
const target = entries[0];
// 관찰 대상 요소가 뷰포트에 진입했는지를 나타내는 불리언 값
if (target.isIntersecting) {
setIsLoading(true);
if (loadMoreTimeoutRef.current) {
// 이전에 설정된 타임아웃을 제거 함으로써 이전의 타임아웃 이벤트가 실행되지 않도록 한다.
clearTimeout(loadMoreTimeoutRef.current);
}
// this timeout debounces the intersection events
// intersection 이벤트를 디바운스한다. 500ms의 지연 시간 후에 함수 내의 코드 블록이 실행된다.
loadMoreTimeoutRef.current = setTimeout(() => {
axios
.get(
`${CourseApiConfig.course}/placeSearch?keyword=${keyword}&lastId=${lastItemId}&lastRating=${lastItemRating}`
)
.then((resp) => {
const { hasNext, places } = resp.data.data;
if (places?.length > 0) {
const newDynamicPosts = [...dynamicPosts, ...places];
setDynamicPosts(newDynamicPosts);
setIsInfiniteScrolling(true);
setIsLastPage(!hasNext);
if (hasNext) {
const lastIndex = places.length - 1;
setLastItemId(places[lastIndex].placeId);
setLastItemRating(places[lastIndex].rating);
}
}
})
.finally(() => {
setIsLoading(false);
});
}, 500);
}
},
[keyword, lastItemId, lastItemRating, hasDynamicPosts, setDynamicPosts]
);
const loadMoreCallback = useCallback(
// el: Intersection Observer의 대상 요소
(el: HTMLDivElement | null) => {
if (isLoading || !el) return;
// 이전에 관찰하던 요소를 중단하고 새로운 요소를 관찰하기 위해 이전에 생성된 IntersectionObserver 인스턴스를 해제한다.
if (observerRef.current) observerRef.current.disconnect();
const option: IntersectionObserverInit = {
root: null,
rootMargin: "0px",
threshold: 1.0,
};
observerRef.current = new IntersectionObserver(handleObserver, option);
if (el) observerRef.current.observe(el);
},
[handleObserver, isLoading]
);
return {
isLoading,
loadMoreCallback,
isInfiniteScrolling,
dynamicPosts,
isLastPage,
};
};
useInfiniteScroll 훅을 만들어서 사용했다. 코드에 대한 설명은 주석을 살펴보자. 여기서 문제점이 발생했다. keyword가 바껴서 새로운 posts가 추가되면서 무한스크롤이 발생하지 않아야하는데 스크롤이 맨아래에 있어서 새로운 posts가 추가됨과 동시에 자동으로 무한스크롤이 발생했다. keyword로 검색하면서 url이 변경 됨에 따라 즉, 새로운 경로로 이동할 때 기본적으로 스크롤 위치를 맨 위로 이동시키는데 나 같은 경우 url을 이동 시키지도 않고, 하나의 article 내에서 자동완성으로 구현되기 때문에 스크롤 위치를 맨위로 이동시키지 않기 때문이었다. 이러한 문제를 해결해주기 위해 target의 부모요소를 찾아 무한스크롤이 수행되지 않았을 경우(isInfiniteScrolling가 false일 경우) 스크롤을 맨 위로 이동시켰다.
isInfiniteScrolling이 true가 될 때는 api호출을 통해 추가적인 데이터를 얻어왔을 때가 된다.
const handleObserver = useCallback(
(entries: IntersectionObserverEntry[], observer: IntersectionObserver) => {
// 감지된 요소와 관련된 정보를 포함하고 있는 첫 번째 요소 사용.
const target = entries[0];
// 관찰 대상 요소가 뷰포트에 진입했는지를 나타내는 불리언 값
if (target.isIntersecting) {
setIsLoading(true);
if (loadMoreTimeoutRef.current) {
// 이전에 설정된 타임아웃을 제거 함으로써 이전의 타임아웃 이벤트가 실행되지 않도록 한다.
clearTimeout(loadMoreTimeoutRef.current);
}
//새로운 타임아웃이 설정되기 전에 이전 타임아웃이 제거되어 중복 실행되는 이슈를 방지하고, Intersection Observer 이벤트를 디바운스하여 일정 시간 동안 여러 이벤트 중 마지막 이벤트만을 처리할 수 있도록 함.
// intersection 이벤트를 디바운스한다. 500ms의 지연 시간 후에 함수 내의 코드 블록이 실행된다.
loadMoreTimeoutRef.current = setTimeout(() => {
axios
.get(
`${CourseApiConfig.course}/placeSearch?keyword=${keyword}&lastId=${lastItemId}&lastRating=${lastItemRating}`
)
.then((resp) => {
const { hasNext, places } = resp.data.data;
if (places?.length > 0) {
const newDynamicPosts = [...dynamicPosts, ...places];
setDynamicPosts(newDynamicPosts);
setIsInfiniteScrolling(true);
setIsLastPage(!hasNext);
if (hasNext) {
const lastIndex = places.length - 1;
setLastItemId(places[lastIndex].placeId);
setLastItemRating(places[lastIndex].rating);
}
}
})
.finally(() => {
setIsLoading(false);
const parentElement = target.target.parentNode;
if (
parentElement instanceof HTMLElement &&
!isInfiniteScrolling
) {
// 부모 요소의 스크롤을 맨 위로 이동시킨다.
parentElement.scrollTop = 0;
}
});
}, 500);
}
},
[dynamicPosts]
);
'문제 및 해결' 카테고리의 다른 글
프로젝트 중 무한 스크롤을 구현하게 되었다. useRef를 사용하여 컨테이너 요소의 scrollTop, scrollHeight, clientHeight 값을 받아서 구현했다.

브라우저 호환성 문제, 요소의 높이를 부적절하게 구하는 등에 의해서 무한 스크롤이 멈추거나 작동하지 않는 경우가 있었고 단시간에 수백번 호출이 되며 동기적으로 실행되었다. 또한, 각 엘리먼트 마다 이벤트가 등록되어 있는 경우, 사용자가 스크롤할 때마다 이벤트가 끊임없이 호출되기 때문에 몇배로 성능 문제가 발생한다는 것을 알 수 있었다.
일단 기존에 작성한 코드를 한번 살펴보자.
useEffect(() => {
setItems([...places]);
}, [places]);
const handleScroll = () => {
if (listRef.current) {
const { scrollTop, scrollHeight, clientHeight } = listRef?.current;
if (
Math.ceil(scrollTop) + clientHeight >= scrollHeight &&
hasNext &&
!loading
) {
setLoading(true);
setPage((prevPage) => prevPage + 1);
} else {
// TODO: 해야 함
}
}
};
useEffect(() => {
if (listRef.current) {
listRef.current.addEventListener("scroll", handleScroll);
}
return () => {
if (listRef.current) {
listRef.current.removeEventListener("scroll", handleScroll);
}
};
}, [handleScroll]);
props로 받은 places 배열 객체를 places가 변경될때마다 Spread 연산자로 얕은 복사를 수행하여 state에 저장한다.
handleScroll 함수는 Ref 객체로 받은 스크롤 값들로, 아래의 조건일 때 로딩 상태로 만들고 page 1 증가시킨다.
Math.ceil(scrollTop) + clientHeight >= scrollHeight && hasNext && !loading
cleanup 함수를 사용하여 이벤트 리스너를 제거함으로써 메모리 누수를 방지하고 예기치 않은 동작을 방지했다.
useEffect(() => {
if (page === 0) return;
const lastItemIndex = items ? items.length - 1 : -1;
const lastid = items[lastItemIndex]?.placeId;
const lastRating = items[lastItemIndex]?.rating;
async function fetchItems() {
try {
const placeSearchRes = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/course/placeSearch?keyword=${debouncedSearch}&lastId=${lastid}&lastRating=${lastRating}`
);
if (!placeSearchRes.ok) return;
const placeSearchResult = await placeSearchRes.json();
setItems((prevItems) => [
...prevItems,
...placeSearchResult?.data.places,
]);
// placeSearchResult?.data 가 undefined 일 때 기본값 false 를 할당
setHasNext(placeSearchResult?.data?.hasNext || false);
setLoading(false);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
}
fetchItems();
}, [page]);
page가 0일 경우는 Effect를 끝냈다. 만약 앞선 조건이 옳았다면 page가 1이 되면서 해당 Effect가 동작하였을 것이다.
lastItemIndex는 items의 마지막 index 값
lastid는 마지막 item의 placeId 값
lastRating는 마지막 item의 rating값이다.
해당 내용을 담아서 API 호출하였고 response로 hasNext와, 5개의 장소 데이터(places)를 반환해주었다. 그렇게 기존 items에 포함시켜주고, 백엔드로부터 받은 hasNext를 컴포넌트 state에 저장해주었다.
기존에 구현했던 방식을 잠깐 살펴보았다. 그렇다면 intersection observer를 이용하여 무한 스크롤을 구현해보자.
✅Intersection Observer API란 무엇인가?
Intersection Observer API는 대상 요소와 상위 요소 또는 최상위 문서의 뷰포트 사이의 교차점 변화를 비동기적으로 관찰하는 방법을 제공한다.
이 API는 웹 개발에서 요소가 화면에 나타나거나 사라지는 등의 교차점 변화를 감지하고 이를 처리할 수 있는 기능을 제공한다. 기존에는 스크롤 이벤트나 타이머를 사용하여 요소의 가시성을 감지했지만, 이는 비효율적이고 성능에도 문제가 있을 수 있다. Intersection Observer API는 이러한 문제를 해결하기 위해 등장한 API다.
Intersection Observer API는 감시하고자 하는 요소가 다른 요소(viewport)에 들어가거나 나갈때 또는 요청한 부분만큼 두 요소의 교차부분이 변경될 때 마다 실행될 콜백 함수를 등록할 수 있게 한다. 즉, 사이트는 요소의 교차를 지켜보기 위해 메인 스레드를 사용할 필요가 없어지고 브라우저는 원하는 대로 교차 영역 관리를 최적화 할 수 있다.
Intersection Observer의 필요성
- 페이지가 스크롤 되는 도중에 발생하는 이미지나 다른 컨텐츠의 지연 로딩.
- 스크롤 시에, 더 많은 컨텐츠가 로드 및 렌더링되어 사용자가 페이지를 이동하지 않아도 되게 하는 infinite-scroll 을 구현.
- 광고 수익을 계산하기 위한 용도로 광고의 가시성 보고.
- 사용자에게 결과가 표시되는 여부에 따라 작업이나 애니메이션을 수행할 지 여부를 결정.
✅ Intersection Observer 생성하기
let options = {
root: document.querySelector('#scrollArea'),
rootMargin: '0px',
threshold: 1.0
}
let observer = new IntersectionObserver(callback, options);
// 옵저버 생성 뒤 관찰할 대상 요소를 지정한다.
let target = document.querySelector('#listItem');
observer.observe(target);
// the callback we setup for the observer will be executed now for the first time
// it waits until we assign a target to our observer (even if the target is currently not visible)
IntersectionObserver() 생성자에 전달되는 options 객체는 observer 콜백이 호출되는 상황을 조작할 수 있다.
🟡 root
대상 객체의 가시성을 확인할 때 사용되는 뷰포트 요소다. 이는 대상 객체의 조상 요소여야 한다. 기본값은 브라우저 뷰포트이며, root 값이 null 이거나 지정되지 않을 때 기본값으로 설정된다.
🟡rootMargin
CSS의 margin 속성과 유사한 root가 가진 여백이다. 이것은 root 요소의 각 측면의 bounding box를 수축시키거나 증가시키며, 교차성을 계산하기 전에 적용된다.(기본값 0)
🟡threshold
observer의 콜백이 실행될 대상 요소의 가시성 퍼센티지를 나타내는 단일 숫자 혹은 숫자 배열로 만일 50%만큼 요소가 보여졌을 때를 탐지하고 싶다면 0.5로, 25% 단위로 요소의 가시성이 변경될 때마다 콜백이 실행되게 하고 싶다면 [0, 0.25, 0.5, 0.75, 1] 과 같은 배열을 설정한다. 기본값은 0(이는 요소가 1픽셀이라도 보이자 마자 콜백이 실행됨을 의미함). 1.0은 요소의 모든 픽셀이 화면에 노출되기 전에는 콜백을 실행시키지 않음을 의미한다.
🟢 callback
콜백은 관찰할 대상 (target)이 등록되거나, 가시성(visibility: 해당 요소가 뷰포트 혹은 특정 요소에서 보이거나 보이지 않을 때)에 변화가 생기면 실행된다.
콜백은 2개의 인수(entries, observer)를 갖는다.
let observer = new IntersectionObserver((entries, observer) => {}, options);
entries
entries는 IntersectionObserverEntry의 배열을 뜻한다.
IntersectionObserverEntry는 읽기 전용의 여러가지 속성들을 포함한다.
let callback = (entries, observer) => {
entries.forEach(entry => {
// Each entry describes an intersection change for one observed
// target element:
// entry.boundingClientRect
// entry.intersectionRatio
// entry.intersectionRect
// entry.isIntersecting
// entry.rootBounds
// entry.target
// entry.time
});
};
- boundingClientRect: 관찰 대상의 경계 사각형 정보를 나타낸다.
- intersectionRect: 교차되는 영역의 정보를 나타낸다.
- intersectionRatio: 요소가 교차되는 영역의 비율을 0.0과 1.0 사이의 숫자로 나타다.(intersectionRect 영역에서 boundingClientRect 영역까지 비율, Number)
- isIntersecting: 요소가 교차되는지 여부를 나타낸다.
- rootBounds: 뷰포트 또는 루트 요소의 경계 상자 정보를 나타낸다.
- target: 교차 관찰 대상 요소(Element)를 나타낸다.
- time: 변경이 발생한 시간 정보(DOMHighResTimeStamp) 반환한다.



개념에 대한 추가적인 내용은 heropy님의 블로그를 참고하는 게 좋을 것 같다. 아주 잘 정리되어 있다.
✅ 구현하기
// useInfiniteScroll
export const useInfiniteScroll = (
posts: Places[],
hasNext: boolean,
keyword: string | null
): UseInfiniteScroll => {
const [dynamicPosts, setDynamicPosts] = useState<Places[]>(posts);
const [isInfiniteScrolling, setIsInfiniteScrolling] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isLastPage, setIsLastPage] = useState(false);
const [lastItemId, setLastItemId] = useState<number | undefined>(undefined);
const [lastItemRating, setLastItemRating] = useState<string | undefined>(
undefined
);
const observerRef = useRef<IntersectionObserver>();
const loadMoreTimeout: NodeJS.Timeout = setTimeout(() => null, 500);
const loadMoreTimeoutRef = useRef<NodeJS.Timeout>(loadMoreTimeout);
useEffect(() => {
const lastIndex = posts.length - 1;
// post가 변경되면 dynamicPosts에 저장하고 마지막 item의 id와 rating을 저장함.
setDynamicPosts(posts);
setLastItemId(posts[lastIndex]?.placeId);
setLastItemRating(posts[lastIndex]?.rating);
setIsLastPage(!hasNext);
}, [posts]);
/** Intersection Observer의 콜백 함수 */
const handleObserver = useCallback(
(entries: IntersectionObserverEntry[]) => {
// 감지된 요소와 관련된 정보를 포함하고 있는 첫 번째 요소 사용.
const target = entries[0];
// 관찰 대상 요소가 뷰포트에 진입했는지를 나타내는 불리언 값
if (target.isIntersecting) {
setIsLoading(true);
if (loadMoreTimeoutRef.current) {
// 이전에 설정된 타임아웃을 제거 함으로써 이전의 타임아웃 이벤트가 실행되지 않도록 한다.
clearTimeout(loadMoreTimeoutRef.current);
}
// this timeout debounces the intersection events
// intersection 이벤트를 디바운스한다. 500ms의 지연 시간 후에 함수 내의 코드 블록이 실행된다.
loadMoreTimeoutRef.current = setTimeout(() => {
axios
.get(
`${CourseApiConfig.course}/placeSearch?keyword=${keyword}&lastId=${lastItemId}&lastRating=${lastItemRating}`
)
.then((resp) => {
const { hasNext, places } = resp.data.data;
if (places?.length > 0) {
const newDynamicPosts = [...dynamicPosts, ...places];
setDynamicPosts(newDynamicPosts);
setIsInfiniteScrolling(true);
setIsLastPage(!hasNext);
if (hasNext) {
const lastIndex = places.length - 1;
setLastItemId(places[lastIndex].placeId);
setLastItemRating(places[lastIndex].rating);
}
}
})
.finally(() => {
setIsLoading(false);
});
}, 500);
}
},
[keyword, lastItemId, lastItemRating, hasDynamicPosts, setDynamicPosts]
);
const loadMoreCallback = useCallback(
// el: Intersection Observer의 대상 요소
(el: HTMLDivElement | null) => {
if (isLoading || !el) return;
// 이전에 관찰하던 요소를 중단하고 새로운 요소를 관찰하기 위해 이전에 생성된 IntersectionObserver 인스턴스를 해제한다.
if (observerRef.current) observerRef.current.disconnect();
const option: IntersectionObserverInit = {
root: null,
rootMargin: "0px",
threshold: 1.0,
};
observerRef.current = new IntersectionObserver(handleObserver, option);
if (el) observerRef.current.observe(el);
},
[handleObserver, isLoading]
);
return {
isLoading,
loadMoreCallback,
isInfiniteScrolling,
dynamicPosts,
isLastPage,
};
};
useInfiniteScroll 훅을 만들어서 사용했다. 코드에 대한 설명은 주석을 살펴보자. 여기서 문제점이 발생했다. keyword가 바껴서 새로운 posts가 추가되면서 무한스크롤이 발생하지 않아야하는데 스크롤이 맨아래에 있어서 새로운 posts가 추가됨과 동시에 자동으로 무한스크롤이 발생했다. keyword로 검색하면서 url이 변경 됨에 따라 즉, 새로운 경로로 이동할 때 기본적으로 스크롤 위치를 맨 위로 이동시키는데 나 같은 경우 url을 이동 시키지도 않고, 하나의 article 내에서 자동완성으로 구현되기 때문에 스크롤 위치를 맨위로 이동시키지 않기 때문이었다. 이러한 문제를 해결해주기 위해 target의 부모요소를 찾아 무한스크롤이 수행되지 않았을 경우(isInfiniteScrolling가 false일 경우) 스크롤을 맨 위로 이동시켰다.
isInfiniteScrolling이 true가 될 때는 api호출을 통해 추가적인 데이터를 얻어왔을 때가 된다.
const handleObserver = useCallback(
(entries: IntersectionObserverEntry[], observer: IntersectionObserver) => {
// 감지된 요소와 관련된 정보를 포함하고 있는 첫 번째 요소 사용.
const target = entries[0];
// 관찰 대상 요소가 뷰포트에 진입했는지를 나타내는 불리언 값
if (target.isIntersecting) {
setIsLoading(true);
if (loadMoreTimeoutRef.current) {
// 이전에 설정된 타임아웃을 제거 함으로써 이전의 타임아웃 이벤트가 실행되지 않도록 한다.
clearTimeout(loadMoreTimeoutRef.current);
}
//새로운 타임아웃이 설정되기 전에 이전 타임아웃이 제거되어 중복 실행되는 이슈를 방지하고, Intersection Observer 이벤트를 디바운스하여 일정 시간 동안 여러 이벤트 중 마지막 이벤트만을 처리할 수 있도록 함.
// intersection 이벤트를 디바운스한다. 500ms의 지연 시간 후에 함수 내의 코드 블록이 실행된다.
loadMoreTimeoutRef.current = setTimeout(() => {
axios
.get(
`${CourseApiConfig.course}/placeSearch?keyword=${keyword}&lastId=${lastItemId}&lastRating=${lastItemRating}`
)
.then((resp) => {
const { hasNext, places } = resp.data.data;
if (places?.length > 0) {
const newDynamicPosts = [...dynamicPosts, ...places];
setDynamicPosts(newDynamicPosts);
setIsInfiniteScrolling(true);
setIsLastPage(!hasNext);
if (hasNext) {
const lastIndex = places.length - 1;
setLastItemId(places[lastIndex].placeId);
setLastItemRating(places[lastIndex].rating);
}
}
})
.finally(() => {
setIsLoading(false);
const parentElement = target.target.parentNode;
if (
parentElement instanceof HTMLElement &&
!isInfiniteScrolling
) {
// 부모 요소의 스크롤을 맨 위로 이동시킨다.
parentElement.scrollTop = 0;
}
});
}, 500);
}
},
[dynamicPosts]
);