Tanstack Reverse Infinite Scroll + Virtual 채팅방 적용기(2)

dobby·2025년 2월 25일
0
post-thumbnail

이 글은 아래 포스트에 대한 깜빡임 문제를 해결하기 위해 다른 방법으로 채팅 가상 스크롤을 적용한 글이다.
Tanstack Reverse Infinite Scroll + Virtual 채팅방 적용기

이전 가상 스크롤 포스트에서 채팅방 가상스크롤 적용은 끝날 줄 알았다.
노트북 상에서는 깜빡이는 문제가 잘 보이지 않아서 괜찮은 줄 알았는데, 확실히 노트북과 모바일의 성능 차이가 큰지 모바일에서는 너무나도 문제점이 잘 보였다.

그래서 깜빡이는 문제를 덜기 위해 다른 방법으로 가상 스크롤을 적용하기로 했다.

적용 자체는 한참 전에 했는데, 사정이 생겨서 한동안 쉬게 되어 포스트하지 못했다.


결과 미리보기

메시지 내용들은 테스트용으로 아무거나 쓴거라 그냥 넘기는걸로...
이전과 다른 것은 다음과 같다.

  • 스크롤의 최상단으로 올라가기 전에 데이터 불러와 더 스크롤을 더 자연스럽게 적용
  • 기존의 스크롤 이벤트 방향을 반대로 돌리기
  • 메시지들 반전시키기

github PR로 날렸던 내용이 있어서 가져와봤다.

크게 보면 위와 같다.
한참 전에 적용한거라, 당시 어떤 생각으로 적용했던 건지는 기억이 잘 나지 않는다.
(인터넷을 탐색하다 어떤 글에서 찾은 건데, 지금 찾을려고 하니까 안보인다.)
그래서 무한 스크롤 + 가상 스크롤에서 생각나는 부분들을 최대한 골라 작성하고자 한다.


기존의 스크롤 이벤트 방향 반대로 돌리기

일단 기존의 스크롤 방향을 보자면 다음과 같다.

  • 휠을 위로 돌리면, 스크롤 바도 같이 올라간다.
  • 휠을 아래로 돌리면, 스크롤 바도 같이 내려간다.

이를 반대로 만들어주는 것이다.

  // 여기서 `listRef`는 메시지 리스트를 감싸는 container element이다.
  const listRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    // 반대로 스크롤 되도록 이벤트 핸들러 등록
    const handleScroll = (e: WheelEvent) => {
      e.preventDefault();
      const currentTarget = e.currentTarget as HTMLElement;

      if (!isNull(currentTarget)) {
        currentTarget.scrollTop -= e.deltaY;
      }
    };

    const currentListRef = listRef.current;
    if (!isNull(currentListRef)) {
      currentListRef.addEventListener('wheel', handleScroll, {
        passive: false,
      });
    }
    return () => {
      if (!isNull(currentListRef)) {
        currentListRef.removeEventListener('wheel', handleScroll);
      }
    };
    // 의존성 배열의 status는 useSuspenseInfiniteQuery로 데이터를 받아올 때의 상태를 의미하며 객체 구조분해 할당으로 얻을 수 있다.
  }, [status]);

의존성 배열의 status는 다음과 같이 useSuspenseInfiniteQuery에서 구조분해를 통해 얻어온 것이다.

const {
    data,
    status,
    hasPreviousPage,
    isFetchingPreviousPage,
    fetchPreviousPage,
  } = useSuspenseInfiniteQuery<
      { chats: IMessage[]; meta: Meta },
    Object,
    InfiniteData<{ chats: IMessage[]; meta: Meta }>,
    [_1: string, _2: string, _3: string],
    { meta: Meta }
  >
      ...

이러면 기존의 휠 동작이 반대로 동작하게 될 것이다.

이때 너무 신기해서 막 조작했던 기억이..


채팅 리스트 반전시키기

우선, 가상 스크롤을 위해 추가했던 virtualizer 를 조금 수정해줘야 한다.

virtualizer 수정

  const virtualizer = useVirtualizer({
    count: hasPreviousPage ? messages.length + 1 : messages.length,
    getScrollElement: () => listRef.current,
    overscan: 5,
    estimateSize: () => 30,
    scrollMargin: 10,
    measureElement:
      typeof window !== 'undefined' &&
      navigator.userAgent.indexOf('Firefox') === -1
        ? (element) => element?.getBoundingClientRect().height
        : undefined,
  });

const virtualItems = virtualizer.getVirtualItems(); // 선언

기존엔 count: messages.length 였던 것을 count: hasPreviousPage ? messages.length + 1 : messages.length 로 수정해주었다.
그리고 -15로 설정해줬던 scrollMargin10으로 바꿨다.
overscan은 기존대로 둬도 상관없다.

그리고 가상스크롤 아이템들을 따로 선언해준다.


새 메시지 불러오기

const adjustScrollRef = useRef(false);

  useEffect(() => {
    const [lastItem] = [...virtualItems].reverse();

    if (isNull(lastItem)) {
      return;
    }

    if (
      lastItem.index >= messages.length - 1 &&
      hasPreviousPage &&
      !isFetchingPreviousPage &&
      !adjustScrollRef.current
    ) {
      adjustScrollRef.current = true;
      fetchPreviousPage().then(() => {
        setMessages((prev) => {
          const newMessages = data?.pages[0].chats || [];
          return [...prev, ...newMessages];
        });
        adjustScrollRef.current = false;
      });
    }
  }, [
    hasPreviousPage,
    fetchPreviousPage,
    messages.length,
    isFetchingPreviousPage,
    virtualItems,
  ]);

