내가 만들어본 뒤로가기 관련 커스텀 훅

내가 만들어본 뒤로가기 관련 커스텀 훅

회사에서 열심히 개발하다 보면 공통으로 요구하는 사항이 있다. 이러한 공통 요구 사항을 커스텀 훅으로 개발한 것 중 뒤로가기와 관련된 커스텀 훅 개발 경험을 공유해본다~!

커스텀 훅마다 JSdoc으로 파일 상단에 간단한 설명을 적었다.

useAppointedGoBack

import { useCallback, useEffect } from 'react';

import { useRouter } from 'next/router';

/**
 * 현재 페이지에서 뒤로가기 시 지정된 url로 뒤로가기합니다.
 *
 * 앱 네이티브 Back은 useEffect로 처리합니다. 
 * 웹 헤더의 Back 경우, 해당 버튼 onClick에 goBack를 할당하면 됩니다.
 *
 * @param {string} url
 * @returns {function: void}
 */

const useAppointedGoBack = (url: string) => {
  const router = useRouter();
  const goBack = useCallback(() => {
    router.back();
  }, [router]);

  useEffect(() => {
    router.beforePopState(() => {
      router.replace(url);
      return false;
    });

    return () => {
      router.beforePopState(() => true);
    };
  }, [router, url]);

  return goBack;
};

export default useAppointedGoBack;

useAppointedGoBack 은 뒤로가기 시 지정한 곳으로 뒤로 갈 수 있는 커스텀 훅이다. Page [A] → Page [B] → Page [C] 처럼 이어지는 플로우에서 예를 들면 Page [C]에서 뒤로가기 시 Page [B]가 아니라 Page [A] 혹은 플로우에 없는 아예 다른 페이지로 이동하고 싶은 요구사항이 종종 있다. 이러한 요구사항을 해결할 수 있는 커스텀 훅이다.

코드를 이해하기 위해선 goBack 함수와 useEffect 안에 있는 로직을 이해하면 된다.

goBack

  const goBack = useCallback(() => {
    router.back();
  }, [router]);

간단한 함수다. goBack 함수를 실행하면 기본적인 뒤로가기를 수행한다. 기본 동작이라 useAppointedGoBack 를 사용하는 페이지에서 goBack 를 따로 사용하지 않아도 무방하지만, 응집성을 이유로 넣어놨다. (뒤로가기 수행은 페이지 내에서 처리하고 지정된 페이지로 이동은 이 훅에서 처리하면 헷갈릴 수도..)

useEffect 내부

  useEffect(() => {
    router.beforePopState(() => {
      router.replace(url);
      return false;
    });

    return () => {
      router.beforePopState(() => true);
    };
  }, [router, url]);

먼저 beforePopState 를 알아야 하는데 뒤로가기를 클릭하면 아래와 같은 기능이 수행된다.

뒤로가기 버튼 클릭 → 브라우저 주소 변경 (히스토리는 하나 줄었음) → router.beforePopState 실행 → router.beforePopState 함수가 falsereturn 하면 주소만 변경되고 실제로 화면을 이동하지 않음

위 특성을 활용하여 Page [A] → Page [B] → Page [C] 인 케이스에서 Page [C]에서 뒤로가기 시 Page [A]로 이동하고 싶다면 url 에 Page [A]를 할당하고 뒤로가기 버튼 클릭 시 goBack 함수를 실행하도록 해주면 된다.

그럼 Page [A] → Page [B] → Page [C] 인 케이스에서 Page [C]에서 뒤로가기 시 Page [B]로 주소가 이동되고 router.beforePopState 의 callback이 실행되면서 Page [B]가 Page [A]로 replace된다.

replace를 사용한 이유

결과적으로 Page [A] → Page [B] → Page [C] 인 케이스에서 Page [C]에서 뒤로가기 시 Page [A]로 이동하면 히스토리는 Page [A] → Page [A]가 된다. 지정된 페이지로 이동하긴 했지만, 히스토리가 약간 이상해 보일 수도 있다.

구현은 여러 가지 방법이 있겠지만 일단 이렇게 로직을 짠 이유는 요구사항이 그렇기 때문이다.

