thumbnail
기술아티클
React-Router Deep Dive 2. 최신의 history 객체 보장하기
react-router의 선언적 모드의 동작 방식을 살펴봅니다.
April 5, 2025

앞으로 두개의 아티클을 통해 주소창의 주소를 직접 변경하거나, history.push와 같은 메서드를 사용해 브라우저의 주소를 변경하였을때 주소와 매치되는 컴포넌트가 렌더링되는 과정을 살펴보겠습니다. 이번 아티클에서는 우리가 사용하는 history 객체가 어떻게 최신임을 보장받을수 있는지 살펴볼것입니다.

분석에 사용할 코드는 아래와 같습니다.

import { BrowserRouter, Routes, Route } from "react-router"

const App = () => {
  return (
    <BrowserRouter>
      <Routes>
        <Route index element={<Home />} />
        <Route path="about" element={<About />} />
        {/* multi level path */}
        <Route path="dashboard" element={<Dashboard />}>
          <Route index element={<Summary />} />
          <Route path="settings" element={<Settings />} />
        </Route>
      </Routes>
    </BrowserRouter>
  )
}

BroserRouter

가장 처음 실행하게 되는 컴포넌트는 BroserRouter 입니다. 이 컴포넌트는 이름에서 알 수 있듯이 브라우저를 위한 설정을 한뒤 Router컴포넌트를 반환하는 컴포넌트입니다.

export function BrowserRouter({
  basename,
  children,
  window,
}: BrowserRouterProps) {
  let historyRef = React.useRef<BrowserHistory>()
  if (historyRef.current == null) {
    // createBrowserHistory를 이용해 history 생성
    historyRef.current = createBrowserHistory({ window, v5Compat: true })
  }
  let history = historyRef.current

  let [state, setStateImpl] = React.useState({
    action: history.action,
    location: history.location,
  })

  //  React.startTransition으로 setState함수를 한번 래핑
  let setState = React.useCallback(
    (newState: { action: NavigationType; location: Location }) => {
      React.startTransition(() => setStateImpl(newState))
    },
    [setStateImpl]
  )

  // history.listen 함수를 호출해 history 구독
  React.useLayoutEffect(() => history.listen(setState), [history, setState])

  return (
    <Router
      basename={basename}
      children={children}
      location={state.location}
      navigationType={state.action}
      navigator={history}
    />
  )
}

코드를 살펴보면 createBrowserHistory함수를 이용해 history객체를 생성한뒤 setState를 콜백함수로 넣어 구독하고 있습니다. 따라서history객체가 변경되면 컴포넌트가 리렌더링되어 변경된 history가 하위 컴포넌트에 반영되므로 우리가 history에서 꺼내어 사용하는 주소에 관한 정보들(pathname, hash, search등)은 항상 최신값을 유지할 수 있게 됩니다.

그렇다면 어떤 행위가 history객체를 변경하는 행위이고 어떤 과정을 거쳐 객체 변경이 컴포넌트에 통지될까요? 이를 알아보기 위해 history객체를 생성하는 createBrowserHistory함수를 살펴보겠습니다.

setState를 사용할때, useState에서 바로 꺼내어 사용하지 않고, startTransition으로 한번 래핑하여 사용하고 있습니다. 이유는 간단한데, 유저의 입력이벤트를 막으면서 까지 이동한 페이지의 렌더링을 하게되면 유저경험을 해칠수 있기 때문입니다. 따라서 startTransition를 사용하여 이동한 페이지의 렌더링 우선순위를 낮춰주고 있습니다. 자세한 내용은 링크를 참고해보세요.

createBrowserHistory

export function createBrowserHistory(
  options: BrowserHistoryOptions = {}
): BrowserHistory {
  function createBrowserLocation(
    window: Window,
    globalHistory: Window["history"]
  ) {
    let { pathname, search, hash } = window.location
    return createLocation(
      "",
      { pathname, search, hash },
      // state defaults to `null` because `window.history.state` does
      (globalHistory.state && globalHistory.state.usr) || null,
      (globalHistory.state && globalHistory.state.key) || "default"
    )
  }

  function createBrowserHref(window: Window, to: To) {
    return typeof to === "string" ? to : createPath(to)
  }

  return getUrlBasedHistory(
    createBrowserLocation,
    createBrowserHref,
    null,
    options
  )
}

