OPEN HYPER STEP
← 목록으로 (stack-analysis)
STACK-ANALYSIS · 66 / 90
stack-analysis
CHAPTER 66 / 90
읽기 약 2
FUNCTION

검색: 전문 검색(Full-text) + 필터링


핵심 개념

PostgreSQL tsvector·Meilisearch·Algolia·typo tolerance — 빠른 검색.

본문

검색 솔루션 비교

📋 코드 (13줄)
| 솔루션 | 셋업 | 한국어 | 비용 | 속도 |
|---|---|---|---|---|
| PostgreSQL FTS | 무료·간단 | ⭐⭐ | 0 | ⭐⭐⭐ |
| Meilisearch | 셀프호스트 | ⭐⭐⭐ | 0~ | ⭐⭐⭐⭐⭐ |
| Algolia | SaaS | ⭐⭐⭐⭐ | $$$ | ⭐⭐⭐⭐⭐ |
| Elasticsearch | 복잡 | ⭐⭐⭐⭐ | $$ | ⭐⭐⭐⭐ |
| Typesense | 셀프호스트 | ⭐⭐⭐ | 0~ | ⭐⭐⭐⭐⭐ |


결론:
- MVP·작은 데이터 → PostgreSQL FTS
- 빠른 통합·UX 우선 → Meilisearch
- 글로벌·예산 충분 → Algolia

PostgreSQL FTS (한국어)

SQL📋 코드 (35줄)
-- 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;

한국어 형태소 분석

SQL📋 코드 (12줄)
-- 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 (권장)

BASH📋 코드 (2줄)
# 도커
docker run -p 7700:7700 -v $(pwd)/data:/data getmeili/meilisearch
TYPESCRIPT📋 코드 (72줄)
import { 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

TSX📋 코드 (36줄)
'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