본문 바로가기

FrontEnd

React Remix Framework로 알아보는 중첩 경로(nested routing)

반응형
해당 게시물을 이해하는데 React Remix 프레임워크에 대한 사전 지식은 필요하지 않습니다.

 

컴포넌트 정의와 프로젝트 구조는 수 많은 리액트 개발자들의 큰 고민거리 중 하나입니다.

지금까지 컴포넌트의 크기, 컴포넌트의 역할에 집중하여

아토믹 디자인, 컨테이너 / 프리젠터 패턴 등을 프로젝트에 도입해 왔습니다.

 

제 생각에는 중요함에도 지금까지 의식하지 못한 하나의 관점이 있다고 생각합니다.

이는 해당 컴포넌트의 렌더링을 결정하는 url 경로(route) 입니다.

예시: apollo-client 공식 예제 : odyssey-lift-off

 

GitHub - apollographql/odyssey-lift-off-part5-client: Odyssey Lift-off V - Client - Course Companion App

Odyssey Lift-off V - Client - Course Companion App - GitHub - apollographql/odyssey-lift-off-part5-client: Odyssey Lift-off V - Client - Course Companion App

github.com

해당 프로젝트는 컴포넌트 단위 결정에 컨테이너/프리젠터 패턴과, 페이지 개념을 사용하였습니다.

특히 흥미로운 부분은, 경로를 다루는 방법이었습니다.

 

기본적으로 상위 컴포넌트는 하위 컴포넌트를 렌더링하며 레이아웃을 결정합니다.

소스코드를 보면, :변수명을 통해 해당 경로로 변수를 전달합니다.

 

경로를 지정하는 페이지 컴포넌트

export default function Pages() {
  return (
    <Router primary={false} component={Fragment}>
      <Tracks path="/" />
      <Track path="/track/:trackId" />
      <Module path="/track/:trackId/module/:moduleId" />
    </Router>
  );
}

 

 

해당 경로의 컴포넌트에서는 그 변수를 활용하여 서버에서 데이터를 가져옵니다.

/track/:trackId 경로에 해당하는 Track 컴포넌트.

const Track = ({ trackId }) => {
  const { loading, error, data } = useQuery(GET_TRACK, {
    variables: { trackId },
  });

  return (
    <Layout>
      <QueryResult error={error} loading={loading} data={data}>
        <TrackDetail track={data?.track} />
      </QueryResult>
    </Layout>
  );
};

export default Track;

 

위 소스코드에서 배울수 있는 점은, url 경로가 레이아웃과 데이터 의존성을 결정한다는 것입니다.

여기서 한 발 더 나아갈 수 있지 않을까요?

 

즉, url 경로를 이용해 프로젝트를 구조화 하면,

디렉터리 구조를 통해 컴포넌트의 레이아웃을 예상할 수 있고,

호출 API(데이터 의존성)도 파악할 수 있습니다.

또한, 디렉터리 단위로 코드 스플리팅을 적용할 수 있습니다.

 

https://ui.toast.com/weekly-pick/ko_20211214

 

React UI 상태를 URL에 저장해야 하는 방법과 이유

많은 기능, 모달 창 또는 사이드 패널을 가진 복잡한 웹앱을 사용해 본 적이 있는가? 이 문제는 모바일 앱에서 앱을 특정 페이지나 UI 상태로 열기 위해 사용하는 딥링크로 해결할 수 있다. 하지

ui.toast.com

중첩 경로(nested routing)

 

Remix Framework를 통해 해당 개념을 다시 만나게 되었습니다.

해당 프레임워크의 공식 문서에서는 중첩 경로 개념을 다음과 같이 소개합니다.

중첩 경로는 URL의 세그먼트를 UI의 컴포넌트 계층에 연결하는 일반적인 개념입니다.
우리는 거의 모든 경우에 URL의 세그먼트가 다음을 결정한다는 것을 발견했습니다.

즉, URL 세그먼트는 아래 3가지를 결정합니다.

  • 레이아웃 (jsx 계층구조)
  • 코드 조각 (import)
  • 데이터 종속성 (fetch)

즉, /sales/invoices/102000이란 경로는

root(루트 -사용자 정보) > sales(사용자의 총 판매 내역) > invoices(송장) > id가 102000인 invoice(송장)

이렇게 4단계의 데이터 계층과, 레이아웃 계층, 4 덩어리의 분리 가능한 코드(자바스크립트) 덩어리을 의미한다는 것입니다.

 

상단의 레이아웃 계층은 하위 레이아웃 계층이 변경되어도 변화하지 않습니다.

즉 좌측의 네비게이션은, 조회 대상 invoice의 id 변경에 영향받지 않습니다.

 

개념을 알았으니, Remix Framework에서 해당 개념이 어떻게 구현되는지 알아봅시다.

경로(Routes) 정의

