개발 블로깅/오늘의 TIL

[2021.01.24] Next.js에서 페이지 뒤로가기 시 이전 페이지 스크롤 위치로 돌아가기

Hello이뇽 2021. 1. 24. 14:00

이번에 새로 입사하게 된 기업에서 프로젝트 코드 파악 겸 작은 기능 하나를 구현하는 첫 업무를 맡게 되었는데, 페이지에서 '뒤로 가기' 시 이전 페이지의 스크롤 위치 그대로 유지하도록 하는 기능이었다.

이전에는 무조건 페이지 이동이 생기면 스크롤 최상단으로 이동하도록 되어있었는데, 그러다 보니 상품 리스트가 나열된 페이지에서 어느 정도 하위에 배치된 상품을 클릭한 뒤 '뒤로 가기'를 하면, 스크롤 최상단부터 다시 서치를 해야 되는 문제가 발생하던 것이다.

이를 방지하고자, 이전 페이지로 되돌아가도 최근에 탐색했던 위치부터 이어서 탐색할 수 있도록 하기 위해 이 기능을 구현하게 되었다.

 

처음 이 기능에 대한 내가 들었던 생각은 이러했다.

1. 이전 페이지를 캐싱 시켜서, 뒤로 가기 시에 캐싱된 페이지와 스크롤 상태를 그대로 쓸 수 있는지

2. 뒤로가기로 페이지 접근을 한 것인지 여부를 알 수 있는 요소가 있는지 

 

만약 1번이 가능하다면, 페이지 이동 시 뒤로 가기 방식으로 접근을 한 것인지 여부를 통해 바로 이전 페이지의 상태로 되돌리기만 하면 된다.

그래서 캐싱된 웹 페이지를 사용할 수 있는지 찾아보았는데, 아쉽게도 이 방법을 사용할 수 없음을 알게 되었다.

 

캐싱된 웹 페이지의 상태를 그대로 쓸 수 없는 이유

캐시는 알다시피, 서버에서 요청했던 데이터들을 특정 공간 어딘가에 저장했다가 그 데이터가 다시 필요할 때 서버의 요청 없이 바로 가져와 사용하여 웹 성능을 최적화 시킬 수 있는 브라우저 기능이다. 

여기서 캐싱되는 것은 말 그대로 서버에서 가져온 데이터뿐이다. 웹 캐시로 따지면 정적 콘텐츠(html, css, javascript 등), 동적 콘텐츠 데이터들이 되겠다. 

해당 웹 화면 자체는 캐싱된 데이터를 이용하게 돼서 서버에서 다시 요청하지 않지만, 해당 페이지의 DOM 요소와 State까지는 캐싱이 되지 않고 사라지게 되어 다른 페이지로 넘어가는 순간 해당 페이지 화면에 대한 상태는 더 이상 기억할 수 없게 된다.

 

그렇다면 내가 할 수 있는 방법은, 다른 페이지로 넘어가도 이전 페이지의 상태를 알 수 있도록 하는 것이다. 여기서 상태는 리스트 데이터에서 현재까지 가져온 아이템, 그리고 현재 화면 스크롤 위치가 될 것이다.

상태 데이터들을 유지했다가, 뒤로가기를 이용해 해당 페이지로 다시 접근을 하면 저장했던 리스트 데이터들과 스크롤 상태들을 다시 세팅하도록 하면 된다.

알고 보니, 이미 리스트 데이터는 Store에 저장하고 있었다. 그러면 나는 스크롤 위치 데이터만 저장하도록 하면 된다.

 

스크롤 저장하는 방식

처음에는 리스트 중 특정 아이템을 클릭하여 페이지 이동을 하려고 할 때, 현재 스크롤 위치를 저장하도록 하려고 했다.

// viewStore는 mst 스토어 중 뷰 관련 스토어

router.push(`/item/${id}`).then(
  ()=> viewStore.setScrollY(window.scrollY)
 );

 

그러나 이렇게 하니 예상치 못한 문제가 발생했는데, 가끔 한 번씩 엉뚱한 스크롤 위치 값을 저장하는 것이다.
어쩔 때는 현재 이동한 스크롤 위치 값을 제대로 저장되어서 뒤로 가기 시에도 이전 위치 그대로 돌아갈 수 있었는데, 어쩔 때는 가장 아래로 이동되어야 하는 스크롤 위치가 이상하게 페이지의 중간쯤에 위치하여 돌아간다.