기획자가 사용자에게 기대하는 플로우는 Page [A] → Page [B] → Page [C] → Page [A] 인데 Page [B] 또는 Page [C] 에서 뒤로가기를 할 수 있어서 Page [B] 또는 Page [C] 에서 뒤로가기 할 때 원래 이동해야 할 페이지로 이동해주세요 라는 요구사항이 발생한 것이다.

replace를 사용하면 현재 위치한 곳을 말 그대로 대체해버리기 때문에 replace를 활용해서 다시 돌아가면 안 되는 페이지를 다른 페이지로 대체할 수 있었다.

추가로 히스토리가 Page [A] → Page [A]가 되었으니 문제가 되지 않나요? 라고 생각할 수 있다. 이는 기능을 개발할 때 인지하고 있는 부분이고 위치 시킬 Page [A]가 되는 페이지는 해당 페이지에서 웹뷰를 종료한다거나 다른 방식의 뒤로가기를 수행하는 페이지로만 이동할 수 있게끔 기획자랑 협의가 되어있는 상태라 문제가 되지 않았다.

사용 예시

const Component = () => {
  const goBack = useAppointedGoBack('이동을 원하는 페이지 URL');

  ...
  
  return (
    <Header
	    goBack={goBack}
	    needButton={false}
	    isShare={isShare}
	    XButton
    />
  )
}

useScrollRestoration

import { useEffect, useState } from 'react';

import { useRouter } from 'next/router';

import Loading from '@/components/Loading';

import useIsomorphicLayoutEffect from '@/hooks/useIsomorphicLayoutEffect';

interface Props {
  key: string;
  isLoading?: boolean;
}

/**
 * 현재 페이지에 대한 스크롤 기록이 있을 경우, 해당 위치로 이동합니다.
 *
 * route 변경이 시작될 때, 세션 스토리지에 스크롤 값을 저장합니다.
 *
 * * Loading 상태를 표기하고 싶을 경우
 * 1. param { isLoading }에 Loading State를 넣어줍니다.
 * 2. 해당 훅의 return 값인 ScrollRestorationLoading, isScrollLoading로 컴포넌트에서 Loading 상태를 표기합니다.
 *    ex) src/features/credit/main/Normal/index.tsx
 *
 * * Loading 상태가 필요 없을 경우
 *
 * 1. param { isLoading }에 아무 값도 넣지 않으며,
 * 해당 훅의 return 값인 ScrollRestorationLoading, isScrollLoading를 컴포넌트에서 사용하지 않습니다.
 *
 * @param {key} string - 고유한 string
 * @param {boolean} isLoading - fetching 등에 대한 loading 값
 *
 * @returns {Component, boolean}
 */
 
const useScrollRestoration = ({ key = '', isLoading = false }: Props) => {
  const router = useRouter();
  const [scrollY, setScrollY] = useState<number>(0);
  const [isScrollLoading, setIsScrollLoading] = useState<boolean>(true);

  useEffect(() => {
    if (!key) return undefined;

    /**
     * route 변경이 시작될 때, session 스토리지에 현재 위치를 저장합니다.
     */
    const routeChangeStartHandler = () => {
      sessionStorage.setItem(
        `__next_scroll_${key}`,
        JSON.stringify({
          x: window.pageXOffset,
          y: window.pageYOffset,
        }),
      );
    };
    router.events.on('routeChangeStart', routeChangeStartHandler);
    return () => {
      router.events.off('routeChangeStart', routeChangeStartHandler);
    };
  }, [key, router.events]);

  useIsomorphicLayoutEffect(() => {
    if (!key || isLoading || !router.isReady) return;

    window.history.scrollRestoration = 'manual';
    const scroll = sessionStorage.getItem(`__next_scroll_${key}`);
    if (scroll) {
      const { y } = JSON.parse(scroll);
      setScrollY(y || 0);

      sessionStorage.removeItem(`__next_scroll_${key}`);
    }
    setIsScrollLoading(false);
  }, [isLoading, key, router.isReady]);

  useIsomorphicLayoutEffect(() => {
    if (isScrollLoading) return;
    window.scrollTo(0, scrollY);
  }, [isScrollLoading, scrollY]);

  const ScrollRestorationLoading = () => {
    if (isLoading || isScrollLoading) {
      return <Loading />;
    }

    return (
      <section
        className="w-full"
        style={{ height: scrollY }}
      />
    );
  };
  return { ScrollRestorationLoading, isScrollLoading };
};

