Skip to content
This page has been auto-translated and may contain errors.View in English

중첩 라우트와 레이아웃

라우팅에서 다룬 것은 단순한 형태였습니다. 페이지마다 Route 하나씩, 각각의 pathelement를 가진 구조였습니다. 실제 앱은 곧 이 구조를 벗어나게 됩니다. 왜냐하면 URL이 중첩되기 때문입니다. /host/income 같은 경로는 두 부분으로 나뉘는데, 보통 더 깊은 경로의 페이지는 더 얕은 경로의 페이지 대부분을 유지합니다. 같은 네비게이션 바, 같은 섹션 탭이 있고, 아래에만 새로운 내용이 바뀝니다. 중첩 라우트는 이를 React Router로 표현하는 방식입니다. Route 요소를 서로 중첩시키면, 라우터는 일치하는 전체 체인을 한 번에 렌더링합니다. 부모가 자식을 감싸는 형태입니다.

그래서 "중첩 라우트"는 두 가지 관련된 의미를 가집니다. URL 세그먼트가 중첩되고, UI도 함께 중첩됩니다. 페이지의 일부는 더 깊이 네비게이션하면서도 유지되고, 내부 부분만 바뀝니다. 두 번째 특징이 진정한 가치입니다.

Outlet으로 라우트 중첩하고 자식 렌더링하기

Route는 자체 종료 요소로 작동할 수도 있고, 자식을 가질 수도 있습니다. 자식은 다른 Route 요소여야 합니다. 자식 라우트가 일치하면, React Router는 부모의 element를 먼저 렌더링하고, 부모는 Outlet을 렌더링해서 자식의 element가 어디에 나타날지 결정합니다.

jsx
import { BrowserRouter, Routes, Route } from 'react-router-dom'

function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route element={<Layout />}>
          <Route path="/" element={<Home />} />
          <Route path="about" element={<About />} />
          <Route path="vans" element={<Vans />} />
        </Route>
      </Routes>
    </BrowserRouter>
  )
}
jsx
import { Outlet } from 'react-router-dom'
import Header from './Header'

export default function Layout() {
  return (
    <>
      <Header />
      <Outlet />
    </>
  )
}

Outlet은 부모의 element에서 일치하는 자식이 렌더링될 위치에 구멍을 뚫어줍니다. 컴포지션에서 children prop이 하는 역할과 같습니다. 부모는 공유되는 구조를 렌더링하고 변동하는 부분이 들어갈 자리를 표시합니다. 다른 점은 React Router가 그 구멍을 채우는데, 현재 URL과 일치하는 자식 라우트를 선택한다는 것입니다. Outlet 없으면 부모의 element가 페이지를 차지하고 일치하는 자식은 어디도 렌더링되지 않는데, 중첩 페이지가 빈 화면으로 나타날 때 먼저 확인해야 할 것입니다.

위의 외부 라우트는 **레이아웃 라우트**입니다. element는 있고 path는 없습니다. 경로 없는 라우트는 자식이 일치하는 모든 URL과 일치하므로, Layout이 모든 페이지를 감싼다는 뜻입니다. 바로 헤더와 푸터 같은 사이트 전체 UI가 들어갈 자리입니다. Routes 옆에 렌더링된 헤더도 같은 일을 하지만, 앱에 정확히 하나의 레이아웃만 있을 때만 가능합니다. 레이아웃 라우트는 /host 같은 섹션이 아래 페이지를 건드리지 않으면서 다른 UI로 바뀔 수 있게 합니다. 피해야 할 선택지는 모든 페이지 컴포넌트 안에 헤더를 반복하는 것입니다.

경로를 가진 레이아웃

레이아웃 라우트는 path도 가질 수 있는데, 이렇게 하면 공유되는 UI의 범위가 앱의 한 섹션으로 제한됩니다. 과정의 VanLife 프로젝트에는 자체 보조 네비게이션을 가진 /host 섹션이 있으므로, 호스트 페이지는 첫 번째 레이아웃 안에 중첩된 두 번째 레이아웃을 얻습니다.

