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

dobby·2024년 12월 28일
1

채팅 서비스라고 한다면, 메시지를 보내고 받을 때마다 해당 메시지들은 DOM tree에 쌓이게 된다.

메시지가 한 두개가 아니기 때문에, 지속해서 DOM에 쌓이게 될 경우 성능에 문제가 발생하게 된다.

이때 사용하는 것이 '가상 스크롤'이다.

사진 출처

채팅 서비스는 스크롤 바를 위로 올려 과거의 메시지를 보도록 한다.
그렇기에 일반 Infinite Scroll이 아닌, 그 반대인 Reverse Infinite Scroll을 적용해야 한다.

이는 Tanstack에서 제공해주는 useInfiniteQuery or useSuspenseInfiniteQuery를 사용하면 쉽게 구현할 수 있다.

무한 스크롤 적용하기

// Message.tsx
const { data, hasPreviousPage, isFetching, fetchPreviousPage } =
  useSuspenseInfiniteQuery<
    { chats: IMessage[]; meta: Meta },
    Object,
    InfiniteData<{ chats: IMessage[]; meta: Meta }>,
    [_1: string, _2: string, _3: string],
    { meta: Meta }
  >({
    queryKey: getChatMessagesQueryKey(agoraId),
    queryFn: getChatMessages(session),
    staleTime: 60 * 1000,
    gcTime: 500 * 1000,
    initialPageParam: { meta: { key: null, effectiveSize: 20 } },
    getPreviousPageParam: (firstPage) => {
      return firstPage.meta.key !== -1 ? { meta: firstPage.meta } : undefined;
    },
    getNextPageParam: (lastPage) =>
      lastPage.meta.key !== -1 ? { meta: lastPage.meta } : undefined,
  });

fetchPreviousPage 를 통해 이전 페이지 데이터를 불러올 수 있다.
설정 프로퍼티 중, getNextPageParam은 필수이기 때문에 안쓰더라도 추가해줘야 한다.

useSuspenseInfiniteQuery의 타입을 확인하면,

import { DefaultError, InfiniteData, QueryKey, QueryClient } from '@tanstack/query-core';
import { UseSuspenseInfiniteQueryOptions, UseSuspenseInfiniteQueryResult } from './types.js';

declare function useSuspenseInfiniteQuery<TQueryFnData, TError = DefaultError, TData = InfiniteData<TQueryFnData>, TQueryKey extends QueryKey = QueryKey, TPageParam = unknown>(options: UseSuspenseInfiniteQueryOptions<TQueryFnData, TError, TData, TQueryFnData, TQueryKey, TPageParam>, queryClient?: QueryClient): UseSuspenseInfiniteQueryResult<TData, TError>;

export { useSuspenseInfiniteQuery };
  • 첫 번째로, fetch하여 얻어오는 데이터의 타입을 작성해준다.
    { chats: IMessage[]; meta: Meta }

  • 두 번째로, Error 타입을 작성해준다. 작성하지 않으면 기본으로 DefaultError로 할당된다.

  • 세 번째로, InfiniteData 타입을 넣어준다. 이는 fetch하여 얻어온 데이터 타입에 InfiniteData를 붙여주면 된다.
    InfiniteData<{ chats: IMessage[]; meta: Meta }>

  • 네 번째로, 쿼리 키의 타입을 작성해준다.
    [_1: string, _2: string, _3: string]
    queryKey: 여기에 작성해주는 키의 타입을 그대로 적어주면 된다.

  • 다섯 번째로, pageParam 타입을 작성해준다.
    서버로부터 pageParam에 대한 데이터를 받아올텐데, 그에 대한 타입을 작성해주면 된다.


initialPageParam은 백엔드와 협의해서 처음 pageParam 데이터를 어떻게 줄 건지 결정한 값을 넣으면 된다.

initialPageParam: { meta: { key: null, effectiveSize: 20 } },

필자는 처음에 page keynull로 주기로 했다.
effectiveSize는 한 번에 얼만큼의 데이터를 불러올건지에 대한 값으로, 설정한 값으로 데이터를 보내주겠다고 하셔서 일단 20으로 했다.


getPreviousPageParamgetPreviousPageParam는 데이터를 불러온 후 pageParam을 재설정하는 부분이다.

getPreviousPageParam: (firstPage) => {
    return firstPage.meta.key !== -1 ? { meta: firstPage.meta } : undefined;
  },
  getNextPageParam: (lastPage) =>
    lastPage.meta.key !== -1 ? { meta: lastPage.meta } : undefined,