export default useScrollRestoration;

useScrollRestoration 은 뒤로가기 시 스크롤을 복원해주는 커스텀 훅이다.

// next.config.js
module.exports = {
  experimental: {
    scrollRestoration: true
  }
}

위와 같이 NextJS에서 experimental 로 스크롤 복원을 제공해주긴 하지만, 이 훅을 개발할 때는 회사 프로젝트가 13 미만 버전이기도 했고 13.x.x 버전에서도 잘 작동하지 않는다는 Issue를 본 거 같아서 (13.x.x대에서 잘 안되다 해결되었다는 트윗) 직접 구현했고 현재도 잘 작동한다.

그리고 직접 만든 useScrollRestoration 은 Loading 컴포넌트가 노출되는 화면에서도 Loading이 끝나고 스크롤을 복원해주는 기능도 있다.

우선 route 변경이 시작될 때, 세션 스토리지에 스크롤 값을 저장하고 돌아올 때 해당 값으로 이동해서 스크롤 복원을 수행한다. 이는 NextJS의 scrollRestoration 기능 구현과 비슷할 것이다.

useScrollRestoration 은 Loading 상태를 표기하고 싶을 경우, Loading 상태를 표기하고 싶지 않은 경우로 나누어서 설명하겠다.

Loading 상태를 표기하고 싶지 않은 경우

이 경우는 일반적인 스크롤 복원과 같다.

  useEffect(() => {
    if (!key) return undefined;

    /**
     * route 변경이 시작될 때, session 스토리지에 현재 위치를 저장합니다.
     */
    const routeChangeStartHandler = () => {
      sessionStorage.setItem(
        `__next_scroll_${key}`,
        JSON.stringify({
          x: window.pageXOffset,
          y: window.pageYOffset,
        }),
      );
    };
    router.events.on('routeChangeStart', routeChangeStartHandler);
    return () => {
      router.events.off('routeChangeStart', routeChangeStartHandler);
    };
  }, [key, router.events]);

Page [A]에서 route가 변경될 때, 세션 스토리지에 스크롤 위치인 window.pageXOffset, window.pageYOffset 를 저장한다. key는 고유한 string 이고 key가 없다면 훅은 작동하지 않는다.

  const router = useRouter();
  const [scrollY, setScrollY] = useState<number>(0);
  const [isScrollLoading, setIsScrollLoading] = useState<boolean>(true);
  
  ...
  
  useIsomorphicLayoutEffect(() => {
    if (!key || isLoading || !router.isReady) return;

    window.history.scrollRestoration = 'manual';
    const scroll = sessionStorage.getItem(`__next_scroll_${key}`);
    if (scroll) {
      const { y } = JSON.parse(scroll);
      setScrollY(y || 0);

      sessionStorage.removeItem(`__next_scroll_${key}`);
    }
    setIsScrollLoading(false);
  }, [isLoading, key, router.isReady]);

  useIsomorphicLayoutEffect(() => {
    if (isScrollLoading) return;
    window.scrollTo(0, scrollY);
  }, [isScrollLoading, scrollY]);

스크롤 복원은 useLayoutEffect 로 수행한다. useEffect 는 paint 작업 이후 비동기로 작동하기 때문에 사용자가 보기에 페이지가 뜬 후, 스크롤이 이동되는 것처럼 보일 수 있다. 이는 유저 경험을 떨어뜨리기에 paint 이전에 동기적으로 수행하는 useLayoutEffect 를 사용했다.

추가로 NextJS warning을 제거하기 위해 useIsomorphicLayoutEffect 를 사용하였다.

코드 상단에 있는 useIsomorphicLayoutEffect 부터 보면 조건문이 있는데, key가 없거나 router가 준비가 안 되었을 때 return 한다.

isLoading 은 Loading 상태를 표기하고 싶을 때 필요하고 기본값은 false 기 때문에 여기서는 무시해도 된다.

