2020-NAVER-CAMPUS-HACKDAY / Influencer

🧏🏻 간단한 사용자 인터랙션을 통한 상품 추천 및 전시 개발 (a.k.a 쇼핑의 신) 🚚
109 stars 7 forks source link

Lazy Loading 적용 및 성능 개선 리포트 #143

Open minsour opened 4 years ago

minsour commented 4 years ago

LAI (에라이?)

LAI (Loading And Interaction)는 FE 성능 분석을 할 때 주요 관심사입니다.

  1. 초기 로딩 속도 (Loading) 얼마나 빨리 페이지를 볼 수 있는가?
  2. 인터렉션 속도 (Interaction) 스크롤, 키보드 입력, 애니메이션 등이 얼마나 매끄럽게 동작하는가?

서비스에서 많이 사용되거나 사용자에게 가치 있는 화면이라면 LAI를 개선시킬 필요가 있습니다.

이번 이슈에서는 React로 구현한 리스트 컴포넌트에 Lazy Loading을 적용하면서 LAI(Loading And Interaction) 를 개선한 내용을 정리해 보려고 합니다.

1. 초기 로딩 속도(Loading) 개선

초기 로딩 속도를 개선하기 위해 Lazy Loading이라고 흔히 부르는 방식을 적용하였습니다.

Lazy Loading은 뷰 포트 바깥에 있는 이미지(초기 로딩 시 불필요한 자원)은 뒤로(Lazy) 미루도록 하는 방식입니다.

Lazy Loading은 Intersection Observer API 를 사용하여 구현하였습니다.

useLazyLoadingIO

export const useLazyLoadingIO: (
  params: IntersectionObserverParams,
) => IntersectionObserver = ({ root, threshold = 0, rootMargin = '100px' }) => {
  let observer = useRef(null);

  useEffect(() => {
    if (!root) return;

    observer.current = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            let lazyImage = entry.target as HTMLImageElement;
            lazyImage.src = lazyImage.dataset.src;
            lazyImage.classList.remove('lazy');
            observer.current.unobserve(lazyImage);
          } else {
            let lazyImage = entry.target as HTMLImageElement;
            lazyImage.src = '';
          }
        });
      },
      {
        root: root.current,
        rootMargin,
        threshold,
      },
    );

    const cleanUp = (): void => {
      if (observer.current) {
        observer.current.disconnect();
      }
    };
    return cleanUp;
  }, []);

  return observer.current;
};

IntersectionObserverList

const IntersectionObserverList: FC<IntersectionObserverListProps> = (props) => {
  ...
  const lazyLoadingObserver = useLazyLoadingIO({});

  useEffect(() => {
    if (isLazyLoading) {
      const lazyLoading = () => {
        const images = Array.from(document.getElementsByClassName('lazy'));
        for (const image of images) {
          lazyLoadingObserver.observe(image);
        }
      };
      lazyLoading();
    }
  }, [items]);

  return (
    <React.Fragment>
      <div className={classes.container}>
        <div className={clsx(classes.wrapper, className)}>{children}</div>
      </div>
      {loading && <Loading />}
      <div ref={target} />
    </React.Fragment>
  );
};

로딩 속도 측정 및 분석을 위해 Waterfall 차트를 활용하였습니다.

Lazy Loading 적용 전

lazyloading1

Lazy Loading 적용 후

lazyloading2-min

Lazy Loading으로 Waterfall 차트의 높이(Request 수)를 줄여서 초기 로딩 시 불필요한 이미지는 나중에 받아오도록 함으로써, 브라우저의 초기 로딩 속도를 높이고 이미지를 응답해주는 서버의 오버헤드도 줄였습니다.

차트에 보이는 Time 값은 네트워크 상황이나 서버의 상황에 따라 달라질 수 있는 값이기 때문에 유의미한 비교값은 아닙니다.

2. 인터렉션 속도(Interaction) 개선

인터렉션 속도를 개선하기 위해서는 기본적으로 Main Tread 에서 DOM 조작을 조심해야 합니다.

