Intersection Observer를 활용한 Lazy Rendering - TypeScript

Jinyong Park·2020년 12월 24일
8

Web

목록 보기
1/1

부스트 캠프에서 가계부 서비스를 개발하며 겪었던 난관 중 하나를 해결하는 과정에 대해 공유하고자 합니다. 프로젝트를 진행하며, 한 달간의 거래내역 페이지에서 거래내역 data의 양이 많을 때 랜더링 속도가 매우 느린 것을 관찰하였고 이를 개선하고자 무한스크롤을 구현하려고 도전하였습니다. Intersection Observer와 관련된 글들을 찾아보며 Typescript로 작성된 글이 없어서 작성하였습니다.

문제상황

  • Server로 부터 한달간의 거래내역 data를 받아온다.
  • 한달간의 거래내역 data가 redux Store에 저장되고 이를 통해 다른 컴포넌트의 랜더링에 사용된다.
  • 한달간의 총 수입, 지출 등을 api 분리를 하지 않아 전체 data를 모두 가져오는 상황
  • data 자체는 모두 가지고 있는 상태에서 Rendering 자체만 끊어서 하자!

기술스택

  • React, Typescript, Styled-component, Redux 등

Scroll Event

처음에 시도했던 방식은 Scroll Event를 등록하여 화면 상의 scroll Top의 비율을 통해 일정 비율을 넘었을 때 랜더링하는 방식으로 구현하려 하였습니다. (지금 생각해보면 scroll 의 위치가 가장 밑으로 내려왔을 때 event를 발생시키는 방식이 더 나았을 것 같네요..)

이때 발생가능한 문제는 해당하는 기준이 device 마다 차이가 있으면 동작의 일관성이 유지되지 않는다는 점이였습니다.

추가적으로, 부스트캠프에서 저희 프로젝트 팀이 관심있게 가졌던 주제가 최적화였는데 scroll event 방식은 한 번의 스크롤에 scroll event가 여러번 발생하므로 throttle 또는 debounce를 적용하여 이러한 event가 무한히 발생하지 않도록 관리해줘야 한다는 점이었는 데, 이점에서 scroll event 자체가 매력적으로 느껴지지 않았습니다.

Intersection Observer

scroll event의 대안을 찾다가 Intersection Observer 라는 것을 알게 되었습니다.

Intersection Observer 란?

IntersectionObserver(교차 관찰자 API)는 타겟 엘레멘트와 타겟의 부모 혹은 상위 엘레멘트의 뷰포트가 교차되는 부분을 비동기적으로 관찰하는 API입니다.

ViewPort 는 사용자에게 보여지는 화면이라 생각하면 쉽습니다. 사용자의 화면과 element가 얼마나 교차되는지 비율이 계산되고, 그 비율을 threshold를 통해 콜백함수의 trigger 기준으로 정할 수 있습니다.

Intersection Observer 에 대한 설명은 MDN 과 다른 글들을 참고하시면 쉽게 이해하실 수 있습니다!

어떤 기준으로 적용할 것인가?

  • 저희 프로젝트에서 거래내역 data를 랜더링할 때 일자별로 묶어서 랜더링을 해야했기 때문에 어떤 element를 observe 할지에 대한 기준을 정할 필요성이 있었습니다.
  • 제가 개발과정에서 기준으로 삼았던 점은 5일 단위로 끊어서 랜더링하는 방식으로 lazy Renderig을 구현하기로 결정하였습니다. (기준에 있어서 최적의 방식을 정하진 못한 것 같습니다)

기존의 코드

import React from 'react';
import { useSelector } from 'react-redux';
import TransactionListItem from '@/components/transaction/ListItem';
import { RootState } from '@modules/index';
import { TransactionModel } from '@/commons/types/transaction';
import EmptyStateComponent from '@/components/transaction/EmptyState';
import * as S from './styles';

