[React] 무한 스크롤 로직 오류 해결하기 with 코드 동작 원리

hyeondoonge·2023년 10월 19일
0

지난 7월 원티드 인턴십을 진행하며 무한 스크롤을 구현했는데, 이전에 시도해본 방법과 다른 새로운 방법을 시도했었다. 동작해본결과, 교차가 발생했음에도 새로운 데이터를 가져오지 못하는 문제가 발생했다.

React와 JS 코드의 동작원리를 이해를 기반으로 그때의 문제를 다시 한 번 분석하고 해결해나가보자. 이를 통해 비슷한 문제가 발생한다면 '아, 이 코드는 이런 원리로 동작하기 때문에 잘못된 코드야'라고 스스로 생각할 수 있으면서 올바른 코드를 짤 수 있을 것이다.

우선 원인 정의를 하기 전에, 코드만 보고 어떤게 원인인지 추측해보자.

🤯 문제의 코드

교차타겟이 뷰포트에 교차될 경우, callback이 수행될 것을 기대하고 아래와 같이 코드를 작성했다.

하지만 예상과 달리 제대로 작동하지 않는 문제가 있었다. 다음 코드에는 어떤 문제가 있을까?

// intersection observer 기능을 위한 훅
export default function useIntersectionObserver(callback: () => void) {
  const observer = useRef(
    new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            callback();
          }
        });
      },
      { threshold: 1 },
    ),
  );

  const observe = (element: HTMLElement | null) => {
    element && observer.current.observe(element);
  };

  // ...

  return [observe, unobserve];
}
// issue list상태를 관리하기 위한 custom hook
function useIssues() {
  const { issueList, setIssueList } = useState<IIssueList>([]);
  const [isLoading, setIsLoading] = useState(false);
  // ...
  const fetchMoreIssues = async () => {
    const NEXT_PAGE = Math.floor(issueList.length / PER_PAGE) + 1;
    setIsLoading(true);
    const res = await getIssueList(pathParam, { ...queryParam, page: NEXT_PAGE });
    setIssueList([...issueList, ...res]);
    setIsLoading(false);
  };
}
import React, { useEffect, useRef } from 'react';
import { useIssues } from '../hooks/useIssues';
import IssueList from '../components/IssueList';
import useIntersectionObserver from '../hooks/useIntersectionObserver';
import { styled } from 'styled-components';
import Loading from '../components/Loading';

export default function IssueListPage() {
  const { fetchMoreIssues, issueList, isLoading } = useIssues();
  const { observe, unobserve } = useIntersectionObserver(fetchMoreIssues);

  const target = useRef(null);

  useEffect(() => {
    if (!target.current) return;
    observe(target.current);
    return () => {
      if (!target.current) return;
      unobserve(target.current);
    };
  }, []);

  return (
    <StyledIssueListPlage>
      <IssueList issueList={issueList} />
      {isLoading && <Loading />}
      <div style={{ width: '100%', height: 10 }} ref={target} />
    </StyledIssueListPlage>
  );
}

const StyledIssueListPlage = styled.div`
  // ...
`;

초기 리스트 렌더링 시에는 1 페이지에 해당하는 데이터를 가져와서 화면에 정상적으로 그린다. 하지만, 교차가 발생했을 때 다음 페이지를 불러오지 못했다. 어디서부터 잘못됐는지 확인하기위해 관련된 코드를 디버깅했다. 그러면서 내가 기대했던 것과 달리 동작하는 코드를 발견할 수 있었다. 이를 통해 교차가 됐음에도 1 페이지의 데이터로 계속 세팅하는 문제가 발생하고있음을 식별했다.

🔎 원인

결론부터 얘기하자면, useIntersectionObserver 훅 호출 시점에 전달되는 fetchMoreIssue 함수 내부에서 사용하고있는 issueList의 값이 항상 빈 배열이기 때문이다.

해당 원인은 클로져(Closure) 지식이있으면 이해할 수 있다.

가장 처음으로는 코드의 실행 흐름을 이해해보고자했다.

const { issueList, setIssueList } = useState<IIssueList>([]);
  1. issueList 상태를 hooks API를 통해 선언한다.

    Hooks API인 useState는 클로져형태이다. 따라서 만약 issueList의 상태를 변경하게 되면 리렌더링을 trigger하고, useState가 재호출되는데 이때의 issueList는 이전 issueList와 다른 참조를 갖는다.

const { observe, unobserve } = useIntersectionObserver(fetchMoreIssues);
  1. 타겟이 교차할 경우 호출할 fetchMoreIssues를 전달한다.
