stack-analysis
CHAPTER 66 / 90
읽기 약 2분
FUNCTION
검색: 전문 검색(Full-text) + 필터링
핵심 개념
PostgreSQL tsvector·Meilisearch·Algolia·typo tolerance — 빠른 검색.
본문
검색 솔루션 비교
| 솔루션 | 셋업 | 한국어 | 비용 | 속도 |
|---|---|---|---|---|
| PostgreSQL FTS | 무료·간단 | ⭐⭐ | 0 | ⭐⭐⭐ |
| Meilisearch | 셀프호스트 | ⭐⭐⭐ | 0~ | ⭐⭐⭐⭐⭐ |
| Algolia | SaaS | ⭐⭐⭐⭐ | $$$ | ⭐⭐⭐⭐⭐ |
| Elasticsearch | 복잡 | ⭐⭐⭐⭐ | $$ | ⭐⭐⭐⭐ |
| Typesense | 셀프호스트 | ⭐⭐⭐ | 0~ | ⭐⭐⭐⭐⭐ |
결론:
- MVP·작은 데이터 → PostgreSQL FTS
- 빠른 통합·UX 우선 → Meilisearch
- 글로벌·예산 충분 → AlgoliaPostgreSQL FTS (한국어)
-- 1. tsvector 컬럼 + 가중치
ALTER TABLE posts ADD COLUMN search tsvector;
UPDATE posts SET search =
setweight(to_tsvector('simple', COALESCE(title, '')), 'A') ||
setweight(to_tsvector('simple', COALESCE(content, '')), 'B') ||
setweight(to_tsvector('simple', COALESCE(array_to_string(tags, ' '), '')), 'C');
CREATE INDEX idx_posts_search ON posts USING GIN(search);
-- 2. 트리거로 자동 갱신
CREATE FUNCTION posts_search_trigger() RETURNS trigger AS $$
BEGIN
NEW.search :=
setweight(to_tsvector('simple', COALESCE(NEW.title, '')), 'A') ||
setweight(to_tsvector('simple', COALESCE(NEW.content, '')), 'B');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_posts_search
BEFORE INSERT OR UPDATE ON posts
FOR EACH ROW EXECUTE FUNCTION posts_search_trigger();
-- 3. 검색 쿼리
SELECT
id, title,
ts_rank(search, query) as rank,
ts_headline('simple', content, query, 'MaxWords=20') as snippet
FROM posts, plainto_tsquery('simple', '리액트 타입스크립트') query
WHERE search @@ query
ORDER BY rank DESC
LIMIT 20;한국어 형태소 분석
-- mecab-ko (KISA 한국어 분석기)
CREATE EXTENSION pg_trgm; -- 유사도 검색
-- 트라이그램 검색 (오타 허용)
CREATE INDEX idx_posts_title_trgm ON posts USING GIN (title gin_trgm_ops);
SELECT *, similarity(title, '리액크') as sim
FROM posts
WHERE title % '리액크' -- 자동 유사도 비교
ORDER BY sim DESC;
-- → '리액트' 결과 반환 (오타 허용)Meilisearch (권장)
# 도커
docker run -p 7700:7700 -v $(pwd)/data:/data getmeili/meilisearchimport { MeiliSearch } from 'meilisearch';
const client = new MeiliSearch({
host: process.env.MEILISEARCH_HOST!,
apiKey: process.env.MEILISEARCH_KEY!,
});
const index = client.index('posts');
// 1. 인덱스 셋업
await index.updateSettings({
searchableAttributes: ['title', 'content', 'tags'],
filterableAttributes: ['authorId', 'category', 'published'],
sortableAttributes: ['createdAt', 'likes'],
rankingRules: [
'words', 'typo', 'proximity', 'attribute', 'sort', 'exactness',
],
stopWords: ['그리고', '하지만', '그래서'],
synonyms: {
'typescript': ['ts', '타입스크립트'],
'react': ['리액트'],
},
});
// 2. 데이터 동기화 (Postgres → Meilisearch)
async function syncPost(post: Post) {
await index.addDocuments([{
id: post.id,
title: post.title,
content: post.content,
tags: post.tags,
authorId: post.authorId,
category: post.category,
published: post.published,
createdAt: post.createdAt.getTime(),
likes: post.likesCount,
}]);
}
// Prisma 미들웨어로 자동 동기화
prisma.$use(async (params, next) => {
const result = await next(params);
if (params.model === 'Post') {
if (['create', 'update', 'upsert'].includes(params.action)) {
await syncPost(result).catch(console.error);
} else if (params.action === 'delete') {
await index.deleteDocument(result.id).catch(console.error);
}
}
return result;
});
// 3. 검색 API
app.get('/api/search', async (req, res) => {
const { q = '', filter, sort, page = 1, limit = 20 } = req.query;
const result = await index.search(q as string, {
filter: filter as string,
sort: sort ? [sort as string] : undefined,
offset: (Number(page) - 1) * Number(limit),
limit: Number(limit),
attributesToHighlight: ['title', 'content'],
highlightPreTag: '<mark>',
highlightPostTag: '</mark>',
});
res.json(result);
});검색 UI — Instant Search
'use client';
import { useState, useEffect } from 'react';
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
export function SearchBar() {
const [q, setQ] = useState('');
const dq = useDebouncedValue(q, 200);
const [results, setResults] = useState<any[]>([]);
useEffect(() => {
if (!dq) return setResults([]);
fetch(`/api/search?q=${encodeURIComponent(dq)}`)
.then(r => r.json())
.then(d => setResults(d.hits));
}, [dq]);
return (
<div className="relative">
<input
value={q}
onChange={e => setQ(e.target.value)}
placeholder="검색..."
/>
{results.length > 0 && (
<ul className="absolute top-full bg-white shadow-lg w-full">
{results.map(r => (
<li key={r.id}>
<a href={`/posts/${r.id}`}
dangerouslySetInnerHTML={{ __html: r._formatted.title }} />
</li>
))}
</ul>
)}
</div>
);
}다음 챕터
CH.67 "파일 관리: 업로드/미리보기/버전".
AI 프롬프트
🤖 AI에게 잘 물어보는 법 — 모델·전략별 프롬프트
Claude
무료: Sonnet 4.6 / Pro $20/mo: Opus 4.6
내 프로젝트의 전문 검색 부분을 분석해서 실전 분석 + 개선 우선순위를 알려줘.
ChatGPT
무료: GPT-5.5 / Plus $20/mo: GPT-5.5 Pro
전문 검색 관련 실제 서비스 5개를 비교 분석해서 패턴 추출를 알려줘.
Gemini
무료: 2.5 Flash / Pro $19.99/mo: 3.1 Pro
내 코드베이스에서 전문 검색 최적화 가능 위치를 보고해줘.
Grok
무료: Grok 4.1 / SuperGrok $30/mo
2026년 한국 풀스택 시장의 전문 검색 트렌드를 솔직히 알려줘.
⭐ 이것만 기억하세요
검색: 전문 검색(Full-text) + 필터링은 이 3가지만 확실히 잡으세요
1.검색은 PostgreSQL FTS로 시작 → 사용량 따라 Meilisearch/Algolia
2.Meilisearch = typo tolerance + 한국어 + 무료 셀프호스트
3.Prisma 미들웨어로 자동 동기화 — DB ↔ 검색엔진 일관성
공유하기
진행도 66 / 90