History API를 사용하여 뒤로가기 막기 (Next.js)

History API를 사용하여 뒤로가기 막기 (Next.js)

·

8 min read

사내 프로젝트에서 특정 페이지에서 뒤로가기 이벤트를 실행 시 모달을 띄우는 요구사항 구현을 정리하였습니다.

History API란?

MDN
History API는 브라우저의 세션 기록(탭이나 프레임에서 현재 페이지가 로드된 기록)에 접근할 수 있는 기능을 제공합니다. 이를 통해 사용자는 뒤로 가기와 앞으로 가기, 그리고 히스토리 스택의 내용을 조작할 수 있습니다. 이 API는 메인 스레드(Window)에서만 사용할 수 있으며, Worker나 Worklet 컨텍스트에서는 사용할 수 없습니다.

MDN의 정의를 간단하게 요약하면 브라우저의 세션 히스토리에 접근하여 앞,뒤 state를 조작하는데 사용한다는 의미입니다. 세션 히스토리는 브라우저의 방문기록이 쌓이는 곳으로 브라우저의 뒤로가기 나 앞으로가기 버튼을 우클릭해보면 나오는 것이 바로 세션 히스토리입니다.

next.js에서 window.histroy를 콘솔로 찍어보면

  • constructor History 생성자 함수로 생성

  • prototype에 go, forward, back, length, pushState, replaceState, scrollRestoration등이 선언되어있습니다.

  • History 인스턴스가 생성될 때 length, scrollRestoration, state, pushState, replaceState 가 인스턴스 속성으로 추가된 것을 볼 수 있습니다.

  • 한가지 참고할 점은 next.js를 사용하지 않는 다른 브라우저(naver)에서 history를 콘솔로 찍어보니 pushState, replaceState 는 존재하지않았습니다. (next.js 내부적으로 성능상의 이유로 인스턴스에 직접 추가한 것일 수도 있습니다.)

History API 메서드와 SPA

History API의 메소드는 크게 2가지 특성으로 분류해서 설명할 수 있습니다.

  1. 페이지 이동을 위한 메소드 back, forward, go 입니다.
history.back();      // 뒤로가기
history.forward();   // 앞으로가기

history.go(-1);      // 현재 state 기준 뒤로 1칸 가기 (=history.back())
history.go(0);       // 새로 고침
history.go(1);       // 현재 state 기준 앞으로 1칸 가기 (=history.forward())
  1. 세션 히스토리 변경을 위한 메소드(pushState, replaceState)입니다. 특히 이 메서드들은 SPA의 문제를 해결하기 위해서 나온 것입니다. 어떤 문제인지 상황과 예시코드를 살펴보겠습니다.

    • 상황

      a. 새 페이지를 로드하는 기본 동작을 방지합니다.

      b. 표시할 새 콘텐츠를 가져옵니다.

      c. 새로운 콘텐츠로 페이지를 업데이트합니다.


    document.addEventListener("click", async (event) => {
      const creature = event.target.getAttribute("data-creature");
      if (creature) {
        // Prevent a new page from loading
        event.preventDefault();
        try {
          // Fetch new content
          const response = await fetch(`creatures/${creature}.json`);
          const json = await response.json();
          // Update the page with the new content
          displayContent(json);
        } catch (err) {
          console.error(err);
        }
      }
    });
    // 이 클릭 핸들러에서 링크에 데이터 속성이 포함되어 있으면 
    // "data-creature"해당 속성의 값을 사용하여 페이지의 
    // 새 콘텐츠가 포함된 JSON 파일을 가져옵니다.
    // reponse json
    {
      "description": "Bald eagles are not actually bald.",
      "image": {
        "src": "images/eagle.jpg",
        "alt": "A bald eagle"
      },
      "name": "Eagle"
    }
    // displayContent 함수를 이용한 업데이트
    function displayContent(content) {
      document.title = `Creatures: ${content.name}`;

      const description = document.querySelector("#description");
      description.textContent = content.description;

      const photo = document.querySelector("#photo");
      photo.setAttribute("src", content.image.src);
      photo.setAttribute("alt", content.image.alt);
    }

여기서 문제는 브라우저의 "뒤로"와 "앞으로" 버튼의 예상된 동작이 깨진다는 것입니다.

