Next.js 블로그 만들기 — 정적 블로그에 검색 기능 추가

10 min read
검색UXNext.js
Next.js 블로그 만들기 — 정적 블로그에 검색 기능 추가

정적 블로그에서 검색이 가능한가?

글이 6개뿐일 때는 몰랐는데, 점점 늘어나다 보면 "그 글 어디 있었지?"하는 순간이 온다. 특히 직접 쓴 글인데도 찾기 어려우면 독자는 더하다.

정적 블로그에서 검색을 구현하는 방법은 크게 세 가지다:

방식장점단점
Algolia 같은 외부 서비스빠르고 강력한 검색무료 플랜 제한, 외부 의존성
Fuse.js 같은 fuzzy 검색 라이브러리오타 허용, 정교한 매칭번들 사이즈 증가 (~5KB gzip)
빌드 타임 JSON + String.includes()의존성 제로, 구현 간단대규모 블로그에는 부적합

글이 수백 개까지는 세 번째 방식으로 충분하다. JSON 인덱스를 빌드 시 생성하고, 클라이언트에서 단순 문자열 매칭으로 필터링하는 방식이다. 라이브러리 추가 없이 구현 가능하다.

전체 구조

빌드 타임
─────────────────────────
content/*.mdx → generate-search-index.mjs → public/search-index.json

런타임 (클라이언트)
─────────────────────────
fetch(search-index.json) → useSearch 훅 → SearchOverlay UI

빌드 타임에 MDX 파일들의 메타데이터를 JSON으로 뽑아두고, 클라이언트에서는 이 JSON만 fetch해서 검색한다. 본문 내용까지 인덱싱하면 JSON이 커지니, 제목/설명/태그만 대상으로 한다.

검색 인덱스 생성

기존 generate-sitemap.mjs와 동일한 패턴으로 prebuild 스크립트를 만든다.

scripts/generate-search-index.mjs
import fs from 'node:fs'
import path from 'node:path'
import matter from 'gray-matter'
import readingTime from 'reading-time'
 
const CONTENT_DIR = path.join(process.cwd(), 'content')
const OUTPUT_PATH = path.join(process.cwd(), 'public', 'search-index.json')
 
function getSearchIndex() {
  if (!fs.existsSync(CONTENT_DIR)) {
    return []
  }
 
  return fs
    .readdirSync(CONTENT_DIR)
    .filter((file) => file.endsWith('.mdx'))
    .map((file) => {
      const raw = fs.readFileSync(path.join(CONTENT_DIR, file), 'utf-8')
      const { data } = matter(raw)
      const slug = file.replace('.mdx', '')
      const stats = readingTime(raw)
 
      return {
        slug,
        title: data.title,
        description: data.description,
        tags: data.tags ?? [],
        date: data.date,
        readingTime: stats.text,
      }
    })
    .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
}
 
const index = getSearchIndex()
fs.writeFileSync(OUTPUT_PATH, JSON.stringify(index), 'utf-8')

sitemap 스크립트에서 이미 gray-matter로 frontmatter를 파싱하고 있었으니, 같은 패턴을 그대로 쓴다. reading-time도 이미 프로젝트 의존성에 있어서 추가 설치가 필요 없다.

package.json의 prebuild에 체이닝한다:

package.json
{
  "scripts": {
    "prebuild": "node scripts/generate-sitemap.mjs && node scripts/generate-search-index.mjs"
  }
}

pnpm build를 실행하면 prebuild가 먼저 돌면서 public/search-index.json이 자동 생성된다.

useSearch 훅

검색 인덱스를 fetch하고 쿼리에 따라 필터링하는 커스텀 훅이다.

src/hooks/useSearch.ts
'use client'
 
import { useState, useEffect, useMemo } from 'react'
import { BASE_PATH } from '@/lib/constants'
 
interface SearchItem {
  readonly slug: string
  readonly title: string
  readonly description: string
  readonly tags: readonly string[]
  readonly date: string
  readonly readingTime: string
}
 
export function useSearch() {
  const [index, setIndex] = useState<readonly SearchItem[]>([])
  const [query, setQuery] = useState('')
  const [isLoaded, setIsLoaded] = useState(false)
 
  useEffect(() => {
    if (isLoaded) return
 
    fetch(`${BASE_PATH}/search-index.json`)
      .then((res) => res.json())
      .then((data: SearchItem[]) => {
        setIndex(data)
        setIsLoaded(true)
      })
      .catch((err) => {
        console.error('Failed to load search index:', err)
      })
  }, [isLoaded])
 
  const results = useMemo(() => {
    const trimmed = query.trim().toLowerCase()
    if (trimmed.length === 0) return []
 
    return index.filter((item) => {
      const titleMatch = item.title.toLowerCase().includes(trimmed)
      const descMatch = item.description.toLowerCase().includes(trimmed)
      const tagMatch = item.tags.some((tag) =>
        tag.toLowerCase().includes(trimmed)
      )
      return titleMatch || descMatch || tagMatch
    })
  }, [query, index])
 
  return { query, setQuery, results, isLoaded } as const
}

몇 가지 설계 포인트:

  • fetch는 최초 1회만isLoaded 플래그로 중복 요청 방지. 오버레이를 열 때마다 fetch하지 않는다.
  • useMemo로 필터링 — query나 index가 바뀔 때만 재계산. 매 렌더링마다 배열을 순회하지 않는다.
  • toLowerCase().includes() — 한국어도 정상 동작한다. fuzzy 매칭이 필요 없는 이유는 한국어는 자모 분리 없이 완성형으로 비교하면 충분하기 때문이다.

SearchOverlay 컴포넌트

검색 UI의 핵심이 되는 오버레이 모달이다. macOS Spotlight이나 VS Code의 Command Palette처럼 화면 상단에 큰 입력창이 나타나는 형태다.

src/components/search/SearchOverlay.tsx
'use client'
 
import { useEffect, useRef } from 'react'
import Link from 'next/link'
import dayjs from 'dayjs'
import Tag from '@/components/common/Tag'
import { useSearch } from '@/hooks/useSearch'
 
interface SearchOverlayProps {
  readonly onClose: () => void
}
 
export default function SearchOverlay({ onClose }: SearchOverlayProps) {
  const inputRef = useRef<HTMLInputElement>(null)
  const { query, setQuery, results } = useSearch()
 
  // 열리면 자동 포커스
  useEffect(() => {
    inputRef.current?.focus()
  }, [])
 
  // body 스크롤 잠금
  useEffect(() => {
    document.body.style.overflow = 'hidden'
    return () => {
      document.body.style.overflow = ''
    }
  }, [])
 
  // ESC로 닫기
  useEffect(() => {
    function handleKeyDown(e: KeyboardEvent) {
      if (e.key === 'Escape') {
        onClose()
      }
    }
    window.addEventListener('keydown', handleKeyDown)
    return () => window.removeEventListener('keydown', handleKeyDown)
  }, [onClose])
 
  return (
    <div
      className="fixed inset-0 top-14 z-40 bg-bg/80 backdrop-blur-md"
      onClick={(e) => {
        if (e.target === e.currentTarget) onClose()
      }}
    >
      <div className="mx-auto mt-8 max-w-2xl px-4">
        {/* 검색 입력창 */}
        <div className="relative">
          {/* 돋보기 아이콘 */}
          <svg className="absolute top-1/2 left-4 h-5 w-5 -translate-y-1/2 text-text-secondary" ...>
            <circle cx="11" cy="11" r="8" />
            <line x1="21" y1="21" x2="16.65" y2="16.65" />
          </svg>
          <input
            ref={inputRef}
            value={query}
            onChange={(e) => setQuery(e.target.value)}
            placeholder="검색어를 입력하세요..."
            className="w-full rounded-xl border border-border bg-bg
              py-4 pr-16 pl-12 text-lg ..."
          />
          <kbd className="absolute top-1/2 right-4 -translate-y-1/2 ...">
            ESC
          </kbd>
        </div>
 
        {/* 검색 결과 */}
        {query.trim().length > 0 && (
          <div className="mt-4 max-h-[60vh] overflow-y-auto rounded-xl border border-border bg-bg">
            {results.map((item) => (
              <Link key={item.slug} href={`/posts/${item.slug}`} onClick={onClose}>
                <h3>{item.title}</h3>
                <p className="line-clamp-1">{item.description}</p>
                <div>{dayjs(item.date).format('YYYY년 M월 D일')} · {item.readingTime}</div>
              </Link>
            ))}
          </div>
        )}
      </div>
    </div>
  )
}

