무한 스크롤 구현과 메모리 누수

soo's·2023년 6월 6일
0

TIL

목록 보기
48/53
post-thumbnail

0. 무한 스크롤과 observer 객체

이번 프로젝트에서 사용자의 전체 게시글을 메인 페이지에서 노출시키는 부분과 검색을 통해 일부 게시글을 노출 시키는 부분이 있다.
개인적으로 나는 페이지 번호를 눌러서 이동하는 것보다 스크롤을 내려서 계속해서 새로운 글을 보는 것이 별도의 추가동작(페이지 클릭 이동)이 없기 때문에 편의성을 준다고 생각한다. 또한 우리 프로젝트는 모바일 환경도 지원하기 때문에 스크롤이 주된 동작인 디바이스에서 접근성이 좋다고 판단해서 이 부분을 무한 스크롤로 구현하기로 했다.


간단히 워밍업으로 옵저버 객체에 대해서 말해보자면
옵저버 객체는 이름에서 드러나듯이 감시, 관찰을 통해서 어떤 행동(콜백함수)을 실행할 수 있다. 무한 스크롤과 연관지어 말해보자면 5개의 일기 데이터가 세로로 정렬 되어있고 가장 마지막 일기 데이터가 스크롤을 통해 내 시야(뷰포트)에 노출됐을 때, 새로운 일기 데이터 5개를 불러오는 행동을 할 수 있다.

이 문장을 다시 정리하자면 스크롤을 통해 시야(뷰포트)에 노출 = 관찰에 해당하고, 일기 데이터 5개를 불러오는 것 = 어떤 행동에 해당한다.

mdn 문서를 참고하면 어떤 행동(콜백함수)을 실행 하는 기준은 아래와 같다.

(1) 대상(target) 으로 칭하는 요소가 기기 뷰포트나 특정 요소(이 API에서 이를 root 요소 혹은 root로 칭함)와 교차함.
(2) observer가 최초로 타겟을 관측하도록 요청받을 때마다.


1. 흐름 정리

대애충 어떤 흐름인지를 정리하고 기능을 구현하면 더 이해하기 쉽기 때문에 정말 간단하게 흐름을 정리해보겠다.

먼저 옵저버 객체는 위에서 설명했듯이 관찰을 바탕으로 어떤 행동을 할 수 있게된다. 다시 말하면, 어떤 행동을 하려면 관찰을 해야하고 관찰 기준에 따라 이 행동을 할 수도 있고 안 할수도 있다는거다. (약간.. 수련회 교관재질이네)

따라서 나는 이 부분을 어떤 행동을 일으키기 위해, 옵저버 객체에게 관찰 정보를 제공하는 것이라고 표현하겠다. 이렇게 관찰 정보를 얻게된 옵저버는 이제 어떤 행동을 할지 정해줘야 한다. 이 부분을 observer 설정이라고 하겠다. 마지막으로 이 observer의 관찰을 끊어주는 부분까지 크게 총 3단계의 흐름을 가진다고 보면 된다.

정리하면,
옵저버 객체에게 관찰 정보 제공 -> 옵저버 객체가 어떤 행동 할지 설정 -> 옵저버 관찰 끊기 설정

생각보다는 조금 간단하다...ㅎㅎ


1. observer 객체에게 관찰정보 제공

// 옵저버 객체가 참조할 값 생성
const observer = useRef<any>();
const lastPostRef = useRef();

옵저버 객체가 의미 그대로 뭔가 감시하고 관찰하는 객체인데, 얘가 어디를 감시하고 감찰할지 그 범위 정보를 주기 위해, 참조할 수 있는 역할로서 useRef를 통해 observer라는 ref 객체를 만들어준다.

lastPostRef는 스크롤을 내렸을 때, 새로운 데이터를 불러와야 하는 기준점이다. 가장 마지막 포스트에 도달했을 때, 새로운 포스트들을 불러와서 렌더링해야 하므로 이 범위 정보를 참조할 수 있게 observer와 마찬가지로 ref 객체를 만든다.

자 여기서 생각해야 할 부분은!!
observerobserver.current(현재 참조하고 있는 값)는 lastPostRef에 따라 관찰할 범위가 새롭게 달라지기 때문에 동적으로 값을 할당해주면 되지만, lastPostRef는 내가 불러온 데이터들의 가장 마지막 포스트에 착! 달라붙어서 이제부터 새로운 데이터를 불러와야합니다~ 하고 알려줘야 한다. 따라서 마지막 데이터에 ref를 연결시킨다.

