🦋

react-router 에서 히스토리 API로 어플리케이션 같은 Stack View 만들기

근데 이게 맞는지는 모르겠음

JavaScript
History API

요구사항

문제

하이브리드 애플리케이션에서 동작하는 웹뷰 브라우저에서 히스토리가 A -> B -> C -> D 순서로 추가되었다고 가정하자. 이때, B에 진입한 다음, 뒤로가기 (popstate) 를 하는 경우 언제나 B로 돌아와야 한다.

가령, A -> B -> C -> D로 쌓인 Stack에서 뒤로가기를 하는 경우 C / D를 무시하고 B까지 stack이 pop되어야 한다.

대략적으로 안드로이드의 Intent와 유사한 흐름이라고 할 수 있을 것이다. 단지 그게 웹에서 동작해야 할 뿐...

제약 조건

  1. 히스토리 스택에 쌓이는 페이지의 origin이 언제나 동일하다는 보장이 없다.
  2. 뒤로 갔다가 앞으로 가는 경우는 기본적으로 상정하지 않기로 한다.

구현

/* hooks/use-check-point.ts */
import { useEffect, useCallback } from "react"
import { useBlocker } from "react-router"

// 전역에서 사용할 키를 지정한다.
const SESSION_STORAGE_CHECKPOINT_KEY = "check-point"

export function useCheckPoint() {
  const navigate = useNavigate()
  const location = useLocation()

  /**
   * 현재 상태를 체크포인트에 저장한다.
   */
  function save(): void {
    // 만약 history.state.saved가 존재하지 않을 경우 === 처음 save를 하는 경우
    if (!history.state.saved) {
      // history.pushState를 통해서 history.length를 현재 페이지를 기준으로 삼는다.
      history.pushState({ ...history.state, saved: true }, "", "")
    }

    // 세션 스토리지에 저장한다.
    globalThis.sessionStorage?.setItem(
      SESSION_STORAGE_CHECKPOINT_KEY,
      JSON.stringify({
        // 전 단계에서 history.length가 1개 더 추가되었기에, -1을 통하여 이를 보정한다.
        length: history.length - 1,
      }),
    )
  }

  /**
   * 체크포인트가 아래 조건을 만족하는지 확인한다.
   * - 체크포인트가 존재한다
   */
  function exist() {
    const checkPoint = globalThis.sessionStorage?.getItem(
      SESSION_STORAGE_CHECKPOINT_KEY,
    )
    return checkPoint !== null
  }

  /**
   * 현재 유효한 체크포인트가 있는지 확인한다.
   * 아래 조건에 해당하는 경우 null을 반환한다.
   * - 체크포인트가 없는 경우
   * - 체크포인트의 상태값이 현재 상태값과 일치하는(같은 페이지인) 경우
   */
  function get(): { length: number } | null {
    const checkPoint = globalThis.sessionStorage?.getItem(
      SESSION_STORAGE_CHECKPOINT_KEY,
    )

    if (!checkPoint) return null

    const parsed = JSON.parse(checkPoint) as { length: number }
    if (parsed.length === history.length - 1) {
      return null
    }

    return parsed
  }

  /**
   * 체크포인트로 이동한다.
   * 체크포인트가 없는 경우 아무것도 하지 않는다.
   */
  async function restore(fallback = () => null): Promise<boolean> {
    const checkPoint = get()

    // 만약 체크포인트가 존재하는 경우
    if (checkPoint !== null) {
      const delta = history.length - checkPoint.length

      await navigate(-delta)
      globalThis.sessionStorage?.removeItem(SESSION_STORAGE_CHECKPOINT_KEY)
      return true
    }

    // 가끔은 엣지 케이스로 checkPoint가 존재하지 않는 경우가 있다.
    // 이때는 fallback 코드를 실행한다.
    globalThis.sessionStorage?.removeItem(SESSION_STORAGE_CHECKPOINT_KEY)
    return fallback()
  }
}
/* providers/check-point-provider.tsx */
import { useCheckPoint } from "~/hooks/use-check-point"
import { useEffect, useRef } from "react"
import { useBlocker, useLocation, useNavigate, useOutlet } from "react-router"
import type { Route } from "./+types/index"