사용자 관점에서 보면, 링크를 클릭했고 페이지가 업데이트 되어 새로운 페이지처럼 보입니다. 그런 다음 브라우저의 "뒤로" 버튼을 누르면 링크를 클릭하기 전 상태로 돌아갈 것으로 기대합니다. 하지만 브라우저의 관점에서 보면 마지막 링크는 새로운 페이지를 로드하지 않았기 때문에 "뒤로"를 클릭하면 사용자가 SPA를 열기 전에 로드된 페이지로 브라우저가 이동합니다.

위에 문제를 해결하기 위해 pushState를 사용한 코드 예시를 보겠습니다.

    document.addEventListener("click", async (event) => {
      const creature = event.target.getAttribute("data-creature");
      if (creature) {
        event.preventDefault();
        try {
          const response = await fetch(`creatures/${creature}.json`);
          const json = await response.json();
          displayContent(json);
          // Add a new entry to the history.
          // This simulates loading a new page.
          history.pushState(json, "", creature);
        } catch (err) {
          console.error(err);
        }
      }
    });

pushState의 인자 history.pushState(json, "", creature); 를 살펴보면
첫번째 인자 state - 직렬화가 가능한 histroy와 함께 저장 될 데이터
두번째 인자 unused - 사용하진 않지만 레거시 사이트와의 하위 호환성을 위해 필요하며 항상 빈 문자열이어야 합니다.
세번째 인자 url - 현재 url과 오리진(프로토콜, 호스트명, 포트번호)이 같은 절대 또는 상대 경로 주소창은 바뀌지만 실제 페이지 이동하지 않습니다.

예시 코드를 다시보면 클릭하고 페이지를 이동한 것처럼 histroy에 쌓는 방식으로 해결하였습니다.

여기서 알아야할 개념이 하나 있습니다. 바로 popstate입니다. popstatepushState,replaceState 가 실행될 때 마다 전달되는 state의 사본을 저장합니다. (state는 위에서 pushState에 전달되는 첫번째 인자를 말합니다.)

그리고 뒤로나 앞으로 가기 이벤트가 실행됐을때만 동작합니다. 뒤로 혹은 앞으로 갈때 event.state에 자동으로 popstate에 저장 되었던 state를 사용하여 이전 페이지의 데이터를 보여주는 것입니다.

    window.addEventListener("popstate", (event) => {
      // If a state has been provided, we have a "simulated" page
      // and we update the current page.
      if (event.state) {
        // Simulate the loading of the previous page
        displayContent(event.state);
      }
    });

replaceState는 SPA 초기 로드 했을 때 페이지 정보가 없는 것을 해결하기 위해 있는 메서드입니다. 상황과 예시 코드를 보겠습니다.

  • 상황

    a. SPA 로드: 브라우저가 기록 항목을 추가합니다.

    b. SPA 내부의 링크를 클릭하면 클릭 핸들러가 페이지를 업데이트하고 다음과 같은 기록 항목을 추가합니다. pushState()

    c. 뒤로가기 버튼을 누릅니다.

이제 다시 첫페이지로 돌아가고 싶습니다. 하지만 이는 동일한 문서 내에서의 탐색이기 때문에 페이지가 다시 로드되지 않으며, 초기 페이지는 pushState에 저장된 state가 없으므로 popstate를 사용하여 복원할 수 없습니다.

이를 해결하기 위해서 처음 페이지가 로드되었을 때 state를 가지고있는 history로 변경하는 것입니다. 아래 코드를 보면 initialState state를 갖고 현재 url주소document.location.href를 넣어 페이지 그대로에 상태만 추가한 것을 볼 수 있습니다.

    // Create state on page load and replace the current history with it
    const image = document.querySelector("#photo");
    const initialState = {
      description: document.querySelector("#description").textContent,
      image: {
        src: image.getAttribute("src"),
        alt: image.getAttribute("alt"),
      },
      name: "Home",
    };
    history.replaceState(initialState, "", document.location.href);

여기까지 History API 메서드와 spa를 연관지어 쭉 살펴보았습니다.
추가로 pushState에서 한가지 더 알아야할 지식이 있는데요, pushState를 사용하여 5개의 history를 쌓고 뒤로가기를 2번 한 후 새로 pushState로 다른페이지를 접근한다면 추가된 histroy만 남고 이전에 있던 히스토리는 지워진다는 것입니다. 테스트 코드로 확인해보시면 좋을거 같습니다.

