mimizae 님의 블로그
RouteHandle은 뭐고, 왜 라우트 단에서 헤더를 관리할 수 있을까? 본문
37기 DIVE SOPT에서 진행한 프로젝트 CareNA 프로젝트 초기 세팅 과정을 담은 글입니다! 🙌🏻
💭 해당 구조는 웹 파트 리드님의 아이디어로, 스스로 떠올린 아이디어는 아니지만! 처음 접해본 패턴이라 동작 원리와 설계 의도를 이해해보고자 했다. 단순히 사용하는 데 그치지 않고 왜 이런 방식으로 설계했는지, 어떤 문제를 해결하려는 구조인지를 이해하기 위해 관련 문서와 코드를 참고하며 혼자 정리해보는 시간을 가졌다.
그 과정에서 라우트를 단순한 경로 정의가 아니라 페이지 메타데이터를 관리하는 단위로 확장해볼 수 있다는 관점을 얻었다…!!!

💡 문제점
페이지마다 헤더가 다르다…!!
- 홈: 로고 + 마이페이지 아이콘
- 마이페이지: 뒤로가기 + 타이틀
- 로그인: 헤더 없음
- etc...
React Router 초기 세팅을 진행하면서 페이지마다 layout을 분리하는 게 어떤지? 하는 제안을 했었는데, 그 원인 또한 페이지마다 헤더가 달랐기 때문이었다...!!
(Router 초기 세팅을 하며 현재 mvp 단에서는 페이지 수가 많지 않아 큰 문제는 아니지만, 스프린트를 거듭할 수록 유지보수에 어떤 구조가 좋을지 고민했었다.)
이걸 구현하려고 하면 보통 다음과 같은 선택지들을 고민하게 된다.
- 페이지 컴포넌트 안에서 <Header />를 직접 렌더링
- 전역 상태로 현재 페이지 타입을 관리
- URL을 기준으로 if/else 분기
하지만 이러한 접근 방식들은 공통적으로 페이지의 UI 설정이 라우트 구조와 분리되고, 페이지 수가 늘어날수록 조건 분기가 복잡해진다는 문제를 안고 있었다.
무엇보다도 이 페이지는 어떤 성격의 페이지인가?라는 정보를 코드를 통해 직관적으로 파악하기 어렵다는 점이 가장 큰 아쉬움이었다…!!
💡 React Router의 handle이란?
React Router에서 각 라우트 객체에는 handle이라는 임의의 데이터를 담을 수 있는 필드가 있다.
{
path: "/home",
element: <HomePage />,
handle: { header: "main" }
}
여기서 중요한 점은!! handle은 라우팅에 관여하지 않는다는 점이다.
경로 매칭, 리다이렉트와는 무관하며 오직 개발자가 자유롭게 정의하는 메타데이터 공간이라고 볼 수 있다!
이 handle을 활용해 각 페이지가 어떤 형태의 헤더를 가져야 하는지를 페이지 컴포넌트가 아닌 라우트 정의 단계에서 선언하도록 했다.
이를 통해 페이지 UI에 대한 설정을 라우트 구조 안으로 자연스럽게 끌어올릴 수 있었다!!!!
export interface RouteHandle {
header?: HeaderVariant;
title?: string;
}
RouteHandle 인터페이스는 이 handle에 어떤 정보가 들어갈 수 있는지를 명확히 정의하기 위한 타입이다.
헤더의 형태나 타이틀과 같이 레이아웃에서 공통으로 참조해야 하는 값들을 타입으로 제한함으로써, 라우트 설정 단계에서의 실수를 줄이고 Layout 컴포넌트에서도 안정적으로 해당 정보를 사용할 수 있게 된다!!
💡 음? 근데 이 정보는 어떻게 Layout까지 전달되는 거지?
여기서 등장하는 게 useMatches()다!!
const matches = useMatches();
Layout 컴포넌트에서는 useMatches() 훅을 사용해 현재 URL과 매칭된 모든 라우트 정보를 루트부터 가장 깊은 자식 라우트까지 배열로 얻게 된다!!
예를 들어, 아래와 같은 라우트 구조를 가지고 있는 /my-page에 접속한다면
<App>
<ErrorBoundary>
<AppProvider>
<AuthInitializerProvider>
...
<ProtectedRoute>
<Layout>
<MyPage />
</Layout>
</ProtectedRoute>
...
</AuthInitializerProvider>
</AppProvider>
</ErrorBoundary>
</App>
[
{ handle: undefined }, // ErrorBoundary
...
{ handle: undefined }, // ProtectedRoute
{ handle: undefined }, // Layout
{ handle: { header: "back", title: "마이페이지" } } // MyPage
]
ErrorBoundary → … → ProtectedRoute → Layout → MyPage 순서로 라우트가 매칭되며, 각 라우트에 정의된 handle 정보 역시 이 배열에 함께 포함된다!!
그래서 Layout에서는 이 배열의 가장 마지막 요소를 현재 페이지로 판단하고,
const currentMatch = matches[matches.length - 1]; // MyPage!
를 통해 현재 페이지에 해당하는 라우트의 handle을 가져와서 어떤 헤더를 렌더링할지 결정한다!!
이 방식 덕분에 Layout은 현재 URL이나 페이지 컴포넌트의 내부 구현을 알 필요 없이!!! 라우트 정의에 선언된 메타 정보만을 기준으로 공통 UI를 제어할 수 있게 된다.
💡 이 패턴의 핵심 장점
이 구조의 가장 큰 장점은 역할과 책임이 명확하게 분리된다는 점이다!!
페이지 컴포넌트는 화면과 비즈니스 로직에만 집중하고, 라우트는 페이지의 성격과 메타 정보를 설명하며, Layout은 이 정보를 해석해 공통 UI를 제어한다.
{
path:ROUTE_PATH.MY_PAGE,
element:<MyPage />,
handle: {
header:"back",
title:"마이페이지",
},
}
특히 이러한 라우트 정의를 살펴보는 것만으로도 각 페이지가 어떤 성격의 화면인지 쉽게 파악할 수 있다!!!
마이페이지 라우트에 뒤로가기 헤더와 타이틀이 함께 선언되어 있는 것을 보면, 해당 페이지가 메인 페이지가 아닌 서브 페이지라는 사실을 코드 레벨에서 바로 이해할 수 있을 것이다.
💡 전체 코드
전체 코드를 살펴보며 흐름을 더 확실히 이해해 보자
/**
* 헤더 변형 타입
* - "main": 메인 페이지용 (로고 + 마이페이지 아이콘)
* - "back": 서브 페이지용 (뒤로가기 + 타이틀)
* - "none": 헤더 없음
*/
export type HeaderVariant = "main" | "back" | "none";
/**
* 라우트 핸들 인터페이스
* - useMatches()로 현재 라우트의 handle을 가져와서 Layout에서 헤더를 동적으로 렌더링
*/
export interface RouteHandle {
header?: HeaderVariant;
title?: string;
}
export const router = createBrowserRouter([
{
path: ROUTE_PATH.LOGIN,
element: <LoginPage />,
},
{
element: <ProtectedRoute />,
children: [
{
path: ROUTE_PATH.LAYOUT,
element: <Layout />,
children: [
{
index: true,
element: <Navigate to={ROUTE_PATH.HOME} replace />,
},
{
path: ROUTE_PATH.HOME,
element: <HomePage />,
handle: { header: "main" } satisfies RouteHandle,
},
{
path: ROUTE_PATH.MY_PAGE,
element: <MyPage />,
handle: {
header: "back",
title: "마이페이지",
} satisfies RouteHandle,
},
{
path: "*",
element: <></>,
},
],
},
],
},
]
);
import { Outlet, useMatches } from "react-router";
import { Header } from "@/shared/ui/navigations/header";
import type { RouteHandle } from "./router";
export const Layout = () => {
/**
* useMatches(): 현재 URL과 매칭된 모든 라우트 정보를 배열로 반환
* 예: /home 접속 시 → [{ path: "/" }, { path: "home", handle: { header: "main" } }]
*
* matches[matches.length - 1]: 가장 마지막(가장 깊은) 라우트 = 현재 페이지
*/
const matches = useMatches();
const currentMatch = matches[matches.length - 1];
const headerConfig = currentMatch?.handle as RouteHandle | undefined;
const showHeader = headerConfig?.header !== "none";
return (
<>
<Header variant={headerConfig?.header} title={headerConfig?.title} />
<main className={showHeader ? "pt-[5.6rem]" : ""}>
<Outlet />
</main>
</>
);
};
위와 같이 Layout 컴포넌트에서 useMatches() 훅으로 현재 url에 대한 라우트 구조 정보를 끌어와 어떤 페이지 컴포넌트를 렌더링하고 있는지 알아내어서, currentMatch에 저장한다.
const headerConfig = currentMatch?.handle as RouteHandle | undefined;
이후 currentMatch(현재 컴포넌트)에 포함된 handle 값을 가져와, 이 프로젝트에서 미리 정의해 둔 RouteHandle 인터페이스 형태로 해석한다. 그리고 현재 페이지에서 사용할 헤더 설정(variant, title)을 추출한다!!
export interface RouteHandle {
header?: HeaderVariant;
title?: string;
}
<Header variant={headerConfig?.header} title={headerConfig?.title} />
이렇게 추출한 설정 값을 Header 컴포넌트에 전달하여, 페이지마다 서로 다른 헤더 variant와 title을 렌더링한다.
const showHeader = headerConfig?.header !== "none";
마지막으로, showHeader 값을 기준으로 헤더가 있을 때만 padding-top 적용시켜 페이지 컴포넌트들은 레이아웃 신경 안 써도 되도록 했다!
'FE' 카테고리의 다른 글
| 마이페이지 min-height 문제 (0) | 2026.02.10 |
|---|---|
| ticker 구현기 (0) | 2026.02.09 |
| React Route 초기 세팅 (0) | 2026.02.02 |
| Tanstack Query 초기 세팅 (0) | 2026.01.29 |
| swagger-typescript-api 적용기 (0) | 2026.01.28 |