무한 스크롤 성능 개선의 여정(Intersection Observer API)

Mincho·2024년 5월 7일
1

[프로젝트]

목록 보기
6/6

무한스크롤

무한 스크롤 은 페이지를 따로 넘길 필요가 없이, 스크롤을 통해 콘텐츠를 보여주는 기능입니다. 하지만 이러한 사용자 경험뿐만 아니라 최하단으로 이동 시에만 data를 fetching 함으로써 리소스를 절약할 수도 있습니다.


문제 상황

무한 스크롤은 저의 개인 프로젝트에서도 많이 경험하고 개발했던 기능입니다. 기본적으로 tanstack-query 에서 useInfiniteQuery 를 통해 도움을 받을 수 있습니다.

하지만 문제는 다른 곳에 있었는데요. 기존에 저는 React프로젝트에서 무한 스크롤을 구현했고 무한 스크롤 UI를 보여주기 위해 다른 서드 파트 라이브러리를 이용했었습니다. 간편하게 구현하기 위해 서드파트 라이브러리를 사용하는 것은 기능구현은 쉽지만 다음과 같은 문제가 있었습니다.

  • 서드 파트 라이브러리에 대한 의존성 발생
  • scroll Event방식으로 성능적 이슈 발생.
  • 관련 라이브러리가 부족한 Vue생태계

무한 스크롤과 관련된 라이브러리들은 version관리가 되어 있지 않아 2년이 넘게 방치된 것들이 많았습니다. 버전관리가 되지 않은 채로 의존성을 지니면 문제가 발생할 수도 있을 거라 생각하였습니다.

또한 관련 라이브러리들은 스크롤 이벤트 방식을 기반으로 스크롤 할때마다 함수를 트리거 시켜 성능 이슈가 있었습니다.

react-infinite-scroller 라이브러리에서 스크롤을 기반으로 dom의 위치를 지속적으로 계산하는 것을 확인할 수 있었습니다.

따라서 직접 무한 스크롤을 구현하기로 했습니다.


초기 구현

useInfiniteQuery를 통해 트리거 되는 함수를 만듭니다.
훅에서 옵션을 설정할 때 백엔드분과 상의해 API스펙을 정하고 하는 것이 좋습니다.

/* useInfiniteQuery 기본 구조 */
const {
  fetchNextPage,
  fetchPreviousPage,
  hasNextPage,
  hasPreviousPage,
  isFetchingNextPage,
  isFetchingPreviousPage,
  ...result
} = useInfiniteQuery({
  queryKey,
  queryFn: ({ pageParam = 1 }) => fetchPage(pageParam),
  ...options,
  getNextPageParam: (lastPage, allPages) => lastPage.nextCursor,
  getPreviousPageParam: (firstPage, allPages) => firstPage.prevCursor,
})

그리고 다음은 이를 활용해 스크롤 이벤트에 따른 로직입니다.

export const useHandleListScroll = (
  isFetchingData: Ref<boolean>,
  fetchNextPage: (
    options?: FetchNextPageOptions
  ) => Promise<InfiniteQueryObserverResult<TableResponse<any>[], Error>>
): UseHandleListScrollType => {
	
  const handleScroll = (event: UIEvent) => {
    const taregt = event.target as HTMLElement;
    
    /**scroll Event의 property*/
    const { scrollHeight, scrollTop, clientHeight } = taregt;
    
    /**bottom인지 계산*/
    const isAtTheBottom = scrollHeight === scrollTop + clientHeight;
    
    if (isAtTheBottom) {
    /**바텀이면서 data를 fetching중이 아니라면 다음 data fetching해오기*/
      if (!isFetchingData.value) {
        fetchNextPage();
      }
      return
    }
  };
  return { handleScroll };
};

이 방식은 브라우저 성능에 매우 비효율적입니다. 이유는 이 handleScroll 이벤트를 특정 dom에 바인딩 시키면 이 요소에서 스크롤할 때마다 이벤트가 트리거 됩니다.

즉, 바닥까지 도착했는지를 확인하기 위해 계속해서 연산을 하게 되는 것입니다.

이를 해결하기 위해 디바운스와 쓰로틀링 기법을 생각해낼 수 있었습니다.


디바운스와 쓰로틀링

디바운스

연이어 호출되는 함수들 중 마지막 함수만 호출하도록 처리하는 기법입니다.

예를 들어 검색할 때 연관 검색어 기능을 구현(자동완성)할 때, 디바운싱 처리를 하지 않는다면 input에 입력을 할 때마다 함수가 트리거 되어 그 만큼 API콜을 처리하게 됩니다.

자동 이라는 단어 입력시 →
1. 'ㅈ’
2. ‘자’
3. ‘자ㄷ’
4. ‘자동’
총 4번의 이벤트 트리거 발생

보통 디바운스를 구현할 때 타이머를 통해 구현할 수 있습니다.

