# 무한 스크롤
###### tags: `tech sharing`
SWR을 이용해서 댓글을 무한 스크롤을 구현하려고 하는데 과정은 크게 두 가지로 나눌 수 있었다.
1. useSWRInfinite를 이용해서 페이지 단위로 데이터를 로딩하기.
2. 제일 마지막 댓글이 보여지면 다음 페이지 로딩하도록 하기.
`useSWRInfinite`의 반환값에 현재 로딩한 페이지를 나타내는 size state가 있고 이 state를 set 하는 setSize가 있었다. setSize를 이용해서 size를 변경시켜주면서 다음 페이지의 데이터를 로딩했다.
스크롤이 끝에 다다랐는지를 판단하기 위한 방법은 스크롤 이벤트를 사용하는 방법과 `IntersectionObserver를` 사용하는 방법이 있었다.
스크롤 이벤트를 사용하면 스크롤을 할 때마다 이벤트 핸들러가 호출이 되기 때문에 이벤트 핸들러가 너무 많이 호출이 되고 이런 현상을 해결하기 위해서 `debounce`나 `throttle`을 사용해야 했다. 또한 스크롤 이벤트는 현재 높이 값을 알기 위해서 `Element.getBoundingClientRect`을 사용하는데 정확한 값을 가져오기 위해서 매번 layout을 새로 그려서 reflow가 일어나 브라우저 성능이 저해됐다. 반면에 `IntersectionObserver`는 reflow를 발생시키지 않는다. 또한 비동기적으로 이벤트를 발생시키기 때문에 더 효과적이다.
## Intersection Observer
```javascript=
const io = new IntersectionObserver(callback, options) // 관찰자 초기화
io.observe(element) // 관찰할 대상(요소) 등록
```
### callback
target element가 등록되거나 가시성에 변화가 생기면 관찰자는 callback을 실행한다. callback은 두개의 매개변수 `entries`와 `observer`를 가진다.
#### entries
`IntersectionObserverEntry`의 배열이다. 관찰 대상의 교차 영역 정보, 교차 영역 비율, 교차 상태, 관찰 대상 정보, 시간 등의 정보를 포함한다.
`isIntersecting`이 교차하는지를 나타내준다.

#### observer
callback이 실행되는 intersectionObserver 인스턴스를 나타낸다.
### options
#### root
target의 가시성을 검사하기 위해서 viewport 대신 사용할 element를 지정한다. 기본값은 브라우저의 viewport이다.
#### rootMargin
`rootMargin`을 사용해서 root의 범위를 확장하거나 축소할 수 있다.
#### threshold
observer가 실행되기 위해서 target의 가시성이 얼마나 필요한지 비율로 나타낸다. 배열로 지정하면 각 요소들에 해당할 때 모두 observer가 실행된다.
## 구현 중 에러
```javascript=
import { useRef, useEffect, useState } from 'react';
import { useSWRInfinite } from 'swr';
import { fetcher } from '../fetchers/fetcher';
import { GET_COMMENTS } from '../graphql/post';
import { mergeList } from '../lib/mergeList';
const useComments = ({ postId, infiniteScrollTargetRef }) => {
const intersectionObserverRef = useRef();
const [loadFinished, setLoadFinished] = useState(false);
const getKey = (pageNum, previousPageData) => {
console.log('getKey pageNum:', pageNum);
if (loadFinished) return null;
if (previousPageData && previousPageData.getComments.length < 3) {
setLoadFinished(true);
return null;
}
return [GET_COMMENTS, pageNum, postId];
};
const { data, error, mutate, size, setSize, isValidating } = useSWRInfinite(
getKey,
(query, pageNum, postId) => fetcher(query, { postId, pageNum }),
{
revalidateAll: true,
},
);
console.log('size: ', size);
const flattenData = data ? mergeList(data, 'getComments') : [];
const isLoadingInitialData = !data && !error;
const isLoadingMore = isLoadingInitialData || (size > 0 && data && !data[size - 1]);
useEffect(() => {
const ioCallback = (entries) => {
const isIntersecting = entries.some((entry) => entry.isIntersecting);
if (isIntersecting) {
console.log(
'callback called, size: ',
size,
'isIntersecting',
isIntersecting,
'data:',
data,
'error: ',
error,
'isValidating: ',
isValidating,
);
setSize(size + 1);
}
};
intersectionObserverRef.current = new IntersectionObserver(ioCallback);
intersectionObserverRef.current.observe(infiniteScrollTargetRef.current);
}, [infiniteScrollTargetRef]);
useEffect(() => {
if (loadFinished) {
intersectionObserverRef.current.unobserve(infiniteScrollTargetRef.current);
}
}, [loadFinished]);
return {
comments: flattenData,
isLoadingInitialData,
isLoadingMore,
isError: error,
size,
setSize,
isValidating,
mutate,
};
};
export default useComments;
```
위와 같이 구현을 했는데 계속 두번째 페이지까지만 로딩이 되고 그 이후에 intersecting이 일어날 때마다 계속`ioCallback` 안에서 size가 1인 문제가 있었다. 고민을 해보니 ioCallback이 만들어지는 시점의 size를 참조하고 있어서 이런 문제가 생기는 것 같았다. 따라서 size의 값이 변할 때마다 `ioCallback`에서 다른 size를 참조할 수 있어야했다.
```javascript=
// ...
const [a, setA] = useState(1);
// ...
useEffect(() => {
console.log('recalled, a:', a);
const ioCallback = (entries) => {
const isIntersecting = entries.some((entry) => entry.isIntersecting);
console.log('a: ', a);
if (isIntersecting) setA(a + 1);
// setIsIntersecting(isIntersecting);
};
if (!intersectionObserverRef.current) {
console.log('intersection observer set');
intersectionObserverRef.current = new IntersectionObserver(ioCallback);
intersectionObserverRef.current.observe(infiniteScrollTargetRef.current);
}
}, [infiniteScrollTargetRef, a]);
// ...
```
이렇게 테스트를 해봤는데 실제로 `recalled, a: 2`로 바뀌고 나서도 `ioCallback`안에서 a 값은 그대로 1이었다. a state가 변했지만 new IntersectionObserver를 할 때 매개변수로 들어간`ioCallback`이 참조하는 a states는 이전 상태의 변수였다.

