Next.js에서 세션 히스토리를 상태로 사용하기

twp·2023년 11월 18일
2

npm: https://www.npmjs.com/package/next-navigation-entries
github: https://github.com/yes-xodnd/next-navigation-entries/tree/main#readme

disclaimer:
아래 글과 코드는 Next.js 12버전 이상 14버전 미만, pages router 사용을 가정하고 작성되었습니다.

현재 회사 업무의 대부분이 하이브리드 앱의 웹뷰 화면을 개발하는 일이다. 웹뷰를 개발하다보면 "네이티브 앱같은 경험"을 주는 게 중요한 목표 중의 하나가 된다. 많은 문제들이 라이브러리/프레임워크로 해결되는데, 히스토리를 다루는 것은 필수적이면서도 왠지 어렵다는 느낌이 드는 영역인 것 같다.

CSR 기술이 의존하는 History API는 오래된 API이고, 웹 기술은 "네이티브 앱같은 경험"을 위해 개발된 것이 아니었으니 당연한 일이긴 하다. 최근에는 Navigation API, view transition API 같은 것이 나오기는 했지만 갈 길이 멀다.

개발 배경

앱에서 특정 플로우가 완료되었을 때, 몇 단계 이전 경로로 이동해야 하는 요구사항이 있었다. 각 플로우마다 거쳐야 하는 경로의 갯수가 다르고, 시작 지점이 달랐기 때문에 history.go()의 델타값을 알아내기 어렵다는 문제가 있었다.

해결하고자 하는 문제의 핵심을 정의하자면 다음과 같다.

세션 히스토리에 있는 특정 항목과 현재 위치와의 거리를 확인할 수 있어야 한다

이 문제를 해결하고자 코드를 살펴보고 라이브러리를 개발한 경험을 정리해봤다.

next/router 살펴보기

next/router의 기본 구조

next router는 다른 라우터들과 마찬가지로 로우 레벨에서는 History API를 사용해 구현되어있다.

코드를 아주 단순하게 표현하자면 아래와 같다.

type HistoryMethod = 'replaceState' | 'pushState'

class Router {
  changeState(method: HistoryMethod, url: string) {
    window.history[method]({ url })
  }
  
  push(url: string) {
  	this.changeState('pushState', url)
  }
  
  replace(url: string) {
    this.changeState('replaceState', url)
  }
  
  reload() {
    window.location.reload();
  }
  
  back() {
    window.history.back()
  }
  
  forward() {
    window.history.forward()
  }
}

history.state.idx (deprecated)

next router는 경로 이동 시 세션 히스토리 스택을 구분하기 위한 key로서 history.state.idx를 사용한다.

주의: 14버전부터는 idx가 아니라 난수로 생성된 key를 사용합니다.
Pull Request #36861

type HistoryMethod = 'replaceState' | 'pushState'

class Router {
  private _idx: number = 0
  
  changeState(method: HistoryMethod, url: string) {
    const nextIdx = method === 'pushState' 
      ? this._idx + 1
      : this._idx
    
    window.history[method]({ url, idx })
  }
}

해당 값은 현재 경로가 Next.js 앱의 세션 히스토리 스택에서 몇 번째 항목인지 확인할 수 있는 역할도 할 수 있다. 여기서 문제가 해결의 실마리를 잡나 싶었지만, 새로고침을 하면 Router 인스턴스가 persist되지 않아 idx값도 다시 0으로 초기화된다는 치명적인 단점이 있었다.

그래서 경로가 변경될 때마다 idx와 경로를 확인해 세션 히스토리를 persist해보기로 했다.

Router.events

next router는 라이프사이클마다 이벤트를 발생시키고, 각 이벤트마다 리스너를 부착할 수 있는 기능을 제공한다. (문서) 아쉽게도, 어떤 동작(push, replace 등)으로 인해 route change가 발생했는지 알려주지는 않는다.

경로 변경이 완료되면 발생하는 routeChangeComplete를 이용해서 어떤 동작인지 알아내보기로 했다.