let timer;
 
 const debounce = () => {
  if (timer) {
    clearTimeout(timer);
  }
  timer = setTimeout(function() {
    console.log('ajax 요청');
  }, 200);
 }

새로운 입력이 들어올 경우 지속적으로 timer를 재설정해 최종적으로 마지막 이벤트에 따른 처리를 시행합니다.

쓰로틀링

쓰로틀링은 마지막 호출한 함수가 특정 시간동안 반복해서 호출되지 않도록 방지하는 기법입니다.

보통 특성 자체가 실행 횟수에 제한을 걸기 위해 많이 사용됩니다. 많은 분들이 scroll이벤트 같이 많이 발생하는 이벤트에 대해서 쓰로틀링을 사용합니다.

const throttling = () => {
	if (!timer) {
    timer = setTimeout(function() {
      timer = null;
      console.log('여기에 ajax 요청', e.target.value);
    }, 200);
  }
}

lodash에서는 디바운스와 쓰로틀링을 구현하기 위한 메서드가 제공됩니다.


Intersection Observer API

또 다른 방법인 Intersection Observer도 존재합니다.

웹 페이지에서 요소의 가시성을 모니터링하고 해당 요소가 화면에 나타나거나 사라질 때 인터렉션 프로퍼티를 제공하는 브라우저 API입니다. 스크롤 이벤트를 사용하여 요소의 가시성을 추적하고 다른 요소들과 상호작용할 필요 없이 해당 요소들의 가시성을 관찰할 수 있습니다.

Intersection Observer API 사용에 적합한 상황 (MDN공식문서)

  • 페이지가 스크롤 될 때 이미지 또는 다른 컨텐츠의 지연로딩
  • 스크롤할 때 더 많은 컨텐츠가 로드되고 렌더링 되는 ‘무한 스크롤’ 구현
  • 광고 수익 산정을 위해 광고 가시성 보고
  • 사용자가 결과를 볼 수 있을지 여부에 따라 작업 또는 애니메이션 프로세스 수행 여부 결정
// options에 따라 인스턴스 생성
let observer = new IntersectionObserver(callback, options);

/**callback*/
callback = (entries) => {
	entries.forEach(entry => {
	})
}

/**options*/
let options = {
	root: document.querySelector('#scrollArea'),
	rootMargin: '0px',
  threshold: 1.0
}

다음과 같이 인스턴스를 생성할 수 있습니다. 그리고 callback함수와 options 인자를 받습니다.

  • callback
    • Entries
      • entries는 IntersectionObserverEntry인스턴스를 담은 배열입니다.
      • intersectionObserverEntry는 루트요소와 타겟요소가 교차 했을 때를 묘사합니다.
      • entry(메서드)
        • boundingClinetRect : 타겟 요소의 사각혁 DOM정보
        • intersectionRect : 타겟 요소의 가시성이 감지된 부분의 정보
        • intersectionRatio : 루트 요소와 타겟 요소가 얼마나 교차하는지 (0.0 ~ 1.0 범위)
        • isIntersecting : 루트 요소와 타겟 요소가 교차하는 여부 (boolean)

Intersection Observer API는 루트 요소와 타겟 요소가 교차하지 않아도 초기에 콜백을 호출합니다. 그렇기 때문에 entry.intersectionRatio 를 통해 예외처리를 할 수 도 있습니다.

  • option
    • root : 타깃 요소의 가시성을 확인하는 루트 요소입니다. 설정 값이 없으면 기본 브라우저 뷰포트입니다.
    • rootMargin : margin을 주어 루트 요소의 범위를 확장할 수 있습니다.
    • Threshold : 타깃이 뷰포트에서 어느 정도 보여졌는지 퍼센테이지를 표현합니다. 설정한 범위에 따라 콜백이 실행됩니다. 만약 0.5로 설정되었다면 타겟 요소가 50% 가시성이 확인되면 콜백이 트리거 됩니다.

스크롤 기능 구현에 따른 성능비교

Intersection Observer 의 최대 강점은 성능적 측면입니다. 기존의 scroll event는 계속해서 현재 스크롤이 위치한 높이와 DOM element의 높이 등을 계산해야 합니다. 또한 교차 시 비동기적으로 실행되어 메인스레드에 부담을 주지 않으며, reflow를 발생시키지 않습니다.
다양한 방식으로 스크롤 기능을 구현하여 성능을 비교한 재밌는 자료를 찾을 수 있었습니다.

⚙️ 스크롤 구현 방식에 따른 성능표
chrome 개발자도구 성능 탭에서 비교한 다양한 스크롤 기능 구현에 따른 성능 차이입니다.
유의미한 결과를 내기 위해 CPU에 4배의 속도 저하를 추가했다고 합니다.
또한 전혀 멈추지 않고 스크롤을 지속한 결과입니다.