경로를 정의하는 기본 방법은 app/routes/*에 새 파일을 만드는 것입니다.

jsx파일과 디렉터리를 leaf 경로와 브랜치 경로로 생각할 수 있습니다.

jsx 파일이 레이아웃 역할일 경우, 브랜치 경로 역할을 합니다.

jsx 파일이 말단 컴포넌트 역할일 경우(ex : index.jsx) 리프 경로 역할을 합니다.

 

위 문단 UI 예시에 대한 경로는 다음과 같습니다.

app
├── root.jsx
└── routes
    ├── accounts.jsx
    ├── dashboard.jsx
    ├── expenses.jsx
    ├── index.jsx
    ├── reports.jsx
    ├── sales
    │   ├── customers.jsx
    │   ├── deposits.jsx
    │   ├── index.jsx
    │   ├── invoices
    │   │   ├── $invoiceId.jsx
    │   │   └── index.jsx
    │   ├── invoices.jsx
    │   └── subscriptions.jsx
    └── sales.jsx
  • root.jsx는 전체 애플리케이션의 레이아웃 역할을 하는 "루트 경로(/)"입니다. 모든 경로는 <Outlet/> 내부에서 렌더링 됩니다.
    • 폴더와 동일한 이름과 일치하는 파일이 존재합니다. 이 파일은 컴포넌트 레이아웃 계층을 나타냅니다. 즉, 해당 경로 아래의 레이아웃을 결정합니다. 또한 해당 폴더 내부가 아니라 바로 옆에 있습니다.
    • 예를 들어 sales.jsx는 app/routes/sales/* 폴더 내부의 모든 하위 경로에 대한 상위 경로 역할을 합니다.
      • sales.jsx 컴포넌트 내부의 <Outlet/> 컴포넌트 위치에 sales 디렉토리 내부 경로의 컴포넌트가 렌더링됩니다.
  • index.jsx 경로는 URL이 상위 경로 깊이일 때 상위 <Outlet/> 내부에서 렌더링됩니다.
    • 예: example.com/sales/customers 대신 example.com/sales면 sales/index.jsx가 렌더링 됩니다.
즉, index.jsx는 해당 경로가 leaf가 아닐때 디폴트로 보여주기 위한 컴포넌트며,
폴더명과 동일한 이름의 컴포넌트는 레이아웃 역할로, 하위 컴포넌트를 렌더링할 위치를 마킹합니다.
ex)
sales.jsx는 sales 디렉터리 바로 옆에 존재하며 레이아웃 역할입니다.
sales 경로 및에 하위 경로(customers.jsx)가 존재하기에, sales 경로는 leaf가 아닙니다.
따라서 sales 경로로 접근 시 index.jsx를 보여줍니다.
나머지 파일 및 디렉터리는 leaf 혹은 하위 컴포넌트 계층을 나타냅니다.

경로 계층구조를 반영한 렌더링

URL이 /sales/invoices/102000면, 아래 경로는 모두 해당 URL과 일치합니다.

  • root.jsx
  • routes/sales.jsx
  • routes/sales/invoices.jsx
  • routes/sales/invoices/$invoiceId.jsx
사용자가 이 페이지를 방문하면 Remix는 아래 계층 구조의 컴포넌트를 렌더링합니다.
<Root>
  <Sales>
    <Invoices>
      <InvoiceId />
    </Invoices>
  </Sales>
</Root>

 

컴포넌트 계층 구조가 파일 시스템의 경로 계층구조와 매핑되는 것을 볼 수 있습니다.

즉 파일 구조만 봐도 컴포넌트가 어떻게 렌더링될지 예상됩니다.

app
├── root.jsx
└── routes
    ├── sales
    │   ├── invoices
    │   │   └── $invoiceId.jsx
    │   └── invoices.jsx
    └── sales.jsx
 
URL이 /accounts인 경우 UI 계층 구조가 다음과 같이 변경됩니다.
<Root>
  <Accounts />
</Root>

 

<OutLet/> 컴포넌트

파일 시스템에서 디렉터리 - 파일 관계와 같이, 해당 경로는 leaf일 수도 있고, 아닐 수도 있습니다.

따라서, 상위 경로에서 자식 컴포넌트 계층 트리 혹은 left 컴포넌트를 렌더링할 위치를 지정해주어야 합니다.

아래 예시는 app/root.jsx 파일입니다.

 

앱의 루트에서 자식 경로를 레이아웃의 어느 위치에 포함할 것인지를 <Outlet/> 컴포넌트를 통해 선언합니다 (url "/" 에 해당)

// app/root.jsx
import { Outlet } from "@remix-run/react";

export default function Root() {
  return (
    <Document>
      <Sidebar />
      <Outlet />
    </Document>
  );
}

다음은 자식 경로(app/routes/sales/*.jsx 내부의 모든 경로)를 렌더링하는  app/routes/sales.jsx 파일입니다. (url "/sales" 에 해당)

// app/routes/sales.jsx
import { Outlet } from "@remix-run/react";

export default function Sales() {
  return (
    <div>
      <h1>Sales</h1>
      <SalesNav />
      <Outlet />
    </div>
  );
}

프로젝트 구조에서 해당 파일은 /sales 디렉토리 외부(바로 옆)에 있음을 주의합니다.

 

Index 경로 (index-routes)

위에서 설명한 바와 같이, 렌더링할 자식 경로가 없으면 해당 컴포넌트를 렌더링합니다.

만약 index.jsx 파일이 없다면, 아래와 같이 보일 것입니다.

즉, index.jsx는 <Outlet/> 위치에 보여질 디폴트 컴포넌트 역할을 합니다. 즉 index.jsx는 leaf 경로입니다.

해당 컴포넌트는 sales/index.jsx입니다.

이해를 돕기 위해 추가 예제를 가지고 왔습니다. 아래와 같은 프로젝트 구조가 있다고 가정합시다.

/tests 경로의 경우 tests/index.jsx 파일이 렌더링 됩니다.

tests/index.jsx

/tests/leaf 경로의 경우 tests/leaf.jsx 파일이 렌더링 됩니다.

tests/leaf.jsx

 

둘 다 동일하게 routes/tests.jsx 파일에 의해 레이아웃이 결정됩니다.

 

?index 쿼리 파라미터

아래와 같은 프로젝트 구조를 봅시다.

url 경로가 /sales/invoices/... 면 invoices.jsx 컴포넌트가 렌더링 됩니다.

url 경로가 /sales/invoices 면  index.jsx 컴포넌트가 렌더링 됩니다.

하나는 레이아웃 컴포넌트 역할이며, 하나는 leaf 컴포넌트 역할이죠.

분명 둘 다 form 컴포넌트를 포함하여 서버에 양식을 제출할 수 있습니다.

(둘다 리액트 컴포넌트이기 때문이죠)

두 컴포넌트가 둘 다 /sales/invoice로 양식을 제출한다고 생각해봅시다.

remix 서버는 해당 컴포넌트가 무엇인지 구분할 수 없습니다.

따라서 remix framework는 ?index를 사용하여 해당 애매함을 극복합니다.

└── app
    ├── root.jsx
    └── routes
        ├── sales
        │   ├── invoices
        │   │   └── index.jsx   <-- /sales/invoices?index
        │   └── invoices.jsx    <-- /sales/invoices

 

레이아웃 중첩 없이 중첩 경로 사용하기 (nested-urls-without-nesting-layouts)

때떄로, 레이아웃 중첩은 필요 없는 경우가 존재할 수 있습니다.

invoice 편집 페이지를 만들 예정인데, /sales/invoices/:invoiceId/edit 경로를 사용할 예정이지만,

EditInvoice 컴포넌트 하나로 퉁치고 싶습니다.

 

즉 아래와 같은 구조가 아니라

<Root>
  <Sales>
    <Invoices>
      <InvoiceId>
        <EditInvoice />
      </InvoiceId>
    </Invoices>
  </Sales>
</Root>

다음과 같은 구조를 원합니다.

<Root>
  <EditInvoice />
</Root>

이 경우 파일을 하나 만들고, 각 경로를 .으로 나누어 구분하면 됩니다. 다음과 같습니다.

└── app
    ├── root.jsx
    └── routes
        ├── sales
        │   ├── invoices
        │   │   └── $invoiceId.jsx
        │   └── invoices.jsx
        ├── sales.invoices.$invoiceId.edit.jsx 👈 not nested
        └── sales.jsx

"example.com/sales/invoices/2000/edit" 경로와 비교해보세요

 

해당 경로로 접근하면 아래와 같은 컴포넌트가 렌더링됩니다.

그리고 해당 경로로 접근하면 레이아웃이 중첩되지 않습니다.

<Root>
  <EditInvoice />
</Root>

edit 경로만 제외하면 routes/sales 디렉터리 이하의 경로를 사용합니다.

다시 레이아웃이 중첩됩니다.

<Root>
  <Sales>
    <Invoices>
      <InvoiceId />
    </Invoices>
  </Sales>
</Root>

요약하자면 다음과 같습니다.

  • 중첩 파일: 레이아웃 중첩 + 중첩 URL
  • 플랫 파일: 레이아웃 중첩 없음 + 중첩 URL
 

경로 없이 중첩 레이아웃 공유하기 (pathless-layout-routes)

이전에는 중첩 경로는 사용하지만, 레이아웃은 공유하지 않는 경우를 알아봤습니다.

이번에는 중첩 경로를 사용하지 않으면서, 레이아웃을 공유하는 방법을 알아봅니다.

예를 들어 /auth/login, /auth/logout대신, /login, /logout을 사용하면서 auth.jsx 중첩 경로를 사용하는 것입니다.

 

즉 렌더링되는 컴포넌트 구조는 다음과 같습니다.

<Root>
  <Auth>
    <Login />
  </Auth>
</Root>

 

중첩 경로와 중첩 레이아웃을 사용한다면 디렉터리 구조는 아래처럼 되겠죠.

app
├── root.jsx
└── routes
    ├── auth
    │   ├── login.jsx
    │   ├── logout.jsx
    │   └── signup.jsx
    └── auth.jsx

 

아까 말했듯이 /auth 를 제외하고 /login으로 해당 컴포넌트에 접근하고 싶습니다. 어떻게 할까요?

아래와 같이 레이아웃 컴포넌트명, 디렉터리에 __를 접두사로 붙여 사용하면 됩니다.

app
├── root.jsx
└── routes
    ├── __auth
    │   ├── login.jsx
    │   ├── logout.jsx
    │   └── signup.jsx
    └── __auth.jsx

동적 세그먼트 (dynamic-segments)

파일 이름에 $를 접두사로 붙이면 해당 경로의 URL segment가 동적 세그먼트가 됩니다.

즉, Remix는 URL의 해당 세그먼트에 해당하는 값을 리액트 컴포넌트에 전달합니다.

 

예를 들어 URL이 /sales/invoices/102000(:invoiceId)이고, 말단 컴포넌트가 $invoiceId.jsx 경로이면

Remix는 파일 이름 세그먼트와 동일한 이름(invoiceId)으로 아래와 같이 훅 및 컴포넌트에 값을 전달합니다.

import { useParams } from "@remix-run/react";

export function loader({ params }) {
  const id = params.invoiceId;
}

export function action({ params }) {
  const id = params.invoiceId;
}

export default function Invoice() {
  const params = useParams();
  const id = params.invoiceId;
}
 
경로에는 여러 매개변수가 있을 수 있으며 매개변수는 폴더일 수도 있습니다.
app
├── root.jsx
└── routes
    ├── projects
    │   ├── $projectId
    │   │   └── $taskId.jsx
    │   └── $projectId.jsx
    └── projects.jsx

 

만약 URL이 /projects/123/abc이면, 해당 파라미터는 아래와 같이 접근할 수 있습니다.

params.projectId; // "123"
params.taskId; // "abc"

splats (catch-all 경로)

파일 이름을 $.jsx로 지정하면 해당 경로 경로가 splat 경로가 됩니다.
Remix는 해당 경로로 접근하는 URL외 나머지 경로를 전부 캡처합니다.
 
예를 들어 아래와 같은 파일 구조에서
app
├── root.jsx
└── routes
    ├── files
    │   ├── $.jsx
    │   ├── mine.jsx
    │   └── recent.jsx
    └── files.jsx
URL이 example.com/files/images/work/flyer.jpg인 경우, splat 파라미터는 URL의 후행 세그먼트를 캡처합니다.
즉 /files이후인 /images/work/flyer.jpg 부분을 캡처합니다.
그리고 앱에서 params의 "*" 프로퍼티를 통해 접근 가능합니다. (params["*"])
export function loader({ params }) {
  params["*"]; // "images/work/flyer.jpg"
}

splat은  또한 어떤 경로 수준에도 추가할 수 있습니다. 

 

말 그대로 catch-all이기 때문에, routes/$.jsx 파일에 커스텀 404 not found 파일을 선언할 수 있습니다.

(해당 파일이 없으면 Remix는 에러 핸들러인 CatchBoundary를 렌더링하여 프레임워크 디폴트 404 not found를 보여줍니다.)

/dragonball은 존재하지 않습니다.
/tests/leaf는 말단 컴포넌트입니다. 그 이하 경로는 존재하지 않습니다.

위 경로를 직접 테스트해 보세요

/tests

/tests/leaf

/anyothernotexistingroutes....

 

마무리 :

nested routing을 이용하면 url 세그먼트와 컴포넌트의 계층관계를 활용하여 더 나은 상태관리를 수행할 수 있습니다.

또한 url 단위로 컴포넌트를 분리하여 시맨틱한 코드 스플리팅, 데이터 의존성 관리, 프로젝트 구조 관리가 가능합니다.

디렉터리 구조를 통해 컴포넌트가 어떻게 렌더링될 지 예측할 수 있습니다.

 

참고 : 

해당 기능은 리믹스에만 존재하는것은 아니며 리액트 라우터에도 존재합니다.

 

해당 글을 작성하면서 참고한 게시물들은 다음과 같습니다.

https://blog.logrocket.com/understanding-routes-route-nesting-remix/

https://remix.run/docs/en/v1/guides/routing#what-is-nested-routing

 

반응형