스크롤을 수동으로 복원하기 위해 scrollRestorationmanual 를 할당하고 세션 스토리지에서 값을 가져와 state 에 저장하고 세션 스토리지에 있는 값을 삭제한다. (x값은 아직 쓸 일이 없어 사용은 안 한다.)

다음으로 isScrollLoading 값을 false 로 변경한다. 바로 window.scrollTo(0, scrollY); 를 사용해 이동해도 되지만, Loading 상태를 표기하고 싶을 때 필요한 로직이라 따로 상태를 나누어 처리했다.

코드의 두 번째 useIsomorphicLayoutEffect 에서는 스크롤 값을 가져왔고 state 에 저장된 해당 위치로 스크롤 이동한다.

사용 예시

const NormalCredit = () => {
	...
	
  useScrollRestoration({
    key: '고유한 key string',
  });
  
  ...
};

Loading 상태를 표기하고 싶을 경우

예를 들어, Page [A]는 페이지가 마운트 될 때 API 요청을 하고 그동안 Loading 컴포넌트를 보여준다. 다른 페이지로 이동했다가 Page [A]로 이동할 때도 다시 API 요청을 하고 Loading 컴포넌트를 보여준다.

이 페이지에서 로딩이 완료된 후 스크롤 복원을 하려면 위에 설명한 로직이나 NextJS에서 제공하는 기능으로는 스크롤 복원을 할 수 없다.

위 요구사항을 만족하기 위해 몇 가지 로직을 추가했다.

const useScrollRestoration = ({ key = '', isLoading = false }: Props)

useScrollRestoration 의 Props에 isLoading 를 할당할 수 있는데 이는 API 요청 중을 의미하는 값이다.

useIsomorphicLayoutEffect(() => {
    if (!key || isLoading || !router.isReady) return;
    
    ...
    
}, [isLoading, key, router.isReady]);

아까 본 첫 번째 useIsomorphicLayoutEffect 에서 isLoadingtrue 일 때 (API 요청 중)는 스크롤 복원을 수행하지 않고 API 요청이 끝난 후 스크롤을 복원한다.

  const ScrollRestorationLoading = () => {
    if (isLoading || isScrollLoading) {
      return <Loading />;
    }

    return (
      <div
        className="w-full"
        style={{ height: scrollY }}
      />
    );
  };

API가 요청 중이거나, 스크롤 복원 로직이 아직 끝나지 않았을 경우는 Loading 컴포넌트를 보여준다. API 요청이 끝났고, 스크롤 복원 로직(기존 스크롤 위치를 state 에 저장)도 끝났다면 기존 스크롤 위치만큼 해당하는 빈 div 를 render 한다. 이는 viewport 100%에 해당하는 Loading 컴포넌트가 렌더된 상태에서 스크롤을 복원할 경우, 최대 높이가 100vh가 되어 스크롤 값이 100vh보다 클 때 원하는 높이만큼 스크롤 이동할 수 없기 때문이다.

ScrollRestorationLoading 컴포넌트를 Page [A]에서 Loading 상태를 표기할 때 컴포넌트로 사용하면 요구 사항을 만족할 수 있다.

사용 예시

const NormalCredit = () => {
  ...
  const { isLoading } = useQuery(...); // 이 Query는 마운트 될 때마다 API를 요청해요~
  const { ScrollRestorationLoading, isScrollLoading } = useScrollRestoration({
    key: '고유한 key string',
    isLoading
  });
  
  ...
  
  if(isScrollLoading) return <ScrollRestorationLoading />
  return (
	  ...
  )
};

마무리

useAppointedGoBack , useScrollRestoration 이외에도 다른 훅도 많은데 생각보다 내용이 길어서 2개만 소개해봤다. 코드를 왜 이렇게 짰는지, 나조차 기억이 안 났는데 정리하며 예전에 했던 생각들과 왜 이렇게 해야 했는지 리마인드할 수 있어서 좋았다. 또 소개해볼 만한 커스텀 훅이 있으면 나중에 작성해보겠다~!

Reference

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.

https://nextjs.org/docs/pages/api-reference/functions/use-router#routerbeforepopstate
Functions: useRouter | Next.js