[TIL] 상향식 무한 스크롤 구현하기 (feat. 스크롤 위치 유지)

minami·2025년 9월 27일

일개미

목록 보기
12/14
post-thumbnail

최근 회사 프로젝트에서 채팅창 같은 UI를 가졌지만 실제로는 그냥 페이지네이션이 된 게시판 같은, 채팅인 듯 채팅 아닌 채팅 같은 페이지를 개발했다. 진짜 채팅창이 아니기 때문에 Socket 같은 것도 안 썼고 그냥 보기에만 채팅창처럼 생긴 기능이다보니 기본적인 채팅 기능 자체는 그다지 구현에 어려움이 없었다. 그런데 뜻밖의 복병은 무한 스크롤에서 발생했다. 무한 스크롤의 방향이 반대이기 때문이었쥬...

기존에는 TanStack-Query의 useInfiniteQuery를 활용해서 아래로 스크롤하는 무한 스크롤을 한 번 구현해둔 것을 여러 페이지에 써먹고 있었다. 아무래도 여기저기에 무한 스크롤을 적용할 일이 늘어나니까 팀원이 아예 무한 스크롤용 섹션 컴포넌트를 만들어둬서 적용하기 더 편하기도 했다. 그런데 이번에는 실제 채팅창처럼 사용자가 스크롤을 위로 올릴 때 이전 데이터를 불러오는 '상향식' 무한 스크롤 구현이 필요했고, 상향식 무한 스크롤 구현은 처음이어서 예상치 못한 문제들에 부딪혔다.

처음에는 하향식 무한 스크롤용 컴포넌트에 상향식 무한 스크롤도 가능하도록 개선해보려고 했는데 상향식일 경우에는 데이터를 페칭할 때 스크롤을 유지해야 하고, 채팅창의 TextArea가 가변 높이를 가지는 바람에(^^....) 스크롤 높이 계산에 이 부분을 고려해야 하는 등 여러 조건이 있어서 그냥 컴포넌트 개선은 포기해야 했다. 서버 API도 마지막 페이지에서부터 시작해서 거꾸로 이전 페이지를 불러오는 방식으로 구현된 것이 아니라 다음 페이지를 불러오는 방식이었으며 정렬 순서만 바꿔서 제공하도록 되어 있었다.

그래도 어떻게 하겠어요. 구현해야지. 돈 받았으면 일해야지.
여하간에 이러한 상황에서 상향식 스크롤을 구현하기 위해 고민했던 과정과 해결 방법을 정리해 보려고 한다.

1. 하향식 vs. 상향식 무한 스크롤

먼저, 익숙했던 하향식 무한 스크롤과 새로 구현해야 했던 상향식 무한 스크롤의 차이를 다시 한번 정리해보자.

구분하향식 무한 스크롤상향식 무한 스크롤
스크롤 방향아래로 스크롤위로 스크롤
데이터 페칭fetchNextPage로 다음 페이지 데이터를 불러옴fetchPreviousPage로 과거 데이터를 불러오지만, 화면에는 역순으로 표시해야 함
스크롤 위치새 데이터 로드 시 최하단에 위치새 데이터 로드 시 기존 스크롤 위치를 유지해야 함

간단히 정리하자면 이 정도가 될 듯하다.
이번 프로젝트에서는 위에서 언급했던 대로 서버 API 구현상 맨 마지막 N페이지에서부터 거꾸로 0페이지를 향해 가는 것이 아니기 때문에 하향식과 마찬가지로 fetchNextPage를 쓰는데 과거 데이터를 반대로 가져와야 했고, 이를 화면에 올바르게 표시하는 방법을 찾아야 했다.

2. fetchNextPage로 상향식 스크롤 구현하기🙃

