[ 06 -2 ] Outlet 컴포넌트와 중첩 라우팅
리액트 라우터의 Outlet 컴포넌트
다음그림은 앞절에 만든 화면입니다. 하지만 내비게이션 메뉴가 없어서 다른경로로 가기가 불편합니다.
그런데 내비게이션 메뉴를 만들려고하면 모든 라우트 컴포넌트 마다 거의똑같은 코드를 작성해야 합니다. 이런 번거러움을 줄이고자 Outlet이란 컴포넌트를 사용해보겠습니다.

리액트 라우터 패키지의 버전 6에서 가장 큰 변화는 Outlet컴포넌트가 도입되었다는 것입니다.
Outlet 컴포넌트는 다른 컴포넌트들이 렌더링 되는 위치를 지정해 주는 역할을 합니다.
import {Outlet} from 'react-router-dom'
이제 src/routes/LayOut 디렉터리에 index.tsx파일을 다음처럼 작성합니다. 코드에서 Outlet 컴포넌트는 RoutesSetup.tsx파일에서 설정한 라우트 경로의 컴포넌트가 여기에 위치하게 하는 역할을 합니다.
import { Outlet } from "react-router-dom";
export default function Layout() {
return <Outlet />
}
앞서 언급한대로 Outlet 컴포넌트는 다른 컴포넌트가 렌더링되는 위치를 지정해 주는 용도이므로 Outlet에 보이기 원하는 컴포넌트는 Outlet을 사용하는 컴포넌트(예에서는 Layout 컴포넌트)의 자식 라우터로 구성합니다. 따라서 Board와 NoMatch의 Route는 Layout의 자식으로 설정합니다.
import { Routes, Route } from "react-router-dom"
import Layout from "./Layout"
import NoMatch from "./NoMatch"
import Home from "./Home"
import Card from "./Card"
import Board from "../pages/Board"
export default function RoutesSetup() {
return (
<Routes>
<Route path="/" element={<Layout />}>
<Route path="/board" element={<Board/>} />
<Route path="*" element={<NoMatch />} />
</Route>
</Routes>
)
}
다음은 localhost:3000주소로 접속한 웹브라우저에서 오른쪽 개발 도구창의 elements탭 내용을 보면 Layout 컴포넌트 내부의 <Outlet>컴포넌트가 현재는 존재하는지조차 알 수가 없습니다.
하지만 주소창에 localhost:3000/board 경로를 입력을하면 Board컴포넌트가 나타납니다.
웹브라우저의 화면과 RoutesSetup.tsx 파일 내용을 비교해 보면, Layout 내부의 Outlet 컴포넌트는 자신의 자식 라우트 경로에 설정된 컴포넌트를 화면에 나타나게 하는 역할을 하고 있음을 알 수 있습니다.
<Routes>
<Route path="/" element={<Layout />}>
<Route path="/board" element={<Board/>} />
<Route path="*" element={<NoMatch />} />
</Route>
</Routes>
앞 절에서 본 라우터 설정은 다음처럼 Home과 NoMatch 컴포넌트가 형제 관계로 이루어졌습니다.
<Routes>
<Route path="/" element={<Home />} />
<Route path="*" element={<NoMatch />} />
</Routes>
반면 앞서 본 Outlet 컴포넌트를 사용하면 NoMatch는 Outlet의 자식 라우터로 설정되는데, 리액트 라우터에서는 이를 중첩 라우트 설정이라고 합니다. 리액트에서 부모 컴포넌트는 자식 컴포넌트 안에서 렌더링 될 수 없습니다. 또한 형제 컴포넌트가 다른형제안에서 렌더링 될 수도 없습니다. 따라서 NoMatch가 Outlet안에서 렌더링 되려면 라우터 설정은 부모/자식 관계의 라우팅, 즉 중첩 라우팅 형태로 설정해야 합니다.
// 중첩 라우터
<Routes>
<Route path="/" element={<Layout />}>
<Route path="*" element={<NoMatch />} />
</Route>
</Routes>
색인 라우트 알아보기
앞서 라우트 설정 코드에서 한 가지 문제는 경로가 '/'일 때 Outlet을 채울 컴포넌트를 지정하지 않았으므로 'localhost:3000' 주소로 접속하면 화면에 아무것도 나타나지 않는다는 점입니다. 이제 경로가 '/' 일 때 Outlet에 자동으로 기본 컴포넌트를 나타나게 하는 방법을 알아보겠습니다.
Route 컴포넌트는 index란 이름의 boolean 타입 속성을 제공하는데, <Route index /> 형태로 사용하는 Route를 색인 라우트 라고합니다. 다음 RoutesSetup.tsx 파일은 색인 라우트 형태로 LandingPage라는 컴포넌트의 경로를 설정합니다.
import { Routes, Route } from "react-router-dom"
import Layout from "./Layout"
import NoMatch from "./NoMatch"
import Home from "./Home"
import Card from "./Card"
import Board from "../pages/Board"
export default function RoutesSetup() {
return (
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Home />} />
<Route path="/board" element={<Board/>} />
<Route path="*" element={<NoMatch />} />
</Route>
</Routes>
)
}
실행결과로는 경로가 / 일때 Home컴포넌트가 나타납니다. 앞서 아무것도 안보였던것이 색인 라우트경로로 통하여 자동으로 화면에 나왔다는 차이가 있습니다.
내비게이션 메뉴와 바닥글 만들기
앞에서 알아본 Outlet과 중첩 라우트를 이용하면 어느 컴포넌트에서나 공통으로 보이는 내용을 간편하게 만들 수 있습니다. 여기서는 상단에 내비게이션 메뉴, 하단에는 바닥글이 보이고, 그사이에 Outlet의 자식 라우트 경로에 설정한 컴포넌트가 보이도록 만들어 보겠습니다.
// NavigationBar
import { Link } from "react-router-dom";
export default function NavigationBar() {
return(
<div className="flex p-2 navbar bg-base-100">
<Link to="/">Home</Link>
<Link to="board" className="ml-4">
Board
</Link>
</div>
)
}
//Footer
import * as D from '../../data'
export default function Footer() {
return(
<footer className="p-4 footer footer-center bg-primary text-primary-content">
<div>
<p>Copyright @ 2024 - All right reserved by {D.randomCompanyName()}</p>
</div>
</footer>
)
}
// index
import { Outlet } from "react-router-dom";
import NavigationBar from "./NavigationBar";
import Footer from "./Footer";
export default function Layout() {
return (
<>
<NavigationBar />
<Outlet />
<Footer />
</>
)
}