// intersection observer 기능을 위한 훅
export default function useIntersectionObserver(onIntersect: () => void) {
  const observer = useRef(
    new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            onIntersect();
          }
        });
      },
      { threshold: 1 },
    ),
  );
  // ...
}
  1. Observer를 생성한다. 여기서 동작원리를 깊게 살펴보자.

    useIntersectionObserver 호출이 발생하면, 이 함수를 실행하기 전에 내부를 평가하여 파라미터, 지역변수, 함수 선언부를 통해 식별자를 저장한다. 그리곤 실행한다.

    가장 먼저 onIntersect에는 fetchMoreIssues 함수가 할당된다. 그리고 훅 내부의 모든 할당문과 함수 호출이 실행된다.

    observer에 전달되는 callback을 선언 ⇒ IntersectionObserver생성 및 callback 전달 ⇒ useRef 호출에 의해 Observer객체를 observer.current에 할당

    작업이 끝이나면 observe, unobserve를 반환하는 것을 끝으로 호출이 종료된다.

    이후에 교차가 발생하면 observer의 callback, onIntersect가 호출될 것이다.

    만약 리렌더링이 발생하여 새로운 onIntersect함수가 전달되더라도, 교차시에는 항상 가장 초기에 전달한 함수를 실행한다.

    왜냐하면 초기 Observer가 생성되는 시점에 선언된 callback은 당시에 전달된 onIntersect에 접근할 수 있기 때문이다. 따라서 리렌더링이 발생하면 새로운 onIntersect가 전달될테지만, 이미 observer의 환경은 초기 생성당시의 환경으로 픽스됬기 때문에 아무 효과가 없는 것이다.

function useIssues() {
  const { issueList, setIssueList } = useState<IIssueList>([]);
  // ...
  const fetchMoreIssues = async () => {
    const NEXT_PAGE = Math.floor(issueList.length / PER_PAGE) + 1;
    setIsLoading(true);
    const res = await getIssueList(pathParam, { ...queryParam, page: NEXT_PAGE });
    setIssueList([...issueList, ...res]);
    setIsLoading(false);
  };
}
  1. 교차가 발생하면 onIntersect에 전달된 fetchMoreIssues를 실행한다.

    드디어 위에서 얘기했던, 교차시에 항상 가장 초기에 전달한 함수를 실행함으로써 발생하는 문제가 드러난다. 교차에 의해 함수를 몇 번을 호출하든, issueList는 빈 배열을 가리킨다는 것이다.

    클로져인 fetchMoreIssues의 외부환경에 저장된 issueList는 초기에 [ ]로 세팅됐다. 그리고 이후 해당 상태를 업데이트하면 그대로 리렌더링되면서 새로운 참조를 가진 issueList가 생성이된다. 그리고 최신상태를 올바르게 조회할 수 있는 건, 이 시점에 함께 생성된 fetchMoreIssues함수이다. 이와 반대로 초기 렌더링에서 생성된 fetchMoreIssues (onIntersect로 전달된 함수)는 항상 [ ]를 조회하게 된다는 것이다.

🥳 결론

문제를 유발했던 요인은 2문장으로 요약해서 얘기할 수 있다. 먼저 observer에 전달되는 onIntersect 함수는 항상 같은 참조를 가지는 fetchMoreIssues를 호출한다는 것, 그리고 해당 fetchMoreIssues 함수가 참조하는 issueList는 항상 빈배열을 가리킨다는 것이다. 그렇기 때문에 교차가 발생했음에도 새로운 데이터를 가져오지 못하고, 계속 1페이지의 결과를 렌더링했다.

이를 해결하기 위해서는 onIntersect가 변경이 되면 IntersectionObserver를 매번 새롭게 생성하는 방법이 있다. 이는 객체 생성 비용에 대해 고민해보면 될 것 같다.

또는 하나의 IntersectionObserver만 생성할 수 있는 방법도 있다. 교차가 발생했음을 알릴 상태를 하나 더 두는 방법이다. 코드가 읽기에 복잡할 수 있지만, 이것또한 문제없이 잘 동작한다.

돌고 돌아 문제의 원인을 명확하게 찾을 수 있었다. 코드의 실행 흐름을 적어내려가보거나 머릿속에서 상상해보는 게 오류가 발생한 원인을 찾는데 도움이 됐다. 역시 문제를 해결하는데 있어서 문제를 이해하고 원인을 명확히 파악하는게 중요하다는 것을 느꼈다. 적지 않은 시간이 걸렸지만 비단 이 문제뿐만 아니라 다른 문제를 풀어나가는데도 도움이 될 것이라고 생각해 가치있었다.

0개의 댓글

관련 채용 정보