백엔드분이 마지막 페이지의 경우 key 값을 -1 로 주겠다고 하셔서 -1이 아닌 경우 데이터에서 pageParam 데이터를 뽑아 재설정해주었다.
getNextPageParam 은 사용하지 않기 때문에, getPreviousPageParam 과 동일하게 작성해주었다.


useInView 적용

이제 어떻게 이전 데이터를 호출할지를 작성하면 된다.

// Message.tsx
const { ref, inView } = useInView({
  threshold: 0,
  delay: 0,
  rootMargin: '100px',
});

useEffect(() => {
  if(inView) {
   fetchPreviousPage().then(() => {
     // 데이터 추가
   });
  }
}, [inView]);

return (
  <div className="overflow-y-auto flex-1 w-full h-full">
    <div ref={ref} className="h-3" />
    // 데이터 출력
    ...
  </div>
)

Intersection Observer API 에서 제공되는 hook인 useInView를 사용하면, 특정 영역이 화면에 보이는지를 알 수 있다.

사진 출처

ref로 넣어준 h-3 div 영역이 화면에 보이기 시작하면 inViewtrue로 바뀌게 되고, fetchPreviousPage 함수가 실행되어 새로운 데이터를 받게 된다.

위의 코드가 기본적인 무한 스크롤 적용 코드이고, 여기에 요구사항에 맞춰 수정하면 된다.

  • threshold: 대상 요소가 뷰포트에 얼마나 보였을 때 inView 상태를 true로 변경할지에 대한 설정

    • 값은 0부터 1사이의 숫자 또는 숫자 배열
    • 값이 클수록 더 많은 부분이 뷰포트에 노출되어야 inView 상태가 true가 된다.
  • delay: inView 상태가 변경되기 전에 대기할 시간을 밀리초(ms) 단위로 설정

    • 대상 요소가 뷰포트에 나타나거나 사라질 때, delay 만큼 대기한 후에 inView가 변경된다.
    • 기본값은 0
  • rootMargin: 감지 기준을 설정하는 옵션으로, 대상 요소 주변에 마진을 추가하거나 빼는 역할

    • 뷰포트와 대상 요소 간의 거리를 조정하여 더 일찍 또는 더 늦게 inView 상태를 변경하도록 설정할 수 있다.
    • 기본값은 0px (추가적인 마진 없이 정확히 뷰포트 경계를 기준으로 계산)

이대로 끝낸다면, 이 상태에서 스크롤 제어를 추가해주어야 한다.
채팅방 스크롤 제어
위 게시글에 이전 데이터를 불러왔을 때 사용자가 보던 화면에 고정되게 스크롤을 제어하는 방법을 작성했다.

가상 스크롤 적용하기

이제 채팅방의 성능을 개선하기 위해 가상 스크롤을 적용하자.

Tanstack Virtual 선택 이유

요구사항은 다음과 같았다.
1. reverse infinite scroll
2. dynamic-height
3. 스크롤 제어로 기존 화면 유지
4. 낮은 러닝 커브

react-virtuoso

그렇게 처음에 요구사항에 맞는 라이브러리를 찾았는데, react-virtuoso이다.
reverse-infinite-scroll을 제공한다는 점에 매력적으로 다가왔다.

하지만, 스크롤 제어를 아이템 key로 관리해서 아이템을 기준으로 스크롤의 위치를 옮기는 방식이었다.
이 로직이 이해하기 쉽지 않았을뿐만 아니라, 적용하더라도 화면 깜빡임이 너무 심하다던지, 스크롤이 마음대로 움직인다는 등의 문제가 많았다.

이 라이브러리가 맞는 사람이 있겠지만, 일단 나에겐 맞지 않았다.
그래서 좀 더 찾다가, Tanstack Virtual을 알게 되었다.

react-virtual

dynamic-height를 제공하며, 아이템으로 스크롤을 제어할 수도, offset으로 제어할 수도 있었다.
무엇보다 맘에 든 것은, useInView처럼 사용하기 편해서 빠르게 배울 수 있을 것 같았다.

사실, tanstack에서 reverse infinite scroll은 제공하고 있지 않기에 infinite scroll에 대한 virtual을 활용해야 한다.
다행히 tanstack에서 virtual을 제공하고 문서화도 잘 되어 있어 그대로 사용하면 된다.

Tanstack Virtual 공식문서


tanstack virtual 적용

// yarn
$ yarn add @tanstack/react-virtual

//npm
$ npm install @tanstack/react-virtual
// Message.tsx
import { useVirtualizer } from '@tanstack/react-virtual';