const TransactionListContainer = (): JSX.Element => {
  const { transaction } = useSelector((state: RootState) => state);

  return (
    <>
      {transaction.transactionDetailsByDate.length !== 0 ? (
        transaction.transactionDetailsByDate.map(([date, transactionDetails]) => (
          <S.DateContainer key={`transaction_box_${date}`}>
            <S.DateLabel>{date}일</S.DateLabel>
            {transactionDetails.map((transactionDetail) => (
              <S.TransactionListItemWrapper>
                <TransactionListItem
                  key={`transaction_${transactionDetail.tid}`}
                  transaction={transactionDetail}
                />
              </S.TransactionListItemWrapper>
            ))}
          </S.DateContainer>
        ))
      ) : (
        <EmptyStateComponent />
      )}
    </>
  );
};

export default TransactionListContainer;
  • 기존의 코드는 Redux Store에 저장된 transaction 정보를 가져와서 바로 랜더링하는 방식이었습니다.

랜더링할 list를 따로 상태로 분리

  • Store 에서 가져온 data 를 바로 랜더링하면 안되므로 따로 상태를 두어 그 상태에 끊어서 list를 넘겨주는 방식으로 변경하였습니다.
  const [renderedTransaction, setRenderedTransaction] = useState([] as [number, TransactionModel[]][]);
  • 빈배열의 뒤에 정의된 타입은 Store에서 가져오는 data의 형식을 정의

끊어서 랜더링 되는 기준을 적용

  • 5일을 기준으로 랜더링을 정의했으므로 maxLength를 store에서 가져온 거래내역 list에서 구하고 useRef를 이용하여 length 를 정의하여 현재 랜더링된 상태(얼마나 랜더링이 되었는지)를 계산 수 있도록 설정
const length = useRef(1);
const maxLength = transaction.aggregationByDate.length / 5;
  • 초기의 data가 들어왔을 때 5개의 data를 랜더링에 사용할 상태에 설정
  useEffect(() => {
    if (!transaction.loading) {
      length.current = 1;
      if (transaction.transactionDetailsByDate.length < 5) {
        setRenderedTransaction(transaction.transactionDetailsByDate);
      } else {
        setRenderedTransaction(transaction.transactionDetailsByDate.slice(0, 5));
      }
    }
  }, [transaction]);

Intersection Observer가 observe할 target을 정의

  • useRef를 통해 target을 참조하도록 했습니다.
  • 이때, 코드를 보면 ref가 랜더링 되는 모든 DateContainer 에 대해 이루어진다는 것을 발견할 수 있는데 제 생각에는 계속해서 컴포넌트가 랜더링 되면서 기존의 ref가 덮어씌워지고 마지막에 랜더링이 되는 element에 ref에 대한 참조가 이루어지는 것 같습니다.
  const target = useRef<HTMLDivElement>(null);
	
  ...
  
   return (
    <>
      {transaction.transactionDetailsByDate.length !== 0 ? (
        renderedTransaction.map(([date, transactionDetails]) => (
          <S.DateContainer key={`t_box_${date}${ren}`} ref={target}>
            <S.DateLabel>{date}일</S.DateLabel>
            {transactionDetails.map((transactionDetail) => (
              <S.TransactionListItemWrapper key={`t_Wrap${transactionDetail.tid}`}>
                <TransactionListItem
                  key={`t_${transactionDetail.tid}`}
                  transaction={transactionDetail}
                />
              </S.TransactionListItemWrapper>
            ))}
          </S.DateContainer>
        ))
      ) : (
        <EmptyStateComponent />
      )}
    </>
  );
	

Intersection Observer 정의 및 동작 정의

Intersection Observer 정의

  • threshold는 0.5로 설정하였습니다. (element의 50%가 viewport에 보이면 callback 함수가 실행된다.)
  • cleanup 함수를 통해 observer의 연결을 끊어줬습니다.
  useEffect(() => {
    let observer: IntersectionObserver;
    if (target.current) {
      observer = new IntersectionObserver(onIntersect, { threshold: 0.5 });
      observer.observe(target.current as Element);
    }
    return () => observer && observer.disconnect();
  }, [transaction, renderedTransaction]);