// pushState 테스트 코드

"use client";
import React, { useEffect } from "react";

const Page = () => {
  const handlePushState1 = () => {
    history.pushState({ page: 1 }, "", "?page=1");
  };

  const handlePushState2 = () => {
    history.pushState({ page: 2 }, "", "?page=2");
  };

  const handlePushState3 = () => {
    history.pushState({ page: 3 }, "", "?page=3");
  };

  const handlePushState4 = () => {
    history.pushState({ page: 4 }, "", "?page=4");
  };

  const handleBack = () => {
    history.back();
  };

  const handleForward = () => {
    history.forward();
  };

  const handleGo2 = () => {
    history.go(2);
  };

  console.log(window.history);

  return (
    <>
      <button onClick={handlePushState1}>Push State 1</button>
      <button onClick={handlePushState2}>Push State 2</button>
      <button onClick={handlePushState3}>Push State 3</button>
      <button onClick={handlePushState4}>Push State 4</button>
      <button onClick={handleBack}>Back</button>
      <button onClick={handleForward}>Forward</button>
      <button onClick={handleGo2}>Go 2</button>
    </>
  );
};

replaceState는 첫번째 페이지에 데이터를 저장하기위해서 사용하는 것이었는데 실무에서는 주로 현재페이지에서 이전페이지에 접근하지 못하도록 사용하는것 같습니다.

뒤로가기 막기 해결책은?

위에 공부한 내용을 토대로 useBackNavigationModal 커스텀 훅을 만들어 사용했습니다.

현재 코드는 커스텀 훅 안에 Modal 컴포넌트까지 같이 구현해서 사용하고 있는데 따로 컴포넌트를 분리해서 사용할 예정입니다. 모달은 MUI를 사용했으며 600px이하에서는 ios 모달처럼 아래에서 나오도록 구현했습니다. 추후 리팩토링이나 더 좋은 방법을 찾게된다면 업데이트 하도록하겠습니다!

// useBackNavigationModal

"use client";

import { useState, useEffect, useCallback, useRef } from "react";
import { useRouter } from "next/navigation";
import { useMediaQuery, useTheme } from "@mui/material";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogContentText from "@mui/material/DialogContentText";
import DialogTitle from "@mui/material/DialogTitle";
import { getDataFromLocalStorage } from "@/shared/utils/localStorage";
import { useCommonMediaqueryHooks } from "./useCommonMediaqueryHooks";
import { ErrorOutline } from "@mui/icons-material";
import { colors } from "@/shared/styles/colors";
import { Box } from "@mui/material";
import { CommonButton } from "@/shared/components";

interface UseBackNavigationModalOptions {
  onConfirm?: () => void;
  onCancel?: () => void;
  modalMessage?: string;
  modalTitle?: string;
}