특정 상황이 아니라 랜덤으로 이런 현상이 발생하니 원인을 제대로 파악할 수 없었다. 추측이지만 router.push 이후에 현재 스크롤 값을 저장하다보니 가끔씩 페이지가 이동한 뒤의 window.scrollY 값을 저장하게 되서 그런 것 같기도 하다.

그러면 그냥 router.push 전에 값을 저장하도록 하면 되겠다 싶었는데, 생각해보니 현재 리스트 페이지뿐 아니라, 다른 페이지에서도 이러한 기능이 필요하다면, 그리고 이동하는 클릭 이벤트가 여러 개라면 각 이벤트마다 해당 setScrollY 함수를 추가해 주어야하는 비효율적인 코드 방식이 되어버린다. 

그래서 그냥 모든 페이지에서 스크롤링 할 때마다 해당 스크롤 위치를 항상 저장하도록 하면, 추가로 필요할 때마다 코드를 추가하지 않아도 된다. 

 

next.js에서는 항상 페이지 이동이 있을 때마다, _app.tsx부터 렌더링을 시작하게 된다. 그래서 _app.tsx에 스크롤 이벤트 리스너를 추가해서, 스크롤할 때마다 현재 스크롤 값을 저장하도록 했다.

import { useEffect, useState } from 'react';
import { useRouter } from 'next/router';
import { IRootStore } from '@stores/RootStore';

const useScrollByPageMove = (rootStore: IRootStore) => {
  const { viewStore } = rootStore;
  const router = useRouter();
  const [pathname, setPathname] = useState<string>(router.pathname);

  useEffect(() => {
    const scrollHandler = function () {
      viewStore.setScrollY(router.pathname) // router.pathname은 현재 페이지 경로
    };

    window.addEventListener('scroll', scrollHandler);
    setPathname(router.pathname);

    return () => {
      window.removeEventListener('scroll', scrollHandler);
    };
  }, [router.pathname]);
};

export default useScrollByPageMove;

 

이렇게 하면, 가장 최근에 움직였던 스크롤 위치를 페이지 별로 스토어에 저장할 수 있다.

여기서 조금만 더 최적화를 시켜보자면, 스크롤 이벤트는 조금만 움직여서 반복되는 이벤트가 굉장히 많이 일어난다. 그래서 움직일 때마다 스토어에 값을 갱신하는 작업을 한다면 웹 성능적으로 비효율적일 것이다.

우리에게 필요한 것은 마지막으로 위치한 스크롤 값이므로, Debounce를 이용한다면 반복되는 이벤트를 무시했다가 마지막 이벤트만 발생시켜서 스크롤 값을 저장시킬 수 있다.

 

// useDebounce.tsx

interface useDebounceProps {
  cb: () => void;
  ms: number;
}

const useDebounce = ({ cb, ms }: useDebounceProps) => {
  let timer: number | null = null;

  const paddingFunction = function () {
    if (timer) {
      clearTimeout(timer);
    }

    timer = setTimeout(() => cb(), ms);
  };
  return paddingFunction;
};

export default useDebounce;

 

디바운싱 기능을 커스텀 훅으로 만들었다. 파라미터로 연속으로 발생될 이벤트와 디바운스 처리할 시간을 받으면 디바운스 세팅 후에 실제로 이벤트를 발생시킬 수 있는 함수를 반환한다.

 

import { useEffect, useState } from 'react';
import { useRouter } from 'next/router';
import useDebounce from '@hooks/useDebounce';
import { IRootStore } from '@stores/RootStore';

const useScrollByPageMove = (rootStore: IRootStore) => {
  const { viewStore } = rootStore;
  const router = useRouter();
  const [pathname, setPathname] = useState<string>(router.pathname);

  // 스크롤 할 때마다 해당 스크롤 위치 값을 viewStore에 저장
  const paddingFunction = useDebounce({
    cb: () => viewStore.setScrollY(router.pathname),
    ms: 100,
  });

  useEffect(() => {
    const scrollHandler = function () {
      paddingFunction();
    };

    /**
     * 페이지 이동 시, 스크롤 위치를 처리하는 부분
     * 이동 방식이 '뒤로가기, 앞으로가기'이면, store에 해당 페이지에 함께 저장된 스크롤 값, 아니면 0.
     */
    if (pathname !== router.pathname) {
      const scrollY = viewStore.getScrollY(router.pathname);
      window.scrollTo({ top: scrollY, left: 0 });
    }
    
    window.addEventListener('scroll', scrollHandler);
    setPathname(router.pathname);

    return () => {
      window.removeEventListener('scroll', scrollHandler);
    };
  }, [router.pathname]);
};

export default useScrollByPageMove;

 