export default function StackProvider({ matches }: Route.ComponentProps) {
  const navigate = useNavigate()
  const { get, exist, restore } = useCheckPoint()

  // 현재 뒤로가기가 block 된 이유를 저장한다.
  const blockType = useRef<"checkPoint" | "default" | null>(null)

  // 뒤로가기 action이 발생하는 경우
  // 1. 조건에 따라 blockType을 지정하고
  // 2. 뒤로가기 원래 동작을 무시한다.
  const blocker = useBlocker(
    ({ historyAction, currentLocation, nextLocation }) => {
      switch (historyAction) {
        case "POP": {
          // 체크포인트가 있을 경우, 해당 체크포인트로 이동
          if (get() !== null) {
            blockType.current = "checkPoint"
            return true
          }

          blockType.current = "default"
          return true
        }

        default: {
          return false
        }
      }
    },
  )

  useEffect(
    function processBlocker() {
      void (async () => {
        if (blocker.state === "blocked") {
          switch (blockType.current) {
            // 체크포인트 이벤트가 실행되어야 하는 경우
            case "checkPoint": {
              blockType.current = null
              // 기존 뒤로가기를 무시한다.
              void blocker.reset()
              // 체크포인트로 이동한다.
              void restore()
              return
            }

            // 그 외
            default: {
              blockType.current = null
              // 체크포인트가 존재하는데 뒤로 가는 경우,
              if (exist()) await navigate(-1) // save 과정에서 pushState가 하나 더 생성되었기 때문에 뒤로 1칸 더 이동한다.
              // 기존 뒤로가기 이벤트를 실행한다.
              void blocker.proceed()
              return
            }
          }
        }
      })()
    },
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [blocker.state, matches, navigate, restore],
  )

  return useOutlet()
}

코드의 흐름

페이지별 동작

  • A: 프로세스 진입 전 페이지
  • B: 플로우를 시작하는 페이지
    • C로 넘어가는 무언가가 발동될 경우, save 함수를 호출하여 현재 시점을 저장한다.
  • C: 중간에 발생하는 페이지
    • 그러나 이때부터 뒤로가기 이벤트가 발생할 경우, restore 함수를 호출하여 B로 Stack을 pop할 것이다.
  • D: 결과 페이지
    • 이 페이지에서 뒤로가기 액션을 취할 경우 restore 함수를 호출해야 한다.

실제 코드의 흐름

  • A에 처음으로 진입한다. (history.length === 1)
  • B에서 C로 넘어간다. (2)
    • 이때 save 함수가 실행되며 가상의 stack이 1개 더 추가된다. (3)
  • C에서 D로 넘어간다. (4)
  • D에서 뒤로가기 이벤트가 발생한다. (5)
    • useBlocker hook이 실행된다.
      • get 함수를 호출하여 체크포인트가 존재하는지 확인한다.
      • 존재하기에, blockType을 checkPoint로 지정한다.
    • processBlocker useEffect가 실행된다.
      • 기존 뒤로가기 이벤트를 무시하고
      • restore 함수를 호출한다.
        • B로 돌아간다 (2)
        • 기존 체크포인트를 제거한다.

FAQ

save 함수가 실행되기 전에 pushState를 실행하는 이유는?

기본적으로, 브라우저의 history.length는 현재 페이지의 history.length를 정확하게 가져오지 않기에 pushState를 통해서 앞으로 가기를 제거함으로써 그나마 정확한 history.length를 가져올 수 있게 된다.

왜 이렇게 구현하냐면, 브라우저는 뒤로가기를 했다가 앞으로 가기를 할 수 있기 때문이다. 예시로, A -> (B -> C -> D) -> E처럼 B ~ D 구간에서는 뒤로가기를 막아두지 않은 상태에서 B -> C -> D -> C -> B로 이동하는 경우... 브라우저상의 history.length는 D (4)가 되지만 (C -> D로 돌아갈 수 있음), 실제로는 B (2)이기에 실제와 브라우저상의 환경 불일치가 발생하기 때문이다.

네이티브 기능을 사용하지 않고 이렇게 구현한 이유는?

해당 웹뷰 콘텐츠가 웹으로 오픈될 수 있음을 염두해두어야 했기 때문이다.

지금 와서 생각해보면, RN에서 페이지가 이동될 때 URL을 바탕으로 Stack을 저장하게 하면 정신건강에 더 이로웠을 수도 있다. 하지만 당시에는 이런 판단을 하기에는 시간이 부족했었고, 실제 웹 환경에서도 동일하게 동작해야 하기에 이렇게 구현하기로 판단했었다.

결과적으로는 이게 가장 나은 선택이었던 것 같다. (아닐 수도)

닫으며

세상에서 가장 좋은 코드는 돌아가는 코드라고 하지만, 그래도 썩 만족스럽지는 않은것 같다. 무언가 더 좋은 방법이 있지 않을까? 라는 생각 + 나와 비슷한 사람들이 있을까? 라는 생각으로 이 글을 작성했다.

당신만의 더 좋은 의견이 있다면, hello@nabi.kim 으로 메일을 보내주시면 꼭 사례하도록 하겠습니다 🙇‍♀️