export function useBackNavigationModal({
  onConfirm,
  onCancel,
  modalMessage = "Are you sure you want to leave this page?",
  modalTitle,
}: UseBackNavigationModalOptions = {}) {
  const theme = useTheme();
  const matchDownSize = useMediaQuery(theme.breakpoints.down(string));

  const [isModalOpen, setIsModalOpen] = useState(false);
  const router = useRouter();

  const openModal = useCallback(() => setIsModalOpen(true), []);
  const closeModal = useCallback(() => setIsModalOpen(false), []);

  const handleConfirm = useCallback(() => {
    closeModal();
    if (onConfirm) {
      onConfirm();
    }

    // 로컬 스토리지에 저장되는 이전 url을 찾고 이전경로로 이동 시키는 로직, 
    // 외부링크를 타고들어왔을 때 페이지를 이탈하지않고 홈으로 보내기위한 용도
    // 이 기능이 필요없ㄷ가면 router.back()으로 처리 가능

    const prevUrl = getDataFromLocalStorage("previousUrl");
    router.push(prevUrl === "/signup" ? "/" : prevUrl || "/");
  }, [closeModal, onConfirm]);

  const handleCancel = useCallback(
    (
      event:
        | React.MouseEvent<HTMLButtonElement>
        | React.KeyboardEvent<HTMLDivElement>,
      reason?: string
    ) => {
      if (reason === "backdropClick") {
        return;
      }
      closeModal();
      if (onCancel) {
        onCancel();
      }
      // 현재 URL을 history에 다시 추가하여 뒤로가기를 방지
      window.history.pushState(null, "", window.location.href);
    },
    [closeModal, onCancel]
  );

  useEffect(() => {
    // 페이지 로드 시 현재 상태를 history에 추가, state에 preventRefreshtack추가하여 또 추가 방지

    if (!window.history.state?.preventRefreshtack) {
      window.history.pushState(
        { preventRefreshtack: true },
        "",
        window.location.href
      );
    }

    const handlePopState = (event: PopStateEvent) => {
      // 뒤로가기 버튼 클릭 시 모달 오픈
      event.preventDefault();
      openModal();
    };

    window.addEventListener("popstate", handlePopState);

    return () => {
      window.removeEventListener("popstate", handlePopState);
    };
  }, [openModal]);

  const ModalComponent = (
    <Dialog
      fullWidth={true}
      open={isModalOpen}
      onClose={handleCancel}
      aria-labelledby="alert-dialog-title"
      aria-describedby="alert-dialog-description"
      PaperProps={
        matchDownSize
          ? {
              style: {
                position: "absolute",
                bottom: "-32px",
                width: "100%",
                borderRadius: "20px 20px 0 0",
              },
            }
          : {
              style: {
                borderRadius: "20px",
                width: "500px",
              },
            }
      }
    >
      <Box
        sx={{
          padding: { sm: "20px ", xs: "20px 0px" },
        }}
      >
        {modalTitle ? (
          <DialogTitle id="alert-dialog-title" textAlign={"center"}>
            {modalTitle}
          </DialogTitle>
        ) : (
          <Box
            sx={{
              display: "flex",
              justifyContent: "center",
            }}
          >
            <ErrorOutline
              sx={{
                fontSize: "50px",
                color: colors?.black[300],
              }}
            />
          </Box>
        )}

        <DialogContent>
          <DialogContentText
            id="alert-dialog-description"
            sx={{
              textAlign: "center",
              whiteSpace: "pre-line",
            }}
          >
            {modalMessage}
          </DialogContentText>
        </DialogContent>
        <DialogActions>
          <Button
            onClick={handleCancel}
          >
            돌아가기
          </Button>
          <Button onClick={handleConfirm}>
            나가기
          </Button>
        </DialogActions>
      </Box>
    </Dialog>
  );

  return {
    isModalOpen,
    ModalComponent,
  };
}
// page

"use client";

import { useBackNavigationModal } from "@/shared/hooks/useCommonBackNavigationModal";

const SignupPage = () => {

  const { ModalComponent } = useBackNavigationModal({
    onConfirm: () => {
      console.log("User confirmed leaving the page");
    },
    onCancel: () => {
      console.log("User cancelled leaving the page");
    },
    modalMessage: `페이지를 나갈경우 입력된 정보가 모두 사라집니다.\n나가시겠습니까?`,
  });


  return (
    <>
        ...//다른 컴포넌트
      {ModalComponent}
    </>
  );
};

export default SignupPage;
// previousUrl 
// layout.tsx에 아래 처럼 선언 되어있음
//        <Suspense fallback={null}>
//          <NavigationEvents />
//        </Suspense>
'use client';

import { useEffect, useRef } from 'react';
import { usePathname, useSearchParams } from 'next/navigation';

export function NavigationEvents() {
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const prevUrlRef = useRef<string | null>(null);

  useEffect(() => {
    // const currentUrl = `${pathname}${searchParams ? `?${searchParams}` : ''}`;
    const currentUrl = `${pathname}`;

    // 이전 URL이 있으면 localStorage에 저장
    if (prevUrlRef.current) {
      localStorage.setItem('previousUrl', prevUrlRef.current);
    }

    // 현재 URL을 이전 URL로 설정
    prevUrlRef.current = currentUrl;

    console.log({
      currentUrl,
      previousURL: localStorage.getItem('previousUrl'),
    });
  }, [pathname, searchParams]);

  return null;
}

참고자료

MDN History API

MDN pushState

MDN replaceState

MDN popstate

벨로그