[React] 무한 스크롤 기능 공식문서 해부하기 & 더 좋은 코드를 작성하는 방법은?

GY·2021년 12월 31일
0

리액트

목록 보기
36/54
post-thumbnail

무한 스크롤 기능

지금까지 intersectionObserver를 활용해 무한스크롤을 총 3번 만들어보았다.

총 3번의 무한스크롤 기능 구현 코드는 다음 포스팅에서 비교해두었습니다.
👉 3개의 무한스크롤 기능 비교 포스팅 보러가기

1.Vanilla JS2.React3.React

3번째 컬리플라워 커머스 사이트를 구현하면서 무한스크롤 기능을 구현할 때 보다 좋은 코드를 작성하기 위해 고민한 내용을 정리해보았다.


처음 무한 스크롤 기능을 만들 때부터 useEffect의 클린업 함수를 잘 알지 못한채로 제대로 작성해 사용하고 있지 않다는 느낌이 들었고, 이외에도 고려해야할 만한 부분이 있는 다른 좋은 코드작성법을 알고 싶었다. 그래서 여러가지 다른 사람들의 코드를 구글링해 참고해보니 오히려 왜 이렇게 코드를 썼는지 이해하기 어려운 것들이 많았고, 그만큼 무엇이 좋은 코드이고 지금 내가 구현하고자 하는 무한 스크롤에서는 어떤것이 필요한 부분인지 알아내기가 어려웠다.

그래서 오늘은 찾고 찾다가 마주한 그나마 이해하기 쉬운 공식문서의 자료에서 예시 코드를 모아서, 하나하나 어떻게 작동하는지 왜 이렇게 썼는지 뜯어보고 정리해보려고 한다.

고민 포인트

  • 왜 ref값이 있을 경우로 조건문을 작성해주는지
  • useEffect내부에서 ref의 current값을 복사해서 사용하는지
  • 왜 커스텀훅으로 무한스크롤을 구현할 때 [state, ref]의 형태로 리턴해 사용하는지.

💎 ref.current값 복사해 클린업 함수에 적용하기

useEffect 의 cleanUp함수를 잘 사용하고 싶다.