구현

구현하고자 하는 요구사항은 다음과 같다.

  • Next.js 앱 내의 세션 히스토리 스택을 React 상태로 읽어올 수 있다.
  • 세션 히스토리 스택이 변경되면 React 상태도 업데이트 된다.

context

위의 요구사항을 만족하기 위해, React Context API를 사용하기로 했다. 단순한 구현을 위해, 히스토리 스택의 엔트리는 string 타입으로 URL만 저장한다.

import { createContext, useContext, propsWithChildren } from "react"

type Entry = string

const context = createContext<Entry[]>([])

const useNavigationEntries = () => {
  return useContext(context)
}

const NavigationEntriesProvider = ({ children }: PropsWithChildren) => {
  const [entries, setEntries] = useState<Entry[]>([])
  
  return <context.Provider value={entries}>{children}</context.Provider>
}

persister

persister를 구현하고, entries 상태가 초기화될 때 persister가 저장한 값을 참조할 수 있도록 한다.

type Persister<T> = {
  save(data: T): void;
  restore(): T | void;
}

const NavigationEntriesProvider = ({ children }: PropsWithChildren) => {
  const persister = useRef<Persister<Entry[]>>(createPersister())
  const [entries, setEntries] = useState<Entry[]>(persister.current.restore() || [])
  // ...
}

Router.events

Router.events를 통해 경로 변경이 완료될 때마다 실행되는 handleRouteChange() 리스너를 등록한다. handleRouteChange()는 기존 엔트리와 history.state를 이용해 경로 변경 타입을 구분해 엔트리를 업데이트하고 persist한다.

import { useRouter } from "next/router"

//...

export const NavigationEntriesProvider = ({ children }: PropsWithChildren) => {
  const router = useRouter()
  const persister = useRef<Persister<Entry[]>>(createPersister())
  const [entries, setEntries] = useState<Entry[]>(persister.restore() || [])

  useEffect(() => {
    const handleRouteChange = () => {
      setEntries(entries => {
        const nextEntries = getNextEntries(entries, history.state)
        persister.save(nextEntries)
        
        return nextEntries
      })
    }

    router.events.on("routeChangeComplete", handleRouteChange)
    
    return () => {
      router.events.off("routeChangeComplete", handleRouteChange)
    }
  }, [router.events, setEntries])
  // ...
}

getNextEntries() 함수는 경로변경 타입을 구분하는 함수엔트리의 다음 상태를 구하는 함수로 분리해 구현되어 있다.

한계와 개선방향

중복 경로

현재 구현된 코드로는 히스토리 스택에 중복된 경로가 있을 경우, 경로변경 타입을 구분하는 것이 불가능하다. (A => B => A 이동 후 새로고침 시 뒤로가기 / 새로고침 구분 불가) 중복된 경로가 없는 웹뷰 개발 시 사용하기 위해 개발했기 때문에 문제는 없지만, 라이브러리로서 큰 한계가 있다고 할 수 있다. 뒤로가기 동작은 popState 이벤트를 이용해 구분하는 것으로 개선할 필요가 있다.

Next.js 14버전 대응

14버전에서는 idx가 key로 변경되므로 정상적으로 동작할 수 없다. history.state.key를 이용해 경로변경 타입을 구분할 수 있도록 개선이 필요하다.

마무리

오랜만에 오픈소스 코드를 뜯어볼 수 있는 기회였고, 첫 npm 라이브러리 배포이기도 해서 오래 기억에 남을 것 같다.

Next.js에서 확실하고 편리한 API가 제공되면 좋을 것 같지만... Apps Router에서는 Router.events가 아예 사라져서, 추구하는 방향이 아닌 것 같다는 생각이 든다. 아예 브라우저 차원에서 Navigation API의 호환성 문제가 해결되는 것이 가장 좋겠지만, 당분간은 이 코드를 유지보수하면서 사용해봐야겠다.

profile
웹 프론트엔드 개발자

0개의 댓글