초록집사 표지

이슈

내가 구현한 무한스크롤에서 문제점이 한 가지 발생했다.
그것은 바로, 유저가 다른 페이지를 탐색하다가 뒤로가기를 누르면
원래의 위치로 되돌아 올 수 없다는 것이다.
이는 사용성에 좋지 않다.

사실, 이것은 무한 스크롤이 가지고 있는 본질적인 문제점이다.
기본적으로 React-Router는 페이지 이동 시 스크롤을 유지해주지만,
무한스크롤이라면 이야기는 달라진다.
무한스크롤로 불러온 데이터는 페이지를 이동하면 전부 초기화되기 때문이다.
그렇기 때문에 원래의 스크롤 위치로 복원하려면 별도의 처리가 필요하다.

문제 해결

먼저 해결해야 할 사안을 구체적으로 파악하자.
무한스크롤이 구현된 페이지는 MainPage이다.
내가 원하는 것은 MainPage로 돌아올 때 무한스크롤의 위치를 복원하는 것이다.
이를 구조도로 나타내면 다음과 같다.

초록집사 표지

내가 생각해낸 방법은 ‘세션 스토리지’를 사용하여
MainPage에서 불러 온 데이터의 개수를 임시 저장하는 것이었다.

우선 useSessionStorage라는 커스텀 훅을 만들었다.
세션 스토리지를 사용할 수 있는 훅이다.

import { useState } from 'react';

const useSessionStorage = (key, initialValue) => {
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = sessionStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error(error);
      return initialValue;
    }
  });

  const setValue = (value) => {
    try {
      setStoredValue(value);
      sessionStorage.setItem(key, JSON.stringify(value));
    } catch (error) {
      console.error(error);
    }
  };

  return [storedValue, setValue];
};

export default useSessionStorage;

이 커스텀 훅은 storedValue와 setValue를 반환한다.
storedValue는 세션 스토리지에 저장된 값이며,
setValue는 이 값을 갱신할 수 있는 함수이다.

다음으로 useScrollPosition이라는 커스텀 훅을 만들었다.
세션 스토리지의 key를 선언하고,
useSessionStorage를 실행하는 화살표 함수를 리턴한다.

import useSessionStorage from './useSessionStorage';

const SCROLL_POSITION_KEY = 'prevPostIndex';

export default () => useSessionStorage(SCROLL_POSITION_KEY, 0);

이제 이 useScrollPosition을 다음과 같이 불러와 사용하면 된다.

const [prevPostIndex, setPrevPostIndex] = useScrollPosition();

먼저 PostDetailPage로 이동하는 경우이다.
setPrevPostIndex를 봐주기 바란다.

  const handleClickPost = useCallback(() => {
    if (isDetailPage) {
      return;
    }
    setPrevPostIndex(index + 1);
    navigate(`/post/detail/${postId}`, {
      state: {
        post,
        index,
      },
    });
  }, [setCurrentPostIndex, index, postId, post, isDetailPage, navigate]);

navigate는 useNavigate를 통해 반환된 메서드이다.
navigate를 통해 페이지를 이동하기 전, index를 세션 스토리지에 저장한다.

참고로, index는 포스트가 몇 번째 순서인가를 가리킨다.
그림으로 나타내면 다음과 같다.

초록집사 표지

이 index를 기억함으로써, 무한스크롤의 데이터를 복원할 것이다.

다음은 SearchTagPage로 이동하는 경우다.

  const handleClickTag = useCallback(
    (tag) => {
      setCurrentPostIndex(index + 1);
      navigate(`/tag/${tag.slice(1)}`, {
        state: {
          tag,
        },
      });
    },
    [index, setCurrentPostIndex, navigate],
  );

마찬가지로, setPrevPostIndex를 통해 페이지를 이동하기 전 index를 세션 스토리지에 저장한다.

과연 세션 스토리지에 저장이 잘 되었을까?

초록집사 표지

저장이 잘 된 것을 확인할 수 있다!
이제 이 값을 MainPage로 다시 돌아올 때 사용하면 된다.
중요한 코드에 주석 1, 2, 3을 달아두었다.

const LIMIT = 5; // 한 번에 불러오는 데이터의 개수

const MainPage = () => {
  const [posts, setPosts] = useState([]);
  const [offset, setOffset] = useState(0); // 불러올 데이터(post)의 시작 인덱스
  const [max, setMax] = useState(0); // 전체 데이터의 개수 
  const targetRef = useRef(null);
  const [prevPostIndex, setPrevPostIndex] = useScrollPosition(); 

  useEffect(() => {
    const limit = prevPostIndex ? prevPostIndex : LIMIT; // 주석 1
    (async () => {
      const nextPosts = await getPostsPart({
        offset,
        limit,
      }).then((res) => res.data);
      setPosts(nextPosts);
      setMax(nextPosts[0].channel.posts.length);
      setOffset(prevPostIndex ? prevPostIndex : LIMIT); // 주석 2
    })();
  }, []);

  useEffect(() => { // 주석 3
    if (targetRef.current && prevPostIndex) { 
      window.scrollTo(0, document.body.scrollHeight); 
      setPrevPostIndex(0);
    }
  }, [targetRef, prevPostIndex, setPrevPostIndex]);

  // 중략...

  return (
    <PageWrapper header nav info>
      <PostList>
        {posts?.map((post, i) => {
          return (
            <li key={i} ref={posts.length - 1 === i ? targetRef : null}>
              <PostItem key={i} index={i} post={post} />
            </li>
          );
        })}
      </PostList>
    </PageWrapper>
  );
};


설명

  1. prevPostIndex가 존재한다면, prevPostIndex를 limit으로 하여 데이터를 불러온다.
    prevPostIndex가 없다면(0인 경우), 기존의 LIMIT를 사용한다.
  2. 데이터를 불러온 뒤에, prevPostIndex로 offset을 변경해준다.
  3. 데이터를 불러왔으므로 새롭게 렌더링이 발생한다.
    targetRef.current 또한 변경될 것이다.
    스크롤을 최하단으로 내리고, prevPostIndex를 0으로 초기화한다.

결과적으로 스크롤을 이전과 같이 복원할 수 있게 된다.

최선의 해결책이 아니다

눈치채셨을지 모르지만, 이것은 Hack한 방법이다.
특히 렌더링 비용을 고려한다면 이 방법은 최선의 해결책이 아니다.

만일 사용자가 100번째 포스트를 보고 있다고 생각해 보라.
그러면 스크롤을 복원하기 위해
100개의 포스트를 한꺼번에 불러와서 렌더링해야만 한다.
100개가 아니라 500개, 1000개라면 어떠할까?
데이터의 개수가 많아질수록 비용은 선형적으로 증가할 것이다.

지금은 프로젝트 기간이라서 많이 바쁘기 때문에, 어쩔 수 없이 이 방법을 사용하였다.
그러나 다시 한 번 말하지만, 이 방법은 근본적인 해결책이 아니다.

추후 이 부분을 개선해보고 싶다.
특히 가상 스크롤(Virtual Scroll)이라는 것에 대해 관심이 간다.

가상 스크롤이란
“화면에 직접적으로 보여지는 부분만 그리고, 나머지 부분은 가상으로 그려내는 것”을 말한다.

어려운 주제일지도 모르지만 도전욕구가 샘솟는다.
앞으로 리팩터링을 더 진행하면서
가상스크롤로 대체해보고 싶다는 생각이 든다.


학습을 진행하면서 작성한 글 입니다.
정확하지 않은 지식이 있을 수 있습니다. 참고로만 봐주시기 바랍니다.