[React] 뒤로가기 시 이전 스크롤 위치로 이동하기

Chex·2024년 8월 8일
1
post-thumbnail

Indiero.com 프로젝트를 진행하면서, 정책 상세를 보다가 뒤로가기를 클릭했을 때 원래 보고있던 정책부터 보려면 스크롤을 처음부터 내려야하는 것이 불편하다는 피드백을 받아 뒤로가기 시 이전 스크롤 위치로 이동하는 기능을 구현했습니다.

✨ 동작과정

  • 사용자가 스크롤 할 때마다 사용자가 스크롤한 위치를 sessionStorage에 저장한다.
  • 정책목록 스크롤 > 정책상세 클릭 > 뒤로가기 클릭 시에만 이전 스크롤 위치로 이동해야하므로
  • '뒤로가기'를 통한 접근인지 판별한다.
  • '뒤로가기'를 통한 접근인 경우, sessionStorage에서 이전 스크롤 위치를 꺼내와 이동한다.

🤔 sessionStorage 사용이유?

sessionStorage

  • 현재 떠 있는 탭 내에서만 데이터 사용
  • 새로고침했을 때에도 데이터 유지
  • 탭을 열고 닫을 때에 데이터 삭제

localStorage

  • 오리진이 같은 경우 모든 탭과 창에서 데이터 공유
  • 브라우저나 OS가 재시작하더라도 데이터 유지

사용자가 스크롤을 내리다가 원하는 정보를 확인하고 뒤로가기 했을 때 이전에 보던 지점부터 다시 탐색하는 동작에서 사용하는 스크롤 위치 정보는 해당 탭 내에서만 유지되는 것이 자연스럽다고 생각하여 sessionStorage를 사용했습니다.

🛠️ 트러블 슈팅

1차 시도

  • 아이템 클릭 시 window.scrollYsessionStorage에 저장한다.
  • 뒤로가기하여 목록 페이지로 돌아온 경우 이전 스크롤 위치로 이동(window.scrollTo(0, savedScrollY))한다.

문제점

실제 동작을 확인해보니 스크롤 이동이 동작하지 않는 것처럼 보였습니다. 그래서 저장한 스크롤 위치값(window.scrollY)을 출력 해보니 계속 0이 나오는 것을 확인했습니다.

해결방안

window.scrollY값이 아닌 목록을 감싸고 있는 실제 컨테이너(overflow-y: scroll 속성이 설정된)의 스크롤 위치(scrollTop)에 접근해야했습니다.

2차 시도

문제점

아이템에 onClick 핸들러를 달아서 아이템 클릭 시 스크롤 위치를 sessionStorage에 저장해야했으나
스크롤 위치를 가진 컨테이너는 다른 페이지(PolicyListPage)에 있었기 때문에
해당 컨테이너에 대한 ref 값을 props로 전달하는 방법을 시도했지만 ref값이 계속 undefined가 뜨는 문제가 있었습니다.

해결방안

스크롤 위치를 저장하는 시점을 클릭했을 때가 아니라
목록을 감싼 컨테이너에 스크롤 이벤트 리스너를 달아서 스크롤이 발생할 때마다 스크롤 위치(scrollTop)를 저장하는 방식으로 변경했습니다.

3차 시도

문제점

목록 컴포넌트에 진입할 때마다 저장된 스크롤 위치로 이동하는 문제가 있었습니다.

1) 홈화면 > 정책목록 > 이전 스크롤 위치로 이동 (X)
2) 정책목록 > 정책 아이템 상세 > 뒤로가기 > 이전 스크롤 위치로 이동(O)

올바른 동작은 2)와 같습니다.

// 뒤로가기인 경우 스크롤 위치 sessionStorage에서 가져오기
const navigationEntry = performance.getEntriesByType('navigation')[0];

if (navigationEntry.type === 'back_forward') {
	const savedScrollY = sessionStorage.getItem('scrollY');

  if (savedScrollY && scrollRef.current) {
		scrollRef.current.scrollTop = parseInt(savedScrollY, 10);
  }
}

그래서 뒤로가기 이벤트를 감지한 후에만 스크롤 이동이 동작하도록 window.performance.getEntriesByType('navigation')[0].type값이 'back_forward'인지 조건문을 걸어 실행시켜보았지만 뒤로가기를 했을 때에도 'back_forwrd'가 아닌 'reload'값으로 나오는 것을 확인했습니다.

원인

원인은 React Router와 브라우저의 History API의 차이에 있었습니다.

  • 브라우저의 history API: window.history 객체를 사용하여 페이지를 이동시키거나 뒤로 가기를 수행합니다. 이 경우에는 브라우저의 히스토리 스택에 영향을 미치며, performance API를 통해 이벤트를 감지할 수 있습니다.