여기서 핵심은 history객체를 반환하는 getUrlBasedHistory함수입니다. 하지만 이 함수 내부를 분석하기전에 createBrowserLocation함수와 createBrowserHref함수를 간단히 살펴보겠습니다.

createBrowserLocation

함수명 그대로 브라우저용 location 객체를 만드는것입니다.

function createBrowserLocation(
  window: Window,
  globalHistory: Window["history"]
) {
  let { pathname, search, hash } = window.location
  return createLocation(
    "",
    { pathname, search, hash },
    (globalHistory.state && globalHistory.state.usr) || null,
    (globalHistory.state && globalHistory.state.key) || "default"
  )
}

export function createLocation(
  current: string | Location,
  to: To,
  state: any = null,
  key?: string
): Readonly<Location> {
  let location: Readonly<Location> = {
    pathname: typeof current === "string" ? current : current.pathname,
    search: "",
    hash: "",
    ...(typeof to === "string" ? parsePath(to) : to),
    state,
    key: (to && (to as Location).key) || key || createKey(),
  }
  return location
}

pathname, search, hash등을 가진 location 객체를 생성합니다. 이때 to파라미터로 넘어온 객체는 실제 window.location에서 꺼내온 pathname, search, hash입니다.

createBrowserHref

함수명 그대로 브라우저용 href를 만드는것입니다. pathname, search, hash가 합쳐진 값을 반환합니다.

function createBrowserHref(window: Window, to: To) {
  return typeof to === "string" ? to : createPath(to)
}

export function createPath({
  pathname = "/",
  search = "",
  hash = "",
}: Partial<Path>) {
  if (search && search !== "?")
    pathname += search.charAt(0) === "?" ? search : "?" + search
  if (hash && hash !== "#")
    pathname += hash.charAt(0) === "#" ? hash : "#" + hash
  return pathname
}

pathname 뒤에 searchhash를 순서대로 붙여서 반환하게됩니다.

getUrlBasedHistory

function getUrlBasedHistory(
  getLocation: (window: Window, globalHistory: Window["history"]) => Location,
  createHref: (window: Window, to: To) => string,
  validateLocation: ((location: Location, to: To) => void) | null,
  options: UrlHistoryOptions = {}
): UrlHistory {
  let { window = document.defaultView!, v5Compat = false } = options
  let globalHistory = window.history
  let action = Action.Pop
  let listener: Listener | null = null

  let index = getIndex()!
  if (index == null) {
    index = 0
    globalHistory.replaceState({ ...globalHistory.state, idx: index }, "")
  }

  function getIndex(): number {
    let state = globalHistory.state || { idx: null }
    return state.idx
  }

  function handlePop() {
    action = Action.Pop
    let nextIndex = getIndex()
    let delta = nextIndex == null ? null : nextIndex - index
    index = nextIndex
    if (listener) {
      listener({ action, location: history.location, delta })
    }
  }

  function push(to: To, state?: any) {
    action = Action.Push
    let location = createLocation(history.location, to, state)
    if (validateLocation) validateLocation(location, to)

    index = getIndex() + 1
    let historyState = getHistoryState(location, index)
    let url = history.createHref(location)

    globalHistory.pushState(historyState, "", url)

    if (v5Compat && listener) {
      listener({ action, location: history.location, delta: 1 })
    }
  }

  function replace(to: To, state?: any) {
    action = Action.Replace
    let location = createLocation(history.location, to, state)
    if (validateLocation) validateLocation(location, to)

    index = getIndex()
    let historyState = getHistoryState(location, index)
    let url = history.createHref(location)
    globalHistory.replaceState(historyState, "", url)

    if (v5Compat && listener) {
      listener({ action, location: history.location, delta: 0 })
    }
  }

  function createURL(to: To): URL {
    let base =
      window.location.origin !== "null"
        ? window.location.origin
        : window.location.href

    let href = typeof to === "string" ? to : createPath(to)
    href = href.replace(/ $/, "%20")
    invariant(
      base,
      `No window.location.(origin|href) available to create URL for href: ${href}`
    )
    return new URL(href, base)
  }

  let history: History = {
    get action() {
      return action
    },
    get location() {
      return getLocation(window, globalHistory)
    },
    listen(fn: Listener) {
      if (listener) {
        throw new Error("A history only accepts one active listener")
      }
      window.addEventListener(PopStateEventType, handlePop)
      listener = fn

      return () => {
        window.removeEventListener(PopStateEventType, handlePop)
        listener = null
      }
    },
    createHref(to) {
      return createHref(window, to)
    },
    createURL,
    encodeLocation(to) {
      // Encode a Location the same way window.location would
      let url = createURL(to)
      return {
        pathname: url.pathname,
        search: url.search,
        hash: url.hash,
      }
    },
    push,
    replace,
    go(n) {
      return globalHistory.go(n)
    },
  }

  return history
}