jsx
<Route element={<Layout />}>
  <Route path="/" element={<Home />} />
  <Route path="about" element={<About />} />
  <Route path="host" element={<HostLayout />}>
    <Route path="income" element={<Income />} />
    <Route path="reviews" element={<Reviews />} />
  </Route>
</Route>

HostLayout은 호스트 네비게이션과 Outlet을 렌더링하므로, /host/income에서 라우터는 Layout, 그 outlet에 HostLayout, 호스트 레이아웃의 outlet에 Income을 렌더링합니다. 세 개의 라우트가 동시에 일치하고, 각 부모는 다음 것을 감쌉니다. 디자인이 요구하는 만큼 깊이 레이아웃을 쌓을 수 있습니다.

인덱스 라우트

위 설정에 한 가지 빈틈이 있습니다. HostLayout/host에서 렌더링되지만, 그 outlet에 보여줄 것이 없습니다. 모든 자식이 경로에 세그먼트를 추가하기 때문입니다. 해결책은 인덱스 라우트입니다. path 대신 index prop을 사용하면, 그 자식은 부모의 outlet에서 부모 자신의 경로에 렌더링됩니다.

jsx
<Route index element={<Dashboard />} />

이제 /host는 호스트 네비게이션을 보여주고 그 안에 대시보드를 표시하고, /host/income은 대시보드를 소득 페이지로 바꿉니다. index를 "기본 자식"으로 생각하면 됩니다. 더 깊은 세그먼트가 추가되기 전에 outlet에 속할 것입니다. 최상위 레벨에서도 작동하는데, <Route index element={<Home />} />은 루트 레이아웃 안의 홈 페이지이고, 위 예제에서 path="/"로 쓴 것과 같은 라우트입니다.

상대 경로와 상대 링크

위의 자식 경로를 보세요. income에는 슬래시가 없습니다. 슬래시로 시작하는 경로는 절대 경로이고, 사이트의 루트에서 측정됩니다. 슬래시 없는 경로는 부모 라우트에 상대적이므로, 중첩된 income/host/income을 의미합니다. 이렇게 하면 깊은 라우트 트리를 유지하기 쉬워집니다. host를 한 곳에서 admin으로 이름을 바꾸면 모든 하위 경로가 따라갑니다.

링크도 같은 방식입니다. 라우트된 element 안에서 렌더링된 Link는 상대 경로를 그것을 렌더링한 라우트에 대해 해석하므로, HostLayout 안의 네비게이션은 완전히 /host 접두사를 빼버릴 수 있습니다.

jsx
import { Link, Outlet } from 'react-router-dom'

export default function HostLayout() {
  return (
    <>
      <nav>
        <Link to=".">Dashboard</Link>
        <Link to="income">Income</Link>
        <Link to="reviews">Reviews</Link>
      </nav>
      <Outlet />
    </>
  )
}

두 개의 특수한 값은 파일 시스템 경로에서 의미를 가져옵니다. to="." 는 현재 라우트로 링크하므로, 대시보드 링크가 하드코딩하지 않고도 /host 자체를 목표로 합니다. to=".." 는 위로 올라가는데, "목록으로 돌아가기" 링크에 유용합니다. 한 가지 미묘한 점이 있는데, URL 세그먼트 하나가 아니라 라우트 하나를 올라가기 때문입니다. 한 라우트가 여러 세그먼트를 소유할 때부터 중요해지는데, 라우트 params와 location에서 실제 뒤로 가기 버튼에 이를 활용합니다.

NavLink와 상위 요소 일치

Link 요소를 NavLink로 바꾸면 현재 페이지를 스타일링할 수 있고, 과정에서 호스트 네비게이션에서 이를 하므로, 상위 요소 일치가 작동합니다. 라우터는 현재 URL의 모든 상위 요소를 일치한 것으로 계산하므로, to="." 대시보드 링크는 /host/income/host/reviews에서도 활성으로 표시됩니다. 그 링크에 end를 추가하면 /host 아래 어디서든 스타일이 없어집니다.

