thumbnail
기술아티클
React-Router Deep Dive 3. 주소 맞는 컴포넌트 렌더링하기
react-router가 주소에 맞는 컴포넌트를 렌더링하는 방법을 살펴봅니다.
August 30, 2025

이번 아티클에서는 앞선 분석에 이어서 브라우저의 주소를 변경하였을때 주소와 매치되는 컴포넌트가 렌더링되는 과정을 살펴보겠습니다.

지난 아티클과 마찬가지로 분석에 사용할 코드는 아래와 같습니다.

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>
  )
}

BrowserRouter

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}
    />
  )
}

앞서 살펴보았던 BrowserRouter 컴포넌트입니다. history 객체와 관련된 내용은 앞선 아티클에서 살펴보았으니, 이번 아티클에서는 해당 컴포넌트가 반환하는 Router 컴포넌트부터 살펴보겠습니다.

Router

export function Router({
  basename: basenameProp = "/",
  children = null,
  location: locationProp,
  navigationType = NavigationType.Pop,
  navigator,
  static: staticProp = false,
}: RouterProps): React.ReactElement | null {
  // Router 안에서 또다른 Router 사용 불가.
  invariant(
    !useInRouterContext(),
    `You cannot render a <Router> inside another <Router>.` +
      ` You should never have more than one in your app.`
  )

  let basename = basenameProp.replace(/^\/*/, "/")
  let navigationContext = React.useMemo(
    () => ({
      basename,
      navigator,
      static: staticProp,
      future: {},
    }),
    [basename, navigator, staticProp]
  )

  if (typeof locationProp === "string") {
    locationProp = parsePath(locationProp)
  }

  let {
    pathname = "/",
    search = "",
    hash = "",
    state = null,
    key = "default",
  } = locationProp

  let locationContext = React.useMemo(() => {
    let trailingPathname = stripBasename(pathname, basename)

    if (trailingPathname == null) {
      return null
    }

    return {
      location: {
        pathname: trailingPathname,
        search,
        hash,
        state,
        key,
      },
      navigationType,
    }
  }, [basename, pathname, search, hash, state, key, navigationType])

  warning(
    locationContext != null,
    `<Router basename="${basename}"> is not able to match the URL ` +
      `"${pathname}${search}${hash}" because it does not start with the ` +
      `basename, so the <Router> won't render anything.`
  )

  // pathname이 잘못된 경우 null
  if (locationContext == null) {
    return null
  }

  return (
    <NavigationContext.Provider value={navigationContext}>
      <LocationContext.Provider children={children} value={locationContext} />
    </NavigationContext.Provider>
  )
}

꽤 많은 코드들이 있지만, useMemo를 통해서 navigationlocation객체를 생성한뒤 이를 Provider컴포넌트를 통해 제공하는 역할을 하는것이 전부입니다. 따라서 자식 컴포넌트에서 useNavigationuseLocation을 이용해 navigationlocation를 사용할수 있게 됩니다.

다음으로 LocationContext Provider에서 children을 반환하였으므로, 자식 컴포넌트인 Routes로 이동해보겠습니다.

Routes

export function Routes({
  children,
  location,
}: RoutesProps): React.ReactElement | null {
  return useRoutes(createRoutesFromChildren(children), location)
}

단순히 useRoutes를 호출하고, 이를 반환하는 컴포넌트입니다. createRoutesFromChildren의 실행결과와 이를 인자로 받아 실행되는 useRoutes의 실행과정 두가지를 살펴보겠습니다.

createRoutesFromChildren

export function createRoutesFromChildren(
  children: React.ReactNode,
  parentPath: number[] = []
): RouteObject[] {
  let routes: RouteObject[] = []

  React.Children.forEach(children, (element, index) => {
    // React Element가 아니면 무시합니다. 이 코드로 인해 isActive  && <Route></Route> 와같은 코드가 가능합니다.
    if (!React.isValidElement(element)) {
      return
    }

    // 현재 Route의 경로
    let treePath = [...parentPath, index]

    // <></> <React.Fragment></React.Fragment>인 경우 자식요소를 사용
    if (element.type === React.Fragment) {
      // Transparently support React.Fragment and its children.
      routes.push.apply(
        routes,
        createRoutesFromChildren(element.props.children, treePath)
      )
      return
    }

    // Route 컴포넌트가 아니면 에러 발행
    invariant(
      element.type === Route,
      `[${
        typeof element.type === "string" ? element.type : element.type.name
      }] is not a <Route> component. All component children of <Routes> must be a <Route> or <React.Fragment>`
    )

    // index Route에 자식요소 포함하면 에러 발행
    invariant(
      !element.props.index || !element.props.children,
      "An index route cannot have child routes."
    )

    let route: RouteObject = {
      // 자식요소인경우 id는 3-1-0 과 같이 결정된다.
      id: element.props.id || treePath.join("-"),
      caseSensitive: element.props.caseSensitive,
      element: element.props.element,
      Component: element.props.Component,
      index: element.props.index,
      path: element.props.path,
      loader: element.props.loader,
      action: element.props.action,
      hydrateFallbackElement: element.props.hydrateFallbackElement,
      HydrateFallback: element.props.HydrateFallback,
      errorElement: element.props.errorElement,
      ErrorBoundary: element.props.ErrorBoundary,
      hasErrorBoundary:
        element.props.hasErrorBoundary === true ||
        element.props.ErrorBoundary != null ||
        element.props.errorElement != null,
      shouldRevalidate: element.props.shouldRevalidate,
      handle: element.props.handle,
      lazy: element.props.lazy,
    }

    // 자식요소가 있을경우 재귀적으로 createRoutesFromChildren함수 실행
    if (element.props.children) {
      route.children = createRoutesFromChildren(
        element.props.children,
        treePath
      )
    }

    routes.push(route)
  })

  return routes
}

createRoutesFromChildren함수 이름으로부터 추측할수 있듯이, Routes하위의 Route컴포넌트들을 배열형태의 객체로 변경하는 함수입니다. 이때 Route컴포넌트가 부모 자식 관계로 중첩된 경우 children 프로퍼티에 createRoutesFromChildren함수를 한번더 실행하여 결과를 담습니다.

한가지 더 알아두면 좋을 부분은 id를 구성하는 방식입니다. 기본적으로 우리가 담은 순서대로 구성되지만, 자식요소일 경우 다음레벨에서 순서가 다시 시작됩니다. 예를들어 세번째 요소의 첫번째 자식요소라면 2-0과 같은 번호가 부여되는것입니다.

결과적으로 생성되는 routes는 다음과 같습니다. 이 결과를 잘 기억하면서 useRoutes로 이동해봅시다.

[
  {
    "id": "0",
    "path": null,
    "element": "<Home/>"
  },
  {
    "id": "1",
    "path": "about",
    "element": "<About/>"
  },
  {
    "id": "2",
    "path": "dashboard",
    "element": "<Dashboard/>",
    "children": [
      {
        "id": "2-0",
        "element": "<Summary/>",
        "path": null
      },
      {
        "id": "2-1",
        "element": "<Settings/>",
        "path": "settings"
      }
    ]
  }
]

useRoutes

export function useRoutesImpl(
  routes: RouteObject[],
  locationArg?: Partial<Location> | string,
  dataRouterState?: DataRouter["state"],
  future?: DataRouter["future"]
): React.ReactElement | null {
  // useRoutes는 Router 컴포넌트 내에서만 써야함
  invariant(
    useInRouterContext(),
    `useRoutes() may be used only in the context of a <Router> component.`
  )

  let { navigator, static: isStatic } = React.useContext(NavigationContext)
  let { matches: parentMatches } = React.useContext(RouteContext)
  let routeMatch = parentMatches[parentMatches.length - 1]
  let parentParams = routeMatch ? routeMatch.params : {}
  let parentPathname = routeMatch ? routeMatch.pathname : "/"
  let parentPathnameBase = routeMatch ? routeMatch.pathnameBase : "/"
  let parentRoute = routeMatch && routeMatch.route

  let locationFromContext = useLocation()

  let location = locationFromContext

  let pathname = location.pathname || "/"

  let remainingPathname = pathname

  // matchRoute찾기
  let matches =
    !isStatic &&
    dataRouterState &&
    dataRouterState.matches &&
    dataRouterState.matches.length > 0
      ? (dataRouterState.matches as AgnosticRouteMatch<string, RouteObject>[])
      : matchRoutes(routes, { pathname: remainingPathname })

  // 매치 함수 렌더
  let renderedMatches = _renderMatches(
    matches &&
      matches.map(match =>
        Object.assign({}, match, {
          params: Object.assign({}, parentParams, match.params),
          pathname: joinPaths([
            parentPathnameBase,
            // Re-encode pathnames that were decoded inside matchRoutes
            navigator.encodeLocation
              ? navigator.encodeLocation(match.pathname).pathname
              : match.pathname,
          ]),
          pathnameBase:
            match.pathnameBase === "/"
              ? parentPathnameBase
              : joinPaths([
                  parentPathnameBase,
                  // Re-encode pathnames that were decoded inside matchRoutes
                  navigator.encodeLocation
                    ? navigator.encodeLocation(match.pathnameBase).pathname
                    : match.pathnameBase,
                ]),
        })
      ),
    parentMatches,
    dataRouterState,
    future
  )

  return renderedMatches
}

이 함수에서 핵심적으로 확인해야할 부분은 matches를 만드는 부분과 matches를 렌더링하는 부분입니다. 하나씩 살펴봅시다.

matches만들기

let matches =
  !isStatic &&
  dataRouterState &&
  dataRouterState.matches &&
  dataRouterState.matches.length > 0
    ? (dataRouterState.matches as AgnosticRouteMatch<string, RouteObject>[])
    : matchRoutes(routes, { pathname: remainingPathname })

이전에 dataSource를 채운적은 없으므로 matchRoutes가 실행됩니다. routes는 앞서 만들었던 값이며, pathname에는 현재 locationpathname이 담기게됩니다.

export function matchRoutes<
  RouteObjectType extends AgnosticRouteObject = AgnosticRouteObject
>(
  routes: RouteObjectType[],
  locationArg: Partial<Location> | string,
  basename = "/"
): AgnosticRouteMatch<string, RouteObjectType>[] | null {
  return matchRoutesImpl(routes, locationArg, basename, false)
}

export function matchRoutesImpl<
  RouteObjectType extends AgnosticRouteObject = AgnosticRouteObject
>(
  routes: RouteObjectType[],
  locationArg: Partial<Location> | string,
  basename: string,
  allowPartial: boolean
): AgnosticRouteMatch<string, RouteObjectType>[] | null {
  // 로케이션 파싱
  let location =
    typeof locationArg === "string" ? parsePath(locationArg) : locationArg

  // basename이 "/" 이므로 그대로 pathname
  let pathname = stripBasename(location.pathname || "/", basename)

  if (pathname == null) {
    return null
  }

  // routes에 자식요소가 있을경우 이를 플랫하게 펴주는 로직
  let branches = flattenRoutes(routes)
  // 브랜치의 랭크를 이용해 정렬함
  rankRouteBranches(branches)

  let matches = null
  for (let i = 0; matches == null && i < branches.length; ++i) {
    let decoded = decodePath(pathname)
    matches = matchRouteBranch<string, RouteObjectType>(
      branches[i],
      decoded,
      allowPartial
    )
  }

  return matches
}

가장 먼저 routes를 자식 요소를 같은 레벨 요소로 만드는 flattenRoutes함수의 실행을 분석해보겠습니다.

function flattenRoutes<
  RouteObjectType extends AgnosticRouteObject = AgnosticRouteObject
>(
  routes: RouteObjectType[],
  branches: RouteBranch<RouteObjectType>[] = [],
  parentsMeta: RouteMeta<RouteObjectType>[] = [],
  parentPath = ""
): RouteBranch<RouteObjectType>[] {
  let flattenRoute = (
    route: RouteObjectType,
    index: number,
    relativePath?: string
  ) => {
    let meta: RouteMeta<RouteObjectType> = {
      relativePath:
        relativePath === undefined ? route.path || "" : relativePath,
      caseSensitive: route.caseSensitive === true,
      childrenIndex: index,
      route,
    }

    if (meta.relativePath.startsWith("/")) {
      invariant(
        meta.relativePath.startsWith(parentPath),
        `Absolute route path "${meta.relativePath}" nested under path ` +
          `"${parentPath}" is not valid. An absolute child route path ` +
          `must start with the combined path of all its parent routes.`
      )

      meta.relativePath = meta.relativePath.slice(parentPath.length)
    }

    let path = joinPaths([parentPath, meta.relativePath])
    let routesMeta = parentsMeta.concat(meta)

    // 자식요소의 경우 재귀적으로 flattenRoutes 실행
    if (route.children && route.children.length > 0) {
      invariant(
        // Our types know better, but runtime JS may not!
        // @ts-expect-error
        route.index !== true,
        `Index routes must not have child routes. Please remove ` +
          `all child routes from route path "${path}".`
      )
      flattenRoutes(route.children, branches, routesMeta, path)
    }

    if (route.path == null && !route.index) {
      return
    }

    branches.push({
      path,
      score: computeScore(path, route.index),
      routesMeta,
    })
  }

  // routes 순회
  routes.forEach((route, index) => {
    if (route.path === "" || !route.path?.includes("?")) {
      flattenRoute(route, index)
    } else {
      for (let exploded of explodeOptionalSegments(route.path)) {
        flattenRoute(route, index, exploded)
      }
    }
  })

  return branches
}

routes요소에 대해 순회하면서 branchespath, score, routesMeta로 구성된 branch를 넣습니다. 이때 자식요소는 재귀적으로 돌면서 branches에 넣어주고있음을 확인할수 있습니다. 여기서 한가지 더 보면 좋을것은 branch의 점수를 결정하는 computeScore입니다.

const paramRe = /^:[\w-]+$/
const dynamicSegmentValue = 3
const indexRouteValue = 2
const emptySegmentValue = 1
const staticSegmentValue = 10
const splatPenalty = -2
const isSplat = (s: string) => s === "*"

function computeScore(path: string, index: boolean | undefined): number {
  let segments = path.split("/")
  // 1. 길이에 따른 점수
  let initialScore = segments.length

  // 2. "*"유무에 따른 점수
  if (segments.some(isSplat)) {
    initialScore += splatPenalty
  }

  // 3. index 라우트 유무에 따른 점수
  if (index) {
    initialScore += indexRouteValue
  }

  // 4. 세그먼트를 순회하면서 점수 적용
  return segments
    .filter(s => !isSplat(s))
    .reduce(
      (score, segment) =>
        score +
        (paramRe.test(segment)
          ? dynamicSegmentValue
          : segment === ""
          ? emptySegmentValue
          : staticSegmentValue),
      initialScore
    )
}
  1. initialScore를 살펴보면, /로 분리되는 세그먼트 사이즈를 더합니다. 즉 길이가 길수록 점수가 높습니다.

  2. path에 *를 포함하면 점수를 -2점 빼줍니다. 참고로 *는 모든 문자에 매치되는것을 의미합니다.

  3. index 라우트 인경우 2점을 더해줍니다.

  4. 세그먼트를 순회하면서 점수를 적용하는데, 이미 적용한 *는 점수에서 제외합니다. 그리고 동적 경로(:path)는 3점을 더하고, 빈 경로("")는 1점을 더하고, 기본 경로는 10점을 더하게 됩니다.

이제까지 살펴본 과정을 일반화 해보면, 점수가 낮을수록 일반적이고, 점수가 높을수록 구체적인 path입니다. 예를들어 /dashboard/home의 경우 점수를 더해보면 22점이고, /dashboard/:path의 경우 점수를 더해보면 15점입니다. 전자의 경우 보다 후자의 경우 동적 경로 때문에 매치할수있는 라우트가 훨씬 많습니다.

따라서 점수가 높은 라우트와 먼저 비교해보고, 이와 일치한다면 하위 점수의 라우트와는 비교하지 않고 그대로 해당 컴포넌트를 반환하게됩니다. 아래 함수는 이를위해 점수가 높은순으로 브랜치를 재배치하는 함수입니다.

function rankRouteBranches(branches: RouteBranch[]): void {
  branches.sort((a, b) =>
    a.score !== b.score
      ? b.score - a.score // Higher score first
      : compareIndexes(
          a.routesMeta.map(meta => meta.childrenIndex),
          b.routesMeta.map(meta => meta.childrenIndex)
        )
  )
}

다음으로는 matchRoutesImpl함수의 마지막 부분을 살펴보겠습니다.

let matches = null
for (let i = 0; matches == null && i < branches.length; ++i) {
  // Incoming pathnames are generally encoded from either window.location
  // or from router.navigate, but we want to match against the unencoded
  // paths in the route definitions.  Memory router locations won't be
  // encoded here but there also shouldn't be anything to decode so this
  // should be a safe operation.  This avoids needing matchRoutes to be
  // history-aware.
  let decoded = decodePath(pathname)
  matches = matchRouteBranch<string, RouteObjectType>(
    branches[i],
    decoded,
    allowPartial
  )
}

function matchRouteBranch<
  ParamKey extends string = string,
  RouteObjectType extends AgnosticRouteObject = AgnosticRouteObject
>(
  branch: RouteBranch<RouteObjectType>,
  pathname: string,
  allowPartial = false
): AgnosticRouteMatch<ParamKey, RouteObjectType>[] | null {
  let { routesMeta } = branch

  let matchedParams = {}
  let matchedPathname = "/"
  let matches: AgnosticRouteMatch<ParamKey, RouteObjectType>[] = []
  for (let i = 0; i < routesMeta.length; ++i) {
    let meta = routesMeta[i]
    let end = i === routesMeta.length - 1
    let remainingPathname =
      matchedPathname === "/"
        ? pathname
        : pathname.slice(matchedPathname.length) || "/"
    let match = matchPath(
      { path: meta.relativePath, caseSensitive: meta.caseSensitive, end },
      remainingPathname
    )

    let route = meta.route

    if (
      !match &&
      end &&
      allowPartial &&
      !routesMeta[routesMeta.length - 1].route.index
    ) {
      match = matchPath(
        {
          path: meta.relativePath,
          caseSensitive: meta.caseSensitive,
          end: false,
        },
        remainingPathname
      )
    }

    if (!match) {
      return null
    }

    Object.assign(matchedParams, match.params)

    matches.push({
      params: matchedParams as Params<ParamKey>,
      pathname: joinPaths([matchedPathname, match.pathname]),
      pathnameBase: normalizePathname(
        joinPaths([matchedPathname, match.pathnameBase])
      ),
      route,
    })

    if (match.pathnameBase !== "/") {
      matchedPathname = joinPaths([matchedPathname, match.pathnameBase])
    }
  }

  return matches
}

여기서는 모든 브랜치를 순회하면서 pathname과 비교하여 일치하는 대상을 찾게됩니다. 일치하는지 여부는 matchPath 함수를 실행하여 알게됩니다.

이때 branch 내부에 있는 routesMeta를 순회하게되는데 이를 순회하는 이유는 중첩된 라우팅 때문입니다. 예를들어 /about인 경우 자식요소가 없기 때문에 순회가 의미 없지만, dashboard/setting인 경우 부모요소와 자식요소가 있기 때문에 두 요소의 정보가 모두 필요합니다. 구체적으로 정보를 어떻게 사용하는지는 아래 코드를 살펴보면 이해할수 있습니다.

matches렌더링

export function _renderMatches(
  matches: RouteMatch[] | null,
  parentMatches: RouteMatch[] = [],
  dataRouterState: DataRouter["state"] | null = null,
  future: DataRouter["future"] | null = null
): React.ReactElement | null {
  let renderedMatches = matches
  let errors = dataRouterState?.errors

  return renderedMatches.reduceRight((outlet, match, index) => {
    // Only data routers handle errors/fallbacks
    let error: any
    let shouldRenderHydrateFallback = false
    let errorElement: React.ReactNode | null = null
    let hydrateFallbackElement: React.ReactNode | null = null
    if (dataRouterState) {
      error = errors && match.route.id ? errors[match.route.id] : undefined
      errorElement = match.route.errorElement || defaultErrorElement

      if (renderFallback) {
        if (fallbackIndex < 0 && index === 0) {
          warningOnce(
            "route-fallback",
            false,
            "No `HydrateFallback` element provided to render during initial hydration"
          )
          shouldRenderHydrateFallback = true
          hydrateFallbackElement = null
        } else if (fallbackIndex === index) {
          shouldRenderHydrateFallback = true
          hydrateFallbackElement = match.route.hydrateFallbackElement || null
        }
      }
    }

    let matches = parentMatches.concat(renderedMatches.slice(0, index + 1))
    let getChildren = () => {
      let children: React.ReactNode
      if (error) {
        children = errorElement
      } else if (shouldRenderHydrateFallback) {
        children = hydrateFallbackElement
      } else if (match.route.Component) {
        children = <match.route.Component />
      } else if (match.route.element) {
        // route의 element 렌더링
        children = match.route.element
      } else {
        children = outlet
      }
      return (
        <RenderedRoute
          match={match}
          routeContext={{
            outlet,
            matches,
            isDataRoute: dataRouterState != null,
          }}
          children={children}
        />
      )
    }
    // Only wrap in an error boundary within data router usages when we have an
    // ErrorBoundary/errorElement on this route.  Otherwise let it bubble up to
    // an ancestor ErrorBoundary/errorElement
    return dataRouterState &&
      (match.route.ErrorBoundary || match.route.errorElement || index === 0) ? (
      <RenderErrorBoundary
        location={dataRouterState.location}
        revalidation={dataRouterState.revalidation}
        component={errorElement}
        error={error}
        children={getChildren()}
        routeContext={{ outlet: null, matches, isDataRoute: true }}
      />
    ) : (
      getChildren()
    )
  }, null as React.ReactElement | null)
}

중요한 코드만 남겨두었습니다. 핵심은 renderedMatches.reduceRight함수입니다. 일반적인 reduce함수와 달리 마지막 요소가 시작점이 됩니다. /about과 같이 자식요소가 없는 경우, renderedMatches가 하나이기 때문에, children = match.route.element 이후 RenderedRoute 컴포넌트를 반환하여 우리가 Routes에 넣은 element를 렌더링하게됩니다.

반면 /dashboard/settings 와 같이 부모 요소가 존재하는 경우 RenderedRoute 컴포넌트를 통해 route를 중첩해 렌더링하게됩니다. 이때 routeContextoutlet에 이전 컴포넌트가 포함되기 때문에, 자식요소가 부모요소의 Outlet컴포넌트를 통해 렌더링되게 됩니다.

만약 중첩 라우트에서 Outlet컴포넌트의 사용이 생소하시다면 공식문서의 Nested Routes 부분을 확인해주세요

마치며

이번 아티클에서는 React-router가 주소에 맞는 컴포넌트를 렌더링 하는 방식을 살펴보았습니다. 추가적으로 다른 모드들에서 라우트가 렌더링되는 방식도 살펴보시면 라이브러리의 동작을 이해하는데 많은 도움이 될것입니다.