랜딩 페이지 만들기
사용자가 특정 웹 사이트에 접속할 때 처음 보게 되는 페이지를 랜딩 페이지 라고 부릅니다. 보통 랜딩페이지는 방문자가 호감을 가질 수 있도록 마케팅 관점에서 웹 사이트의 대표 이미지 슬로건 프로모션등의 내용으로 채웁니다.
이런 랜딩페이지를 만들어보겠습니다.

히어로 영역 만들기
랜딩페이지에서 해당 웹 사이트를 대표하는 이미지를 히어로 이미지라고 부르는 경향이 있습니다. 그리고 히어로 이미지와 함께 해당 웹 사이트를 대표하는 슬로건 대표 메뉴로 가는 버튼을 함께 제공하는데 이를 히어로 영역이라고합니다.
import { Link } from "react-router-dom";
import { Div } from "../../components";
import { Button } from "../../theme/daisyui";
import * as D from '../../data'
export default function Hero() {
return (
<div className="flex items-center p-4">
<Div minWidth="30rem" width="30rem" maxWidth="30rem">
<div className="flex flex-col justify-center p-4 font-bold">
<p className="text-3xl italic text-center line-clamp-5">
{D.randomSentence(20)}
</p>
<div className="flex items-center justify-center mt-4">
<Link to="board">
<Button className="btn-primary btn-outline">
go to Board
</Button>
</Link>
</div>
</div>
</Div>
<Div
src={D.randomImage(2000, 1600, 100)}
className="w-full ml-4"
minHeight="20rem"
height="20rem"
/>
</div>
)
}