디바운스 커스텀 훅에 현재 스크롤 위치 값을 저장하는 이벤트를 파라미터로 보낸다. 스크롤 이벤트 핸들러가 발생할 때마다 디바운스 커스텀 훅에서 반환된 함수를 연속으로 실행시키지만, 계속해서 100ms 단위로 이벤트를 무시했다가 마지막에 해당 이벤트가 발동돼서 값을 저장하게 될 것이다.

이렇게 현재 스크롤 위치 값을 저장하는 기능은 구현했다.

 

뒤로가기로 해당 페이지를 접근했는지 여부 확인

페이지 접근 후에, history 값 내에서 해당 페이지 접근을 어떻게 했는지에 대한 정보가 있을 줄 알았다. 그러나 현재 페이지를 어떤 경로에서 왔는지, 이전 페이지에서 함께 가져온 쿼리스트링 값 등만 있고, '페이지 접근 방식'에 대한 요소는 없었다.

뒤로가기 여부를 바로 알 수 있는 방법은 없고, 그 여부를 알 수 있도록 기능을 구현할 수 있었다.

import { useEffect, useState } from 'react';
import { useRouter } from 'next/router';
import useDebounce from '@hooks/useDebounce';
import { IRootStore } from '@stores/RootStore';

const useScrollByPageMove = (rootStore: IRootStore) => {
  const { viewStore } = rootStore;
  const router = useRouter();
  const [pathname, setPathname] = useState<string>(router.pathname);

  // 스크롤 할 때마다 해당 스크롤 위치 값을 viewStore에 저장
  const paddingFunction = useDebounce({
    cb: () => viewStore.setScrollY(router.pathname),
    ms: 100,
  });

  useEffect(() => {
    // 뒤로 가기를 이용한 페이지 이동인지 여부를 판단하도록.
    const historyBackHandler = function () {
      viewStore.setHistoryBack(true);
    };
    const scrollHandler = function () {
      paddingFunction();
    };

    /**
     * 페이지 이동 시, 스크롤 위치를 처리하는 부분
     * 이동 방식이 '뒤로가기, 앞으로가기'이면, store에 해당 페이지에 함께 저장된 스크롤 값, 아니면 0.
     */
    if (pathname !== router.pathname) {
      const scrollY = viewStore.getScrollY(router.pathname);
      window.scrollTo({ top: scrollY, left: 0 });
      viewStore.setHistoryBack(false);
    }

    window.addEventListener('popstate', historyBackHandler);
    window.addEventListener('scroll', scrollHandler);
    setPathname(router.pathname);

    return () => {
      window.removeEventListener('popstate', historyBackHandler);
      window.removeEventListener('scroll', scrollHandler);
    };
  }, [router.pathname]);
};

export default useScrollByPageMove;

 

_app.tsx에서 popstate 이벤트 리스너를 이용하면, 페이지 이동 시 라우터 이동이 아닌 뒤로 가기, 앞으로 가기일 시에만 이벤트를 발생시킬 수 있다. 이 이벤트 리스너를 이용해서 해당 이벤트가 발생하면 스토어에 뒤로 가기 이동을 했는지 여부(코드 내 isHistoryBack)를 저장 후 각 페이지에서 해당 스토어의 값을 가져와서 확인하면 된다.

isHistoryBack이 true이면 이동하려는 페이지의 스크롤 값을 가져와서 이동하고, false이면 라우터 이동이므로 최상단으로 스크롤 이동을 한다.

 

이렇게 하면, 각 페이지 별로 코드를 추가할 필요 없이, 최상단 _app.tsx에서 모두 해당 기능을 사용할 수 있고, 특정 페이지에서 뒤로가기 여부로 특정 작업이 필요할 경우 viewStore에서 isHistoryBack 값을 가져와서 처리하면 된다. 

 

Explorer 11 브라우저 호환

이건 그냥 간단한건데, 위에 썼던 window.scrollY은 윈도우 익스플로러에서는 동작하지 않는다...

https://stackoverflow.com/questions/44757869/window-pageyoffset-vs-window-scrolly-on-ie11

 

window.pageYOffset vs window.scrollY on IE11

Window.scrollY does not show the correct top-scroll value on IE11 but Window.pageYOffset, the alias of Window.scrollY, works as expected. I find confusing the fact that the alias works better than ...

stackoverflow.com

그래서 대신 window.pageYOffset을 이용하면 된다.


크게 어려운 기능은 아니었으나, 이 기능을 구현하려고 구글링을 꽤나 한 것 같아 다시 정리를 좀 해봤다.

