Skip to main content

Command Palette

Search for a command to run...

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

Published
8 min readView as Markdown
History API를 사용하여 뒤로가기 막기 (Next.js)
J

하루하루 꾸준히 자라고 있는 프론트엔드 개발자입니다. 내일배움캠프 리액트 튜터로 활동하고있습니다.

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

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

벨로그

More from this blog

프론트엔드 반응형 테스트 도구모음

💡 프론트엔드 개발을하면서 반응형 테스트를 좀 더 쉽게하기위해서 찾아본 것들입니다. Responsively APP 첫번째는 Responsively App입니다. 아래 화면처럼 모바일, 패드, 데스크탑을 3개의 화면을 동시에 키고 테스트해 볼 수 있습니다.그리고 오른쪽 상단에 있는 카메라 버튼을 누르면 각각의 화면을 캡쳐하는 기능도있습니다.개발하면서 혼자서 반응형 기기별로 테스트 해보기 좋습니다.홈페이지에 들어가서 각 pc환경에 맞게 다운로...

Jul 9, 20241 min read
프론트엔드 반응형 테스트 도구모음

user-agent를 이용한 모바일만 접근하기

사내 프로젝트에서 회원가입페이지를 모바일 기기에서만 접근할 수 있도록 만들어달라는 요청이있어 구현과정을 정리해보았습니다. 프로젝트는 next.js v14.2버전으로 진행했습니다. User-agent란? 사용자 에이전트 (User Agent)란, 우리가 사용하는 웹 브라우저 속에 숨겨진 중요한 기능 중 하나를 말합니다. 간단히 말해 내가 어떤 OS를 쓰고 있고, 버전은 어떤 버전인지 웹 브라우저의 정보는 어떤 것인지 등을 담고 있는 번호판 같은 ...

Jul 3, 20244 min read
user-agent를 이용한 모바일만 접근하기

Next.js v14 아키텍처(폴더구조)

💡 Next.js 폴더구조(아키텍처)에 대해 정리했습니다. Next.js v14 + MUI v5 셋팅에 이어서 폴더 구조(아키텍처)에 대해서 설명하려합니다.저는 항상 프로젝트를 시작, 진행하면서 고민되는 포인트는 프로젝트 아키텍처를 어떻게 설계해 나가야 되는 것인가 대한 고민을 했었는데요. 프로젝트 규모에 따른 아키텍처 설계 기준을 정리해보려합니다. 일단 현재 셋팅하고있는 Next.js +. MUI프로젝트는 FSD(기능 분할 설계)를 활...

Jun 5, 20244 min read
Next.js v14 아키텍처(폴더구조)

Next.js v14 App router + MUI v5

💡 Next.js 와 MUI 셋팅법에 대해 정리하였습니다. Node.js 설치 Next.js를 설치하기위해서는 Node.js 18.17 or later 버전이 필요합니다.Node.js는 Javascript로 작성된 프로그램을 운영체제 상에서 일반 애플리케이션 프로그램처럼 실행시켜주는 런타임입니다. Next.js를 사용해보기 위해서 설치해 봅니다. node.js 공식문서 https://nodejs.org/en LTS 다운로드 클릭합니다....

Jun 4, 20247 min read
Next.js v14 App router + MUI v5

GC_EE's blog

6 posts

효과적이고 효율적인 의사결정을 좋아하는 3년차 프론트엔드 엔지니어입니다.