page-cover
🪄
개발
IntersectionObserver 이용한 테이블 성능 개선
sooros5132-avatarsooros51323/5/2023적용기 React
사실 https://upbit-api-trade-demo.vercel.app의 초기 구상에서 react-windowreact-virtualized를 이용해 코인목록에 적용하려 했지만 계속 우선순위가 미뤄지고 있었다. 그러던 중 아이패드로 이 사이트를 열어놓고 충전기를 꽂으면 엄청난 발열과 충전이 안될정도로 부하가 심하길래 최적화를 해줘야겠다고 다짐한다. 실제 적용한건 1달 뒤… 여행다녀오고 쉬고 하다 보니 밀렸다. 아무튼 IntersectionObserver를 이용한 성능 최적화와 그 적용기이다.
내가 원하는 기능은 화면에 보여지지 않는 아이템은 렌더링이 안됐으면 좋겠는데 내껀 인피니티 스크롤도 아니고 그 기능만을 위해 라이브러리를 쓰기엔 코스트가 너무 많이 드는거 같아서 직접 구현하기로 결정한다.
초기 페이지 렌더링때 결과(tr 태그 114개)
Before
After
114개인데도 107ms → 3.8ms 약 28배 성능 향상이 됐다. 태그 안에서 렌더링 하는게 많다 보니 엄청난 차이가 발생했다.
IntersectionObserver을 이용해 현재 화면에서 보이지 않는 부분은 내용을 렌더링 하지 않게 구상했다. 코드가 간단해서 적용하는데 어렵진 않았다.

Intersection Observer API - Web API | MDN

Intersection Observer API는 상위 요소 또는 최상위 문서의 viewport와 대상 요소 사이의 변화를 비동기적으로 관찰할 수 있는 수단을 제공합니다.

Intersection Observer API - Web API | MDN-faviconhttps://developer.mozilla.org/ko/docs/Web/API/Intersection_Observer_API

먼저 여러 컴포넌트에서 사용 할 것이니 커스텀훅을 생성하고
useIntersectionObserver.ts
typescript
import { Dispatch, SetStateAction, useEffect, useState } from 'react';

type targetType = HTMLElement | null;

export type useIntersectionObserverType = (
  onIntersect: IntersectionObserverCallback,
  options?: IntersectionObserverInit
) => {
  setTarget: Dispatch<SetStateAction<targetType>>;
};

export const useIntersectionObserver: useIntersectionObserverType = (onIntersect, options) => {
  const [target, setTarget] = useState<targetType>(null);
  const { root, rootMargin = '0px', threshold = 0 } = options || {};

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

    const observer: IntersectionObserver = new IntersectionObserver(onIntersect, {
      root,
      rootMargin,
      threshold
    });
    observer.observe(target);

    return () => {
      observer.unobserve(target);
    };
  }, [onIntersect, root, rootMargin, target, threshold]);

  return { setTarget };
};
사용할 컴포넌트에 반환값으로 오는 setTarget을 ref속성에 넣는다.
AwesomeComponent.tsx
typescript
const AwesomeComponent: React.FC = () => {
  const [isIntersecting, setIsIntersecting] = useState<boolean>(false);

  const onIntersect: IntersectionObserverCallback = (entries) => {
    const isIntersecting = entries?.[0]?.isIntersecting ?? false;
    setIsIntersecting(isIntersecting);
  };

  const { setTarget: setTargetRef } = useIntersectionObserver(onIntersect);

  return (
    <tr ref={setTargetRef} style={!isIntersecting && { height: 44 }}>
      {isIntersecting && (
        <td>내용</td>
      )}
    </tr>
  );
};
훅을 사용할 컴포넌트에서 위 처럼 적용하면 tr태그가 화면 안으로 들어올 때 내용을 렌더링 하게 된다. 여기서 isIntersectingfalse상태일 때 기본 높이는 꼭 설정해 줘야 한다.
이렇게 간단했나… 너무 잘 된다. 시간도 별로 안걸렸고 진작에 적용할걸 생각이 든다
위 코드는 잘 작동하지만 렌더링 될 때 내용이 없는 오류가 있었는데
오류가 있었던 코드
typescript
import { Dispatch, SetStateAction, useEffect, useState } from 'react';

