본문 바로가기

Client/Front-end

<FE> 리액트 프로젝트에 무한스크롤 도입하기

 

배경

 최근 진행한 프로젝트의 메인페이지에서는 리스트 형태로 유저의 할일 목록을 보여주고 있습니다. 단순 todo List라면 그 양이 적을 수 있으나, 해당 팀에서 유저가 할당받은 task 들도 함께 보여주므로 한 번에 받아오기에는 양이 많았습니다. 그래서 스크롤의 위치에 따라 데이터를 조금씩 받아오는 무한스크롤을 구현하기로 하였습니다.

고민

 무한 스크롤 코드를 작성하기 전에 3가지 고민이 있었습니다. 첫째, 어떤 방식으로 언제 요청할 것인가 입니다. 처음에는 스크롤의 위치를 관찰하여 약 80%위치에 도달하면 api 요청을 보내는 방식을 생각했습니다. 그러나 이는 너무 많은 이벤트가 일어나고, 이를 조절하기위해 debounce 또는 throttle을 구현해야 했습니다. 게다가 브라우저는 offsetTop 값을 구할 때마다 정확한 값을 위해 매번 Reflow 가 발생한다고 합니다. 그래서 저희는 Intersection Observer API를 사용하기로 하였습니다.

 

❝Intersection Observer API는 타겟 요소와 상위 요소 또는 최상위 document 의 viewport 사이의 intersection 내의 변화를 비동기적으로 관찰하는 API입니다.❞

 

 MDN에서는 위와 같이 Intersection Observer API를 소개하고 있습니다. 즉 타겟 요소가 사용자의 화면에 보이는지에 대해서만 확인한다고 합니다. 따라서 스크롤의 위치를 관찰하는 것보다 훨씬 좋을 것이라고 판단했습니다.

두 번째 고민은 어떤 기준으로 가져올 것인가 였습니다. 사용자의 개인 업무만 보여주는 환경이었다면 큰 고민이 필요없지만, 사용자의 팀 업무 도 함께 보여줘야 했습니다.

 

 업무들을 최신순으로 섞어서 보여줘야 했기에 단순히 팀 업무 10개, 개인 업무 10개 와 같은 방식으로 요청할 수는 없었습니다. 결국 일정 기간 단위(30일)로 각 업무들을 받아와서 클라이언트에서 보내주는 방식으로 기울었습니다. 그러나 여기서 세 번째 고민이 생겼습니다. 요청을 보내는 것을 언제 그만둘 것인가에 대한 것이었습니다. 기간 단위로 데이터를 요청하게되면 클라이언트는 데이터의 끝을 알 수가 없었습니다. 이를 해결하기 위해 기간이 아닌 개수로 요청을 보내는 것이 낫다고 판단했습니다. 그래서 저희는 db에서 uinon select 를 사용하여 두 업무를 합친 후, 10개씩 받아오는 방법을 택했습니다.

SELECT ID as id, NAME as name, createdAt, updatedAt, USER_ID as userId, STATUS as status, PROJECT_ID AS projectId FROM TASKS WHERE USER_ID=${id}
 UNION SELECT ID, NAME, createdAt, updatedAt, USER_ID, STATUS, NULL AS PROJECT_ID FROM TODO WHERE USER_ID=${id}
 ORDER BY updatedAt DESC LIMIT 10 OFFSET ${offset};

도입

리액트에서 Intersection Observer 을 편하게 사용하기 위해 react-intersection-observer 을 설치하여 사용했습니다. useInView 라는 훅에 Intersection Observer가 있어서 리액트에서 편하게 사용할 수 있습니다.

const ListView = () => {
  const [isScrollEnd, setIsScrollEnd] = useState(false);
  const [ref, inView] = useInView();
  const [offset, setOffset] = useState(0);
  const [loading, setLoading] = useState(false);
  useEffect(() => {
    if (inView && !loading) {
      setOffset((prev) => prev + 10);
    }
  }, [inView]);
  useEffect(() => {
    (async () => {
      setLoading(true);
      const newTasks = await getAlltasks(offset);
      setAllTasks((prev) => [...prev, ...newTasks]);
			if (newTasks.length < 10) {
        setIsScrollEnd(true);
        return;
      }
      setLoading(false);
    })();
  }, [offset]);
// ...
  return (
    < 리스트 영역 />
    {!isScrollEnd &&
          (loading ? <Spinner /> : <div ref={ref} />)}
  );
};

 

 관찰할 요소에 ref를 달게되면, 해당 요소가 화면에 보일 때마다 inView 가 true로 변경되게 됩니다. 이를 사용하여 true 일 경우마다 offset을 10씩 늘려가며 데이터를 요청하였습니다. 데이터가 10개 미만으로 올경우에는 더이상 요청할 필요가 없으므로 isScrollEnd 를 true로 주어 더 이상 요청을 보내지 않도록 하였습니다.

아쉬운 점

 위와 같이 간단하게 무한스크롤을 구현했지만 여전히 2가지의 아쉬움이 남아있었습니다. 먼저 중복데이터 요청 문제입니다. 저희 서비스는 개인 업무 뿐만아니라 팀 업무도 리스트에 보여주고 있습니다. 만약 사용자가 스크롤을 내리는 도중에 팀 업무가 할당되면, 이전에 이미 받은 데이터를 받게될 가능성이 있습니다. 이 문제는 offset이 아닌 id를 기준으로 10개씩 받으면 해결될 문제이지만, 안타깝게도 개인 업무와 팀 업무는 다른 테이블이므로 같은 id가 존재하였습니다.

 

 두 번째 아쉬움은 백엔드 성능 문제입니다. db에 union select를 하여 합치고 날짜순으로 정렬하여 10개씩 받아오고 있는데, 과연 이게 좋은 방법인지 의문입니다. 이 또한 개인 업무와 팀 업무가 하나의 테이블이었다면 어땠을까하는 아쉬움이 남아있습니다. 추후 이 부분은 성능을 측정하여 다른 개선 방법이 없을지 알아볼 예정입니다.