프로모션 영역 만들기
랜딩 페이지는 마케팅적인 측면이 강하므로 특정 고객이 어떤 평가를 하는지 구체적인 사례를 보여 줄때가 많습니다
그런화면을 만들어보겠습니다.
import * as C from './chance'
import * as I from './image'
export type CustomerComment = {
uuid: string
name: string
jobTitle: string
company: string
avatar: string
comment: string
}
export const makeCustomerComment = (
uuid: string,
name: string,
jobTitle: string,
company: string,
avatar: string,
comment: string
): CustomerComment => ({uuid, name, jobTitle, company, avatar, comment})
export const makeRandomCustomerComment = () =>
makeCustomerComment(
C.randomUUID(),
C.randomName(),
C.randomJobTitle(),
C.randomCompanyName(),
I.randomAvatar(),
C.randomParagraphs(1)
)
import type {FC} from 'react'
import type {CustomerComment as CustomerCommentType} from '../../data'
import {Div, Avatar} from '../../components'
export type CustomerCommentProps = {
customerComment: CustomerCommentType
}
const CustomerComment: FC<CustomerCommentProps> = ({customerComment}) => {
const {name, jobTitle, company, avatar, comment} = customerComment
return (
<Div
className="relative p-2 mx-2 mt-8 boarder-2 rounded-lg shadow-lg border-primary"
minWidth="20rem"
width="20rem"
minHeight="20rem"
height="1-rem">
<div className="absolute flex items-center justify-center w-full -top-7">
<Avatar src={avatar} className="border-2 border-primary" />
</div>
<div className="flex flex-col">
<div className="flex flex-col p-4 font-bold">
<p>{name}</p>
<p>{jobTitle}</p>
<p>{company}</p>
</div>
<p className="mt-4">{comment}</p>
</div>
</Div>
)
}
export default CustomerComment
import { useMemo } from "react";
import CustomerComment from "./CustomerComment";
import { Div } from "../../components";
import * as D from '../../data'
export default function Promotion() {
const comments = useMemo(() => D.makeArray(3).map(D.makeRandomCustomerComment), [])
const children = useMemo(
() =>
comments.map(comment => (
<CustomerComment key={comment.uuid} customerComment={comment} />
)),
[comments]
)
return (
<section className="w-full mt-4" >
<h2 className="ml-4 text-5xl font-blod">What our coustomer say:</h2>
<div className="flex justify-between w-full p-4">
<Div
width="15%"
minWidth="15%"
className="flex items-center justify-center text-white bg-primary"
>
your massage here
</Div>
<div className="flex flex-wrap justify-center p-4 mt-4">{children}</div>
<Div
width="15%"
minWidth="15%"
className="flex items-center justify-center text-white bg-primary"
>
your advertizement here
</Div>
</div>
</section>
)
}

커스텀 Link 컴포넌트 만들기
대부분의 웹 페이지는 다음 그림에서 보듯 메뉴(링크) 밑에 밑줄을 긋는 방식으로 현재 보이는 웹페이작 어떤 메뉴에 속하는지 알려 줍니다. 이 화면에서 상단 메뉴는 daisyui의 버튼 컴포넌트를 사용한 것으로 daisyui는 버튼이 활성화된 것을 시각적으로 보여 주는 btn-active 클래스를 제공합니다.
link컴포넌트 초기 모습을 구현
import type {FC} from 'react'
import type {LinkProps as RRLinkProps} from 'react-router-dom'
import {Link as RRLink} from 'react-router-dom'
export type LinkProps = RRLinkProps & {}
export const Link: FC<LinkProps> = ({className: _classname, to, ...props}) => {
const className = [_classname].join(' ')
return <RRLink {...props} to={to} className={className} />
}
index.ts파일에 다음처럼 link를 추가
export * from './Icon'
export * from './Texts'
export * from './Div'
export * from './Avatar'
export * from './Overlay'
export * from './ListDraggable'
export * from './ListDroppable'
export * from './CardDraggable'
export * from './CardDroppable'
export * from './Link'
link 컴포넌트를 적용합니다.
// import { Link } from "react-router-dom";
import { Link } from "../../components";
export default function NavigationBar() {
return(
<div className="flex p-2 navbar bg-base-100">
<Link to="/">Home</Link>
<Link to="board" className="ml-4">
Board
</Link>
</div>
)
}
useResolvedPath와 useMatch훅 알아보기
이제 Link 컴포넌트가 현재 페이지에 해당하는 메뉴에만 밑줄 표시를 하도록 만들어 보겠습니다.
여기서는 useResolvedPath와 useMatch훅을 사용합니다.
import type {FC} from 'react'
import type {LinkProps as RRLinkProps} from 'react-router-dom'
import { useResolvedPath, useMatch } from 'react-router-dom'
import {Link as RRLink} from 'react-router-dom'
export type LinkProps = RRLinkProps & {}
export const Link: FC<LinkProps> = ({className: _classname, to, ...props}) => {
const resolved = useResolvedPath(to)
console.log('resolved', resolved)
const match = useMatch({path: resolved.pathname, end: true})
console.log('match', match)
const className = [_classname, match ? 'btn-active' : ''].join(' ')
return <RRLink {...props} to={to} className={className} />
}


위 사진은 Board메뉴를 눌렀을때랑 home 메뉴를 눌렀을때의 console창을 비교해본것입니다.
이내용은 link의 to설정값이 /board일때 해소된(즉, resolved) 경로 정보 입니다.
두내용을 비교해보면 활성화되지 않은 ㄴ메뉴는 match값이 항상 null임을 알 수 있습니다. 그리고 다음 useMatch 코드를 보면 쿼리 매개변수 부분을 제외한 경로, 즉 resolved.pathname과 RoutesSetup.tsx에 설정된 모든 라우터 경로를 비교하여 같을때만 유요한 match결과를 반환함을 알 수 있습니다.