펀잇팀의 무한스크롤 개발기

📢 이 글은 무한스크롤을 구현하는 방법보다는 구현 과정에서 있었던 일에 대해 담고 있습니다.

펀잇 서비스에는 ‘상품 목록’을 보여주는 화면과 상품 상세 페이지에서 ‘리뷰 목록’을 보여주는 화면이 있다. 이러한 긴 목록들을 보여주는 방법에는 페이지네이션과 무한스크롤이 있다. 그 중에서 우리 팀은 무한스크롤 방식을 선택했고 왜 이 방식을 선택했는지, 그리고 어떤 방식으로 구현 했는지 글을 작성해보려고 한다.

페이지네이션

스크린샷 2023-08-06 오후 2 00 16

페이지네이션은 화면 하단의 숫자를 클릭하여 선택한 페이지 넘버에 해당하는 페이지를 선택하는 방식이다. 대표적으로 구글이 이런 방식을 채택하고 있다. (요즘에는 더보기 버튼이 보이는 것 같기도..?)

일정한 양을 매번 보여줌으로써 사용자들이 원하는 컨텐츠의 위치를 파악할 수 있도록 도와준다. 페이징이 일어날 때마다 사용자는 ‘클릭’이라는 행동을 취해야 한다는 특징이 있다.

무한스크롤

ezgif com-video-to-gif (4)

무한스크롤은 사용자가 스크롤을 내릴 때 페이징이 일어나는 방식이다. 사용자들이 직접 선택한 페이지로 이동하는 것은 불가능하지만, 스크롤만 해도 상품들이 더 노출되어 사용자의 행동을 최소화해준다는 장점이 있다.

어떤 UX를 고를 것인지는 서비스의 의도, 환경 등에 따라 달라지게 된다고 생각한다.

이런 무한스크롤도 구현 방법이 나뉘는데 scroll event를 사용하는 방법과 intersection observer을 사용하는 방법으로 나뉜다.

scroll event는 매번 스크롤 이벤트가 일어날 때마다 높이를 감지하는 방식이고 intersection observer는 교차하는 지점을 감지하여 무한스크롤을 하는 방식이다.

최종적으로 우리 팀에서는 intersection observer를 사용한 무한스크롤 방식을 채택하게 되었는데 그 이유는 아래와 같다.

  1. 펀잇은 모바일 기준으로 제작되어 클릭 액션을 최소화한 무한스크롤이 더 좋은 사용자 경험을 제공할 것임
  2. 펀잇에는 수천개의 상품이 존재하는데 이를 내릴 때마다 스크롤 이벤트가 발생하게 된다면 페이지 부하가 심해질 것임

구현 방법

useIntersectionObserver hook 작성

  1. options
const defaultOptions = {
  root: null,
  rootMargin: '0px',
  threshold: 0.3,
}
  • root: 타겟 요소의 가시성을 검사할 때 사용하는 루트 요소 (null 일 때 브라우저 뷰포트로 설정)
  • rootMargin: 루트요소의 범위를 확장하여 확장된 영역에 들어가면 가시성 변화 발생하게 한다. 단위를 필수적으로 적어줘야 한다.
  • threshold: 요소가 어느정도로 보여졌을 때 콜백을 실행할 것인지 결정
  • observer 생성
const observer = useRef(
  new IntersectionObserver(entries => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        callback()
      }
    })
  }, defaultOptions)
)
  1. observe, unobserve 함수 작성
const observe = (element: T) => {
  observer.current.observe(element);
};

const unobserve = (element: T) => {
  observer.current.unobserve(element);
};

useEffect(() => {
  if (!targetRef.current) {
    return;
  }

  if (isLastPage) {
    unobserve(targetRef.current);
    return;
  }

  observe(targetRef.current);

  return () => {
	    observer.current.disconnect();
	  };
	}, [targetRef.current]);
};

완성본

import type { RefObject } from 'react';
import { useRef, useEffect } from 'react';

const defaultOptions = {
  root: null,
  rootMargin: '0px',
  threshold: 1.0,
};

const useIntersectionObserver = <T extends HTMLElement>(
  callback: () => void,
  targetRef: RefObject<T>,
  isLastPage: boolean | undefined
) => {
  let isInitial = true;

  const observer = useRef(
    new IntersectionObserver((entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          if (isInitial) {
            isInitial = false;
          } else {
            callback();
          }
        }
      });
    }, defaultOptions)
  );

  const observe = (element: T) => {
    observer.current.observe(element);
  };

  const unobserve = (element: T) => {
    observer.current.unobserve(element);
  };

  useEffect(() => {
    if (!targetRef.current) {
      return;
    }

    if (isLastPage) {
      unobserve(targetRef.current);
      return;
    }

    observe(targetRef.current);

    return () => {
      observer.current.disconnect();
    };
  }, [targetRef.current]);
};

트러블슈팅

펀잇에는 여러개의 카테고리가 존재한다. 일반 상품 5가지, PB 상품 4가지로 총 9가지의 탭에서 모두 무한스크롤이 일어나게 된다. 따라서 사용자가 카테고리 탭을 바꿀 때마다 page = 0 으로 초기화해서 요청을 보내야했다.

이 때, page 값 초기화와 비동기 요청의 순서를 보장할 수 없는 문제가 있었다. 예를 들어 간편식사 탭에서 page = 4 까지의 요청을 보낸 뒤 과자류 탭으로 이동했을 때 page = 4 의 요청이 먼저 간 뒤 page = 0 요청이 일어나는 것이었다.

도대체 왜 이런 현상이 일어나는 것일까 고민해봤는데 내가 생각한 문제점은 다음과 같았다.

무한스크롤을 할 때 두 가지의 작업을 하게 된다.

  1. setPage 를 통해 page를 초기화하는 과정
  2. 팀 내에서 만든 useGet 을 통해 data를 가져오는 과정 → 이 때 의존성 배열 [categoryId, page] 가 존재한다.

이때 page 상태가 0으로 초기화되기 전에 API 요청이 완료되어 상품 목록을 응답하게 되면 두 과정 사이의 race condition이 발생하는 것으로 판단했습니다. setState를 하는 함수는 렌더링 동일성이 보장되지만 비동기 요청은 순서를 보장하지 않기 때문이다.

🤔 해결방안

문제를 해결하기 위해 생각했던 방법은 카테고리값이 변경된 것을 확인하고 요청을 보내는 것이었다. 공식 문서에서 ref는 어떠한 정보를 저장하고 싶지만 렌더는 일으키고 싶지는 않을 때도 사용한다는 내용을 참고해 해당 방법으로 접근했다.

previousCategoryId를 ref로 값을 저장

그 후 categoryId와 비교해서 값이 바뀌었다면 page를 초기화 한 후 비동기 요청을 보내게끔 구현

const prevCategoryId = useRef(categoryId)
const nextPage = prevCategoryId.current !== categoryId ? 0 : page

useEffect(() => {
  setPage(0) //초기화
  prevCategoryId.current = categoryId
}, [categoryId, sort])

// useCategoryProducts에 전달해주는 값들이 refetch 조건임
const { data: productListResponse } = useCategoryProducts(
  categoryId,
  nextPage,
  sort
) //비동기 요청

물론 이 문제는 Suspense를 사용하면 해결이 가능하다.

이에 대해서는 2탄으로 이어적어보도록 하겠다!


Written by@타미
공부하고 경험한 내용을 글로 작성합니다. 지적, 보충은 언제나 환영입니다 🙂

GitHub