안녕하세요, 단테입니다. 오늘은 next.js 13의 라우팅 세번째 시간으로 아래 내용을 같이 다루겠습니다.
이전 라우팅 강의에서 동적 라우팅에 대해 배웠습니다. 대괄호를 사용해 [slug]
와 같이 세그먼트를 만들어 사용했었는데요,
이 때 동적으로 생성되는 부분이 slug라는 url path의 이름 뿐만 아니라 계층까지 확장된 개념이 catch all route입니다.
예를 들어 docs/[slug]
는 docs/1, docs/2, docs/3 ...와 같이 확장될 수 있으나
docs/1/1/ docs/1/2와 같이 또 다른 계층으로 확장되지는 못합니다.
이를 가능하게 해주는 것이 catch all segments 기능입니다.
아래와 같이 shop 세그먼트를 생성해봅시다. 판매 사이트에서 다룰 수 있는 아이템 카테고리는 필요에 따라 유연하게 변경될 수 있기에 slug의 종류나 계층또한 유연하게 변경될 수 있어야 합니다.
/shop/clothes/tops
나 /shop/clothes/tops/t-shirts
와 같은 모양도 가능해야 하죠.
위의 폴더 구조같이 app 아래 [...slug]
를 생성합니다.
catch all segments의 convention은 대괄호 내부에 ... 표기를 prefix로 같이 붙이는 것입니다. ..와 같이 '.'의 갯수가 달라지면 에러를 발생시키니 유의하세요!
page.tsx에 전달되는 props 중 params로 call all segments를 이루는 세그먼트들을 배열 형식으로 받아볼 수 있습니다.
// app/shop/[...slug]/page.tsx
type ShopPageProps = {
params: {
slug: string[]
}
}
const ShopPage = ({ params }: ShopPageProps) => {
console.log(params)
return (
...
)
catch all segments를 사용해 온라인 쇼핑몰에서 동적으로 데이터를 표현해볼까요?
category/id
형식으로 사용자가 페이지에 접근했을때 데이터베이스에 존재하는 데이터의 경우 아래와 같이 표기하고
존재하지 않는 카테고리 혹은 아이템의 경우는 아래와 같이 빈 페이지를 보여주는 경우 catch all segments는 굉장히 유용하게 사용할 수 있습니다.
아래와 같이 코드를 작성하겠습니다.
type ShopPageProps = {
params: {
slug: string[]
}
}
const products = [
{
id: 1,
name: "T-Shirt fabric",
category: "T-Shirts",
img: "https://plus.unsplash.com/premium_photo-1673356301514-2cad91907f74?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MXx8dCUyMHNoaXJ0c3xlbnwwfHwwfHx8MA%3D%3D&auto=format&fit=crop&w=1400&q=60",
},
{
id: 2,
name: "T-Shirt non-fabric",
category: "T-Shirts",
img: "https://images.unsplash.com/photo-1562157873-818bc0726f68?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8NHx8dCUyMHNoaXJ0c3xlbnwwfHwwfHx8MA%3D%3D&auto=format&fit=crop&w=1400&q=60",
},
{
id: 3,
name: "cap",
category: "Hats",
img: "https://images.unsplash.com/photo-1521369909029-2afed882baee?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8Mnx8aGF0fGVufDB8fDB8fHww&auto=format&fit=crop&w=1400&q=60",
},
{
id: 4,
name: "Car SUV",
category: "Cars",
img: "https://images.unsplash.com/photo-1533473359331-0135ef1b58bf?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8M3x8Y2FyfGVufDB8fDB8fHww&auto=format&fit=crop&w=1400&q=60",
},
{
id: 5,
name: "Car PORSCHE",
category: "Cars",
img: "https://images.unsplash.com/photo-1503376780353-7e6692767b70?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8OHx8Y2FyfGVufDB8fDB8fHww&auto=format&fit=crop&w=1400&q=60",
},
{
id: 6,
name: "Shoes Canvas",
category: "Shoes",
img: "https://plus.unsplash.com/premium_photo-1665664652418-91f260a84842?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MXx8c2hvZXN8ZW58MHx8MHx8fDA%3D&auto=format&fit=crop&w=1400&q=60",
},
{
id: 7,
name: "Shoes Nike",
category: "Shoes",
img: "https://images.unsplash.com/photo-1542291026-7eec264c27ff?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8Mnx8c2hvZXN8ZW58MHx8MHx8fDA%3D&auto=format&fit=crop&w=1400&q=60",
},
]
const ShopPage = ({ params }: ShopPageProps) => {
const category = params.slug[0]
const itemId = params.slug[1] ?? 0
const renderProductsByCategory = (category: string, itemId: number) => {
const filteredProducts = products.filter(
(product) => product.category.toLowerCase() === category.toLowerCase()
)
const product = filteredProducts[itemId - 1]
if (filteredProducts.length === 0 || !product) {
return <p className="text-gray-600">No items in this category.</p>
}
return (
<div className="bg-white rounded shadow-lg p-6" key={product.id}>
<img src={product.img} alt="Product" className="w-full mb-4 rounded" />
<h2 className="text-lg font-semibold mb-2">{product.name}</h2>
<p className="text-gray-600 mb-4">Product description...</p>
<button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
Add to Cart
</button>
</div>
)
}
return (
<div className="bg-gray-100 text-black">
<h1 className="text-4xl font-semibold mb-6">
Welcome to the Dante Shopping Mall
</h1>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-2 gap-8">
<h2 className="text-2xl font-semibold mb-4">
{category.toUpperCase()}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-1 lg:grid-cols-2 gap-8 md:w-2/3">
{renderProductsByCategory(category, +itemId)}
</div>
</div>
</div>
)
}
export default ShopPage
코드를 잠깐 잘라서 보면
아래와 같이 실습에 필요한더미 데이터를 만들었습니다.
const products = [
{
id: 1,
name: "T-Shirt fabric",
category: "T-Shirts",
img: "https://plus.unsplash.com/premium_photo-1673356301514-2cad91907f74?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MXx8dCUyMHNoaXJ0c3xlbnwwfHwwfHx8MA%3D%3D&auto=format&fit=crop&w=1400&q=60",
},
{
id: 2,
name: "T-Shirt non-fabric",
category: "T-Shirts",
img: "https://images.unsplash.com/photo-1562157873-818bc0726f68?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8NHx8dCUyMHNoaXJ0c3xlbnwwfHwwfHx8MA%3D%3D&auto=format&fit=crop&w=1400&q=60",
},
{
id: 3,
name: "cap",
category: "Hats",
img: "https://images.unsplash.com/photo-1521369909029-2afed882baee?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8Mnx8aGF0fGVufDB8fDB8fHww&auto=format&fit=crop&w=1400&q=60",
},
{
id: 4,
name: "Car SUV",
category: "Cars",
img: "https://images.unsplash.com/photo-1533473359331-0135ef1b58bf?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8M3x8Y2FyfGVufDB8fDB8fHww&auto=format&fit=crop&w=1400&q=60",
},
{
id: 5,
name: "Car PORSCHE",
category: "Cars",
img: "https://images.unsplash.com/photo-1503376780353-7e6692767b70?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8OHx8Y2FyfGVufDB8fDB8fHww&auto=format&fit=crop&w=1400&q=60",
},
{
id: 6,
name: "Shoes Canvas",
category: "Shoes",
img: "https://plus.unsplash.com/premium_photo-1665664652418-91f260a84842?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MXx8c2hvZXN8ZW58MHx8MHx8fDA%3D&auto=format&fit=crop&w=1400&q=60",
},
{
id: 7,
name: "Shoes Nike",
category: "Shoes",
img: "https://images.unsplash.com/photo-1542291026-7eec264c27ff?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8Mnx8c2hvZXN8ZW58MHx8MHx8fDA%3D&auto=format&fit=crop&w=1400&q=60",
},
]
ShopPage 컴포넌트 내부에서는 이 데이터를 파싱하고 카드 컴포넌트로 그려주기 위한 함수를 만듭니다.
const category = params.slug[0]
const itemId = params.slug[1] ?? 0
const renderProductsByCategory = (category: string, itemId: number) => {
const filteredProducts = products.filter(
(product) => product.category.toLowerCase() === category.toLowerCase()
)
const product = filteredProducts[itemId - 1]
if (filteredProducts.length === 0 || !product) {
return <p className="text-gray-600">No items in this category.</p>
}
return (
<div className="bg-white rounded shadow-lg p-6" key={product.id}>
<img src={product.img} alt="Product" className="w-full mb-4 rounded" />
<h2 className="text-lg font-semibold mb-2">{product.name}</h2>
<p className="text-gray-600 mb-4">Product description...</p>
<button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
Add to Cart
</button>
</div>
)
}
renderProductsByCategory
함수는 사용자가 진입한 url path의 category와 itemId를 인자로 받습니다.
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-2 gap-8">
<h2 className="text-2xl font-semibold mb-4">
{category.toUpperCase()}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-1 lg:grid-cols-2 gap-8 md:w-2/3">
{renderProductsByCategory(category, +itemId)}
</div>
</div>
이 함수를 호출하는 부분을 보면 slug에서 추출한 category와 itemId를 인자로 사용하고 있습니다.
이렇게 작성된 앱의 동작은 아래와 같습니다.
그런데 우리가 만든 앱에서는 문제가 있습니다.
사용자가 원하는 제품을 구매하기 위해서는 항상 제품의 유니크 아이디를 알고 주소창에 직접 입력해야 합니다.
localhost:3000/shop
으로 접근했을때 404 에러가 발생하는 것 또한 문제입니다.
이는 catch all segments [...slug]가 /shop url path로 접근했을때 이 path를 포함하지 않기 때문입니다.
이에 대한 대안으로 optional catch all segments를 사용해볼 수 있습니다.
두 catch all segments를 비교해보면 optional catch all segments는 /shop으로 접속시 slug props에 접근할 수 없지만 별도 페이지를 추가 작업할 필요 없이 동일한 segments 안에서 처리할 수 있음을 알 수 있습니다.
optional catch all segments를 사용하기 위해서는 아래와 같이 대괄호로 segment를 한번 더 감싸줍니다.
[[...shop]]
/shop
페이지 접속 시 404가 표기되지 않고 유저가 어떤 아이템이 있는지 확인할 수 있게 변경해보겠습니다.
먼저 디렉토리 이름을 재명명합니다.
그리고 page.tsx를 다음과 같이 수정합니다.
const ShopPage = ({ params }: ShopPageProps) => {
const category = params.slug?.[0] ?? null
const itemId = params.slug?.[1] ?? 0
...
return (
<div className="bg-gray-100 text-black">
<h1 className="text-4xl font-semibold mb-6">
Welcome to the Dante Shopping Mall
</h1>
{category ? (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-2 gap-8">
<h2 className="text-2xl font-semibold mb-4">
{category.toUpperCase()}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-1 lg:grid-cols-2 gap-8 md:w-2/3">
{renderProductsByCategory(category, +itemId)}
</div>
</div>
) : (
<ProductList products={products} />
)}
</div>
)
}
위에서 본 비교 테이블과 같이 params의 slug가 undfined일 수 있기 때문에 optional chaning과 nullish coalescing을 이용해 nullish check를 해주었습니다.
/shop
주소로 들어왔을 때의 category는 null일 것이기 때문에 삼항연산자(ternary operator)로 null일 경우 <ProductList products={products} />
를 표기합니다.
이전 강의에서와 마찬가지로 각 페이지에서 사용되는 컴포넌트들은 app/ui
디렉토리 하위에 생성합니다.
type ProductListProps = {
products: Array<{ id: number; img: string; name: string; category: string }>
}
export const ProductList = ({ products }: ProductListProps) => {
return (
<div className="flex flex-wrap justify-center">
{products.map((product) => (
<div key={product.id} className="m-4">
<div className="max-w-sm rounded overflow-hidden shadow-lg">
<img src={product.img} alt={product.name} className="w-full" />
<div className="px-6 py-4">
<div className="font-bold text-xl mb-2">{product.name}</div>
<p className="text-gray-700 text-base">{product.category}</p>
</div>
</div>
</div>
))}
</div>
)
}
optional catch all segements를 적용했습니다. localhost:3000/shop으로 접속하여 다음과 같이 리스트가 잘 표기되는지 확인해봅니다.
현재 각 카드를 눌러도 해당 카드 id에 해당하는 주소로 이동하지 않습니다.
지난 강의에서 배운 페이지간 네비게이션 기법을 사용해 카드를 클릭시 해당 주소로 이동하도록 코드를 수정해보세요!
오늘 배운 catch all segements 까지 마스터하셨다면 동적 라우팅에 대한 전반적이 내용을 모두 이해하셨습니다. 직접 실습을 해보시며 오늘 새로 배운 개념들에 대한 이해도를 올리시기 바랍니다.
다음 강의에서 뵙겠습니다!
마지막에 내주신 과제에서 다음과 같은 어려움을 겪고 있습니다.
ui/ProductList.tsx에서 'use client' 로 설정하고
ProductList 함수 return문에서 {products.map((product) => (
이렇게까지는 해봤는데 마지막 id값을 어떻게 주어야 할지 잘 모르겠습니다.
위와 같이 하면 t-shirt까지는 문제가 없으나 cap을 클릭시 /shop/cap/3 으로 라우팅되고 3번에는 아이템이 없기때문에 no items를 랜더합니다. cap을 클릭시 /shop/cap/1 으로 라우팅을 시키려면 어떻게 해야할지 모르겠습니다..