이 함수는 앞서 말씀드린대로 history객체를 반환하는 함수입니다. 반환하는 객체를 살펴보면 listen이라는 history객체를 구독할수 있도록 해주는 함수도 있고, 우리가 주소를 변경할때 사용하는 push, replace함수도 있습니다.

listen

function listen(fn: Listener) {
  // history의 리스너는 하나만 등록 가능함.
  if (listener) {
    throw new Error("A history only accepts one active listener")
  }
  window.addEventListener(PopStateEventType, handlePop)
  listener = fn

  return () => {
    window.removeEventListener(PopStateEventType, handlePop)
    listener = null
  }
}

먼저 listen 함수를 살펴봅시다. 인자로 넣은 listener를 등록하는 역할 이외에도 popState이벤트를 구독하는 역할도 하고있습니다. popState이벤트는 브라우저의 뒤로가기 버튼을 누를때 트리거 되므로, 이를 통해 뒤로가기로 인한 주소 변경을 감지할수 있게 됩니다.

주소 변경

뒤로가기 버튼을 눌러 실행되는 handlePop함수나, 주소를 변경하기위해 사용하는 push,replace함수를 살펴보면, 동작이 거의 유사함을 알 수 있습니다.

function handlePop() {
  action = Action.Pop
  let nextIndex = getIndex()
  let delta = nextIndex == null ? null : nextIndex - index
  index = nextIndex
  if (listener) {
    listener({ action, location: history.location, delta })
  }
}

function push(to: To, state?: any) {
  action = Action.Push
  let location = createLocation(history.location, to, state)
  if (validateLocation) validateLocation(location, to)

  index = getIndex() + 1
  let historyState = getHistoryState(location, index)
  let url = history.createHref(location)

  globalHistory.pushState(historyState, "", url)

  if (v5Compat && listener) {
    listener({ action, location: history.location, delta: 1 })
  }
}

function replace(to: To, state?: any) {
  action = Action.Replace
  let location = createLocation(history.location, to, state)
  if (validateLocation) validateLocation(location, to)

  index = getIndex()
  let historyState = getHistoryState(location, index)
  let url = history.createHref(location)
  globalHistory.replaceState(historyState, "", url)

  if (v5Compat && listener) {
    listener({ action, location: history.location, delta: 0 })
  }
}

위 세가지 함수(handlePop, push, replace)는 공통적으로 다음 동작을 수행합니다.

  1. 액션 타입 변경
  2. push 또는 replace는 pushState또는 replaceState함수를 호출하여 주소 변경
  3. index 변경
  4. action, location, delta 정보를 포함하여 listener함수 실행

이러한 과정을 통해 주소가 변경되었을때, React 컴포넌트가 주소에 관련된 정보와 함께 history객체의 변경사실을 통지받을 수 있음을 알 수 있습니다. 따라서 이후 살펴볼 컴포넌트나, 내부적으로 사용자가 작성한 컴포넌트에서 사용하는 history객체는 항상 최신임을 보장받을수 있게 됩니다.

마치며

이번 아티클에서는 React-router가 어떻게 최신 history 객체를 보장하는지 살펴보았습니다. 다음 아티클에서는 라우트에 맞는 컴포넌트를 렌더링하는 방법을 살펴보겠습니다.

참고자료

startTransition