useOutletContext로 데이터 전달하기

레이아웃은 종종 자식이 필요한 데이터를 소유합니다. 과정의 HostVanDetail은 한 대의 밴을 가져오고 상태에 유지한 다음, 탭 네비게이션과 정보, 가격 책정 또는 사진 페이지가 나타나는 Outlet을 렌더링합니다. Props는 Outlet을 통과할 수 없습니다. 레이아웃이 그 컴포넌트를 직접 렌더링하지 않기 때문입니다. React Router의 답은 Outletcontext prop이고, 다른 쪽에서는 useOutletContext로 읽습니다.

jsx
<Outlet context={{ currentVan }} />
jsx
import { useOutletContext } from 'react-router-dom'

export default function HostVanInfo() {
  const { currentVan } = useOutletContext()

  return <h4>Name: {currentVan.name}</h4>
}

context에 전달하는 어떤 값이든 useOutletContext가 현재 outlet을 채우는 자식에서 반환하는 것입니다. 객체를 전달하고 받는 쪽에서 구조 분해하는 것이 일반적인 형태이고, 나중에 더 많은 값을 추가할 여지를 남깁니다. Context에 익숙하다면, 이것도 익숙할 것입니다. outlet은 페이지의 정확히 한 구멍으로 범위가 제한된 provider로 작동합니다.

중첩할지 말지

중첩은 라우트가 UI를 공유할 때 의미가 있습니다. 이것이 기준입니다. URL만으로는 중첩으로 유도할 수 있습니다. /vans/vans/:id는 세그먼트를 공유하니까 라우트도 공유하지 않을까요? 하지만 VanLife에서 그 두 페이지는 사이트 전체 UI 너머로는 아무것도 공유하지 않습니다. 중첩하면 어색한 형태가 강요됩니다. path는 있고 element는 없는 부모 라우트가 생기고, 이것은 단순히 Outlet을 렌더링하는 것으로 폴백되며, index 자식이 목록을 렌더링합니다. 이는 더 많은 구조이면서 이점이 없습니다.

공유되는 UI를 화면에 유지할 필요가 있으면, 중첩하고 부모에 Outlet을 가진 레이아웃 element를 제공하세요. 유일한 동기가 반복되는 경로 세그먼트를 피하는 것이라면, 두 개의 평탄한 라우트가 더 간단하고 명확한 선택입니다.

버전 참고

여기의 예제는 react-router-dom에서 import하는데, v6과 v7에서 모두 작동합니다. 라우팅에서 v7이 패키지에 대해 변경한 내용을 다룹니다. 중첩 라우트 개념은 두 버전에서 동일합니다.

일치는 트리 작업입니다. React Router는 라우트 트리의 모든 분기를 URL에 대해 순위 매기고 전체 승리 분기를 렌더링합니다. 가장 바깥쪽 element부터 시작해서 각 Outlet이 아래 element를 받습니다. 경로 없는 레이아웃 라우트는 일치에 기여하지 않습니다. element를 체인에 주입할 뿐입니다. 그래서 모든 것을 감쌀 수 있습니다. 또한 Outlet 없는 부모 element가 조용히 자식을 삼키는 실패 모드도 설명합니다. 일치는 성공했고, 분기는 세 라우트 길이이지만, 렌더 체인이 계속할 구멍이 없습니다.

상대 해석은 URL 문자열이 아니라 라우트 트리를 따릅니다. 그 구분은 동적 세그먼트에서 드러납니다. host 아래 중첩된 <Route path="vans/:id">로 렌더링된 컴포넌트에서, to=".."/host로 해석됩니다. :idvans 둘 다를 건너뜁니다. 왜냐하면 .. 는 한 라우트를 올라가고 그 단일 라우트가 둘 다 소유하기 때문입니다. URL 세그먼트 동작을 원할 때, <Link to=".." relative="path">로 선택합니다. 기본값이 틀린 것은 아닙니다. 라우트 상대 링크는 경로 이름 바꾸기를 견디고, 경로 상대 링크는 뒤로 가기 버튼에 대한 사용자 직관과 맞습니다.