그래서 useRef를 사용하는 방법과 state와 useEffect를 사용하는 방법으로 해결을 할 수 있을 것 같은데 useRef를 사용하면
```javascript=
useEffect(() => {
const ioCallback = (entries) => {
const isIntersecting = entries.some((entry) => entry.isIntersecting);
if (isIntersecting) {
console.log('size: ', sizeRef.current);
sizeRef.current += 1;
setSize(sizeRef.current);
}
};
if (!intersectionObserverRef.current) {
console.log('intersection observer set');
intersectionObserverRef.current = new IntersectionObserver(ioCallback);
intersectionObserverRef.current.observe(infiniteScrollTargetRef.current);
}
}, [infiniteScrollTargetRef]);
```
이렇게 만들 수 있을 것 같다. state와 useEffect를 사용하면
```javascript=
useEffect(() => {
const ioCallback = (entries) => {
const isIntersecting = entries.some((entry) => entry.isIntersecting);
setIsIntersecting(isIntersecting);
};
if (!intersectionObserverRef.current) {
console.log('intersection observer set');
intersectionObserverRef.current = new IntersectionObserver(ioCallback);
intersectionObserverRef.current.observe(infiniteScrollTargetRef.current);
}
}, [infiniteScrollTargetRef]);
// ...
useEffect(() => {
console.log('isIntersecting:', isIntersecting);
if (isIntersecting) {
console.log(size);
setSize(size + 1);
}
}, [isIntersecting]);
```
이렇게 바꿀 수 있을 것 같다.
최종 코드
```javascript=
const useInfinite = ({ query, variables, fetcher, queryTarget, infiniteScrollTargetRef }) => {
const intersectionObserverRef = useRef();
const sizeRef = useRef(1);
const getKey = (pageNum, previousPageData) => {
if (previousPageData && previousPageData.getComments.length < 3) {
intersectionObserverRef.current.unobserve(infiniteScrollTargetRef.current);
return null;
}
return [query, pageNum, ...variables];
};
const { data, error, mutate, size, setSize } = useSWRInfinite(getKey, fetcher, {
revalidateAll: true,
});
const flattenData = data ? mergeList(data, queryTarget) : [];
const isLoadingInitialData = !data && !error;
const isLoadingMore = isLoadingInitialData || (size > 0 && data && !data[size - 1]);
useEffect(() => {
const ioCallback = (entries) => {
const isIntersecting = entries.some((entry) => entry.isIntersecting);
if (isIntersecting) {
console.log('size: ', sizeRef.current);
sizeRef.current += 1;
setSize(sizeRef.current);
}
};
if (!intersectionObserverRef.current) {
intersectionObserverRef.current = new IntersectionObserver(ioCallback);
intersectionObserverRef.current.observe(infiniteScrollTargetRef.current);
}
}, [infiniteScrollTargetRef]);
return {
comments: flattenData,
isLoadingInitialData,
isLoadingMore,
isError: error,
mutate,
};
};
```
```javascript=
const useComments = ({ postId, infiniteScrollTargetRef }) => {
const { comments, mutate } = useInfinite({
query: GET_COMMENTS,
variables: [postId],
fetcher: (query, pageNum, postId) => fetcher(query, { postId, pageNum }),
queryTarget: 'getComments',
infiniteScrollTargetRef,
});
return {
comments,
mutate,
};
};
```
## 참고
https://heropy.blog/2019/10/27/intersection-observer/
https://tech.lezhin.com/2017/07/13/intersectionobserver-overview
https://velog.io/@yejinh/Intersection-Observer로-무한-스크롤-구현하기