SHIN STORYSHIN STORY
홈포스트C#TypeScriptNext.jsNode.js시리즈
</>SHIN STORY

sdf

탐색

  • 홈
  • 모든 포스트
  • 시리즈
  • 검색

카테고리

  • C#
  • TypeScript
  • Next.js
  • Node.js
  • 알고리즘
  • 개발 도구

© 2025 Shin Blog. All rights reserved.

GitHubRSS
목록으로
Next.js#TypeScript#Next.js#Prisma

7편. 검색·카테고리·태그·시리즈 기능 구현

SHIN

2025년 3월 30일8분 읽기0
📚

Next.js App Router로 풀스택 블로그 만들기

9편
  1. 11편. 프로젝트 소개와 기술 스택 선정
  2. 22편. 프로젝트 초기 설정과 디렉토리 구조
  3. 33편. Prisma 스키마 설계와 마이그레이션
  4. 44편. NextAuth.js로 관리자 인증 구현하기
  5. 55편. 관리자 대시보드 — 포스트 CRUD 구현
  6. 66편. 블로그 프론트엔드와 마크다운 렌더링
  7. 7편. 검색·카테고리·태그·시리즈 기능 구현현재
  8. 98편. SEO 최적화 — 메타태그·sitemap·RSS
  9. 99편. Vercel 배포와 성능 최적화

7편. 검색·카테고리·태그·시리즈 기능 구현

전문 검색 구현

Prisma의 OR + contains로 제목, 내용, 태그, 카테고리를 동시에 검색합니다.

CODE
const where = {
  status: 'PUBLISHED',
  OR: [
    { title:   { contains: query } },
    { excerpt: { contains: query } },
    { content: { contains: query } },
    { tags:     { some: { tag: { name: { contains: query } } } } },
    { category: { name: { contains: query } } },
  ],
}

const [posts, total] = await Promise.all([
  prisma.post.findMany({ where, take, skip, orderBy: { publishedAt: 'desc' } }),
  prisma.post.count({ where }),
])

SQLite FTS5를 사용하면 훨씬 빠른 전문 검색이 가능하지만, 이 시리즈에서는 Prisma 레벨로 단순하게 구현합니다.

검색 결과 하이라이팅

CODE
function highlight(text: string, query: string) {
  if (!query.trim()) return <>{text}</>
  const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
  const parts = text.split(new RegExp(`(${escaped})`, 'gi'))
  return (
    <>
      {parts.map((part, i) =>
        part.toLowerCase() === query.toLowerCase()
          ? <mark key={i} className="bg-indigo-500/30 text-indigo-200 rounded px-0.5">{part}</mark>
          : part
      )}
    </>
  )
}

URL 기반 필터링 — searchParams 패턴

필터 상태를 URL에 담으면 새로고침, 공유, 북마크 모두 동작합니다.

CODE
// src/components/blog/PostsFilterSidebar.tsx
'use client'
import { useRouter, useSearchParams } from 'next/navigation'

export function PostsFilterSidebar({ categories, tags }) {
  const router = useRouter()
  const sp = useSearchParams()

  const setFilter = (key: string, value: string | null) => {
    const params = new URLSearchParams(sp.toString())
    if (value) params.set(key, value)
    else params.delete(key)
    params.delete('page') // 필터 변경 시 1페이지로 리셋
    router.push(`/posts?${params.toString()}`)
  }

  return (
    <aside>
      <h3>카테고리</h3>
      {categories.map(cat => (
        <button
          key={cat.id}
          onClick={() => setFilter('category', sp.get('category') === cat.slug ? null : cat.slug)}
          className={sp.get('category') === cat.slug ? 'active' : ''}
        >
          {cat.name}
        </button>
      ))}
    </aside>
  )
}

페이지네이션 컴포넌트

CODE
function Pagination({ page, totalPages, buildUrl }) {
  const pages = Array.from({ length: totalPages }, (_, i) => i + 1)
    .filter(p => p === 1 || p === totalPages || Math.abs(p - page) <= 2)
    .reduce<(number | '...')[]>((acc, p, i, arr) => {
      if (i > 0 && p - (arr[i - 1] as number) > 1) acc.push('...')
      acc.push(p)
      return acc
    }, [])

  return (
    <div className="flex gap-1 justify-center">
      {page > 1 && <Link href={buildUrl(page - 1)}>이전</Link>}
      {pages.map((p, i) =>
        p === '...'
          ? <span key={`d${i}`}>…</span>
          : <Link key={p} href={buildUrl(p)} className={p === page ? 'active' : ''}>{p}</Link>
      )}
      {page < totalPages && <Link href={buildUrl(page + 1)}>다음</Link>}
    </div>
  )
}

시리즈 박스 — 현재 포스트 강조

포스트 상세에서 같은 시리즈의 목록을 보여주고 현재 편을 강조합니다.

CODE
{post.series.map(({ series }) => (
  <div key={series.id} className="series-box">
    <h3>{series.name}</h3>
    <ol>
      {series.posts.map((sp) => {
        const isCurrent = sp.postId === post.id
        return (
          <li key={sp.postId} className={isCurrent ? 'current' : ''}>
            {isCurrent ? (
              <span>{sp.post.title} <Badge>현재</Badge></span>
            ) : (
              <Link href={`/posts/${sp.post.slug}`}>{sp.post.title}</Link>
            )}
          </li>
        )
      })}
    </ol>
  </div>
))}

다음 편에서는 OpenGraph, JSON-LD, sitemap, RSS 등 SEO 최적화를 다룹니다.

공유
S

SHIN

.NET 개발자입니다

GitHub

이전 포스트

6편. 블로그 프론트엔드와 마크다운 렌더링

다음 포스트

8편. SEO 최적화 — 메타태그·sitemap·RSS

같은 카테고리 포스트

9편. Vercel 배포와 성능 최적화

2025년 4월 10일· 7분

8편. SEO 최적화 — 메타태그·sitemap·RSS

2025년 4월 5일· 2분

6편. 블로그 프론트엔드와 마크다운 렌더링

2025년 3월 25일· 10분

댓글