기존의 하향식 구현 방식을 그대로 적용해보니 여러 문제가 발생했다.
가장 최신의 데이터인 맨 마지막 페이지가 몇 페이지인지 클라이언트에서는 알 수 없기 때문에 0페이지부터 호출을 하게 되는데 그러면 최신 데이터부터 거슬러 올라가는 것이 아니기 때문에 가장 과거의 데이터가 채팅창 상단에서부터 그려지면서 일반적인 하향식 무한 스크롤과 동일한 결과가 나타났다. 그래서 서버 개발자에게 거꾸로 거슬러 올라가야 하기 때문에 가장 최신의 데이터부터 볼 수 있게 해달라고 했더니, 정렬 옵션을 이용할 수 있게 API 작업을 해줬다. 즉, 프론트에서 과거순 데이터를 호출할 수 있게 되면서 0페이지가 최신 데이터가 되고 1페이지, 2페이지로 갈수록 과거 데이터가 되는 것이다.fetchNextPage 써야 하는 건 똑같았다는 이야기다...

여하간에 과거순 데이터 호출이 가능해지긴 했으나, 받아온 데이터를 그대로 map()으로 돌려버리면 이번에는 과거 데이터가 채팅창의 하단이 아닌 상단부터 나오게 되는 문제가 생긴다. fetchNextPage로 데이터를 불러오면 데이터가 단순히 data.pages 배열 뒤에 순서대로 추가되기 때문이다.
그래서 select 옵션을 이용해서 과거순 정렬일 경우에는 전체 페이지 데이터에 reverse()를 이용하여 거꾸로 정렬해줌으로써 메시지 정렬 순서를 다시 한번 바꿔줘야 했다. 그러면 반환된 값의 순서가 뒤집힌 상태이기 때문에 렌더링 시에는 올바른 순서로 표시되었다.

// useInfiniteQuery 옵션에 select 추가
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
  // ... 기존 useInfiniteQuery 옵션들
  getNextPageParam: (lastPage) => lastPage.nextCursor, // 구현에 따라 다음 페이지 조건 분기 처리 필요
  select: (data) => ({
    pages: [...data.pages].reverse(), // 페이지 순서를 뒤집어 최신 데이터가 아래로 오도록 함
    pageParams: [...data.pageParams],
  }),
});

이제 데이터는 원하는 순서대로 정렬이 잘 되었지만, 아직 다음 관문이 남아 있다.

기존 하향식 무한 스크롤이야 IntersectionObserverreact-intersection-observer를 사용해서 옵저버를 스크률 최하단에 두고 데이터 페칭을 하면 되었겠지만 상향식 무한 스크롤은 반대로 스크롤과 데이터 페칭이 일어나야 하기 때문에 추가적인 스크롤 컨트롤이 필요하기 때문이다.

3. 스크롤 위치 관리

상향식 무한 스크롤에서 중요한 부분은 새로운 데이터가 로드될 때 스크롤 위치를 유지하는 것이었다. 이 부분이 사실상 제일 까다로웠던 부분이다. 기존 하향식 무한 스크롤에도 사용했던 react-intersection-observeruseInview 훅을 이용해서 옵저버를 스크롤 가장 상단에 두고 데이터 페칭을 하는 것까진 쉽게 했지만, 페이지 첫 진입 시에 옵저버가 상단에 있으니까 이 다음 페이지를 자동으로 불러와버리는 문제가 생겼다. 게다가 다음 페이지를 불러오면 스크롤이 최상단으로 제멋대로 가버린다던지 하는 문제도 있었다.

처음에는 커서에게 한 번 문제 해결을 맡겨봤지만, 영 시원치 않았다. 커서 써본 사람들은 다들 알겠지만 AI가 개발자 대체한다는 거 그거 다 거짓말이다. 아직 멍청이라 제대로 해결 못한다...

