지난 시간에는 글 목록과 상세 페이지를 완성했습니다. 이번에는 헤더에 미리 걸어둔 두 링크(/tags, /search)를 채웁니다 — 태그 시스템과 검색 기능이 추가됩니다.
태그 목록 페이지
먼저 모든 태그를 보여주는 페이지부터. 각 태그 옆에 그 태그가 붙은 글 개수를 표시할 거예요.
유틸 함수 추가
src/app/lib/posts.js에 태그 관련 함수를 추가합니다.
export function getAllTags() {
const posts = getAllPosts();
const tagCount = {};
for (const post of posts) {
for (const tag of post.frontmatter.tags ?? []) {
tagCount[tag] = (tagCount[tag] ?? 0) + 1;
}
}
return Object.entries(tagCount)
.map(([tag, count]) => ({ tag, count }))
.sort((a, b) => b.count - a.count);
}
export function getPostsByTag(tag) {
return getAllPosts().filter(post =>
(post.frontmatter.tags ?? []).includes(tag)
);
}getAllTags는 [{ tag, count }, ...] 형태의 배열을 반환합니다 (개수 내림차순). getPostsByTag(tag)는 그 태그가 붙은 글만 필터링.
/tags 페이지
src/app/tags/page.js:
import Link from 'next/link';
import { getAllTags } from '../lib/posts';
export default function TagsPage() {
const tags = getAllTags();
return (
<main style={{ maxWidth: '600px', margin: '0 auto', padding: '24px' }}>
<h1>태그</h1>
{tags.length === 0 ? (
<p>태그가 없습니다.</p>
) : (
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{tags.map(({ tag, count }) => (
<Link
key={tag}
href={`/tags/${tag}`}
style={{
padding: '6px 12px',
background: '#f0f0f0',
borderRadius: '16px',
textDecoration: 'none',
color: '#333',
}}
>
#{tag} <span style={{ color: '#888', fontSize: '12px' }}>({count})</span>
</Link>
))}
</div>
)}
</main>
);
}저장하고 /tags에 접속하면 모든 태그가 (글 개수 내림차순) 표시됩니다. 클릭하면 그 태그의 글 모음 페이지로 이동.
태그별 글 모음 페이지
src/app/tags/[tag]/page.js:
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { getAllTags, getPostsByTag } from '../../lib/posts';
import PostCard from '../../PostCard';
export async function generateStaticParams() {
return getAllTags().map(({ tag }) => ({ tag }));
}
export default async function TagPage({ params }) {
const { tag } = await params;
const decodedTag = decodeURIComponent(tag);
const posts = getPostsByTag(decodedTag);
if (posts.length === 0) {
notFound();
}
return (
<main style={{ maxWidth: '600px', margin: '0 auto', padding: '24px' }}>
<Link href="/tags" style={{ fontSize: '14px' }}>← 태그 목록</Link>
<h1>#{decodedTag}</h1>
<p>{posts.length}개의 글</p>
<ul style={{ listStyle: 'none', padding: 0 }}>
{posts.map(post => (
<PostCard key={post.slug} post={post} />
))}
</ul>
</main>
);
}핵심 포인트:
decodeURIComponent
URL의 :tag 부분에 한글 태그(예: "공지")가 들어올 때, 브라우저가 자동으로 URL 인코딩합니다 (/tags/%EA%B3%B5%EC%A7%80). params.tag로 받을 때는 인코딩된 채로 들어오니 decodeURIComponent로 복원해야 데이터와 매칭됩니다.
generateStaticParams로 정적 생성
export async function generateStaticParams() {
return getAllTags().map(({ tag }) => ({ tag }));
}존재하는 태그들에 대해 빌드 시점에 정적 페이지를 생성합니다. 글 상세 페이지와 동일 패턴.
PostCard 재사용
PostCard를 그대로 가져다 씁니다. #2에서 글 목록용으로 만든 컴포넌트인데, 똑같은 모양이 태그 페이지에도 어울려요. 재사용 가능한 단위로 분리해둔 효과가 여기서 나옵니다.
동작 확인 (태그 부분)
/tags— 모든 태그가 개수 순으로 표시- 태그 클릭 → 그 태그의 글 모음 페이지로 이동
- 한글 태그 (
/tags/공지같은) URL이 정상 동작 - 존재하지 않는 태그 (
/tags/없는태그) → 404 - 글 카드의 태그를 클릭해도 같은 페이지로 이동
검색 — searchParams
검색은 태그와 좀 다른 패턴입니다. /search?q=리액트 같은 쿼리 스트링으로 동작하므로, 동적 폴더가 아니라 같은 페이지 안에서 searchParams를 읽는 방식이에요.
검색 유틸 함수
src/app/lib/posts.js:
export function searchPosts(query) {
if (!query || !query.trim()) return [];
const q = query.toLowerCase();
return getAllPosts().filter(post => {
const title = post.frontmatter.title.toLowerCase();
const description = (post.frontmatter.description ?? '').toLowerCase();
const content = post.content.toLowerCase();
return title.includes(q) || description.includes(q) || content.includes(q);
});
}단순한 substring 매칭입니다. 실전에선 형태소 분석, 가중치 등이 들어가지만 학습 목적은 충분하죠. 큰 블로그라면 Algolia, Meilisearch, Pagefind 같은 검색 엔진을 도입하는 게 일반적입니다.
검색 페이지
src/app/search/page.js:
import { searchPosts } from '../lib/posts';
import PostCard from '../PostCard';
import SearchInput from './SearchInput';
export default async function SearchPage({ searchParams }) {
const params = await searchParams;
const query = params.q ?? '';
const results = query ? searchPosts(query) : [];
return (
<main style={{ maxWidth: '600px', margin: '0 auto', padding: '24px' }}>
<h1>검색</h1>
<SearchInput defaultQuery={query} />
{query && (
<p style={{ marginTop: '16px', color: '#555' }}>
"{query}" 검색 결과 {results.length}건
</p>
)}
{query && results.length === 0 ? (
<p style={{ color: '#888' }}>검색 결과가 없습니다.</p>
) : (
<ul style={{ listStyle: 'none', padding: 0, marginTop: '16px' }}>
{results.map(post => (
<PostCard key={post.slug} post={post} />
))}
</ul>
)}
</main>
);
}핵심:
searchParams도 Promise
const params = await searchParams;
const query = params.q ?? '';params와 마찬가지로 Next.js 15부터 searchParams도 Promise입니다. await 후 객체로 사용.
검색 페이지는 동적 렌더
searchParams를 읽는 페이지는 자동으로 동적 렌더 모드가 됩니다. 매 요청마다 검색이 새로 실행되니 자연스럽죠 (정적 생성을 하면 모든 가능한 검색어를 미리 알 수 없으니까요).
검색 입력창 — Client Component
검색어 입력창은 사용자 인터랙션이 필요하니 Client Component입니다.
src/app/search/SearchInput.jsx:
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export default function SearchInput({ defaultQuery = '' }) {
const [query, setQuery] = useState(defaultQuery);
const router = useRouter();
function handleSubmit(e) {
e.preventDefault();
if (query.trim()) {
router.push(`/search?q=${encodeURIComponent(query.trim())}`);
} else {
router.push('/search');
}
}
return (
<form onSubmit={handleSubmit}>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="검색어 입력 후 엔터"
style={{ width: '100%', padding: '8px', fontSize: '16px' }}
/>
</form>
);
}useRouter를 사용해 폼 제출 시 /search?q=...로 이동시킵니다. 이동하면 같은 페이지가 새 searchParams로 다시 렌더되어 결과가 갱신돼요.
동작 확인 (검색 부분)
/search— 빈 검색 페이지- 검색어 입력 후 엔터 → URL이
/search?q=리액트등으로 바뀌고 결과 표시 - URL을 직접 입력 (
/search?q=공지) — 검색이 그대로 동작 - 검색 결과 없으면 안내 문구
- 결과 글의 태그 클릭 → 태그 페이지로 이동
URL이 검색 상태를 그대로 담고 있으니 URL을 공유하면 같은 검색 결과를 볼 수 있고, 새로고침해도 검색어가 유지됩니다. SPA 검색 박스에서는 별도 처리가 필요한 일이 자연스럽게 해결돼요.
디바운스 검색 (선택)
지금 검색은 폼 제출(엔터)이 있어야 동작합니다. 입력 중 실시간 검색을 원한다면 useDebounce(#13)를 활용한 디바운스 패턴을 쓸 수 있어요.
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
function useDebounce(value, delay) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
export default function SearchInput({ defaultQuery = '' }) {
const [query, setQuery] = useState(defaultQuery);
const debounced = useDebounce(query, 400);
const router = useRouter();
useEffect(() => {
const target = debounced.trim()
? `/search?q=${encodeURIComponent(debounced.trim())}`
: '/search';
router.replace(target);
}, [debounced, router]);
return (
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="입력하면 자동 검색"
style={{ width: '100%', padding: '8px', fontSize: '16px' }}
/>
);
}400ms 동안 입력이 멈추면 URL을 갱신하고, 그러면 Server Component가 다시 실행되어 검색 결과를 보내줍니다. **router.replace**를 쓴 이유는 매 디바운스마다 history에 새 entry가 쌓이지 않게 하기 위함이에요 (뒤로 가기 버튼이 검색 키 입력 하나씩 거꾸로 가는 걸 방지).
이 시리즈에서는 단순함을 위해 폼 제출 방식으로 가지만, 디바운스 패턴은 알아두면 좋아요.
빈 상태 처리 정교화
검색 페이지의 빈 상태를 더 친절하게 만들어볼까요? 검색어가 없을 때, 검색했는데 결과가 없을 때, 결과가 있을 때를 구분합니다.
// ...
return (
<main style={{ maxWidth: '600px', margin: '0 auto', padding: '24px' }}>
<h1>검색</h1>
<SearchInput defaultQuery={query} />
{!query && (
<p style={{ marginTop: '16px', color: '#888' }}>
제목, 요약, 본문에서 검색합니다.
</p>
)}
{query && (
<p style={{ marginTop: '16px', color: '#555' }}>
"{query}" 검색 결과 {results.length}건
</p>
)}
{query && results.length === 0 && (
<p style={{ color: '#888' }}>
검색 결과가 없습니다. 다른 키워드를 시도해보세요.
</p>
)}
<ul style={{ listStyle: 'none', padding: 0, marginTop: '16px' }}>
{results.map(post => (
<PostCard key={post.slug} post={post} />
))}
</ul>
</main>
);세 상태를 명시적으로 분기 — #7에서 다룬 조건부 렌더링 그대로입니다.
한 가지 짚어둘 점 — 정적 vs 동적 라우트
이번 글에서 두 가지 라우팅 패턴이 등장했습니다.
| 라우트 | 패턴 | 정적/동적 | 이유 |
|---|---|---|---|
/tags/[tag] | 동적 폴더 + generateStaticParams | 정적 | 가능한 값들이 빌드 시점에 알려져 있음 |
/search?q=... | 같은 페이지 + searchParams | 동적 | 검색어가 무한 가지수, 미리 생성 불가 |
이 차이를 의식하면 새 페이지를 설계할 때 어떤 도구를 쓸지 자연스럽게 결정됩니다. 알려진 값들의 집합 → 동적 라우트 + 정적 생성, 임의 입력 → 쿼리 파라미터 + 동적 렌더.
마무리
이번 글에서는 두 라우팅 패턴을 다뤘습니다.
- 태그:
/tags/[tag]동적 폴더 +generateStaticParams로 정적 생성 - 검색:
/search?q=...쿼리 파라미터 + 동적 렌더 decodeURIComponent로 한글 URL 처리- 검색 입력창은 Client Component, 결과 페이지는 Server Component
- (선택)
useDebounce로 실시간 검색
지금까지 우리 블로그는 읽기 전용이었습니다. 글을 보고 검색하고 분류만 했지 사용자가 뭔가 입력하는 액션은 없었어요. 다음 글인 "Next.js로 블로그 만들기 #4 댓글 (Server Actions)"에서는 각 글에 댓글을 다는 기능을 만들면서, 모던 리액트 시리즈에서 배운 Server Actions를 본격적으로 활용해보겠습니다.