Ch.1~4에서 배운 개념을 총동원하여 블로그를 고도화합니다. 구체적으로:
기존 db.json에 category 필드를 추가하고, 글을 4개로 확장합니다:
{
"posts": [
{
"id": 1,
"slug": "nextjs-routing",
"title": "Next.js 라우팅 이해하기",
"category": "nextjs"
},
{
"id": 2,
"slug": "react-server-components",
"title": "React 서버 컴포넌트란?",
"category": "react"
},
{
"id": 3,
"slug": "react-hooks-guide",
"title": "React Hooks 완벽 가이드",
"category": "react"
},
{
"id": 4,
"slug": "nextjs-data-fetching",
"title": "Next.js 데이터 가져오기",
"category": "nextjs"
}
]
}
json-server를 재시작하면 http://localhost:4000/posts?category=react처럼 카테고리별 필터링이 자동으로 지원됩니다.
http://localhost:4000/posts에 접속하여 4개의 글이 표시되는지 확인합니다. ?category=react를 붙이면 React 카테고리 글만 나옵니다.
세션 1에서 배운 NavLink 컴포넌트를 적용해보겠습니다.
// app/components/NavLink.tsx
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
export function NavLink({
href,
children,
}: {
href: string;
children: React.ReactNode;
}) {
const pathname = usePathname();
const isActive = pathname === href || pathname.startsWith(href + '/');
return (
<Link
href={href}
className={isActive ? 'font-bold text-blue-600' : 'text-inherit'}
>
{children}
</Link>
);
}
// app/layout.tsx
...
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang='ko'>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<header>
<nav>
<NavLink href='/'>홈</NavLink>
<NavLink href='/blog'>글 목록</NavLink>
<NavLink href='/about'>소개</NavLink>
</nav>
</header>
<main>{children}</main>
<footer>Copyright 2026 My Blog</footer>
</body>
</html>
);
}
/blog/nextjs-routing 같은 하위 경로에서도 "블로그" 링크가 활성 상태여야 합니다.
이 Step이 이번 실습의 핵심입니다. Ch.1~4의 개념이 모두 적용됩니다:
app/blog/page.tsx)fetch로 필터링된 데이터 조회)Ch.2에서는 useState로 카테고리 열기/닫기를 관리했습니다. 이제 Link와 useSearchParams로 URL 기반 필터링으로 전환합니다:
// app/components/CategorySidebar.tsx
'use client';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
const categories = [
{ key: 'react', label: 'React' },
{ key: 'nextjs', label: 'Next.js' },
];
export function CategorySidebar() {
const searchParams = useSearchParams();
const current = searchParams.get('category');
return (
<ul>
<li>
<Link
href='/blog'
className={!current ? 'font-bold' : undefined}
>
전체
</Link>
</li>
{categories.map((category) => (
<li key={category.key}>
<Link
href={`/blog?category=${category.key}`}
className={current === category.key ? 'font-bold' : undefined}
>
{category.label}
</Link>
</li>
))}
</ul>
);
}
변경 전후를 비교하면:
| Before (Ch.2) | After (Ch.4) | |
|---|---|---|
| 상태 관리 | useState (컴포넌트 내부) | URL 쿼리 스트링 |
| 카테고리 선택 | onClick → setState | <Link href="?category=..."> |
| 새로고침 시 | 선택 초기화 | 유지됨 |
| URL 공유 | 불가 | 가능 (/blog?category=react) |
서버 컴포넌트인 블로그 페이지에서 searchParams prop으로 카테고리를 읽고, 필터링된 데이터를 가져옵니다:
// app/blog/page.tsx
import Link from 'next/link';
import { SearchablePostList } from '../components/SearchablePostList';
export default async function Blog({
searchParams,
}: {
searchParams: Promise<{ category?: string }>;
}) {
const { category } = await searchParams;
const url = category
? `http://localhost:4000/posts?category=${category}`
: 'http://localhost:4000/posts';
const res = await fetch(url);
const posts = await res.json();
return (
<div>
<h1>글 목록</h1>
<SearchablePostList posts={posts} />
<Link href='/blog/new'>새 글 작성</Link>
</div>
);
}
흐름을 정리하면:
/blog?category=react로 변경searchParams에서 category 값을 읽음fetch URL에 카테고리 쿼리를 추가하여 필터링된 데이터만 조회useSearchParams로 현재 카테고리를 읽어 활성 상태 표시searchParams → fetch URL). 현재 선택된 카테고리의 UI 표시는 클라이언트에서 처리합니다 (CategorySidebar의 useSearchParams). 같은 URL 쿼리를 서버와 클라이언트가 각자의 역할에 맞게 사용하는 패턴입니다.
/blog?category=react처럼 변경되고, 새로고침해도 필터 상태가 유지되어야 합니다.
존재하지 않는 slug로 접속하면 에러 대신 친절한 404 페이지를 보여줍니다.
// app/blog/[slug]/page.tsx
...
export default async function Post({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const res = await fetch(`http://localhost:4000/posts?slug=${slug}`);
const posts = await res.json();
if (posts.length === 0) {
notFound();
}
const post = posts[0];
return (
<div>
<h1>{post.title}</h1>
<p>이 글의 내용이 여기에 표시됩니다.</p>
<LikeButton />
</div>
);
}
// app/blog/[slug]/not-found.tsx
import Link from 'next/link';
export default function NotFound() {
return (
<div>
<h2>글을 찾을 수 없습니다</h2>
<p>요청한 글이 존재하지 않습니다.</p>
<Link href='/blog'>글 목록으로 돌아가기</Link>
</div>
);
}
not-found.tsx는 loading.tsx처럼 Next.js의 특수 파일입니다. notFound()가 호출되면 가장 가까운 not-found.tsx를 찾아 렌더링합니다. 블로그 레이아웃 안에서 표시되므로 사이드바도 함께 보입니다.
/blog/없는-slug-123 같은 존재하지 않는 경로에 접속합니다. "글을 찾을 수 없습니다" 메시지와 목록으로 돌아가는 링크가 표시되면 성공입니다.
4개의 챕터를 거쳐 완성된 프로젝트 구조입니다:
app/
├── about/
│ └── page.tsx [서버] 소개 페이지
├── blog/
│ ├── [slug]/
│ │ ├── not-found.tsx 커스텀 404
│ │ └── page.tsx [서버] fetch + notFound()
│ ├── new/
│ │ └── page.tsx [서버] PostForm import
│ ├── layout.tsx [서버] 블로그 레이아웃
│ ├── loading.tsx 자동 로딩 UI
│ └── page.tsx [서버] searchParams + fetch
├── components/
│ ├── CategorySidebar.tsx [클라이언트] Link + useSearchParams
│ ├── LikeButton.tsx [클라이언트] 좋아요 버튼
│ ├── NavLink.tsx [클라이언트] usePathname + Link
│ ├── PostForm.tsx [클라이언트] useActionState + 폼
│ └── SearchablePostList.tsx [클라이언트] 검색 + 목록 표시
├── actions.ts [서버] 'use server' - createPost
├── layout.tsx [서버] NavLink 내비게이션 + 루트 레이아웃
└── page.tsx [서버] 홈 페이지
db.json json-server 데이터 (카테고리 포함)
| 챕터 | 적용한 개념 | 적용 위치 |
|---|---|---|
| Ch.1 | 파일 기반 라우팅, 레이아웃 시스템 | 전체 폴더 구조, layout.tsx |
| Ch.2 | 서버/클라이언트 경계, children/props 전달 | CategorySidebar, NavLink (클라이언트) + 페이지 (서버) |
| Ch.3 | 서버 fetching, Server Actions | blog/page.tsx의 fetch, actions.ts |
| Ch.4 | Link, usePathname, useSearchParams, notFound | NavLink, CategorySidebar, not-found.tsx |
빈 폴더에서 시작해서, 라우팅을 만들고, 서버와 클라이언트의 경계를 나누고, 데이터를 가져오고, URL로 상태를 관리하는 블로그를 완성했습니다. 4개 챕터 동안 만든 건 블로그지만, 진짜 얻은 건 Next.js 앱을 스스로 설계하고 만들어낼 수 있다는 자신감입니다.
이제 공식 문서를 펼쳐도 "이게 무슨 말이지?" 대신 "아, 이건 그때 해본 거네"가 먼저 떠오를 겁니다. 여기까지 해낸 여러분, 진심으로 대단합니다!
블로그를 더 발전시키고 싶다면:
generateMetadata로 SEO 최적화