virtualItems 들을 spread 연산자로 펼치고, 이를 뒤집는다.
원래는 깜빡이는걸 조금이라도 줄여보고자 unstable_batchedUpdates를 사용했었는데, 소용이 없어서 제거했다.

메시지를 저장할 때도 원래는

setMessages((prev) => {
 const newMessages = data?.pages[0].chats || [];
 newMessageCount = newMessages.length;
 return [...newMessages, ...prev];
});

이렇게 작성했는데, 메시지가 reverse로 뒤집혔으니 저장하는 것도 반대로 해줘야 한다.
adjustScrollRef 는 데이터를 새로 불러오는 동안 또 fetch 되지 않도록 막기 위해 사용해주었다.


리스트가 reverse 되었으니, 서버로부터 받아오던 메시지 리스트도 수정해줘야 한다.

const result = res.response;

return {
  // chats: result.chats.reverse(),
  chats: result.chats,
  meta: result.meta,
};

서버로부터 데이터를 받고 reverse 시켜 리턴했으니, reverse를 제외하여 리턴해준다.


메시지 반전시키기

<div
  key={agoraId}
  ref={listRef}
  className="h-full w-full flex overflow-auto flex-col transform-scale-y-inverted"
>
  <div
    className="relative w-full flex flex-col"
    style={{
      height: `${virtualizer.getTotalSize()}px`,
    }}
  >
    {virtualizer.getVirtualItems().map((item) => {
      return (
        <div
          ref={virtualizer.measureElement}
          key={item.key}
          data-index={item.index}
          className="absolute top-0 left-0 w-full"
          style={{
            transform: `translateY(${item.start}px) scaleY(-1)`,
          }}
        >
          <MessageItem
            message={messages[item.index]}
            isMyType={isMyType}
            getTimeString={getTimeString}
            nextMessage={messages[item.index - 1] || null}
            prevMessage={messages[item.index + 1] || null}
            queryClient={queryClient}
            agoraId={agoraId}
          />
       </div>
      );
    })}
  </div>
</div>

scaleY(-1) 을 사용해서 반전시켜준다.
아래는 기존 코드다.

<div
  key={agoraId}
  ref={listRef}
  className="overflow-y-auto flex-1 w-full h-full"
>
  {!adjustScroll && <div ref={ref} className="h-3" />}
  <div
    className="relative w-full"
    style={{
      height: virtualizer.getTotalSize(),
      contain: 'strict',
      overflowAnchor: 'none',
      overscrollBehavior: 'none',
    }}
   >
   {virtualizer.getVirtualItems().map((item) => (
     <div
     ref={virtualizer.measureElement}
     key={item.key}
     data-index={item.index}
     className="absolute top-0 left-0 w-full"
     style={{
       zIndex: 0,
       transform: `translateY(${item.start + 15}px)`,
       willChange: 'transform',
       containIntrinsicSize: `auto ${item.size}px`,
      }}
     >
       <MessageItem
        message={messages[item.index]}
        isMyType={isMyType}
        getTimeString={getTimeString}
        nextMessage={messages[item.index + 1] || null}
        prevMessage={messages[item.index - 1] || null}
        client={client}
        queryClient={queryClient}
        agoraId={agoraId}
       />
     </div>
    ))}
  </div>
</div>

많이 바뀐건 없고, stylenextMessage, prevMessage 에 전달해주는 메시지를 바꾸었다.
그리고 상위 div 엘리먼트에 transform-scale-y-inverted 스타일을 추가해줬는데, tailwind에 커스텀으로 추가한 스타일이다.
이는 다음과 같다.

'.transform-scale-y-inverted': {
    transform: 'scaleY(-1)',
 },

스크롤 조정

가장 최근 메시지를 보기 위해 스크롤을 조작하고자 할 때는, 스크롤이 뒤집혔으니 조작도 반대로 해주어야 한다.

// 기존
listRef.current.scrollTo(0, listRef.current.scrollHeight);

// 수정
listRef.current.scrollTo({ top: 0 });

이렇게 반전과 뒤집힘에 의해 scaleY(-1) 를 해줘야 하는 부분, 스크롤 조작을 수정해줘야 하는 부분이 있다.
이는 어떤 기능을 넣었는지에 따라 달라지기 때문에 확인하며 수정해줘야 한다.


마무리

이정도 하면 어느정도 될.. 것이라 생각한다.
github history랑 코드랑 보면서 어떤게 수정됐는지 비교하면서 작성했다.
크게 보면 위에 작성한대로 수정하면 되고, 나머지는 각자의 기능이나 디자인에 맞춰 추가 수정하면 된다.

여기서 함정은!
스크롤와 리스트를 뒤집어줬기 때문에, 메시지를 전송할 때 아래에서 쌓인다는 것이다.

이것도 괜찮아서 이대로 진행하기로 했다.

아래 영상은 이전에 가상스크롤 적용하기 전에 찍었던 영상이다.
재미로 보긔

채팅 시연 영상 - 유튜브

영상 찍을 때 START 버튼 누르는걸 까먹었다. ㅠ

profile
성장통을 겪고 있습니다.

0개의 댓글

관련 채용 정보