const virtualizer = useVirtualizer({
  count: messages.length,
  getScrollElement: () => listRef.current,
  overscan: 10,
  estimateSize: () => 30,
  scrollMargin: -15,
  measureElement:
    typeof window !== 'undefined' &&
    navigator.userAgent.indexOf('Firefox') === -1
      ? (element) => element?.getBoundingClientRect().height
      : undefined,
});
  • count: 전체 데이터 개수
  • getScrollElement: 스크롤이 적용될 영역
  • overscan: 화면에 보이지 않는 영역에 얼만큼 데이터를 보존할 것인지
  • estimateSize: 아이템 사이즈
  • scrollMargin: 스크롤이 적용되는 곳의 상하 margin 값
  • measureElement: 아이템 높이 계산

measureElement는 공식문서의 예시에 적혀있는 코드를 그대로 사용했다.
estimateSize는 최소 높이인 것 같고, 30px이 넘더라도 화면에 잘 보인다.

// Message.tsx
let prevMessageCount = 0;
let newMessageCount = 0;

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


  virtualizer.scrollToIndex(newMessageCount - prevMessageCount, {
    align: 'start',
    behavior: 'auto',
  });
});

이런식으로 데이터를 불러온 뒤, 스크롤 위치를 조정해주면 된다.
아이템 인덱스를 기준으로 스크롤을 옮길 수 있고, align 으로 해당 인덱스의 아이템이 화면에 어디에 위치하도록 스크롤을 조정할 것인지 설정하면 된다.

behavior 는 스크롤이 조정될 때 어떻게 조정될 것인지를 의미한다.
auto 는 바로 스크롤 위치가 바뀌고,
smooth 는 천천히 애니메이션이 적용되듯 변경된다.


이제 데이터 리스트를 렌더링해야 한다.

// Message.tsx
return ( 
  <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>
)

나는 이렇게 작성했다.
공식문서에서 제공해주는 코드를 대부분 그대로 사용했다.
transform 의 item.start + 15 는 align: 'start'를 주게 되면 사용자 이름에 대한 부분이 가려지면서 스크롤이 조금 부자연스러워졌다.
그래서 스크롤 위치를 조금 더 밑에 위치하도록 조정했다.

이는 scrollMargin: -15 와 동일한 값으로 주었다. (합치면 0)

결과 데모

사실 react-virtuoso 만큼은 아니지만 깜빡이는 현상이 조금 있었다.
항상 나타나는건 아니고, 가끔 나타나는데 이를 해결하려고 여기저기 다른 사람 코드를 봤지만 해결되지 않았다.
아무래도 지금 프로젝트의 요구사항에 맞는 코드가 있지 않아서 쉽게 고치지 어려운 것 같다.

offset를 활용한 스크롤 조정의 경우 깜빡이는 현상이 없었다.
만약 window scroll을 사용해도 된다면, offset를 쓰는게 더 좋을 것 같다.

offset을 사용해 스크롤 조정한 코드 (양방향 무한 스크롤)

위 코드를 참고하면 금방 구현할 수 있을 것이다.


(항상 불평하지만 .mov 에서 .gif로 변환 후 올리면 느려지는거 화나네유)

1. virtual 적용한 스크롤 제어

2. 화면에 보이는 부분만 DOM에 쌓이는 Message

음 2번 영상은 잘 안보이긴 하는데, data-index를 잘 보면 계속 바뀌는 것을 볼 수 있다.


마무리

가상 스크롤을 적용하는데 생각보다 오래 걸렸다.
요구사항에 딱 맞는 라이브러리가 없어서 그냥 구현해야 하나... 싶었는데,
사실 나는 나는 그 정도의 수준이 되진 않아서 그냥 적용하지 말지 고민이 되었다.

하지만 이 때문에 성능이 느려진다면 UX에 굉장히 안좋을 것 같았기에 시간이 걸리더라도 어떻게 해결해보려고 했다.

그 덕에 tanstack 라이브러리를 찾아서 얼추 적용할 수 있긴 했지만,,,
아직 해결해야 할게 하나 더 남았다!

홈 화면에 채팅방 리스트를 일반 Infinite Scroll로 적용했는데,
이건 grid가 필요해서 react-virtuoso 를 사용했다.

하지만 가상 스크롤 적용한다고 라이브러리를 두 개를 쓰기엔 너무 과한 것 같다.
tanstack 라이브러리를 어떻게 어떻게 잘 조율해서 수정하는게 다음 목표다!

모바일에서 확인하니 화면 깜빡이는 현상이 너무나도 잘 보여서, 방식을 바꿔서 적용하기로 했다.
이에 대한 글은 아래 링크를 타고 가면 된다.
https://velog.io/@dobby_/Tanstack-Reverse-Infinite-Scroll-Virtual-%EC%B1%84%ED%8C%85%EB%B0%A9-%EC%A0%81%EC%9A%A9%EA%B8%B02

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

0개의 댓글

관련 채용 정보