OTTER-LOG

뒤로가기를 눌러도 스크롤 유지하기

뒤로가기를 눌러도 스크롤 유지하기
#next
by otter2023년 1월 25일에 최종수정되었습니다.
잘못된 내용이 있으면 댓글을 달아주세요.

어떤 페이지가 세로단으로 카드들이 연결되고 있고 나는 이 세로페이지를 스크롤 해서 하단에 있는 카드를 눌러 상세페이지에 접근했다고 하자. 상세페이지를 열심히 보고, 뒤로가기 버튼을 눌러 다른 카드를 눌러야지! 했는데 스크롤바의 위치가 최상단으로 다시 바뀌었다면? 생각만해도 불편하다. (거기다 사실 카드 부분이 무한 스크롤로 이루어져 있어서 아래로 내려가는 부분이 컸다면? 나는 그 카드를 다시 찾으러 가지 않을 것이다.. 😅)

아무생각없이 적용했던 내 블로그의 무한스크롤에서 이 부분이 너무 큰 문제로 다가왔다. **(현재 시점에서는, 무한스크롤이 적용되어있지 않습니다!)**심지어, 아직 필터링기능이나 검색기능이 만들어져 있지 않아 모든 사용자는 단순히 스크롤을 내려서 항상 포스트를 확인해야 했다. 이를 실제로 사용하는 사용자가 있다면 그 소수의 사용자분들을 위해서라도 고치고 싶었다. 고치는 게 쉬워보이진 않았다. 생각할 점도 많았다.

문제해결을 위해 필요한 부분 정리하기

이 문제를 해결하기 위해 어떠한 점을 고민해야 하는지 먼저 생각해보자. 지금 문제가 되고 있는 부분은 다음과 같다.

  • 무한스크롤을 적용해서, 새로운 카드 컴포넌트를 불러왔다.
  • 무한스크롤을 통해 새로 생성된 카드 컴포넌트를 클릭해 해당 페이지로 이동했다.
  • 해당 페이지의 내용을 모두 듣고, 뒤로 가기 버튼을 클릭한다.
  • 무한스크롤이 초기화되어서 초기 컴포넌트로 생성되었던 세가지 카드 컴포넌트만 보이고, 스크롤바의 위치가 초기화 되어있다. -> 이 부분이 문제다.

그러면 우리는 이 문제를 해결하기 위해 어떤 부분을 확인해야 할까?

우선 첫번째로, 뒤로가기를 감지해야 한다. 우리가 특정 버튼을 눌러서 뒤로가기를 한다면 감지하기가 어렵지 않겠지만 우리는 브라우저에 내장된 뒤로가기버튼이 실행되는 타이밍을 감지하고 이때에 특정 콜백함수를 넣어줘야 한다.

두번째로, 스크롤바의 위치 또한 기억해야한다. 뒤로갔을때 해당 url에 따라 스크롤바의 위치를 저장해두고 저장해둔 값이 있다면 스크롤바의 위치로 이동해야한다. 스크롤바를 감지하는 방법은 무수하지만 이걸 실제로 스크롤링할때 저장할 것인가? 또는 저장되는 시점이 존재하는가? 가 중요할 것 같다. 스크롤링을 할때마다, 스크롤의 위치를 저장하는 로직으로 간다면 debounce의 사용이 필수적이기 때문이다.

세번째로는, 무한스크롤에 적용하기 이다. 그러니까 위의 첫번째 두번째로 우리는 사용성을 증가시킬 수 있다. 일단 대부분의 페이지에서 스크롤바의 위치를 기억하고 있는 것이 사용자의 사용성에 좋다고 하니까. 그런데 사실 우리의 핵심적인 문제는 무한스크롤의 경우였다. 무한스크롤의 경우 단순히 스크롤바의 위치만 기억한다고 하여 적용되지 않을 것이다. 해당하는 카드 컴포넌트의 개수도 기억해야 한다.

그러면, 문제해결을 위해 알아봐야할 부분을 정리해보자.

  • 뒤로가기, 앞으로가기 감지하기

  • 스크롤 바의 위치 기억하기.

  • 무한스크롤로 만들어진 카드수 감지하기