useOutletContext는 내부적으로 문자 그대로 context입니다. Outlet은 일치하는 자식을 provider로 감쌉니다. 이는 context의 재렌더링 동작을 제공하므로, context prop에 전달된 새 객체 리터럴은 useOutletContext를 읽는 모든 컴포넌트가 레이아웃이 다시 렌더링될 때마다 재렌더링됨을 의미합니다. 페이지 규모에서는 거의 문제 되지 않습니다. 네비게이션이 어차피 서브트리를 바꾸기 때문입니다.

세 가지 전달 메커니즘 중 범위로 선택하세요. 자식을 직접 렌더링하는 곳에는 props를 쓰고, 레이아웃이 그 구멍을 채우는 어떤 페이지든 제공할 때는 outlet context를 쓰고, 라우트된 서브트리 밖의 컴포넌트가 값이 필요할 때는 전체 context를 씁니다. 한 가지 구조적 주의: outlet context는 자식을 부모 레이아웃의 형태에 결합시킵니다. useOutletContext를 읽는 컴포넌트는 그 형태를 제공하는 레이아웃 아래 마운트될 때만 작동합니다. context 값을 작게 유지하고 레이아웃의 공개 API의 일부로 취급하세요.

Juno라우트를 중첩해서 공유 UI를 페이지에 유지하기 페이지가 화면의 부분을 공유할 때, 예를 들어 콘텐츠 아래가 바뀌어도 제자리에 있어야 하는 네비게이션 바가 있을 때, 한 라우트를 다른 라우트 안에 중첩시킬 수 있습니다. 부모 라우트는 공유되는 부분을 렌더링하고 자식 페이지가 나타날 곳에 Outlet을 놓습니다.

element는 있고 경로는 없는 라우트는 모든 것을 감싸는 레이아웃이고, index 라우트는 부모 자신의 주소에서 보여지는 기본 자식입니다.

두 페이지가 화면에서 아무것도 공유하지 않으면, 중첩할 필요가 없습니다.

Juno라우트를 중첩해서 공유 UI를 페이지에 유지하기 자식 라우트를 부모 Route로 감싸고, 부모에 Outlet을 가진 레이아웃 element를 주고, 기본 자식을 index로 표시하세요. 슬래시를 없애서 경로와 링크가 부모에 상대적으로 해석되게 하면, 이름 바꾸기가 한 줄 변경이 됩니다. to="."를 사용해서 현재 라우트로 링크하세요.

레이아웃이 그 outlet 자식이 필요한 데이터를 소유할 때, Outletcontext prop으로 전달하고 useOutletContext로 읽으세요.

공유되는 UI를 위해 중첩하세요. 유일한 이득이 경로 문자열이 짧아지는 것이면 건너뛰세요.

Juno라우트를 중첩해서 공유 UI를 페이지에 유지하기 라우터는 라우트 트리의 분기를 일치시키고 가장 바깥쪽부터 렌더링합니다. 각 Outlet이 다음 element를 받고, 경로 없는 레이아웃이 일치에 영향 없이 element를 주입합니다. 상대 링크는 라우트 트리에 대해 해석되므로 ..는 한 라우트가 여러 URL 세그먼트를 소유하더라도 한 라우트를 올라갑니다. relative="path"로 세그먼트 올라가기에 선택합니다.

Outlet context는 범위가 있는 provider이고, 레이아웃-투-페이지 데이터에는 편리하지만 결합 지점이므로, 형태를 작게 안정적으로 유지하세요.

공유되는 UI를 위해 중첩을 예약하세요. element 없는 경로를 가진 부모는 평탄한 버전이 맞았던 신호입니다.

다음: 라우트 params와 location. 한 라우트가 많은 페이지를 제공합니다.