컨셉비는 모바일을 주로 타겟팅 하는 웹 서비스이다. 따라서 한 페이지에서 보여지는 정보가 PC에 비해 적은데 이는 곧 페이지 전환이 자주 발생한다는 뜻으로 볼 수 있겠다. 처음에는 특별히 스크롤 위치에 대해서 별다른 피드백이 없었는데, QA를 위한 데이터를 쌓고 보니 그 문제점이 명확하게 보였다.
스크롤 위치 유지 기능이 도입되기 전 상태는 아래와 같았다.
메인 페이지에서 특정 피드를 클릭하여 피드 상세 페이지로 넘어간 뒤, 뒤로가기를 했을 경우 메인 페이지의 최상단으로 위치하게 된다. 이는 사용자가 이전에 봤던 피드까지 다시 스크롤 하면서 내려와야하는 불편함이 생긴다. 이 불편함을 개선하기 위해 다음의 요구 사항을 도출했다.
위 요구사항을 충족하기 위해 세션 스토리지와 useLayoutEffect를 활용하여 페이지 별로 스크롤 위치를 기억하는 기능을 구현했다. 반영구적인 특징을 갖는 로컬 스토리지와는 다르게 세션 스토리지는 다음의 차이점을 갖는다.
따라서 각 페이지의 이름 또는 URL을 Key로 두고 마지막 스크롤 위치를 Value로 두어, 세션 스토리지에 저장한다면 페이지 별로 스크롤 위치를 기억할 수 있을 것이다. 또한 사용자가 브라우저를 닫으면 자연스럽게 스크롤 위치값을 모두 초기화 할 수 있다. 이제 코드로 보자.
import { useLayoutEffect } from 'react';
import useRouteMatched from './useRouteMatch';
import { useMobileViewRefContext } from '../layouts/contexts/MobileViewContext';
const getPageScrollPosition = (pageName: string) => {
return JSON.parse(sessionStorage.getItem(pageName) || '{}');
};
const setPageScrollPosition = (pageName: string, position: number) => {
sessionStorage.setItem(pageName, JSON.stringify(position));
};
const useInitScrollPosition = (pageName: string) => {
// window 대신 스크롤 영역이 생기는 Element
const mobileViewRef = useMobileViewRefContext();
useLayoutEffect(() => {
const mobileView = mobileViewRef.current;
if (!mobileView) return;
// 이전 스크롤 위치가 있다면 세션 스토리지에서 가져와 이전 스크롤 위치로 조정합니다.
const prevScrollPosition = getPageScrollPosition(pageName) || 0;
mobileView.scroll({ top: prevScrollPosition, behavior: 'instant' });
return () => {
setPageScrollPosition(pageName, mobileView.scrollTop);
};
}, [mobileViewRef, pageName, hasMatched]);
};
export default useInitScrollPosition;
간단하게 해석하면 페이지 unmount 시에, 페이지 이름을 Key로, 현재의 scrollTop
위치를 Value로 하여 세션 스토리지에 기록하는 것을 볼 수 있다. 실제로 다음과 같이 기록이 된다.
이후 모든 페이지 접속 시 세션 스토리지로부터 페이지 이름에 해당되는 스크롤 값을 가져와 scroll
메서드를 통해 해당 위치로 적용한다. useEffect 대신 useLayoutEffect를 쓴 것에 대해서는 후술하겠다.
글쓰기, 프로필 수정 등과 같은 페이지는 굳이 이전 스크롤 위치를 기억할 필요가 없다. 따라서 특정 URL의 경우는 이전 스크롤 위치와 상관 없이 일괄적으로 최상단(x:0, y:0)으로 초기화할 필요가 생겼다.
위 훅의 사용처에서 prop으로 스크롤 기록을 할지 말지 선택할 수 있는 boolean 값을 내려주는 것도 괜찮은 방법이겠지만, 매 페이지마다 해당 prop을 구분해서 내려주는게 좀 피곤할 것 같다.
따라서 이전에 네비게이션 바 구현 시 사용한 useRouteMatched
라는 훅을 활용해 위 기능을 구현해보기로 했다.
import { useCallback } from 'react';
import { useLocation, matchPath } from 'react-router-dom';
const useRouteMatched = () => {
const location = useLocation();
const hasMatched = useCallback(
(...paths: string[]) => {
const isMatched = paths.some((path) => {
const match = matchPath(path, location.pathname);
return match !== null;
});
return isMatched;
},
[location],
);
return { hasMatched };
};
export default useRouteMatched;
위 훅은 location
객체를 통해 받아온 현재의 URL이 사용자가 넘겨준 URL List 중에 포함되어 있다면 true
를 반환하는 함수를 반환한다.
hasMatched
라는 함수에 스크롤 위치를 세션 스토리지에 기록하지 않을 URL List를 넘기고, 현재의 URL이 해당 리스트에 포함되어 있을 때 setPageScrollPosition
함수를 실행하기 전 return 해주면 될 것이다.
적용한 코드를 보자. 구분이 쉽게 추가된 로직을 ✅ 표시 해두었다.
import { useLayoutEffect } from 'react';
import useRouteMatched from './useRouteMatch';
import { useMobileViewRefContext } from '../layouts/contexts/MobileViewContext';
const getPageScrollPosition = (pageName: string) => {
return JSON.parse(sessionStorage.getItem(pageName) || '{}');
};
const setPageScrollPosition = (pageName: string, position: number) => {
sessionStorage.setItem(pageName, JSON.stringify(position));
};
const useInitScrollPosition = (pageName: string) => {
const mobileViewRef = useMobileViewRefContext();
// ✅ location URL의 값을 읽어, 인수로 넘긴 URL List와 일치하는지 확인하는 함수
const { hasMatched } = useRouteMatched();
useLayoutEffect(() => {
const mobileView = mobileViewRef.current;
if (!mobileView) return;
// 이전 스크롤 위치가 있다면 세션 스토리지에서 가져와 이전 스크롤 위치로 조정합니다.
const prevScrollPosition = getPageScrollPosition(pageName) || 0;
// ✅ 글쓰기, 글 수정, 프로필 수정, 회원 가입 페이지는 스크롤 위치를 기록하지 않는다.
const isNotRecordScrollPosition = hasMatched('/write', '/write-edit', 'profile-edit', 'sign-up');
mobileView.scroll({ top: prevScrollPosition, behavior: 'instant' });
return () => {
// ✅ 스크롤 위치를 기록 전 return
if (isNotRecordScrollPosition) return;
setPageScrollPosition(pageName, mobileView.scrollTop);
};
}, [mobileViewRef, pageName, hasMatched]);
};
export default useInitScrollPosition;
위 방법으로 스크롤 기록이 필요하지 않는 페이지 대응을 진행할 수 있었다.
우선 useEffect, useLayoutEffect 두 훅의 차이점을 간단하게 정리하면 다음과 같다.
useLayoutEffect는 페인팅(커밋) 이전에 동기적으로 실행되서 성능 저하가 발생할 수도 있다. 그래서 React 공식 문서에서 커다랗게 주의 문구를 두고 있기도 하다. 하지만 위 로직은 useLayoutEffect를 써야만 한다. 그 이유로 다음과 같다.
실제로 useEffect를 써본 결과 깜빡임, 레이아웃 들썩거림은 내 로컬 환경에선 발생하지 않았는데, 이는 디바이스 성능에 따라 차이가 날 수 있을 것 같다. useEffect는 페인팅 이후 비동기적으로 동작해서 비동기 작업이 예정되어 있는 페이지로 전환 시, 스크롤 위치가 전환 후의 페이지 위치로 초기화 된다. 따라서 다음과 같이 피드 상세 최초 접속 시 스크롤 위치가 0으로 초기화 되는 것을 볼 수 있다.
따라서 위와 같은 이유로 useEffect 대신 useLayoutEffect를 사용했다. 추가로 useEffect로 비동기 작업이 예정되어 있는 페이지로 전환 시, 스크롤 위치가 잘못 조정되는 것이 Suspense의 영향이 있는지도 추후 실험해볼 생각이다. 🤔
세션 스토리지 특성상 브라우저를 닫아야 스토리지가 비워지게 되는데, 새로 고침 시에도 이전 스크롤을 유지하는게 다소 어색하면서 불편하다. 따라서 App.tsx
에 새로 고침 시 세션 스토리지를 비우는 로직을 추가했다.
function App() {
const clearSessionStorage = () => {
sessionStorage.clear();
};
// 새로고침 시 세션 스토리지에 기록된 페이지 스크롤 위치를 모두 삭제합니다.
// Safari 대응을 위해 load 대신 pagehide 이벤트 사용합니다.
useEffect(() => {
window.addEventListener('pagehide', clearSessionStorage);
return () => window.removeEventListener('pagehide', clearSessionStorage);
}, []);
// 중략..
}
export default App;
load
이벤트 대신 pagehide
이벤트로 세션 스토리지 비우기 로직을 핸들링 중인 점이 조금 특이하다. 이는 Safari에서 load
이벤트가 타 브라우저와는 다르게 동작해서, 새로 고침 시 세션 스토리지 비우기가 정상적으로 진행되지 않기에 그렇다.
요약하면 Safari는 load
이벤트 핸들러를 추가하기 전에 페이지 로드가 이미 완료되어 추가적인 방법이 필요하다. 자세한 내용은 여기를 참고해보면 좋을 것 같다. 나는 load
대신 pagehide
이벤트를 사용했다.
위와 같이 '페이지 별로 마지막 스크롤 위치 기억하기' 기능이 동작하는 것을 볼 수 있다. 😇
꿀팁 감사합니다!!!!