내 코드를 가지고 예시를 들자면

filteredPosts.map((post: any, index) => {
                if (index === allPosts.length - 1) {
                  return (
                    <Post ref={lastPostRef}/>
                  );
                }
                return (
                  <Post/>
                );
              })

filteredPosts는 포스트들이 담긴 배열이다. 이 배열을 map 메서드를 통해 Post라는 컴포넌트에 포스트 각각의 정보를 프롭으로 전달해 렌더링하는 코드이다. 이때 가장 마지막 포스트에 lastPostRef 참조 객체를 붙여서 이 포스트가 나오면 이제부터 새로운 포스트를 받아와야 합니다~ 하고 옵저버 객체의 관찰 범위에 정보를 제공하는거다.

📌 한 줄 정리 : 옵저버 객체가 관찰하다가 마지막 포스트다!하면 새로운 데이터 불러오자~ 라는 동작을 하기 위해 마지막 포스트에 ref를 붙여서 관찰 정보를 제공해주자.


2. observer 객체 설정

// 옵저버 객체 생성
  useEffect(() => {
    observer.current = new IntersectionObserver(콜백함수, 옵션);
  }, []);

1번 과정에서 선언한 observer는 참조 객체이기 때문에 .current로 접근할 수 있다. 이 observer의 current 값으로 옵저버 객체를 지정해줄건데, new 키워드와 함께 IntersectionObserver(콜백함수, 옵션)를 통해 만들 수 있다.
첫 번째 인자인 콜백함수는 옵저버 객체가 관찰을 통해 실행할 어떤 행동에 해당하고, 두 번째 인자는 옵저버 객체와 관련된 옵션을 설정해 줄 수 있다.

나는 옵저버 객체가 마지막 포스트를 관찰하면(시야에 노출되면) 새로운 데이터를 불러오는
.

일단

if (observer.current) observer.current.disconnect();

이 부분은 컴포넌트가 리렌더될 때마다 이전에 관찰하던 요소들을 해제하는 코드임. observer.current.disconnect()로 이전에 관찰하던 요소들에 대한 관찰을 중단. 이 작업이 필요한 이유는 observer가 이전에 관찰하던 요소들에 대한 참조를 끊어 주지 않으면 메모리 누수가 발생할 수 있다고 한다.

밍 메모리 누수?!

🚰 intersection observer의 메모리 누수

메모리 누수는 프로그램이 동적으로 할당한 메모리를 필요 없어지거나 사용하지 않을 때도 해제하지 않는 상황을 말함. 이런 상황은 시간이 지날수록 불필요하게 사용 중인 메모리가 쌓여 시스템의 성능에 악영향을 줄 수 있음.

IntersectionObserver 인스턴스에 관련된 메모리 누수는 다음과 같은 상황에서 발생할 수 있는데
IntersectionObserver는 DOM 요소들을 참조함. 이 요소들이 페이지에서 제거되더라도, IntersectionObserver가 여전히 참조하고 있다면 브라우저는 해당 메모리를 해제하지 않음!!! 이것이 메모리 누수!!!

IntersectionObserver 얘로 만들어지는 인스턴스 자체(내 코드에서 observer)가 메모리를 차지하고 있음 ㅇㅇ. 컴포넌트가 마운트 해제되더라도, 이 인스턴스에 대한 참조가 존재하면 그 인스턴스는 메모리에서 해제되지 않는다고 한다. 따라서 메모리 누수가 발생함.

내 코드에서 observer.current.disconnect()를 호출하면 IntersectionObserver가 참조하고 있던 모든 요소들에 대한 참조를 해제하고, IntersectionObserver 로 만든 인스턴스 자체도 해제할 수 있다.

눈에 보이는 동작으로서는 저 코드를 주석처리해도 똑같이 동작함. 하지만 메모리 누수는 즉시 문제를 일으키는 부분이 아니고 장시간 실행되거나 상태변경이 많을때 성능 문제를 일으키는 원인이 될 수 있음. 따라서 지금 당장은 똑같이 동작하더라도 성능적인 부분에서 꼭 필요 없어진 메모리는 해제해야함!!!

  // 옵저버 객체 생성
  useEffect(() => {
    observer.current = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting && entries[0].intersectionRatio >= 1) {
          setPage((prevPage) => prevPage + 1);
        }
      },
      { threshold: 1 }
    );
    if (lastPostRef.current) {
      observer.current.observe(lastPostRef.current);
    }
  }, [allPosts, filteredPosts]);

0개의 댓글