type targetType = HTMLElement | null;

export type useIntersectionObserverType = (
  target: targetType,
  onIntersect: IntersectionObserverCallback,
  options?: IntersectionObserverInit
) => void;

export const useIntersectionObserver: useIntersectionObserverType = (target, onIntersect, options) => {
  const { root, rootMargin = '0px', threshold = 0 } = options || {};

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

    const observer: IntersectionObserver = new IntersectionObserver(onIntersect, {
      root,
      rootMargin,
      threshold
    });
    observer.observe(target);

    return () => {
      observer.unobserve(target);
    };
  }, [onIntersect, root, rootMargin, target, threshold]);
};
typescript
const AwesomeComponent: React.FC = () => {
  const tableRowRef = useRef<HTMLTableRowElement>(null);
  const [isIntersecting, setIsIntersecting] = useState<boolean>(false);

  const onIntersect: IntersectionObserverCallback = (entries) => {
    const isIntersecting = entries?.[0]?.isIntersecting ?? false;
    setIsIntersecting(isIntersecting);
  };

  useIntersectionObserver(tableRowRef.current, onIntersect);

  return (
    <tr ref={tableRowRef} style={!isIntersecting && { height: 44 }}>
      {isIntersecting && (
        <td>내용</td>
      )}
    </tr>
  );
};
처음엔 useRef를 이용해 target을 파라미터로 넘겨주는 방식이였다. 하지만 여기서 target이 없으면 observer를 만들지 않기 때문에 useIntersectionObserver에서 target이란 state를 두고 파라미터로 target을 전달하는게 아닌 return으로 오는 setTarget을 이용해 상태를 업데이트 하는 방식으로 변경했다.
이렇게 하면 target이 설정되는 순간 useEffect가 다시 작동해 observer을 만드려고 시도하고 정상적으로 렌더링이 된다.
사실 적용하고 나서 체감은 처음 로딩때도 그렇고 성능차이가 잘 느껴지지 않았다. 처음 로딩은 다른 컴포넌트들이 현저히 느리고 실시간 시세는 react memo기능을 써서 시세가 변경된 코인만 재렌더링이 되기 때문이다.
오히려 이걸 적용하고 나서 크게 성능향상을 느낀건 바로 코인을 검색할때 였는데 검색어를 입력하면 빠른 결과를 보여주기 위해 입력할 때 마다 114개 항목에서 array filter를 하게 된다. 여기서 검색어를 모두 지우면 항상 처음 렌더링처럼 거의 모든 걸 다시 렌더링을 하게 된다. 이때 100ms씩 계속 딜레이가 생긴다고 보면 되는데 화면이 순간적으로 멈춘거 처럼 보이게 됐었다. 이게 사이트 이용할 때 마다 매우 불쾌했던 요소였다. 이상하게 이쪽 문제가 해결이 됐네
react-window나 react-virtualized처럼 태그를 지우는 방식이 아닌 내용을 지우는 방식이라 사진처럼 tr껍데기도 남아있고 라이브러리가IntersectionObserver랑은 차이가 얼마나 클진 둘 다 적용해봐야 알거 같다.

Scroll listener vs Intersection Observers: a performance comparison | by Aggelos Arvanitakis | ITNEXT

The observer API has landed for some time now and is fully supported by all modern browsers. One of them, is the IntersectionObserver which helps you get callbacks fired when certain DOM elements…

Scroll listener vs Intersection Observers: a performance comparison | by Aggelos Arvanitakis | ITNEXT-faviconhttps://itnext.io/1v1-scroll-listener-vs-intersection-observers-469a26ab9eb6