교차시 발생할 동작 정의

  • 교차가 발생했을 때(entry.isIntersecting === true)
  • 현재 랜더링된 상태(length)가 maxLength 보다 크지 않다면 observer를 unobserve 하고 랜더링할 상태에 배열 5개를 추가하였습니다.
  const changeExtraTransaction = () => {
    const newrenderedTransaction = renderedTransaction.concat(
      transaction.transactionDetailsByDate.slice(5 * length.current, 5 * length.current + 5),
    );
    length.current += 1;
    setRenderedTransaction(newrenderedTransaction);
  };

  const onIntersect: IntersectionObserverCallback = (entries, observer) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting && length.current < maxLength) {
        observer.unobserve(entry.target);
        changeExtraTransaction();
      }
    });
  };

전체코드

import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import TransactionListItem from '@/components/transaction/ListItem';
import { RootState } from '@modules/index';
import { TransactionModel } from '@/commons/types/transaction';
import EmptyStateComponent from '@/components/transaction/EmptyState';
import * as S from './styles';

const TransactionListContainer = (): JSX.Element => {
  const { transaction } = useSelector((state: RootState) => state);
  const length = useRef(1);
  const target = useRef<HTMLDivElement>(null);
  const [renderedTransaction, setRenderedTransaction] = useState(
    [] as [number, TransactionModel[]][],
  );
  const maxLength = transaction.aggregationByDate.length / 5;

  useEffect(() => {
    if (!transaction.loading) {
      length.current = 1;
      if (transaction.transactionDetailsByDate.length < 5) {
        setRenderedTransaction(transaction.transactionDetailsByDate);
      } else {
        setRenderedTransaction(transaction.transactionDetailsByDate.slice(0, 5));
      }
    }
  }, [transaction]);

  const changeExtraTransaction = () => {
    const newrenderedTransaction = renderedTransaction.concat(
      transaction.transactionDetailsByDate.slice(5 * length.current, 5 * length.current + 5),
    );
    length.current += 1;
    setRenderedTransaction(newrenderedTransaction);
  };
  
  const onIntersect: IntersectionObserverCallback = (entries, observer) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting && length.current < maxLength) {
        observer.unobserve(entry.target);
        changeExtraTransaction();
      }
    });
  };

  useEffect(() => {
    let observer: IntersectionObserver;
    if (target.current) {
      observer = new IntersectionObserver(onIntersect, { threshold: 0.5 });
      observer.observe(target.current as Element);
    }
    return () => observer && observer.disconnect();
  }, [transaction, renderedTransaction]);

  return (
    <>
      {transaction.transactionDetailsByDate.length !== 0 ? (
        renderedTransaction.map(([date, transactionDetails]) => (
          <S.DateContainer key={`t_box_${date}${ren}`} ref={target}>
            <S.DateLabel>{date}일</S.DateLabel>
            {transactionDetails.map((transactionDetail) => (
              <S.TransactionListItemWrapper key={`t_Wrap${transactionDetail.tid}`}>
                <TransactionListItem
                  key={`t_${transactionDetail.tid}`}
                  transaction={transactionDetail}
                />
              </S.TransactionListItemWrapper>
            ))}
          </S.DateContainer>
        ))
      ) : (
        <EmptyStateComponent />
      )}
    </>
  );
};

export default TransactionListContainer;

결과


  • 약 800개의 거래내역 data를 가지고 chrome 개발자 도구로 performance를 체크하였을 때 위와같은 변화를 보였습니다. (체감상으로도 훨씬 빨라졌다)

고려할사항

  • Intersection Observer API 는 IE에 지원되지 않습니다.
    -> pollyfill 을 적용시켜주자

참고자료

1개의 댓글

comment-user-thumbnail
2021년 7월 10일

좀 치시네요 남다님?

답글 달기