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>,
  );
}

설계 포인트

Need Help

도움이 필요하신가요?

주님의교회 PCL 디자인 시스템을 적용하시다가 막히는 부분이 있다면, 다음 경로로 직접 문의하실 수 있습니다.