[React] useRef로 무한 스크롤 만들기

tamagoyakii·2023년 9월 14일
0

Tamagoyaki Learning

목록 보기
2/3
post-thumbnail

닥터퐁에는 귀여운 채팅 기능이 있다.

사용해 보고 싶다면 ~~? ---> 바로 가기

채팅방에는 모든 채팅 내역을 자연스럽게 보여줘야 하기 때문에 무한 스크롤로 데이터를 불러온다. 또한 사용자가 옛날 채팅을 보고있을 때 새로운 채팅이 오면 페이지 하단에 <Preview /> 컴포넌트로 새로운 메시지가 있음을 알려준다.

  1. 스크롤이 맨 위인 경우 새로운 데이터 받아오기.
  2. 스크롤이 맨 아래가 아닌 경우 프리뷰 보여주기.
  3. 프리뷰를 클릭한 경우 스크롤을 맨 아래로 내려주기.
  4. 스크롤이 맨 아래인 경우 프리뷰 보여주지 않기.

그러기 위해서는 채팅창 엘리먼트의 상단과 하단에 대한 정보가 필요하다.

원래 어땠지?

기존의 코드는 채팅 엘리먼트 상하단에 임의의 <div></div>를 넣어 ref를 주고 IntersectionObserver로 해당 div가 노출되어있는지 판별하는 방식이었다. 대충 흐름만 써본 기존 코드다.

const topRef = useRef<HTMLDivElement | null>(null);
const bottomRef = useRef<HTMLDivElement | null>(null);
const [topRefVisible, setTopRefVisible] = useState<boolean>(true);
const [bottomRefVisible, setBottomRefVisible] = useState<boolean>(false);
  
const handlePreviewClick = () => {
  topRef.current?.scrollIntoView({ behavior: 'auto' };
};
// 프리뷰 클릭시 맨 아래로 이동
  
useEffect(() => {
  const observer = new IntersectionObserver(
    (entries) => {
      entries.forEach((entry) => {
        if (entry.target === topRef.current) {
          setIsTopRefVisible(entry.isIntersecting);
        } else if (entry.target === bottomRef.current) {
          setIsBottomRefVisible(entry.isIntersecting);
        }
      });
    },
    { threshold: 0.5 }
  );

  const timeout = setTimeout(() => {
    if (topRef.current) observer.observe(topRef.current);
    if (bottomRef.current) observer.observe(bottomRef.current);
  }, 300)
  // 0.3초마다 상하단 ref가 노출되어 있는지 검사

  return () => {
    clearTimeout(timeout);
    if (topRef.current) observer.unobserve(topRef.current);
    if (bottomRef.current) observer.unobserve(bottomRef.current);
  };
  // 리랜더링될 때 타이머 초기화
}, []);
  
useEffect(() => {
  if (topRefVisible) {
    // 상단 ref가 보일 때 다음 페이지 데이터 요청
    // useQuery의 hasNextPage, isFetchingNextPage 등의 값도 봐야함
  }
}, [topRefVisible]);

useEffect(() => {
  if (!bottomRefVisible) {
    // 하단 ref가 보이지 않을 때 프리뷰 표시
  }
}, [bottomRefVisible]);

return (
  <div>
    <div>
      <div ref={bottomRef}></div>
      <Chats />
      <div ref={topRef}></div>
    </div>
    <Preview onClick={handlePreviewClick} />
  </div>
);
}

물론 코드 사이사이에 조건문들이 더 붙어있긴 하지만, 스크롤과 관련된 내용만 적어봤다. 근데 아무리 봐도 상하단에 빈 div를 넣어 스크롤 위치를 판별하는 것이 별로였다. 그래서 빈 div를 빼고 스크롤을 관리할 수 있는 방법으로 코드를 리팩토링하기로 했다.

새로운 방법!

