Next.JS 풀스택 앱에서 Postgres Full-Text Search 사용하기
Source: Dev.to
위에 제공된 소스 링크 외에 번역할 텍스트가 포함되어 있지 않습니다. 번역을 원하는 본문을 알려주시면 도와드리겠습니다.
목차
- 가정
- 전체 텍스트 검색 인덱스 만들기 (Neon)
- Drizzle을 Neon에 연결하기
- 검색 API 라우트 (
/api/search) - Shadcn UI 컴포넌트 설치
- 검색 페이지 UI (
/search) - 작동 방식 (요약)
- 기사 검색 컴포넌트 (Next.js)
가정
- Next.js 풀‑스택 앱은 완전합니다.
- Neon에 호스팅된 PostgreSQL 데이터베이스가 이미 연결되어 있습니다.
articles테이블에는 검색하려는 데이터(title,summary,content등)가 포함되어 있습니다.
전체 텍스트 검색 인덱스 만들기 (Neon)
다음 SQL을 Neon SQL Editor(또는 PostgreSQL 클라이언트)에서 실행하십시오.
이 명령은 전체 텍스트 쿼리를 빠르게 수행하고 관련성에 따라 순위를 매길 수 있는 GIN 인덱스를 생성합니다.
CREATE INDEX articles_search_idx
ON articles
USING GIN (
to_tsvector(
'english',
title || ' ' || summary || ' ' || content
)
);
articles_search_idx를 원하는 이름으로 바꿀 수 있습니다.
Drizzle를 Neon에 연결하기
데이터베이스 연결이 아직 설정되지 않은 경우, 다음 파일을 추가하세요:
📁 db/index.ts
import { drizzle } from "drizzle-orm/neon-http";
import { neon } from "@neondatabase/serverless";
import * as schema from "./schema";
const sql = neon(process.env.DATABASE_URL!); // The rest of the app should already import `db` from this module.
검색 API 라우트 (/api/search)
쿼리 문자열을 받아 전체 텍스트 검색을 수행하고, 결과를 순위 매겨 상위 20개의 글을 반환하는 API 엔드포인트를 생성합니다.
📁 app/api/search/route.ts
import { NextResponse } from "next/server";
import { db } from "@/db";
import { sql } from "drizzle-orm";
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const query = searchParams.get("q");
if (!query) {
return NextResponse.json([]);
}
const results = await db.execute(sql`
SELECT
id,
title,
slug,
summary,
image_url,
created_at,
ts_rank(
to_tsvector('english', title || ' ' || COALESCE(summary, '') || ' ' || content),
plainto_tsquery('english', ${query})
) AS rank
FROM articles
WHERE published = true
AND to_tsvector(
'english',
title || ' ' || COALESCE(summary, '') || ' ' || content
) @@ plainto_tsquery('english', ${query})
ORDER BY rank DESC
LIMIT 20
`);
return NextResponse.json(results.rows);
}
여기서 일어나는 일
- 엔드포인트가 요청에서
?q=파라미터를 읽어옵니다. - 사용자 입력으로부터
plainto_tsquery를 생성합니다. title + summary + content를 결합한 벡터를 검색합니다.ts_rank가 각 행에 점수를 매기고, 그 점수를 기준으로 결과를 정렬합니다.
Shadcn UI 컴포넌트 설치
UI는 Shadcn + Tailwind를 사용합니다. 명령을 한 번 실행하세요 (컴포넌트 파일이 src/components/ui에 추가됩니다).
npx shadcn@latest init
npx shadcn@latest add input
npx shadcn@latest add button
npx shadcn@latest add card
npx shadcn@latest add separator
검색 페이지 UI (/search)
클라이언트‑사이드 페이지를 만들어 사용자가 쿼리를 입력하고 API를 호출하여 순위가 매겨진 결과를 표시합니다.
"use client";
import { useState } from "react";
import Link from "next/link";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
type Article = {
id: number;
title: string;
slug: string;
summary: string | null;
created_at: string;
};
export default function ArticleSearchPage() {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
async function handleSearch() {
if (!query.trim()) return;
setLoading(true);
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
const data = await res.json();
setResults(data);
setLoading(false);
}
return (
<div className="max-w-2xl mx-auto p-4">
{/* Page Header */}
<h1 className="text-2xl font-bold">Search Articles</h1>
<p className="text-muted-foreground mb-4">
Find articles by title, summary, or content
</p>
{/* Search Input */}
<div className="flex gap-2 mb-4">
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search articles..."
className="flex-1"
/>
<Button onClick={handleSearch} disabled={loading}>
{loading ? "Searching…" : "Search"}
</Button>
</div>
{/* Results */}
<div className="space-y-4">
{results.length === 0 && !loading && query && (
<p>No articles found.</p>
)}
{results.map((article) => (
<Card key={article.id}>
<CardContent className="pt-4">
<Link href={`/articles/${article.slug}`} className="text-lg font-medium hover:underline">
{article.title}
</Link>
{article.summary && (
<p className="text-sm text-muted-foreground mt-1">
{article.summary}
</p>
)}
<p className="text-xs text-muted-foreground mt-2">
{new Date(article.created_at).toDateString()}
</p>
</CardContent>
</Card>
))}
</div>
</div>
);
}
작동 방식 (요약)
| 단계 | 무슨 일이 일어나는가 |
|---|---|
| 사용자가 용어를 입력하고 검색을 클릭합니다 | 프론트엔드가 GET /api/search?q=term을 호출합니다 |
| API 라우트가 쿼리를 받습니다 | - 용어를 plainto_tsquery 로 변환합니다 - to_tsvector 를 사용해 articles 테이블을 검색합니다 - 각 행을 ts_rank 로 점수 매깁니다 |
| 데이터베이스가 관련도 순으로 상위 20개의 행을 반환합니다 | GIN 인덱스(articles_search_idx) 덕분에 빠릅니다 |
프론트엔드가 JSON을 받아 Card 컴포넌트 목록을 렌더링합니다 | 제목, 선택적 요약, 발행 날짜를 표시합니다. 제목을 클릭하면 해당 기사 페이지로 이동합니다. |
🎉 완료!
이제 PostgreSQL, Neon, Drizzle ORM으로 구동되고 Shadcn UI로 스타일링된 Next.js 블로그에 성능 좋은, 관련도 정렬 전체 텍스트 검색이 통합되었습니다. 필요에 따라 순위 공식을 조정하거나, 페이지네이션을 추가하거나, 검색 가능한 필드를 확장해도 좋습니다. 즐거운 코딩 되세요!
Source: …
기사 검색 컴포넌트 (Next.js)
개요
-
사용자 흐름
- 사용자가 쿼리를 입력 → 프런트엔드에서
query를 업데이트합니다. - Search 버튼을 클릭하면
handleSearch()가 실행되어/api/search?q=…를 호출합니다. - 백엔드에서는 PostgreSQL 전체 텍스트 검색을 수행합니다.
- 결과가 JSON 형태로 반환되고 UI가 업데이트됩니다.
- 사용자가 쿼리를 입력 → 프런트엔드에서
-
선택적 실시간 검색 – 사용자가 입력할 때마다 검색을 시작합니다(구글과 유사).
프런트엔드 코드 (app/articles/search/page.tsx)
"use client";
import { useState } from "react";
import Link from "next/link";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
type Article = {
id: number;
title: string;
slug: string;
summary: string | null;
created_at: string;
};
export default function ArticleSearchPage() {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
async function handleSearch() {
if (!query.trim()) return;
setLoading(true);
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
const data = await res.json();
setResults(data);
setLoading(false);
}
return (
<div className="max-w-2xl mx-auto p-4">
{/* Page Header */}
<h1 className="text-2xl font-bold">Search Articles</h1>
<p className="text-muted-foreground mb-4">
Find articles by title, summary, or content
</p>
{/* Search Input */}
<div className="flex gap-2 mb-4">
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search articles..."
className="flex-1"
/>
<Button onClick={handleSearch} disabled={loading}>
{loading ? "Searching…" : "Search"}
</Button>
</div>
{/* Results */}
<div className="space-y-4">
{results.length === 0 && !loading && query && (
<p>No articles found.</p>
)}
{results.map((article) => (
<Card key={article.id}>
<CardContent className="pt-4">
<Link href={`/articles/${article.slug}`} className="text-lg font-medium hover:underline">
{article.title}
</Link>
{article.summary && (
<p className="text-sm text-muted-foreground mt-1">
{article.summary}
</p>
)}
<p className="text-xs text-muted-foreground mt-2">
{new Date(article.created_at).toDateString()}
</p>
</CardContent>
</Card>
))}
</div>
</div>
);
}
실시간 검색 (선택 사항)
정적 핸들러를 on‑change 로 교체하여 키 입력마다 API를 호출하도록 할 수 있습니다:
{
const value = e.target.value;
setQuery(value);
if (!value.trim()) {
setResults([]);
return;
}
setLoading(true);
const res = await fetch(
`/api/search?q=${encodeURIComponent(value)}`
);
const data = await res.json();
setResults(data);
setLoading(false);
}
팁: 요청을 디바운스(예: 300 ms)하여 서버에 과부하가 걸리지 않도록 하세요.
검색 백엔드 확장
- Algolia 또는 Typesense 를 사용하면 PostgreSQL 전체 텍스트 검색을 대체하여 더 빠르고 오타를 허용하는 결과를 얻을 수 있습니다.
- API 라우트(
/api/search)는 단순히 선택한 서비스에 쿼리를 전달하고 해당 서비스의 JSON 응답을 반환하도록 구현하면 됩니다.
데모
옵션인 실시간 검색을 포함한 컴포넌트의 실시간 데모는 다음에서 확인할 수 있습니다:
https://your-demo-url.com/articles/search
감사합니다. 이 글을 읽어 주셔서 🙏