[ESLint] The ref value 'feedEndRef.current' will likely have changed by the time. this effect cleanup function runs. If this ref points to a node rendered by React, copy 'feedEndRef.current' to a variable inside the effect, and use that variable in the cleanup function.


  useEffect(() => {
    observer.observe(feedEndRef.current);
    return () => {
      observer.unobserve(feedEndRef.current);
[ESLint] The ref value 'feedEndRef.current' 
will likely have changed by the time this effect cleanup function runs. 
If  this ref points to a node rendered by React, 
copy 'feedEndRef.current' to a variable inside the effect, 
and use that variable in the cleanup function. 

feedEndRef.current는 useEffect의 cleanup 함수가 실행될때마다 값이 바뀔 수 있다.
만약 이 ref가 리액트가 렌더링 하는 노드를 가리키고 있다면 feedEndRef.current를 useEffect 내부의 변수로 복사한 다음, cleanup함수에서 이것을 사용하는 것이 좋다.

이 말을 이렇게 이해했다.

feedEndRef 값은 useEffect 내부에서 observe가 부착 되었다가 언마운트되면서 바로 다시 unobserve가 된다.

그런데 ref 값은 리렌더링이 되어도 변경되지 않는 걸로 알고 있는데?

useRef로 생성한 ref객체는 컴포넌트가 리렌더링 되더라도 다시 생성되지 않고 기존의 것을 사용한다.

ref 객체는 .current라는 프로퍼티를 가지며 이 값을 자유롭게 변경할 수 있다.
event.target과 같다고 보면 된다.


그럼, 이 경고를 해결하기 위해서는 어떻게 해야할까?


ref callback 을 사용해야 한다.

https://medium.com/@teh_builder/ref-objects-inside-useeffect-hooks-eb7c15198780

https://codesandbox.io/s/01m0ok09rv?from-embed=&file=/src/index.js


ref는 매번 다른 DOM node에 부착될 수 있다.

https://reactjs.org/docs/hooks-faq.html#how-can-i-measure-a-dom-node

공식문서의 내용을 빌려보자.

useRef는 현재 ref 값이 변화해도 우리에게 알려주지 않는다. callback ref는 자식 요소가 이후에 변경되더라도 이것을 알려주어 업데이트할 수 있다.

useCallback에는 의존성배열을 빈 배열로 주었는데, 이것은 ref callback 함수가 리렌더링 되는 동안 바뀌지 않도록 해 불필요한 호출을 막기 위함이다.

아래 예시에서 callback ref는 컴포넌트가 마운트될때와 언마운트될때만 호출된다.


💎 예시로 알아보자(1)

공식문서 중 even if a child component displays the measured node later

https://codesandbox.io/s/818zzk8m78?file=/src/index.js

리액트 공식문서에서 공개한 데모이다.
공식문서에서는 DOM node의 사이즈나 위치를 알아내야 할 때 왜 callback ref를 사용해야 하는지 예시를 들어 설명하고 있다. 이 것을 사용하면 리액트는 ref가 변경되거나 다른 node에 붙었을 때 이 callback을 호출한다고 한다.


import React, {useState, useCallback} from "react";
import ReactDOM from "react-dom";

function MeasureExample() {
  const [height, setHeight] = useState(0);

  const measuredRef = useCallback(node => {
    if (node !== null) {
      setHeight(node.getBoundingClientRect().height);
    }
  }, []);

  return (
    <>
      <h1 ref={measuredRef}>Hello, world</h1>
      <h2>The above header is {Math.round(height)}px tall</h2>
    </>
  );
}

const rootElement = document.getElementById("root");
ReactDOM.render(<MeasureExample />, rootElement);

callback ref?

setState처럼 명확한 함수가 별도로 존재하는 것은 아니고, ref값 대신 사용할 콜백함수를 말하는 것 같다.
여기서 h1 태그에 이 콜백함수인 measuredRef를 ref로 지정해두었다.
ref에는 해당 노드가 current 에 담겨 객체형태로 반환되니까, measuredRef에는 h1노드가 담겨 node인자로 전달되었을 것이다.

확인해보자

callback ref 내부에 매개변수 node 를 출력해보았다.

  const measuredRef = useCallback((node) => {
    if (node !== null) {
      console.log(node)
      setHeight(node.getBoundingClientRect().height);
    }
  }, []);

예상대로 h1 노드가 나온다.

이 node가 비어있지 않을 때 즉 렌더링을 마쳤을 때 이 콜백함수는 node의 높이를 반환한다.
만약 그냥 ref 객체를 useRef로 생성하여 넘겨주었다면 h1이 렌더링 되기 전부터 가진 초기값 0을 반환할 것이다.

정말일까? 확인해보자

코드 샌드박스에서 코드를 수정해 테스트해보았다.
callback ref를 사용하지 않고 useRef의 인스턴스를 생성하여 같은 h1요소의 ref값으로 지정했다.

import React, { useState, useCallback, useRef } from "react";
import ReactDOM from "react-dom";

function MeasureExample() {
  const [height, setHeight] = useState(0);
  const testRef = useRef();//테스트용으로 만든 ref 객체

setTimeout(() => {       
   console.log(setHeight(testRef.current.getBoundingClientRect().height));
 }, 3000);
  
  return (
    <>
      <h1 ref={testRef}>Hello, world</h1>
      <h2>The above header is {Math.round(height)}px tall</h2>
    </>
  );
}

const rootElement = document.getElementById("root");
ReactDOM.render(<MeasureExample />, rootElement);

3초가 지난 뒤 높이가 측정되어 나온다.
setTimeout함수를 사용해 시간을 지연시키지 않으면 오류가 난다.
TypeError: Cannot read properties of undefined (reading 'getBoundingClientRect')
렌더링을 마치기 전 ref가 부착이되지 않은 상태에서 ref의 높이를 구하려고 했기 때문이다.


💎 예시로 알아보자(2)

다른 예시를 보자.
동일하게 공식문서에 소개된 예시 코드이다.
https://codesandbox.io/s/818zzk8m78?file=/src/index.js:0-959

import React, {useState, useCallback} from "react";
import ReactDOM from "react-dom";

function MeasureExample() {
  const [height, setHeight] = useState(0);

  // Because our ref is a callback, it still works
  // even if the ref only gets attached after button
  // click inside the child component.
  const measureRef = useCallback(node => {
    if (node !== null) {
      setHeight(node.getBoundingClientRect().height);
    }
  }, []);

  return (
    <>
      <Child measureRef={measureRef} />
      {height > 0 &&
        <h2>The above header is {Math.round(height)}px tall</h2>
      }
    </>
  );
}

function Child({ measureRef }) {
  const [show, setShow] = useState(false);
  if (!show) {
    return (
      <button onClick={() => setShow(true)}>
        Show child
      </button>
    );
  }
  return <h1 ref={measureRef}>Hello, world</h1>;
}

const rootElement = document.getElementById("root");
ReactDOM.render(<MeasureExample />, rootElement);

이렇게 버튼을 클릭했을 때 렌더링된 요소의 사이즈를 잴 때도 사용할 수 있다.

이제 왜 callback ref를 사용하는지는 이해가 된다.


커스텀 훅으로 작성하는 경우

리액트 공식문서에 소개된 예시 코드
https://codesandbox.io/s/m5o42082xy?file=/src/index.js:0-647
다른 사람들의 코드를 찾아보면서 정말 궁금했던 부분이 있었다.
아래 예시 코드와 같이 알 수 없는 [rect, setRef]와 같은 변수를 할당해 리턴시키는 부분이었는데, 이제야 이해가 된다.

import React, {useState, useCallback} from "react";
import ReactDOM from "react-dom";

function useClientRect() {
  const [rect, setRect] = useState(null);
  const ref = useCallback(node => {
    if (node !== null) {
      setRect(node.getBoundingClientRect());
    }
  }, []);
  return [rect, ref];
}

function MeasureExample() {
  const [rect, ref] = useClientRect();
  return (
    <>
      <h1 ref={ref}>Hello, world</h1>
      {rect !== null &&
        <h2>The above header is {Math.round(rect.height)}px tall</h2>
      }
    </>
  );
}

const rootElement = document.getElementById("root");
ReactDOM.render(<MeasureExample />, rootElement);

위의 예시와 동일한 코드를 커스텀 훅으로 만든 것이다.
useClientRect라는 커스텀 훅은 [rect, setRect]라는 state을 만든다.
여기에 새로 만든 ref의 크기를 잰다음 setRect로 rect 상태를 업데이트 해준다.
그리고 리턴은 [rect,ref]로 해준다.

이 부분이 이해가 안가던 부분이었는데, 이제는 알 것 같다.
MeasureExample()을 보자.
useClientRect커스텀 훅이 반환한 [rect, ref]값을 그대로 동일한 변수에 할당해주었다. 이 함수에서는 setRect를 쓸일이 없다. 사이즈를 잰 ref값과 사이즈가 담긴 rect만 필요할 뿐이다.
ref는 h1요소에 달아주고, 높이를 출력할 곳에 rect.height을 사용해준다.


💎 예시로 알아보자(3) - lazy loading

코드샌드박스에 소개된 다른 예시를 갖고 왔다.
https://codesandbox.io/s/01m0ok09rv?from-embed=&file=/src/index.js

App 컴포넌트 부분을 먼저 보자.


function App() {
  const [refCB, readyCB] = useRefCallback();
  const [lazyRefCB, lazyReadyCB] = useRefCallback();

  const [loading, setLoading] = useState(true);

  useEffect(() => setLoading(false), []);

  return (
    <div className="App">
      <p>
        Render the same hook that needs a ref, but the lazy hook only recieves
        that ref after the initial render.
      </p>
      <UseRefComponent />
      <UseRefCallbackComponent />
    </div>
  );
}

여기서의 setLoading은 뭘까..?

useRefComponent와 useRefCallbackComponent는 어떻게 구성되어있을까?
차례대로 살펴보자.

function UseRefComponent() {
  const [ref, ready] = useRefObject();
  const [lazyRef, lazyReady] = useRefObject();
  const loading = useLoading();

  return (
    <>
      <h2>
        With <code>useRef</code>
      </h2>
      <ul>
        <li ref={ref}>
          Initial ref ready: <strong>{ready.toString()}</strong>
        </li>
        {!loading && (
          <li ref={lazyRef}>
            Lazy ref ready: <strong>{lazyReady.toString()}</strong>
          </li>
        )}
      </ul>
    </>
  );
}

위에서 살펴봤던 커스텀 훅으로 ref와 ready 값을 반환해 보여주고 있다.

initial ref ready와 lazy ref ready의 차이점은,
lazy ref ready 값은 loading값이 false 일 때 즉 loading이 끝난 시점에서 값을 보여준다는 것이다.

loading 값이 false일 때?


useLoading

function useLoading() {
  const [loading, setLoading] = useState(true);
  useEffect(() => setLoading(false), []);
  return loading;
}

새로운 state를 선언해준뒤, useEffect를 사용해 최초로 렌더링 된 다음 loading을 false로 변경해주었다.
최초로 렌더링이 한번 끝나고 나면 로딩이 끝난다는 것을 표현하기 위해 loading을 true에서 false로 업데이트 해주고, useEffec의 의존성 배열은 빈 배열로 주었다.

그럼, 이제 useRefObject를 살펴보자.


useRefObject

function useRefObject() {
  const ref = useRef();
  const [ready, setReady] = useState(false);

  useEffect(() => {
    if (ref.current) setReady(true);
  }, [ref.current]);

  return [ref, ready];
}

ready state를 선언해준 뒤, ref.current값이 변경되어 생성되었을때 ready를 true로 업데이트 해주었다.
그리고 ref와 ready만 반환해준다. setState함수는 외부에서 쓰지 않을 거니까!

이제 이해가 된다. 큰 그림을 다시 그려보자.

initial ref ready 부분은 최초렌더링 시에 실행된다.
useRefObject내부에서 useRefComponent가 호출될때 li태그에 ref값이 할당되고, 이것이 current값이 변경됨에 따라 ready를 true로 바꾼다.

useLoading내부에서는 useEffect함수의 의존성 배열을 빈 배열로 주어 최초렌더링 후 loading이 false로 업데이트 된다.

loading이 false가 됨에 따라 lazy ref 영역을 조건부 렌더링 한다.
이 때 useRefObject내부에서는 lazyRef 값이 변화되었다고 인지하지 못해 ready를 true로 업데이트 하지 않는다. 따라서 다음과 같은 결과가 표시된다.


useRefCallbackComponent


function UseRefCallbackComponent() {
  const [refCB, readyCB] = useRefCallback();
  const [lazyRefCB, lazyReadyCB] = useRefCallback();
  const loading = useLoading();

  return (
    <>
      <h2>
        With <code>ref callback</code>
      </h2>
      <ul>
        <li ref={refCB}>
          Initial ref ready: <strong>{readyCB.toString()}</strong>
        </li>
        {!loading && (
          <li ref={lazyRefCB}>
            Lazy ref ready: <strong>{lazyReadyCB.toString()}</strong>
          </li>
        )}
      </ul>
    </>
  );
}

ref를 콜백함수로 전달했을 경우를 살펴보자.


useRefCallback



function useRefCallback() {
  const [ready, setReady] = useState(false);
  const setRef = useCallback(node => setReady(!!node), []);

  return [setRef, ready];
}

useRefObject와 달리, ref값이 아닌 setRef를 리턴해주고 있다.

setRef는 받은 node가 있는지 없는지 여부를 판별하기 위해 NOT NOT 연산자를 사용해 node를 불리언 타입으로 변환했다. 즉, node가 있다면 ready를 true로 업데이트 한다.

loading이 완료된 후 false가 된다면? lazyRef 부분을 조건부 렌더링 한다.
이 때 useRefCallback은 lazyRefCB ref 객체를 받아 ready 를 true로 업데이트 한다.

따라서 lazy ref ready 부분도 true가 된다.


이전에 이해되지 않았던 커스텀 훅을 활용한 무한스크롤 기능을 다시 코드를 가져와 살펴봐야겠다!
다음에는 커스텀 훅을 사용해 코드를 작성할 수 있도록 말이다!



💎 이제, 적용해보자.

🔹 현재 ref 값을 지역변수에 저장해 클린업 함수에서 영향을 받지 않도록 한다.

이렇게 수정해 보았다.


function LoadMoreProducts({ setPage }) {
  const feedEndRef = useRef();
  const observer = useMemo(
    () =>
      new IntersectionObserver(entry => {
        if (entry[0].isIntersecting) {
          setPage(page => page + 1);
        }
      })
  );

  useEffect(() => {
    let observerRefValue = null;

    if (feedEndRef.current) {
      observer.observe(feedEndRef.current);
      observerRefValue = feedEndRef.current;
    }
    return () => {
      if (observerRefValue) observer.unobserve(observerRefValue);
    };
  }, [observer]);

  return <div className="loadMoreProducts" ref={feedEndRef} />;
}
export default LoadMoreProducts;

모두 렌더링 되어
feedEndRef.current이 존재할 때 observe 하고, 이 참조값을 저장해둔다.
클린업함수가 실행될 때 이 저장해둔 참조값을 unobserve해 정리한다.
이 때 새로운 observe값이 생성되더라도 클린업 함수에 의해 영향을 받지 않는다.

아직 이해가 안되는 부분은...

동일한 참조값을 복사해온 것이니 observerRefValue를 unobserve 한것은 똑같이 feedEndRef.current에 적용되는게 아닌가?



Reference

profile
Why?에서 시작해 How를 찾는 과정을 좋아합니다. 그 고민과 성장의 과정을 꾸준히 기록하고자 합니다.

0개의 댓글