UI/UX에서 신경 쓴 부분들:

  • top-14 z-40 — 오버레이가 GNB(z-50) 아래에서 시작한다. 처음에는 inset-0 z-[60]으로 전체를 덮었는데, GNB가 사라지는 게 어색해서 수정했다.
  • bg-bg/80 backdrop-blur-md — 기존 GNB에서 쓰던 패턴과 동일. 배경이 살짝 비치면서 블러 처리된다.
  • max-h-[60vh] overflow-y-auto — 결과가 많아도 화면의 60%까지만 차지. 나머지는 스크롤.
  • body 스크롤 잠금 — 오버레이가 열려 있는 동안 뒤의 본문이 스크롤되지 않는다. cleanup 함수에서 복원.
  • 배경 클릭으로 닫기e.target === e.currentTarget 비교로 자식 요소 클릭은 무시.

SearchButton과 키보드 단축키

GNB에 들어가는 검색 버튼이다. ThemeToggle과 동일한 스타일을 쓴다.

src/components/search/SearchButton.tsx
'use client'
 
import { useState, useEffect, useCallback } from 'react'
import SearchOverlay from '@/components/search/SearchOverlay'
 
export default function SearchButton() {
  const [isOpen, setIsOpen] = useState(false)
 
  const open = useCallback(() => setIsOpen(true), [])
  const close = useCallback(() => setIsOpen(false), [])
 
  useEffect(() => {
    function handleKeyDown(e: KeyboardEvent) {
      if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
        e.preventDefault()
        setIsOpen((prev) => !prev)
      }
    }
    window.addEventListener('keydown', handleKeyDown)
    return () => window.removeEventListener('keydown', handleKeyDown)
  }, [])
 
  return (
    <>
      <button
        type="button"
        onClick={open}
        className="flex h-9 w-9 items-center justify-center rounded-lg
          text-text-secondary transition-colors hover:bg-bg-secondary"
        aria-label="검색"
      >
        {/* 돋보기 SVG */}
      </button>
      {isOpen && <SearchOverlay onClose={close} />}
    </>
  )
}