JavaScript가 DOM 을 건들면 Main Tread 에 의해 Rendering Pipeline이 동작하기 때문입니다.

Browser Rendering Process

rendering

Rendering Pipeline

pipeline
  1. JavaScript로 DOM 을 변경
  2. Style recalculate: DOM 의 최종 스타일을 계산
  3. Layout: DOM의 배치와 크기 계산
  4. Paint: 화면에 그리기
  5. Composite: 레이어 조합

Reflow, Repaint

생성된 DOM 노드의 Layout 수치(너비, 높이, 위치 등) 변경 시 영향 받은 모든 노드의(자신, 자식, 부모, 조상(결국 모든 노드) ) 수치를 다시 계산하여(Recalculate), 렌더 트리를 재생성하는 과정을 Reflow라고 하며, Reflow 과정이 끝난 후 재 생성된 렌더 트리를 다시 그리게 되는데 이 과정을 Repaint 라 합니다.

Reflow 가 트리거되는 경우

Intersection Observer API

과거에 intersection 감지를 구현하면 영향을 받는 모든 요소를 알기 위해서 Element.getBoundingClientRect()와 같은 메서드를 호출하는 여러 이벤트 핸들러와 루프가 얽혀있었습니다. 모든 코드가 메인 스레드에서 실행되기 때문에, 이 중 하나라도 성능 문제를 일으킬 수 있습니다. 사이트가 이러한 테스트들과 함께 로드되면 상황이 더욱 나빠질 수 있습니다.

MDN에서 위와 같이 설명하고 있습니다. 저는 이에 대해서 element.getBoundingClientRect가 scroll 이벤트에서 사용될 경우 Main Thread 가 과부하 될 수 있다는 의미로 이해했습니다.

그래서 저는 Intersection Observer API 를 사용하여 Lazy Loading을 구현한 것입니다.

성능 분석을 위해 Intersection Observer API를 사용하여 Lazy Loading 을 구현한 것과 scroll 이벤트에서 getBoundingClientRect를 호출하여 구현한 것을 비교해 보겠습니다.

scroll 이벤트에서 getBoundingClientRect를 호출하여 구현

const ProductItem: FC<Product> = (props) => {
  ...
  const onScroll = () => {
    const rect = ref.current.getBoundingClientRect();
    if (
      rect.top >= 0 &&
      rect.left >= 0 &&
      rect.bottom <=
        (window.innerHeight || document.documentElement.clientHeight) &&
      rect.right <= (window.innerWidth || document.documentElement.clientWidth)
    ) {
      let lazyImage = ref.current as HTMLImageElement;
      lazyImage.src = lazyImage.dataset.src;
      lazyImage.classList.remove('lazy');
    }
  };

  useEffect(() => {
    window.addEventListener('scroll', onScroll);
    return () => window.removeEventListener('scroll', onScroll);
  }, []);

  return (
    <article className={classes.card}>
      <div className={classes.cardPhoto} onClick={routeDetailPage}>
        <img
          ref={ref}
          className={clsx(classes.image, props.isLazy && 'lazy')}
          data-src={props.productImages && props.productImages[0].url}
        />
      </div>
      ...
    </article>
  );
};
reflow1

각각의 ProductItem에 image DOM의 위치를 파악하는 행위를 하는 onScroll 함수를 scroll event를 바인딩하는 방식입니다.

Intersection Observer API 로 구현

제가 구현한 방식입니다. 위에 useLazyLoadingIO와 IntersectionObserverList를 참고해주세요.

reflow2

크롬 개발자 도구의 Performance 탭을 통해 확인해보면 getBoundingClientRect()호출하는 과정에서 Recalculate Style 이 발생하는 것을 확인할 수 있으며, IntersectionObserver API를 사용하여 구현했을 때는 스크롤 하는 과정에서 Recalculate Style이 발생하지 않는 것을 확인할 수 있습니다.

소감

