Nextjs 스크롤 위치 복원 시키기 (2024)
서론
Next.js 신규 사이드 프로젝트를 새로 시작하게 되었습니다.
그렇다 보니 기본적인 동작들에 대해서 구현이 된 게 없어서 직접 구현을 해야 했습니다..
그중 하나는 상품 상세에 접근했다 리스트 페이지로 돌아갔을 때 스크롤 위치가 복원되어야 하는 기능입니다.
이 기능은 단순해 보이지만 유저한테 끼치는 영향은 막대합니다.
또한 프로젝트에서 계속 쓰이는 기능이기 만들 때 잘 만들어야 합니다.
이 글에서는 커스텀 훅을 만들어서 해당 훅을 통해 스크롤을 복원시키는 방법으로 진행할 것입니다.
어떻게 복원 시키나?
이때 생각난 복원시키는 방법 생각나는 것은 두 가지가 생각났습니다.
1. 세션 스토리지에 저장한다.
2. 전역 상태에 저장한다.
그러면 다른 사이트는 어떻게 되어 있을까?
많이는 아니고 3가지 사이트를 참고했습니다.
쿠팡, 지그재그, 펫프렌즈
우선 쿠팡 같은 경우 세션스토리지 방식인지 확인했는데 이 방식은 사용을 안 하고 있었습니다.
지그재그는 및 펫프렌즈는 세션 스토리지 방식을 채택한 걸 확인할 수 있었습니다.
한 가지 눈에 띄는 건 펫프렌즈 저장 방식입니다.
__next_scroll이라는 명칭을 가지고 x, y 좌표값을 저장해 주는 방식입니다.
이 방식은 어떻게 구현이 된 기능인지 확인해 봤을 때 Nextjs의 scrollRestoration 기능을 사용한 것으로 추측됩니다.
현재 프로젝트에 아래 옵션을 적용해서 확인을 해 봤습니다.
experimental: {
scrollRestoration: true,
},
결과는 아래와 같습니다.
즉 위에 옵션을 켜면 스크롤 복원을 Nextjs가 어느 정도 관리를 해줍니다.
하지만 위에 방식은 채택하지 않았습니다.. 이유는 아래와 같습니다.
1. experimental 옵션이라 아직 실험적 기능이다.
2. 이 기능은 body에 관한 스크롤 처리만 되기 때문에 특정 스크롤 영역에 관해서는 스크롤 위치를 인식할 수 없다.
위에 이유로 직접 만들어서 사용하기로 결정했습니다.
구현
이때 구현 했던 방법은 총 두 가지입니다.
1. 상품을 클릭했을 때 스크롤 위치를 저장시키는 방법
2. 페이지를 이동했을 때 저장 시키는 방법
처음에는 2번이 생각나지 않아서 우선 1번 방법으로 개발 후 2번 방법으로 교체했습니다.
상품을 클릭했을 때 스크롤 위치를 저장시키는 방법
1. 특정 아이템 클릭 시 현재 좌표를 세션 스토리지에 저장
2. 다시 페이지에 왔을 때 저장된 좌표가 있다면 좌표를 이 동시 킨 후 세션스토리지에서 제거
import { useEffect } from 'react'
const useSaveAndRestoreScrollPosition = (key: string) => {
const saveScrollPosition = () => {
const scrollPosition = { x: window.scrollX, y: window.scrollY }
sessionStorage.setItem(key, JSON.stringify(scrollPosition))
}
const clearScrollPosition = () => {
sessionStorage.removeItem(key)
}
useEffect(() => {
window.history.scrollRestoration = 'manual'
const savedScrollPosition = sessionStorage.getItem(key)
if (savedScrollPosition !== null) {
const { x, y } = JSON.parse(savedScrollPosition)
window.scrollTo(x, y)
clearScrollPosition()
}
}, [key])
return { saveScrollPosition }
}
export default useSaveAndRestoreScrollPosition
코드 자체는 어려움 없이 작성되었습니다.
하지만 이렇게 코드를 작성 시 치명적인 단점이 있습니다.
특정 아이템마다 클릭 이벤트를 넣어줘야 한다는 점입니다.
이렇게 되면 스크롤 저장이 필요할 때마다 saveScrollPosition 이벤트를 매번 달아줘야 합니다.
그렇기에 이 방법은 채택하지 않기로 했습니다..
페이지를 이동했을 때 저장 시키는 방법
해당 프로젝트는 nextjs를 사용하고 있기 때문에 nextjs의 라우터 이벤트를 사용해서 구현할 수 있습니다.
https://nextjs.org/docs/pages/api-reference/functions/use-router#routerevents
Functions: useRouter | Next.js
Learn more about the API of the Next.js Router, and access the router instance in your page with the useRouter hook.
nextjs.org
동작원리
1. 페이지가 바뀌었을 때 해당 스크롤 위치를 저장시킨다
2. 다시 그 페이지에 왔을 때 만약 저장된 위치가 있으면 복구시켜준다.
근데 이렇게 구현을 하면 각 페이지마다 스크롤 위치가 다 저장이 되어 있을 거 기 때문에 페이지를 뒤로 갔을 때 세션스토리지를 비워주는 작업이 필요합니다.
구현된 코드는 아래와 같습니다.
import { type RefObject, useLayoutEffect } from 'react';
import { useRouter } from 'next/router';
interface ScrollRestoration {
ref?: RefObject<HTMLElement>; // 선택적으로 특정 HTMLElement를 참조할 수 있는 ref
}
export const useScrollRestoration = (
{ ref }: ScrollRestoration = { ref: undefined }, // 매개변수로 ref를 받으며, 기본값은 undefined
) => {
const router = useRouter(); // Next.js 라우터를 사용
// 경로를 기반으로 스크롤 위치를 저장할 키를 생성하는 함수
const scrollKey = (path: string) => `scrollPos_${path}`;
// 현재 스크롤 위치를 sessionStorage에 저장하는 함수
const saveScrollPos = (path: string) => {
const scrollPos = ref?.current
? { x: ref.current.scrollLeft, y: ref.current.scrollTop } // ref가 있으면 ref의 스크롤 위치 사용
: { x: window.scrollX, y: window.scrollY }; // ref가 없으면 window의 스크롤 위치 사용
sessionStorage.setItem(path, JSON.stringify(scrollPos));
};
// 저장된 스크롤 위치를 복원하는 함수
const restoreScrollPos = (path: string) => {
const savedPos = sessionStorage.getItem(path);
if (!savedPos) return; // 저장된 위치가 없으면 종료
const { x, y } = JSON.parse(savedPos);
const targetElement = ref?.current || window; // 복원할 대상 요소 선택
targetElement.scrollTo(x, y); // 스크롤 위치 복원
};
// 저장된 스크롤 위치를 제거하는 함수
const removeScrollPos = (path: string) => {
sessionStorage.removeItem(path);
};
// 컴포넌트가 마운트되거나 업데이트될 때 실행되는 훅
useLayoutEffect(() => {
if (!('scrollRestoration' in window.history)) return; // 브라우저가 scrollRestoration을 지원하지 않으면 종료
window.history.scrollRestoration = 'manual'; // 브라우저의 자동 스크롤 복원 비활성화
let shouldSaveScroll = true; // 스크롤 위치를 저장할지 여부
const path = scrollKey(router.pathname); // 현재 경로에 대한 스크롤 키
// 라우트 변경 시작 시 실행될 함수
const onRouteChangeStart = () => {
if (shouldSaveScroll) saveScrollPos(path); // 스크롤 저장
};
// 라우트 변경 완료 시 실행될 함수
const onRouteChangeComplete = () => {
restoreScrollPos(path); // 스크롤 복원
};
// 라우트 변경 전(브라우저 back/forward)에 실행될 함수
router.beforePopState(() => {
shouldSaveScroll = false; // 뒤로가기 또는 앞으로가기에서는 스크롤 저장하지 않음
removeScrollPos(path); // 스크롤 위치 제거
return true;
});
// 라우터 이벤트 리스너 등록
router.events.on('routeChangeStart', onRouteChangeStart);
router.events.on('routeChangeComplete', onRouteChangeComplete);
// 컴포넌트가 언마운트되거나 의존성이 변경될 때 실행되는 클린업 함수
return () => {
router.events.off('routeChangeStart', onRouteChangeStart);
router.events.off('routeChangeComplete', onRouteChangeComplete);
};
}, [ref, router.pathname]); // 의존성 배열
}
위에 코드는 라우터 변화를 감지해서 세션스토리지에 저장을 하고 세션 스토리지에 저장된 값이 있다면 그 값을 읽어 들여 스크롤을 복원시켜주는 역할을 합니다.
또한 페이지가 pop 되었을 때는 세션 스토리지에서 저장된 값을 삭제시켜 줍니다.
router.asPath대신 router.pathname을 사용한 이유
asPath는 현재 쿼리 파라미터까지 읽어서 스크롤이 저장되게 합니다.
현재는 쿼리 파라미터로 어디 탭에 위치되어 있는지 알 수 있도록 되어 있습니다.
이렇게 되면 탭 간의 스크롤도 다 세션 스토리지에 저장되게 됩니다.
그러면 현재 pop 되는 asPath를 제외하고 다른 탭들은 세션스토리지에 스크롤 위치가 그대로 저장되어 있을 거 기 때문에
router.pathname을 사용하기로 했습니다.
결과
시행착오
만들면서 크게 겪었던 시행착오는 두 가지가 있었습니다.
1. 스크롤 위치가 바뀌면서 화면이 한번 깜빡 거리는 현상이 존재한다.
2. 이전 페이지의 스크롤 값이 반영된다.
스크롤 위치가 바뀌면서 화면이 한번 깜빡 거리는 현상이 존재한다.
스크롤 위치가 바뀌면서 화면이 깜빡 거리는 현상이 있었습니다.
화면이 빨리 넘어갈 때는 확인하기 어렵지만 네트워크를 3G로 바꾼 후 현상을 쉽게 확인할 수 있었습니다.
해당 현상을 해결하기 위해 찾아본 결과 브라우저는 기본적으로 페이지를 복원하려는 성질을 가지고 있다는 것을 알았습니다.
이 현상을 해결하기 위해서는 브라우저에게 페이지 복원을 하지 말라고 알려줘야 합니다.
위에서 설명된 코드에서는 window.history.scrollRestoration = 'manual'이 옵션이 해당 역할을 하게 됩니다.
기본 설정 값은 'auto'입니다.
위 설정을 적용한 결과 화면이 한번 뒤틀리는 현상을 해결할 수 있었습니다.
이로서 첫 번째 이슈는 해결이 되었습니다.
이전 페이지의 스크롤 값이 반영된다.
이 문제는 페이지가 바뀌었는데도 이전 페이지 스크롤 path를 읽어서 반영되는 문제였습니다.
위에 스크린숏을 보면 router가 읽어 들이는 path는 "/category/[id]"입니다.
하지만 스크롤이 복원되는 시점에 읽어 들이는 값은 "/"입니다.
이 현상이 일어난 이유에 대해서 알아야 합니다.
onRouteChangeComplete 함수가 실행될 때 restoreScrollPos(path) 함수가 동작하게 되는데
router.pathname 설정되기 전에 onRouteChangeComplete 가 실행되는 것으로 보입니다.
useEffect는 DOM 업데이트가 완료된 후 비동기적으로 호출되기 때문에, 라우터의 pathname 상태 업데이트가 완료되지 않았을 가능성이 있습니다.
이 현상을 해결하기 위해서 useLayoutEffect를 사용했습니다.
useLayoutEffect는 DOM 업데이트 직후, 화면이 새로 그려지기 전에 실행되므로, 라우트 변경과 거의 동시에 스크롤 위치를 복원할 수 있습니다.
이로서 스크롤 관련된 공통 훅을 개발할 수 있었습니다.