Cmd+K(macOS) / Ctrl+K(Windows, Linux) 단축키를 지원한다. 개발자들에게 익숙한 패턴이다. e.preventDefault()를 호출해서 브라우저 기본 동작(주소창 포커스 등)을 막는다.

토글 방식이라서 이미 열려 있을 때 다시 누르면 닫힌다.

GNB에 통합

Navbar에 SearchButton을 추가한다. ThemeToggle 왼쪽에 배치.

src/components/layout/Navbar.tsx
import SearchButton from '@/components/search/SearchButton'
import ThemeToggle from '@/components/common/ThemeToggle'
 
export default function Navbar() {
  return (
    <header className="fixed top-0 ... z-50 ...">
      <nav className="mx-auto flex h-14 max-w-5xl items-center justify-between px-4">
        <Link href="/">...</Link>
        <div className="flex items-center gap-1">
          <SearchButton />
          <ThemeToggle />
        </div>
      </nav>
    </header>
  )
}

gap-1로 버튼 사이에 4px 간격을 준다. 두 버튼이 같은 h-9 w-9 사이즈라서 정렬이 자연스럽다.

결과

검색 기능이 정상 동작하는지 확인할 포인트:

  • GNB 우측에 돋보기 아이콘 표시
  • 클릭하면 오버레이가 열리고 입력창에 자동 포커스
  • 한국어/영어 검색어 입력 시 실시간 필터링
  • 결과 클릭하면 해당 포스트로 이동 + 오버레이 닫힘
  • ESC, 배경 클릭, Cmd+K로 오버레이 닫힘
  • 다크/라이트 모드 모두 정상 표시

추후 글이 많아지면 디바운싱이나 Fuse.js 같은 fuzzy 검색으로 업그레이드할 수 있지만, 지금은 이 정도면 충분하다.

관심 있을 만한 포스트

Next.js 블로그 만들기 — 정적 블로그에 맞춤 추천 포스트 기능 추가

localStorage에 조회 이력을 저장하고, 태그 가중치 스코어링으로 정적 블로그에서도 개인화 추천을 구현하는 방법.

Next.jslocalStorage

Next.js 블로그 만들기 — 검색엔진 등록 4종 완전 정복

Google Search Console, Bing Webmaster Tools, 네이버 서치어드바이저, 다음 검색등록까지. Next.js 블로그를 검색엔진에 노출시키는 전체 과정을 정리했다.

SEO검색엔진

Next.js 블로그 만들기 — 스크롤 프로그레스 바와 Canvas 렌더링 이슈 해결

스크롤 진행률 프로그레스 바 구현과 Canvas 커서 효과가 GNB backdrop-blur와 충돌하며 발생한 깜빡임 이슈 해결기.

CanvasUX

Next.js 블로그 만들기 — TOC와 커서 효과로 디테일 살리기

IntersectionObserver 기반 TOC(Table of Contents)와 Canvas 커서 트레일 효과 구현기. 스크롤 하이라이팅, fixed 레이아웃 처리까지.

TOCCanvas

Next.js 블로그 만들기 — 카드 그리드와 포스트 상세 페이지

Velog 스타일 카드 UI와 MDX 렌더링 상세 페이지 구현. 반응형 그리드, SEO 메타데이터, 정적 사이트 생성까지.

Next.jsReact

Next.js 15로 개인 블로그 만들기 — 프로젝트 셋업

왜 직접 블로그를 만들었는지, 기술 스택 선정 이유와 프로젝트 초기 구성까지. Next.js 15 + Tailwind CSS v4 + MDX 기반 블로그의 시작.

Next.js블로그

Bun vs Node.js vs Deno — 뭐가 다른지, 그래서 뭘 쓰면 좋은지 (2026 기준)

런타임 3대장 비교: 호환성(Node), 속도/번들(Bun), 올인원/보안(Deno). 팀/프로덕트 상황별 선택 기준과 체크리스트까지 정리.

BunNode.js

번들러(Bundle)란 뭐고, 왜 필요할까? — 요즘 번들러/빌드 툴 비교 가이드

번들러의 역할(모듈/의존성/트랜스파일/최적화)을 쉽게 설명하고, Vite·Rollup·esbuild·Webpack·Rspack·Turbopack 같은 도구를 상황별로 비교합니다.

BundlerVite