간단히 성능을 비교 했을 때

  • 노란색 지표는 본질적으로 계산 비용을 의미합니다. 이 노란 지표가 많을수록 메인 스레드가 수행해야 하는 작업이 더 많아집니다.
  • 노란색 지표 상단의 초록색은 FPS로 더 높고 안정적일수록 좋은 사용자 경험을 제공합니다.
  • 녹색 위의 빨간 점은 콜백 시간 및 이벤트 루프 시간에 따른 지연을 의미합니다.

스크립팅 작업 수행 시간의 비율

  • 스크롤 이벤트 + 캐싱 및 쓰로틀링 X : 48.9%
  • 스크롤 이벤트 + 캐싱 O, 쓰로틀링 X : 43.5%
  • 스크롤 이벤트 + 캐싱 및 쓰로틀링 O : 28.9%
  • intersection Observer : 23.3%

4가지 방식 중 가장 효율적인 두 가지 방식을 CPU부하를 4배에서 6배까지 증가시키면 어떻게 될까요??

스크립팅 작업 수행 시간의 비율

  • 스크롤 이벤트 + 캐싱 및 스로틀링 : 63.0%
  • intersection Observer : 37.6%

적용해보기

/**html tag에 ref로 연결*/
const bottomRef = ref<HTMLDivElement | null>(null);

/**unmount시에 Observer instance 연결을 끊기 위한 반응형 데이터*/
const observer = ref<IntersectionObserver | null>(null);

/**observer Event*/
const observeBottomScroll = () => {
  observer.value = new IntersectionObserver((entry) => {
	  /**루트 요소와 타겟 요소가 교차하고 있고, 데이터 fetching이 이루어지고 있지 않다면 Fetching*/
    if (entry[0].isIntersecting && !props.isFetching) {
      props.fetchNextPage();
    } else {
      return;
    }
  });
  observer.value.observe(bottomRef.value as HTMLDivElement);
};

/**mount시*/
onMounted(() => observeBottomScroll());

/**update시*/
onUpdated(() => observeBottomScroll());

/**unMount시*/
onUnmounted(() => {
  observer.value?.disconnect();
});

처음 특정 타겟 요소 dom을 구성해줬습니다.

<ul>
	<li/>
	<li/>
	<li/>
	<li/>
	<li/>
	<li/>
	<li/>
	<div ref="bottomRef" v-if="hasNextPage">
		<spinner/>
	</div>
</ul>

ul태그 내부 모든 리스트를 스크롤하여 마지막 바닥 교차점인 div태그를 배치하여 이벤트를 발생시켰습니다. div태그 v-if로 useInfiniteQuery의 hasNextPage로 데이터를 가져올 때 spinner를 표시해줬습니다.

/**observer Event*/
const observeBottomScroll = () => {
  observer.value = new IntersectionObserver((entry) => {
	  /**루트 요소와 타겟 요소가 교차하고 있고, 데이터 fetching이 이루어지고 있지 않다면 Fetching*/
    if (entry[0].isIntersecting && !props.isFetching) {
      props.fetchNextPage();
    } else {
      return;
    }
  });
  observer.value.observe(bottomRef.value as HTMLDivElement);
};

Intersection Observer에서는 entry속성에 isIntersecting을 통해 교차하고 있는지와 데이터를 Fetching하고 있는지를 판단해서 다음 서버 데이터를 Fetching해옵니다.

/**mount시*/
onMounted(() => observeBottomScroll());

/**update시*/
onUpdated(() => observeBottomScroll());

/**unMount시*/
onUnmounted(() => {
  observer.value?.disconnect();
});

vue의 생명주기 메서드로 초기 마운트시와 업데이트 시에 호출합니다. 또한 언마운트 되었을 때 observer연결을 끊음으로서 메모리 누수를 방지했습니다.

마지막으로

직접 실무에 intersection Observer API 를 사용하는 것에는 큰 문제는 없었습니다. 제가 맡은 프로젝트에는 예외 사항이 없었지만 아래와 같은 상황에는 한계가 있었습니다.

  • 스크롤을 내리다가 특정 지점을 감지하고 이벤트를 발생시켜야 하는 경우
  • 스크롤 애니메이션을 세세하게 조정해야 하는 경우

intersection Observer API 의 경우 루트 요소와 타겟 요소의 교차 여부를 통해 트리거 되어 세세한 컨트롤이 부족합니다.

반면 스크롤 이벤트의 경우 스크롤 중 scroll-stop 이벤트를 이용해 스크롤을 멈출 수 있고, 그 때 뷰포트 높이를 계산하면 됩니다. 결국 상황에 맞는 방법을 채택하는 것이 좋을 것 같습니다.

reference

실무에서 느낀 점을 곁들인 Intersection Observer API 정리
Scroll listener vs Intersection Observers: a performance comparison

👍피드백은 언제든지 환영입니다~!

profile
www.mincho130.xyz <-- 블로그 이사했습니당

0개의 댓글

관련 채용 정보