뒤로가기를 눌러도 스크롤 유지하기
문제가 발생한 부분
어떤 페이지가 세로단으로 카드들이 연결되고 있고 나는 이 세로페이지를 스크롤 해서 하단에 있는 카드를 눌러 상세페이지에 접근했다고 하자. 상세페이지를 열심히 보고, 뒤로가기 버튼을 눌러 다른 카드를 눌러야지! 했는데 스크롤바의 위치가 최상단으로 다시 바뀌었다면? 생각만해도 불편하다. (거기다 사실 카드 부분이 무한 스크롤로 이루어져 있어서 아래로 내려가는 부분이 길었다면? 나는 그 카드를 다시 찾으러 가지 않을 것이다.. 😅)
아무생각없이 적용했던 내 블로그의 무한스크롤에서 이 부분이 너무 큰 문제로 다가왔다. ( 현재 시점에서는, 무한스크롤은 지워졌고, 가로축의 카드 컴포넌트를 사용하고 있다. ) 심지어, 아직 필터링기능이나 검색기능이 만들어져 있지 않아 모든 사용자는 단순히 스크롤을 내려서 항상 포스트를 확인해야 했다. 이를 실제로 사용하는 사용자가 있다면 그 소수의 사용자분들을 위해서라도 고치고 싶었다. 고치는 게 쉬워보이진 않았다. 생각할 점도 많았다.
문제해결을 위해 필요한 부분 정리하기
문제를 정리하기
이 문제를 해결하기 위해 어떠한 점을 고민해야 하는지 먼저 생각해보자. 일단 일반적인 스토리 라인은 다음과 같다.
- 무한스크롤을 적용해서, 새로운 카드 컴포넌트를 불러왔다.
- 무한스크롤을 통해 새로 생성된 카드 컴포넌트를 클릭해 해당 페이지로 이동했다.
- 해당 페이지의 내용을 보고, 뒤로 가기 버튼을 클릭한다.
- 세가지 카드 컴포넌트만 보이고, 스크롤바의 위치가 초기화 되어있다.
문제를 한문장으로 다시 확인해 보면 다음과 같다.
뒤로 갈때 스크롤의 위치가 초기화된다.
문제의 해결 방법을 고민해 보기
위의 문제를 어떻게 해결할 수 있을지 고민해보자. 나는 작은 규모의 세가지 해결방법으로 진행하려고 한다.
- 뒤로 가기를 감지해야 한다.
문제는 뒤로 가기
할때 발생한다. 문제를 해결하기 위해서는 뒤로 가기
가 일어 났다는 것을 감지해야 한다. 그리고 이 뒤로 가기
는 브라우저 단위에서 일어나는 이벤트이다.
여기서 뒤로 가기만을 감지해야 한다고 생각했는데, 앞으로 가기 또는 Nav
를 눌러 이동하는 경로의 변경일 때에는 스크롤이 초기화 되는 것이 맞다고 생각했기 때문이다.
- 스크롤바의 위치 계산과 저장
스크롤 이벤트를 사용하는 방법과, window.scrollY
를 사용하는 두가지 방법이 존재한다. 그런데, 혹시 스크롤 이벤트를 사용할 것이라면 쓰로틀링의 최적화가 필요할 것이다.
스크롤바의 위치를 저장하는 것은 전역상태 라이브러리, Context API, 세션 스토리지 등에 저장하는 방법이 존재할 것 같다. 이 중 무엇을 선택해도 괜찮을 것 같다는 생각이 들었다. ( 그런데 결과적으로, 다른 곳에 스크롤바위 위치를 저장하지 않았다. 스크롤바의 위치를 기억하는 훅을 _app.tsx
에서 전체적으로 실행했기 때문이다. 만약, 분적으로 사용하기를 원한다면 해당 부분의 조정이 필요할 것이다. )
우선 위의 두가지 문제를 먼저 해결하고, 무한스크롤에 적용하고자 했다. 두가지 문제를 해결한다면 무한스크롤에 이를 적용만 하면 된다고 생각했기 때문이다.
- 무한 스크롤에 적용해야한다.
일반적으로 무한스크롤이 적용되는 훅은, intersection observer
를 이용해 아래 카드에 도달할 경우 새롭게 데이터를 불러온다. 그런데, 스크롤의 위치만 기억해 둔다면 무한스크롤이 진행되었던 훅의 경우, 초기화 되기 때문에 (전역 상태에 두지 않는 한) 초기의 위치로 돌아가게 된다.
뒤로가기를 감지하기
그렇다면 우리는 뒤로가기를 어떻게 감지할 수 있을까? next.router
에 이에 대한 해결방법이 있다.
router.beforePopState(callback) // 이 callback함수는, 뒤로가기가 동작했을때 그 이전에 실행된다. // 이 부분을 사용할 수 있을 것 같다. // 또, `router.events` 를 사용해보자. // 보통, `next` 의 로딩페이지를 감지하기 위해 사용하는 부분으로 기억하는데 아래와 같은 부분이다. routeChangeStart(url, { shallow }) // 경로의 변경이 시작될때 발생한다. Fires when a route starts to change routeChangeComplete(url, { shallow }) // 경로의 변경이 완료되면 발생한다. Fires when a route changed completely
이 세가지를 이용해볼 수 있을 것 같다.
beforePopState
의 콜백을 통해 뒤로가기로 이동하기 전 어떠한 상태에 현재 진행되고 있는 이벤트가뒤로 가기
임을 저장한다.routeChangeStart
이 시점에서 스크롤바의 위치를 기록하고, 저장한다.routeChangeComplete
메서드를 통해, 라우트의 변경이 완료된다면 해당 다음을 확인한다. 이게 뒤로가기임을 확인 후, 뒤로가기가 맞다면 저장되어져 있는 스크롤바의 위치로 이동한다.
위의 까지를 적용하면 아래와 같이 마무리할 수 있다.
const usePreserveScroll = () => { const router = useRouter(); useEffect(() => { router.beforePopState(() => { // 뒤로가기가 시작되면 일어나는 콜백함수 return true; }); const onRouteChangeStart = () => { // router의 경로 변경이 일어날때의 콜백함수 // 이 시점에서 scroll을 기억하고 저장해 둔다. }; const onRouteChangeComplete = (url: any) => { // router의 처리가 완료되면 할 콜백함수 }; router.events.on("routeChangeStart", onRouteChangeStart); router.events.on("routeChangeComplete", onRouteChangeComplete); return () => { router.events.off("routeChangeStart", onRouteChangeStart); router.events.off("routeChangeComplete", onRouteChangeComplete); }; }, [router]); };
스크롤바의 위치 감지하고 저장하기.
스크롤바의 위치를 감지하기 위해서는 window.scrollY
를 이용했다. 사실 스크롤 이벤트를 이용해 확인할 필요가 전혀 없는 부분이다. 스크롤바의 위치가 바뀜에 따라 어떠한 이벤트를 호출하는 것이 아니기 때문이다. 우리가 원하는것은 라우터의 동작이 있을때 마지막의 스크롤바의 위치 뿐이다.
그리고 이 스크롤바의 위치를 커스텀 훅 내부에 선언해 둔 ref
에 저장해 두었다. 나는 모든 페이지에 스크롤 훅을 적용할 예정이었기 때문에, 전역상태와 세션스토리지는 필요하지 않았다. 만약 내가 사용했다면 나는 세션스토리지를 이용했을 것 같다. 이 부분만을 위한 전역상태 라이브러리의 추가가 번들의 사이즈만 키울 것 같다는 생각이 들었기 때문이다.
const usePreserveScroll = () => { const router = useRouter(); const urlScroll = useRef<{ [url: string]: number }>({}); const isBack = useRef(false); useEffect(() => { router.beforePopState(() => { isBack.current = true; return true; }); const onRouteChangeStart = () => { const url = router.pathname; // 이 부분에서 전역상태 또는 세션스토리지에 스크롤의 값을 기억할 수 있다. urlScroll.current[url] = window.scrollY; }; const onRouteChangeComplete = (url: string) => { if (isBack.current) { window.scroll({ top: urlScroll.current[url], behavior: "auto", }); } // 라우터의 동작이 완료되었다면, 해당 스크롤로 이동하는 동작까지 추가했다. isBack.current = false; }; router.events.on("routeChangeStart", onRouteChangeStart); router.events.on("routeChangeComplete", onRouteChangeComplete); return () => { router.events.off("routeChangeStart", onRouteChangeStart); router.events.off("routeChangeComplete", onRouteChangeComplete); }; }, [router]); };
여기까지의 동작을 점검해 보면, 아래와 같이 확인해 볼 수 있다.
이제 뒤로가기를 눌렀을 때 스크롤바의 위치가 저장되었음을 확인할 수 있다.
무한스크롤에서의 문제
import { RefObject, useCallback, useEffect, useRef, useState } from "react"; import { useRouter } from "next/router"; import useSessionStorage from "./useSessionStorage"; const useSavedInfiniteScroll = ( initialDisplayedLen: number, maxDisplayedLen: number, increasedByIntersecting: number, targetRef: RefObject<Element>, sessionStorageKey: string, ) => { const observer = useRef<IntersectionObserver>(); const [len, setLen] = useState(storedValueInSession); const callback = useCallback( (entries: IntersectionObserverEntry[]) => { entries.forEach((entry) => { if (entry.isIntersecting) { setLen((prev) => prev + increasedByIntersecting); } }); }, [increasedByIntersecting], ); useEffect(() => { observer.current = new IntersectionObserver(callback); targetRef.current && observer.current.observe(targetRef.current); return () => observer.current && observer.current.disconnect(); }, [targetRef, callback]); useEffect(() => { if (!targetRef.current) return; if (len > maxDisplayedLen) observer.current?.unobserve(targetRef.current); }, [len, maxDisplayedLen, targetRef]); return { len }; }; export default useSavedInfiniteScroll;
예전에 작성해 두었던 훅을 통해, 무한스크롤을 적용하고 있었다. 이 훅은, intersection observer API
를 사용해 마지막 지점의 카드를 observe
하게 되면, 카드의 총 길이를 바꿔주고 새로운 총 길이를 반환한다. 여기서 문제 되는 점은 다음과 같다.
- 무한스크롤 훅은, 전역 상태에
len
값을 저장하지 않는다. - 뒤로 가는 이벤트는 해당 훅을 사용하는 페이지를 리렌더링 할 것이고,
len
값은 초기화된다. - 이전에 구현해 놓았던
preserveScroll
훅을 사용하더라도, 렌더링 되어져 있는 카드의 숫자는 예를 들면 3개로 줄어든 상태이므로 기억해 둔 스크롤로 이동하지 못한다.
내가 생각한 해결책은 다음과 같았다.
-
len
값을 이용해 해결len
값을 저장해 놓는다. 그러면 뒤로 갈때 우리는 초기화된len
값이 아닌 저장해둔len
값을 이용해 원하는 만큼의 카드를 렌더링할 수 있고 스크롤도 기억해둔 값으로 적용된다. -
scroll 값을 이용해 해결
scroll 값을 저장해 놓고, 저장해둔 scroll 값을 이용한다. scroll 값과 비교해 필요한 카드 컴포넌트의 숫자를 계산하고, 계산된 카드의 수만큼 렌더링한다.
우선, 간단하다고 생각한 첫번째 방법을 이용해 진행했다.
const useSavedInfiniteScroll = ( initialDisplayedLen: number, maxDisplayedLen: number, increasedByIntersecting: number, targetRef: RefObject<Element>, sessionStorageKey: string, ) => { const router = useRouter(); const observer = useRef<IntersectionObserver>(); const { storedValue: storedValueInSession, setValue: setSessionStorageValue, } = useSessionStorage(sessionStorageKey, initialDisplayedLen); const [len, setLen] = useState(storedValueInSession); // sessionStorage에서 저장된 len값을 불러온다. .... useEffect(() => { if (!targetRef.current) return; if (len > maxDisplayedLen) observer.current?.unobserve(targetRef.current); }, [len, maxDisplayedLen, targetRef]); useEffect(() => { const onRouteChangeStart = () => { setSessionStorageValue(len); }; router.events.on("routeChangeStart", onRouteChangeStart); // router 이벤트가 시작되는 지점에 len 값을 저장해 둔다. return () => router.events.off("routeChangeStart", onRouteChangeStart); }, [router, len, setSessionStorageValue]); return { len }; }; export default useSavedInfiniteScroll;
그런데 이렇게 작성하고 보니 두번째 방법이 조금 더 괜찮았을 것 같다. 두번째 방법으로 진행해보니, 무한 스크롤 훅에서 onRouteChangeStart
을 통해 len
값을 저장해두는 방식이 함수의 의미와 너무나도 동떨어진다는 생각이 들었기 때문이다.
결과적으로 아래와 같이, 무한 스크롤에서도 잘 적용된다.