그것은 생각보다 간단했다! 채팅 전체를 감싸고 있는 엘리먼트에 ref를 주고 그 ref의 scroll을 관리하면 된다. useRef에서 스크롤과 관련해 사용할 수 있는 값들은 다음과 같다.

  • scrollTop : 요소 맨 위에서부터 스크롤바까지의 거리
  • scrollHeight : 요소의 전체 높이
  • clientHeight : 요소 중 현재 보이는 부분의 높이

y 값이 아닌 x 값에 대한 스크롤을 제어하고 싶다면 scrollLeft, scrollWidth, clientWidth 값을 사용하면 되겠다.

이를 적용한 코드는 다음과 같다.

const chatsRef = useRef(null);

useEffect(() => {
  if (chatsRef.current)
    chatsRef.current.addEventListener('scroll', handleScroll);
  return () => {
    if (chatsRef.current)
      chatsRef.current.removeEventListener('scroll', handleScroll);
  }
}, []);

const handleScroll = () => {
  const ref = chattingsRef.current!;
  if (Math.abs(ref.scrollTop) > ref.scrollHeight - ref.clientHeight - 100) {
    // 스크롤이 맨 위인 경우 다음 페이지의 데이터 요청
  }
  if (ref.scrollTop < -50) {
    // 스크롤이 맨 아래가 아닌 경우 프리뷰 표시
  }
  if (ref.scrollTop > -10) {
    // 스크롤이 맨 아래인 경우 프리뷰 제거
  }
};

const pageDown = () => {
  const ref = chattingsRef.current!;
  ref.scrollTop = 0;
};

return (
  <div>
    <div ref={chatsRef}>
      <Chats />
    </div>
    <Preview onClick={pageDown}/>	// 프리뷰 클릭 시 맨 아래로 스크롤
  </div>

);
}

컴포넌트가 랜더링될 때 이벤트 리스너에 handleScroll 함수를 추가했다. 그러면 스크롤 이벤트가 발생할 때마다 해당 함수가 실행되면서 조건문에 맞는 코드가 실행될 것이다.

위의 handleScroll() 함수에 있는 조건문들은 조금 이상하다. 왜냐면......... 닥터퐁 코드에서 chatsRef 엘리먼트의 스타일은 다음과 같기 때문!

div {
  display: flex;
  flex-direction: column-reverse;
  gap: 1rem;
  height: 100%;
  overflow-y: auto;
  padding-inline: 0.7rem;
}

두둥... 받는 데이터를 역순으로 뿌려줘야 하기 때문에 column-reverse로 정렬한다. 그러면 scroll 관련 값들이 음수로 나오더라...! 때문에 위 조건문에 있는 것들은 닥터퐁에 맞게 머리 싸매고 계산한 값이다.

그러나...

위 코드를 적용할 때 굉장한 고통이 있었다. 자꾸 이벤트 리스너에 함수 등록이 안 되는 것이다...

  1. chatsRef 선언
  2. return 문에서 chatsRef가 할당
  3. useEffect에서 chatsRef에 이벤트 리스너가 추가

이게 내가 생각한 순서였는데! 자꾸 chatsRefnull이라는 에러 메시지와 함께 이벤트 리스너 등록에 실패했다... 엄청난 절망에 빠져있던 그때!

자려고 누웠다가 갑자기 엄청난 깨달음과 함께 문제를 해결했다. 문제는 바로 우리의 useQuery get 요청의 로딩 처리였다. 사실 위 코드 사이에는 다음과 같은 조건문이 있었다.

if (isLoading) return <LoadingSpinner />
if (isError) return <ErrorRefresher />

첫 데이터를 받아올 때 isLoading 조건문에 걸려서 리턴이 되어버리면서 chatsRef가 할당되기 전에 useEffect가 실행되어버리는 것이었다ㅠㅠ 위 조건문을 지우고 나니 이벤트 리스너가 정상적으로 등록되는 것을 확인할 수 있었다~~!

성능 차이가 크게 나지는 않는 것 같지만, 개인적으로는 임의의 div를 사용한 방법보다 훨씬 좋은 것 같다고 생각한다.

0개의 댓글