초록집사 표지

이슈

아래는 내가 만들고 있는 피드 목록 페이지다.

TIL 이미지

첫 마운트 시에 5개의 게시물을 불러온 뒤,
사용자가 스크롤을 내려 마지막 게시물에 도달하면
새롭게 5개의 게시물을 불러오는 방식으로 무한 스크롤을 구현하고 있었다.

그러던 중 난감한 버그를 만났다.

  1. 마지막 포스트에 도달하면 서버 요청이 한 번이 아니라 여러 번 발생한다.
  2. 스크롤을 다시 올리는 경우에도 서버 요청이 발생한다.

아래와 같이 의도치 않은 서버 요청이 다수 발생했음을 알 수 있다.

TIL 이미지

대체 왜 그럴까? 아래는 내가 작성한 코드이다.
IntersectionObserver를 사용하여 구현하였다.
onIntersect 함수와 두 번째 useEffect에 주목해주기 바란다.

const MainPage = () => {
  const [posts, setPosts] = useState([]);
  const [isLoading, setIsLoading] = useState(false);
  const [offset, setOffset] = useState(0);
  const [max, setMax] = useState(0);
  const targetRef = useRef(null);

  useEffect(() => {
    (async () => {
      const { data: nextPosts }= await getPostsPart({
        offset,
        limit: LIMIT,
      });
      setPosts(nextPosts);
      setOffset(offset + 5);
      setMax(nextPosts[0].channel.posts.length);
    })();
  }, []);

  const onIntersect = async ([entry], observer) => {
      if (entry.isIntersecting && !isLoading && offset < max) {
        setIsLoading(true);
        setOffset(offset + 5);
        const { data: nextPosts } = await getPostsPart({
          offset,
          limit: LIMIT,
        });
        setPosts([...posts, ...nextPosts]);
        setIsLoading(false);
      }
    };

  useEffect(() => {
    let observer;
    if (targetRef.current) {
      observer = new IntersectionObserver(onIntersect, {
        threshold: 0.4,
      });
      observer.observe(targetRef.current);
    }
  }, [posts.length]);

  return (
    <PageWrapper header nav info>
      <PostList>
        {posts?.map((post, i) => {
          if (posts.length - 1 === i) {
            return (
              <li key={i} ref={targetRef}>
                <PostItem key={i} post={post} />
              </li>
            );
          } else {
            return (
              <li key={i}>
                <PostItem key={i} post={post} />
              </li>
            );
          }
        })}
      </PostList>
    </PageWrapper>
  );
};

const PostList = styled.ul``;

export default MainPage;

문제 해결

원인: 관찰을 끝낸 observer가 제거되지 않았기 때문이다.

IntersectionObserver를 생성하는 useEffect 훅을 보자.
posts.length의 값이 변경되면 옵저버를 새롭게 생성하고 있다.
targetRef.current는 마지막 게시물 요소이고, onIntersect는 콜백함수다.

  useEffect(() => {
    let observer;
    if (targetRef.current) {
      observer = new IntersectionObserver(onIntersect, {
        threshold: 0.4,
      });
      observer.observe(targetRef.current);
    }
  }, [posts.length]);

posts.length가 변경되면 IntersectionObserver가 다시 생성된다.

그럼 예전의 IntersectionObserver는 어떻게 될까?
당연히 사라지지 않고 그대로 남아있게 된다!

따라서 이미 관찰을 끝낸 IntersectionObserver를 제거하는 작업이 필요하다.
다음과 같이 useEffect의 클린업에서
observe가 관찰하는 모든 요소에 대하여 관찰을 중단시킨다.

  useEffect(() => {
    let observer;
    if (targetRef.current) {
      observer = new IntersectionObserver(onIntersect, {
        threshold: 0.4,
      });
      observer.observe(targetRef.current);
    }
    return () => observer && observer.disconnect(); // 추가한 코드
  }, [posts.length]);

그리고 onIntersect에서도 unobserve를 통해
기존 observer에 대한 관찰을 중단시킨다.

  const onIntersect = async ([entry], observer) => {
      if (entry.isIntersecting && !isLoading && offset < max) {
        observer.unobserve(entry.target); // 추가한 코드
        setIsLoading(true);
        setOffset(offset + 5);
        const { data: nextPosts } = await getPostsPart({
          offset,
          limit: LIMIT,
        });
        setPosts([...posts, ...nextPosts]);
        setIsLoading(false);
      }
    };

만약 이러한 처리가 없다면 해당 observer는 계속해서 요소를 관찰할 것이고,
의도치 않은 서버 요청이 발생하고 만다.
클린업 및 unobserve()를 추가한 뒤에는 무한스크롤이 정상적으로 동작한다.


네트워크 탭을 보면 서버 요청이 정상적으로 이루어지고 있음을 알 수 있다.

TIL 이미지

참고자료

Mdn: Intersection_Observer_API