06 · Developer Guide · 레시피
게시판 통합 검색Board Search
새소식·주보·함즐함울·큐티·설교를 한 검색창에서 동시에 찾는 패턴입니다.
정적 시안에서는 Fuse.js 클라이언트 검색으로 충분하고, 동적 운영 단계에서는 Postgres 전문 검색으로 확장합니다.
언제 쓰는가
- 여러 게시판에 흩어진 정보를 한 번에 찾아야 하는 경우
- 설교 본문·성구·설교자 이름으로 영상을 역색인하고 싶을 때
- 오래된 주보·함즐함울을 호별 번호 없이 키워드로 찾을 때
① 두 가지 전략 — 정적 vs 동적
정적 (Fuse.js) — 빌드 타임에 인덱스를 JSON으로 굳혀 클라이언트에 띄움. 게시물 1,000건 이하·서버 비용 0원·키워드 가중치 조절 쉬움.
동적 (Postgres FTS + pgroonga) — 게시물 수천 건 이상·실시간 등록·한국어 형태소 분석이 필요할 때. Supabase 확장으로 한 줄 설치.
② 서버 검색 — Postgres + pgroonga
supabase/migrations/0002_search.sqlsql
-- supabase/migrations/0002_search.sql
-- 한국어 형태소 분석을 위해 mecab-ko 또는 pgroonga 사용
-- (Supabase Cloud은 pgroonga 확장 지원)
create extension if not exists pgroonga;
alter table public.posts
add column search_vector tsvector
generated always as (
setweight(to_tsvector('simple', coalesce(title, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(body, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(meta->>'pastor', '')), 'C')
) stored;
create index posts_search_gin
on public.posts using pgroonga (title, body, meta);
-- 일반 GIN 인덱스 (한국어 무관 영문 검색용 fallback)
create index posts_search_tsv
on public.posts using gin (search_vector);app/(site)/site/search/page.tsxtsx
// app/(site)/site/search/page.tsx — 서버 검색 (Postgres FTS)
import { createClient } from "@/lib/supabase/server";
export default async function SearchPage({
searchParams,
}: {
searchParams: Promise<{ q?: string; kind?: string }>;
}) {
const { q = "", kind } = await searchParams;
if (!q) return <EmptyState />;
const supabase = await createClient();
let query = supabase
.from("posts")
.select("id, slug, kind, title, summary, cover_image, published_at, rank:ts_rank!inner(search_vector, plainto_tsquery('korean', $1))")
.not("published_at", "is", null)
.textSearch("search_vector", q, { type: "websearch", config: "korean" })
.order("rank", { ascending: false })
.limit(30);
if (kind) query = query.eq("kind", kind);
const { data: results } = await query;
return <SearchResults q={q} kind={kind} results={results ?? []} />;
}③ 클라이언트 검색 — Fuse.js + 정적 인덱스
lib/search/build-index.tsts
// lib/search/build-index.ts — Fuse.js용 정적 인덱스 생성 스크립트
import fs from "fs";
import { newsPosts } from "@/lib/preview/news";
import { bulletinIssues } from "@/lib/preview/bulletin";
import { hamjulIssues } from "@/lib/preview/hamjul";
type SearchEntry = {
id: string;
kind: string;
href: string;
title: string;
summary: string;
pastor?: string;
date: string;
};
const entries: SearchEntry[] = [
...newsPosts.map((p) => ({
id: `news-${p.id}`,
kind: "news",
href: `/site/yard/news/${p.id}`,
title: p.title,
summary: p.summary,
date: p.date,
})),
...bulletinIssues.map((b) => ({
id: `bulletin-${b.slug}`,
kind: "bulletin",
href: `/site/yard/bulletin/${b.slug}`,
title: b.title,
summary: b.summary ?? "",
date: b.date,
})),
// ... 다른 게시판
];
fs.writeFileSync(
"public/search-index.json",
JSON.stringify(entries),
);
console.log(`Built search index: ${entries.length} entries`);components/preview/SiteSearchBox.tsxtsx
"use client";
import { useEffect, useMemo, useState } from "react";
import Fuse from "fuse.js";
export function SiteSearchBox() {
const [query, setQuery] = useState("");
const [entries, setEntries] = useState<SearchEntry[] | null>(null);
// 페이지 로드 후 검색 인덱스 비동기 로드
useEffect(() => {
fetch("/search-index.json")
.then((r) => r.json())
.then(setEntries);
}, []);
const fuse = useMemo(
() =>
entries &&
new Fuse(entries, {
keys: [
{ name: "title", weight: 0.6 },
{ name: "summary", weight: 0.3 },
{ name: "pastor", weight: 0.1 },
],
threshold: 0.35,
ignoreLocation: true,
}),
[entries],
);
const results = query && fuse ? fuse.search(query, { limit: 10 }) : [];
return (
<div role="search">
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="설교·주보·새소식 검색"
/>
<ul>
{results.map(({ item }) => (
<li key={item.id}>
<a href={item.href}>
<kbd>{KIND_LABEL[item.kind]}</kbd> {item.title}
</a>
</li>
))}
</ul>
</div>
);
}④ 키워드 하이라이트
tsx
// 검색 결과에서 키워드 하이라이트
function highlight(text: string, q: string) {
if (!q) return text;
const escaped = q.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`(${escaped})`, "gi");
return text.split(re).map((part, i) =>
i % 2 === 1 ? <mark key={i}>{part}</mark> : <span key={i}>{part}</span>,
);
}