사실 처음 작성해보는 성능 리포트였습니다! 다 작성하고 보니 유의미한 성능 비교가 이루어진건가? 하는 의문이 남기도 하네요..ㅎㅎ 그래도 뭐라도 측정해보면서 한 층 더 성장한 것 같아 뿌듯하네요 👍 부족해 보이는 부분이 있으면 언제든지 피드백 주십쇼! 😀

Reference

http://sculove.github.io/blog/2019/04/11/fromTodyIamPA/ http://blog.hyeyoonjung.com/2019/01/09/intersectionobserver-tutorial/

Seogeurim commented 4 years ago

글이 가독성이 너무 좋아서 술술 읽혔습니다 !! FE 성능에 대하여 공부해본 적이 없었는데, 덕분에 공부할 부분이 하나 추가되었습니다 ㅎㅎ 참고 자료로 주신 링크도 보니 너무 좋은 자료들이네요. 기회가 됐을 때 저도 참고해서 성능 분석을 해보고 싶습니다. 감사합니다 😊

jominjimail commented 4 years ago

오 성능 분석 신기하네요 수고하셨습니다. 👍👍👍

글을 읽으면서 궁금한게 생겼습니다.

초기 로딩 시 불필요한 이미지는 나중에 받아온다

이미지는 나중에 받아온다

  1. images.src 로딩을 최대한 나중에 한다
    • images.src을 대채할 빈 네모박스로 일단 render를 하고나서 나중에 이미지 로드가 완료되면 빈 박스를 제거하고 로드된 이미지로 교체한다.

초기 로딩 시 불필요한

  1. 사용자가 현재 보고 있는 화면 뷰 포인트의 이미지만 받아온다. (ex. 9개만 받아온다)
    • 초기에 보여질 화면에는 [0-8]9개의 이미지만 보이면 된다. 스크롤을 내리면 추가적 [9-17]9개의 이미지를 로드한다.

둘 중 무엇인가요????? 저는 둘 다 인것 같은데 섞여있는것 같아서 음 조금 헷갈립니다.

minsour commented 4 years ago

@jominjimail

우선 초기 로딩 시 불필요한 이미지는 나중에 받아온다는 말은 2번입니다!

GET /products 로 30개의 상품 정보를 받아왔다고 했을 때, 그 30개의 상품 정보에 각각 image url 이 있을텐데요. 그 image url 은 보통 Storage 의 역할을 하는 서버에서 이미지를 저장하고 있는 경로에 해당하는 url 입니다. (AWS를 예를 들면 흔히 사용하는 S3가 되겠네요)

그럼 클라이언트에서 애플리케이션 서버로 상품 정보 30개를 요청해서 받아온 후, 상품 30개의 이미지를 띄우기 위해 이미지를 저장하고 있는 서버에 30번의 요청을 더 하게 되는 것이죠.

그래서 그 30번의 요청 중 화면에 보이지 않는 이미지에 대한 요청들은 뒤로 미뤄서 클라이언트의 (미세한)초기 로딩 속도와 스토리지 서버의 오버헤드를 줄일 수 있지 않을까? 하는 게 Lazy Loading에 대한 제 생각이구요!

그리고 말씀해주신 1번은 제가 적용은 안해놨는데, 적용을 한다면 유저한테 좀 더 좋은 UX를 제공할 수 있는 방법이라서 같이 구현해줘도 좋을 방법같습니다 ㅎㅎ

jominjimail commented 4 years ago

아 제가 무엇을 헷갈리는지 알았어요. 제가 구현했을 때는 화면에 보일 10개의 이미지를 요청하고 스크롤 내리면 추가로 10개의 이미지를 fetch 한다. --> 인데,

이제는 30개를 한 번에 fetch하고 화면에 보일 10개만 급하고 나머지 20개는 급하지 않으니깐 급하지 않은 건 뒤로 미룬다!! 이 말씀이군요.

생각해보니 fetch를 작은 단위로 하는 건 개선되어야 할 문제이구 민수님이 그 해결책을 주신 것 같네요. 감사합니다!👏