첫 번째, 웹 캐시와 페이지 내 상태관리는 전혀 관련이 없다는 것.
두 번째, router의 history 내 정보로는 현재 페이지 접근 방식에 대한 정보를 알 수 없다는 것.
세 번째, 뒤로가기 시 이벤트 리스너로는 popstate를 쓰면 된다는 것.
네 번째, 스크롤 값은 window의 scrollY 대신 pageYOffset을 쓰자.

 

번외: 위 기능에서 '뒤로 가기'를 하는 사이에 새로운 최신 아이템이 추가가 되었다면?

갑자기 생각난건데, 한창 면접을 볼 때 이와 같은 상황이 발생하면 어떻게 대처할 것이냐는 질문을 받은 적이 있었다. 그 당시는 떨림+긴장 때문에 제대로 대답을 하지 못했는데, 지금 생각해보니 아래처럼 해결할 수 있을 것 같다.

만약 총 100개의 리스트 아이템이 있고, 한번에 10개씩 스크롤링으로 데이터를 가져올 수 있다고 해보자.
그리고 현재 100부터 80까지 데이터를 가져온 상태이다. (가장 최신 아이템부터 오래된 데이터 순으로 보여주기 때문임)

여기서 90번째 아이템을 클릭하여 90번 아이템에 대한 상세 페이지에 접근한 뒤, 다시 뒤로가기를 한다. 그러면 저장했던 스크롤 위치 값으로 다시 해당 위치로 돌아갈 수 있을 것이다.

그런데, 만약 그 사이에 새로운 데이터 20개가 추가되서 총 120개가 되었다면?

기존에 스크롤링으로 스토어에 저장하면서 뒤로가기 시 다시 스토어 값으로 아이템들을 가져와 보여주기 때문에, API로 새롭게 데이터를 요청하지 않는다면 추가된 120번째부터 101째 데이터는 보여주지 못할 것이다.

그렇다면 뒤로가기를 하는 사이에 새롭게 추가된 데이터도 보여주면서, 클릭했던 아이템이 위치한 스크롤 위치로도 이동할 수 있는 방법이 무엇이 있을까?

 

우선 새롭게 추가된 데이터를 가져오기 위해서는 어쩔 수 없이 새롭게 API 요청을 해야하니, 무조건 해당 페이지에 접근하면 API 요청으로 새롭게 데이터를 가져오기는 해야한다. 그러나 대신에 데이터를 얼마나 가져올 것인지가 달라질 것이다.

스크롤링으로 추가로 데이터를 가져올 때, 가장 마지막으로 가져온 아이템의 ID를 어딘가(Store나 쿠키 등)에 저장한다. 그리고 뒤로가기로 다시 리스트 페이지에 접근하면, 저장했던 아이템의 ID를 limit 값으로 보내서 가장 최신부터 limit 값까지 데이터를 가져오도록 한다. 그러면 가장 최신 아이템부터 내가 스크롤링 해서 가져왔던 아이템까지 최신 리스트로 가져올 수 있다.

ex)

  1. 100~80까지 아이템을 가져오고 90번째 ID의 아이템을 클릭, 80까지 가져왔으니 ID 80을 저장.
  2. 상세페이지에 머무르는 사이에 DB에 새롭게 아이템이 20개가 추가됨. 아이템은 총 120개.
  3. 90번째 아이템의 상세페이지에서 뒤로 가기를 하면서 새롭게 데이터를 가져오는 API를 요청. 요청 시 저장했던 ID 값 80을 limit 값으로 함께 보냄.
  4. 최신 아이템 120부터 80까지 데이터를 가져와서 보여줌.

이제 문제는 스크롤 위치이다.


새롭게 추가된 데이터가 있어서, 90번째 아이템은 저장했던 스크롤 위치 값보다 더 아래로 내려가게 되었다. 

그러면 90번째 아이템이 위치한 곳은 어떻게 알아낼 수 있을까?

생각보다 방법은 간단하다. 
현재 스크롤 위치를 저장하는 대신, 현재 페이지의 총 높이와 현재 스크롤 위치의 차이 값을 저장하면 된다. 새로운 데이터가 추가가 되어도 90번째 아이템의 위치는 상단을 기준으로는 달라졌겠지만, 하단을 기준으로는 달라지지 않기 때문이다.

그러므로 페이지의 상단을 기준으로 하지 않고 페이지 하단을 기준으로 현재 스크롤 위치를 저장하면, 최신 데이터가 생겨도 클릭했던 90번째 아이템의 위치로 이동할 수 있다.

우선 이렇게는 직접 구현해보진 않았지만, 이론 상 간단해서 문제가 없어보인다.

반응형