뒤로가기를 감지하기

그렇다면 우리는 뒤로가기를 어떻게 감지할 수 있을까? 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 메서드를 사용해 현재, 그러니까 이동하기 전의 urlscroll 을 저장한다. (어디에? 세션 또는, 전역상태 둘다 할 수 있을 것 같다.) 이 부분은, 매번 저장한다. 페이지를 이동하는 시점에 한번만 저장이 되므로 매 스크롤바의 위치를 확인하는 로직을 사용하지 않아도 되고 그렇다면 쓰로틀링을 사용할 필요도 없게 된다.
  • routeChangeComplete 메서드를 통해, 라우트의 변경이 완료된다면 해당 다음을 확인한다. 이게 뒤로가기임을 확인 후, 뒤로가기가 맞다면 저장되어져 있는 스크롤바의 위치로 이동한다.

스크롤바의 위치 감지하기

스크롤바의 위치를 감지하는 방식은 아주 많지만 우리는 위의 routeChangeStart 메서드를 사용하 라우트가 이동하기 전에 한번만 스크롤을 감지할 것이므로, 간단히 window.scrollY 를 통해 스크롤의 위치를 가져올 수 있다.

모든 페이지의 스크롤 위치 저장하기

최종적인 코드는 다음과 같다. 사실 내가 작성한 부분은 거의 없다. 구글링을 해보니 아래와 같이 잘 짜여져 있는 훅이 바로 검색되었고 개인적으로 이 방법이 가장 알맞다고 생각했다. 알맞다고 생각한 이유는 다음과 같다.

  • 다른 스크롤 기억 훅같은 경우는 스크롤링을 할때마다 저장했다.

    → 이 부분에서 최적화가 힘들다는 생각이 들었다.