그래서 구글링으로 여러 기술 블로그와 스택 오버플로우 등등 이 길을 먼저 걸은 이들의 게시글을 참고하여 직접 문제 해결의 실마리를 찾았다. 핵심은 새 페이지를 불러오기 전의 스크롤 높이를 상태값으로 저장해두고 있다가 새 페이지를 불러온 직후의 새로운 스크롤 높이와의 차이를 적용해주는 것이었다. 그렇지 않으면 스크롤 위치가 유지되지 않는다. 스크롤 높이 계산을 위해서는 useRef를 사용했다.
무한 스크롤이 적용될 요소에만 스크롤이 되도록 하고, 상위 요소들에는 overflow-hidden을 줘야 한다는 것은 덤으로 알아두자. 그렇지 않으면 왜 스크롤이 안 먹지? 또는 왜 스크롤이 이상하지? 하면서 또 삽질하게 될 것이다. 이것은 저의 피와 눈물로 쓰여졌으며...

따라서 정리해보자면,

  1. 데이터 페칭 트리거 요소: useInview로 관찰할 요소를 스크롤 영역의 최상단에 배치
  2. 데이터 페칭: useInview가 반환하는 inView 값이 true이고 스크롤 요소의 scrollTop값이 10 미만이 되면, useEffect 훅 안에서 fetchNextPage 호출
  3. 스크롤 위치 유지: 초기값은 0으로 세팅. 새로운 데이터 로딩이 끝난 후, 바뀐 전체 스크롤 높이에서 저장해둔 직전 스크롤 높이를 뺀 값만큼 현재 스크롤 위치에 더해줌으로써 스크롤 유지

하는 것이 핵심이다. 이 핵심 로직을 코드로 작성하면 아래와 같다.

// ChatComponent.js (핵심 로직)
import { useInView } from 'react-intersection-observer';
import { useEffect, useRef } from 'react';

const ChatComponent = () => {
  const scrollRef = useRef(null);
  const [ref, inView] = useInView();

  const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
    // ... 위에서 설정한 옵션들
  });

  useEffect(() => {
    if (inView && hasNextPage && !isFetchingNextPage) {
      // 새로운 데이터 로드 전, 스크롤 위치와 전체 높이 저장
      const scrollTop = scrollRef.current.scrollTop;
      const scrollHeight = scrollRef.current.scrollHeight;

      fetchNextPage().then(() => {
        // 데이터 로드 후, 새로운 전체 높이를 기준으로 스크롤 위치 복원
        const newScrollHeight = scrollRef.current.scrollHeight;
        scrollRef.current.scrollTop = scrollTop + (newScrollHeight - scrollHeight);
      });
    }
  }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);

  return (
    <div ref={scrollRef}>
      {/* 로딩 엘리먼트를 스크롤 영역 최상단에 배치 */}
      <div ref={ref}>...로딩 중...</div>
      {data?.pages.map((page) =>
        page.messages.map((message) => <ChatMessage key={message.id} message={message} />),
      )}
    </div>
  );

*위 코드는 실제 적용한 코드는 아니다. 실제 내가 작성한 코드는 회사 자산이기 때문에 제미나이한테 동일한 문제 상황을 던져주고 얻은 코드이다. 핵심 로직을 적용한 샘플 코드로 적당한 것 같아서 가져왔다.

마무리

이번 상향식 무한 스크롤 구현은 단순히 하향식 무한 스크롤과 반대로 로직을 짜는 것이 아니라, 좀 더 복잡한 문제를 풀어내야만 하는 경험이었다. 데이터의 순서를 조작하는 것은 간단했지만, 바뀐 스크롤 방향에 맞게 데이터 페칭 타이밍을 변경하고 스크롤 위치를 유지하는 것은 쉽지 않았다. 특히, 스크롤 위치 유지를 위해서는 요소의 스크롤 높이를 이용하는 방식을 이해해야만 했다.

기능이 잘 작동하는 것도 중요하지만, 사용자 경험을 해치지 않도록 하는 것도 중요하다. 이걸 항상 잘 기억하고 개발해야지. 오늘도 다짐하기!

profile
함께 나아가는 개발자💪

0개의 댓글