# 무한 스크롤 ###### 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`이 교차하는지를 나타내준다. ![](https://heropy.blog/images/screenshot/intersection-observer/intersection-observer-is-intersecting.jpg) #### 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는 이전 상태의 변수였다. ![](https://i.imgur.com/1a4aQ66.png) 그래서 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로-무한-스크롤-구현하기