정책상세에서 뒤로가기 버튼 클릭 시 useNavigation을 이용한 navigate(-1) 코드가 동작하는데

useNavigationReact Router를 통해 React내의 history를 조작하는 것이어서 실제 브라우저의 history 객체와는 별도로 작동하기 때문에 window.performance에서 뒤로가기(back_forward) 동작을 감지할 수 없었던 것이었습니다.

해결방안

react-router-dom v6에도 뒤로가기를 판별할 수 있는 기능이 있는지 찾아보았고
useNavigationType를 사용하면 된다는 것을 알 수 있었습니다.(POP인 경우 = 뒤로가기)

console.log('발생한 Event: ', navigationType);

if (navigationType === 'POP') {
	const savedScrollY = sessionStorage.getItem('scrollY');

  if (savedScrollY && scrollRef.current) {
	  console.log('이전 스크롤 위치:', savedScrollY);

    scrollRef.current.scrollTop = parseInt(savedScrollY, 10);
  }
}

4차 시도

문제점

스크롤이 발생할 때마다 스크롤 위치를 저장하는데,
문제는 스크롤 이벤트의 경우 마우스를 조금만 움직여도 많은 이벤트가 발생한다는 것이었습니다.

해결방안

useThrottle 커스텀 훅을 구현하고 throttle을 적용하여 일정 시간 동안 연속적으로 발생하는 스크롤 이벤트 중 첫번 째 이벤트에서만 콜백함수를 실행하도록 설정하여 불필요한 중복 실행을 줄였습니다.

콜백함수를 실행할지 기다릴지를 판별하는 플래그 변수 isWaiting을 만들고
setTimeout을 이용하여 delay시간이 지나면 isWaitingfalse로 변경하여 콜백함수를 실행하도록 구현했습니다.

import { useCallback, useRef } from 'react';

const useThrottle = () => {
  const isWaiting = useRef(false);

  /** 스크롤 위치를 기록하는 경우 delay 50이하일 때 가장 원활하여 50을 기본값으로 설정 */
  return useCallback(
    (callback: (...arg: any) => void, delay: number = 50) =>
      (...arg: any) => {
        if (!isWaiting.current) {
          callback(...arg);

          isWaiting.current = true;

          setTimeout(() => {
            isWaiting.current = false;
          }, delay);
        }
      },
    [],
  );
};

export default useThrottle;

✨ 결과

✅ 뒤로가기 클릭 시 이전 스크롤 위치로 이동 실행화면

// useScrollRestoration.ts

import { useEffect, useRef } from 'react';
import { useNavigationType } from 'react-router-dom';

import useThrottle from '@/hooks/@common/useThrottle';

const NAVIGATION_TYPE = {
  POP: 'POP',
  PUSH: 'PUSH',
  RELOAD: 'RELOAD',
} as const;

const useScrollRestoration = () => {
  const navigationType = useNavigationType();
  const scrollRef = useRef<HTMLDivElement | null>(null);
  const throttle = useThrottle();

  const SESSION_STORAGE_KEY = {
    SCROLL_Y: 'SCROLL_Y',
  } as const;

  useEffect(() => {
    const handleScrollWithThrottle = throttle(() => {
      if (scrollRef.current) {
        sessionStorage.setItem(
          SESSION_STORAGE_KEY.SCROLL_Y,
          scrollRef.current.scrollTop.toString(),
        );
      }
    });

    scrollRef.current?.addEventListener('scroll', handleScrollWithThrottle);

    return () => {
      scrollRef.current?.removeEventListener('scroll', handleScrollWithThrottle);
    };
  }, [scrollRef]);

  useEffect(() => {
    if (navigationType === NAVIGATION_TYPE.POP) {
      const savedScrollY = sessionStorage.getItem(SESSION_STORAGE_KEY.SCROLL_Y);

      if (savedScrollY && scrollRef.current) {
        scrollRef.current.scrollTop = parseInt(savedScrollY, 10);
      }
    }
  }, [navigationType]);

  return { scrollRef };
};

export default useScrollRestoration;

✅ Throttle 적용 전후 결과비교

Before) 3초 스크롤한 경우 함수 실행 횟수 264

After) 3초 스크롤한 경우 함수 실행 횟수 34

profile
Fake It till you make It!

2개의 댓글

comment-user-thumbnail
2024년 12월 21일

안녕하세요 이 방식을 실제 사용하는 곳에서는 어떻게 사용하면 좋을지 궁금합니다

저는 React를 사용하고 있고
작성자님 말씀처럼 리스트페이지 => 상세페이지에 갔다왔을 때만 복원이 됐으면 좋겠는데
1. 사용하는 방법이 궁금합니다
2. 필요한 곳에서만 넣어서 사용하시는건지 궁금합니다.

감사합니다

1개의 답글