const scrollPositions = useRef<{ [url: string]: number }>({}); const isBack = useRef(false); useEffect(() => { router.beforePopState(() => { isBack.current = true; console.log("beforePopState"); // router.beforePopState의 콜백함수는 // 뒤로가기가 실행되기 이전에 동작한다. // 이 시점에 ref에 뒤로가기가 맞다고 저장한다. return true; }); const onRouteChangeStart = () => { const url = router.pathname; scrollPositions.current[url] = window.scrollY; }; // router change가 시작될때 실행되는 함수 // router가 마무리 되기전- 그니까 아직 페이지 이동전 // 이 url의 scroll 위치를 모두 저장한다. // 한번만 저장하므로, 최적화의 문제가 적다. const onRouteChangeComplete = (url: any) => { if (isBack.current) { window.scroll({ top: scrollPositions.current[url], behavior: "auto", }); } isBack.current = false; }; // ref에 뒤로가기로 저장되어져 있고, url 스크롤바 위치가 있다면 // 해당 스크롤바 위치로 이동시킨다. router.events.on("routeChangeStart", onRouteChangeStart); router.events.on("routeChangeComplete", onRouteChangeComplete); return () => { router.events.off("routeChangeStart", onRouteChangeStart); router.events.off("routeChangeComplete", onRouteChangeComplete); }; }, [router]);

사실 이 코드만 app.tsx에 붙여넣어주어도 잘 작동한다.

그런데, 사실 위의 코드는 문제가 있다. 일단, 무한 스크롤에는 적용되지 않는다. 물론 다른 모든페이지에 이쁘게 적용되고 있지만 핵심적인 문제는 무한스크롤에서 일어나고 있었으니까 말이다.

무한스크롤에서의 문제 해결하기

그렇다면, 이제 무한스크롤에서 위의 문제를 해결해보자. 그런데 우리는 여기서 한가지 짚고 넘어가야 한다. 아무튼 해결방법은 위에서 하는 방법과 비슷하겠고저 좌표값을 -스크롤바의 위치-의 문제는 어느정도 해결해야한다. 지금 무한스크롤의 문제는 무한스크롤로 만들어진 카드 컴포넌트 숫자를 어떠한 상태, 스토리지가 담고 있어야 한다는 점이다. 그런데, 위에서 사용한 모든 페이지의 스크롤바위치 기억하기는 그냥 app 컴포넌트에서 적용했으므로 문제가 없다. 상태값도 객체로 캐시처럼 사용하기도 하니까 말이다.

지금 무한스크롤이 이루어지고 있는 페이지는 Post 페이지로 이 Post 페이지 내부의 상태 (useState)에 저장해두고 페이지 이동에 따라 이를 사용할 수는 없다. 페이지가 이동되고 다른 컴포넌트가 렌더링될때 초기화되기 때문이다. 그렇다면 우리는 여기서 두가지 해결방법을 가진다.

  • 어떻게든 app 컴포넌트에서 관리하기
  • recoil 등 전역 상태관리 라이브러리에 저장하기
  • session 스토리지 사용하기 (local은 아닌것 같으니 그냥 빼자!)

일단, 첫번째 app 컴포넌트에서 관리하는 방법은 바로 기각이다. 어떻게 보면 제일 편할 수 있겠지만, props 드릴링으로 연결조차 할 수 없다. 그렇다면 두번째 방법은 그럴듯해보인다. recoil을 사용한다면 전역상태관리 라이브러리에서 해당 값을 기억하고 있겠고 그렇다면 이는 해결할 수 있을 것 같다. 그런데, 하나 아쉬운점이 단순히 저거 하나만 기억하려고 라이브러리 전체를 가져와야 한다는 부분이다. 또 페이지가 외부의 사이트로 나간다음에 뒤로가기가 올 경우에 대처하기 힘들다.

이러한 이유로 세번째 session stroage 에 무한스크롤의 이미 렌더링된 카드의 숫자를 저장하기로 했다.

import { useCallback, useState, useEffect } from "react"; type value = string | number; const useSessionStorage = <T>(key: string, initialValue: T) => { const getValue = useCallback(() => { if (typeof window === "undefined") return initialValue; try { const curValue = window.sessionStorage.getItem(key); return curValue ? (JSON.parse(curValue) as T) : initialValue; } catch { console.warn("Error reading sessionStorage Key"); return initialValue; } }, [initialValue, key]); // 현재 세션스토리지에 저장된 값이 있는지 확인하고, 값이 있다면 값을 가져온다. // 값이 없다면, initialValue를 가져온다. const [storedValue, setStoredValue] = useState<T>(getValue); const setValue = (storedValue: T) => { if (typeof window === "undefined") return; try { const newValue = JSON.stringify(storedValue); window.sessionStorage.setItem(key, newValue); setStoredValue(storedValue); } catch { console.warn("Error setting sessionStorage"); } }; useEffect(() => { console.log(storedValue); }, [storedValue]); return { storedValue, setValue, }; }; export default useSessionStorage;

이렇게, sessionStorage 훅을 만들어 관리했다. 물론 만들지 않아도 되는데 next 가 빌드될때 window는 없으므로 만들지 않으면 조금 귀찮다.

그리고, 원래 만들어놨던 infinite Scroll 훅을 조금 수정했다.

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 router = useRouter(); const observer = useRef<IntersectionObserver>(); const { storedValue: storedValueInSession, setValue: setSessionStorageValue, } = useSessionStorage(sessionStorageKey, initialDisplayedLen); 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]); useEffect(() => { const onRouteChangeStart = () => { setSessionStorageValue(len); }; router.events.on("routeChangeStart", onRouteChangeStart); return () => router.events.off("routeChangeStart", onRouteChangeStart); }, [router, len, setSessionStorageValue]); return { len }; }; export default useSavedInfiniteScroll;

이렇게 두가지 훅을 이용해서, 무한스크롤이 진행된 부부을 세션스토리지에 담고 세션스토리지에 담겨져 있는 값을 불러와 무한스크롤일때도 스크롤바의 위치를 저장할 수 있다.

나는, 개인적으로 커스텀훅을 만들어 사용했지만 아마 그냥 해도 될 거고 결국 방법은_ 어딘가에 저장해둔다!_ 가 제일 중요했던 것 같다. 이렇게 해서 다음과 같이 문제를 해결했다.

Ref

https://jak-ch-ll.medium.com/next-js-preserve-scroll-history-334cf699802ahttps://usehooks-ts.com/