백엔드 데이터 인프라 39편. PostgreSQL 전문 검색 — to_tsvector·to_tsquery·GIN 인덱스로 ElasticSearch 없이 텍스트 검색 풀어쓴 학습 노트.
이 글은 백엔드 데이터 인프라 시리즈 70편 중 39편이에요. "전문 검색은 무조건 Elasticsearch" 라는 오해를 깨는 PG 의 강력한 도구 — Full-Text Search.
PG 전문 검색의 위치
LIKE '%검색어%' = 풀스캔, 느림. 진짜 "형태소·랭킹·하이라이트" 필요 시 — Elasticsearch 가 표준. 단, 중간 규모(~수백만 문서) 에선 PG 도 충분.
장점: - ElasticSearch 별도 인프라 불필요 - 트랜잭션·외래 키와 통합 - GIN 인덱스로 빠름
단점: - 형태소 분석 (한국어) 약함 (확장 필요) - 대규모 (수억 문서) 엔 ES 우세
tsvector·tsquery — 핵심 타입
-- 텍스트 → 검색 벡터
SELECT to_tsvector('english', 'The quick brown fox jumps');
-- 'brown':3 'fox':4 'jump':5 'quick':2
-- 검색 쿼리
SELECT to_tsquery('english', 'fox & jump');
-- 'fox' & 'jump'
to_tsvector = 텍스트를 "단어 어근(lexeme) + 위치" 벡터로. 영어는 "jumps" → "jump" (스테밍).
검색 — @@ 연산자
CREATE TABLE articles (
id BIGSERIAL PRIMARY KEY,
title TEXT,
body TEXT
);
-- 검색
SELECT * FROM articles
WHERE to_tsvector('english', title || ' ' || body) @@ to_tsquery('english', 'spring & framework');
@@ = "벡터에 쿼리 매칭됨" 검사.
GIN 인덱스 — 필수
CREATE INDEX idx_articles_fts
ON articles USING GIN (to_tsvector('english', title || ' ' || body));
GIN 없으면 — 모든 행 to_tsvector 호출 → 매우 느림. 무조건 인덱스.
tsvector 컬럼 — Generated
ALTER TABLE articles
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || body)) STORED;
CREATE INDEX idx_articles_search ON articles USING GIN (search_vector);
매번 to_tsvector 호출 대신 — 생성 컬럼에 미리 저장. 더 빠름, 가독성 좋음.
SELECT * FROM articles
WHERE search_vector @@ to_tsquery('english', 'spring');
tsquery 문법
| 연산자 | 의미 |
|---|---|
& |
AND |
\| |
OR |
! |
NOT |
<-> |
인접 (단어가 바로 옆) |
<N> |
N단어 거리 안 |
to_tsquery('english', 'spring & framework') -- 둘 다
to_tsquery('english', 'java | spring') -- 둘 중 하나
to_tsquery('english', 'spring & !boot') -- spring 있고 boot 없음
to_tsquery('english', 'spring <-> framework') -- "spring framework" 정확
websearch_to_tsquery — 사용자 친화
SELECT to_tsquery('english', 'spring framework');
-- ERROR (space 처리 X)
SELECT websearch_to_tsquery('english', 'spring framework');
-- 'spring' & 'framework' (자동 AND)
SELECT websearch_to_tsquery('english', '"spring framework" -boot OR react');
-- 따옴표·OR·- 자연스럽게
웹 검색창 같은 입력 형식 자동 파싱. 사용자 친화적.
랭킹 — ts_rank
SELECT
title,
ts_rank(search_vector, query) AS rank
FROM articles, websearch_to_tsquery('english', 'spring framework') query
WHERE search_vector @@ query
ORDER BY rank DESC
LIMIT 20;
ts_rank = "매칭이 얼마나 적절한가" 점수. 단어 빈도·위치·중요도 기반. 상위 결과를 먼저.
가중치
-- 제목·본문 가중치 차이
ALTER TABLE articles ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', title), 'A') ||
setweight(to_tsvector('english', body), 'B')
) STORED;
SELECT ts_rank(search_vector, query, 32) FROM articles, ...;
A·B·C·D 4단계 가중치. 제목(A) > 본문(B) > 태그(C) > 코멘트(D) 같은 우선순위.
하이라이트 — ts_headline
SELECT
title,
ts_headline('english', body, query,
'StartSel=<mark>, StopSel=</mark>, MaxFragments=2')
FROM articles, websearch_to_tsquery('english', 'spring') query
WHERE search_vector @@ query;
검색 결과의 "매칭 부분 강조". 사용자에게 "왜 이게 매칭됐는지" 보여줌.
한국어 검색 — pg_bigm·mecab-ko
PG 의 기본 형태소 분석 = 영어 위주. 한국어는 부족. 해결:
(1) trigram (pg_trgm) — 간단
CREATE EXTENSION pg_trgm;
CREATE INDEX idx_articles_title_trgm ON articles USING GIN (title gin_trgm_ops);
SELECT * FROM articles WHERE title LIKE '%스프링%';
-- pg_trgm 으로 빠름 (% 와일드카드도 OK)
3글자 단위 비교 — 한국어 부분 매칭에 강함. 형태소 분석 X 단순.
(2) pg_bigm — bigram
CREATE EXTENSION pg_bigm;
CREATE INDEX idx_articles_title_bigm ON articles USING GIN (title gin_bigm_ops);
2글자 단위. 한국어·중국어·일본어 검색에 효율적.
(3) mecab-ko — 형태소 분석 (가장 정확)
별도 확장 설치 + 형태소 사전. "스프링" → "스프링 프레임워크" 같은 어근 분석. 본격 한국어 검색 = mecab-ko 필수.
실전 — 게시판 검색
CREATE TABLE posts (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
body TEXT NOT NULL,
tags TEXT[],
created_at TIMESTAMPTZ DEFAULT NOW(),
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(array_to_string(tags, ' '), '')), 'C')
) STORED
);
CREATE INDEX idx_posts_search ON posts USING GIN (search_vector);
CREATE INDEX idx_posts_title_trgm ON posts USING GIN (title gin_trgm_ops); -- 부분 매칭
-- 검색
SELECT
id, title,
ts_rank(search_vector, query) AS rank,
ts_headline('simple', body, query) AS snippet
FROM posts, websearch_to_tsquery('simple', 'spring framework') query
WHERE search_vector @@ query
ORDER BY rank DESC, created_at DESC
LIMIT 20;
게시판 검색 — 한 SQL 로 매칭 + 랭킹 + 하이라이트.
Spring 백엔드 통합
@Query(value = """
SELECT * FROM posts
WHERE search_vector @@ websearch_to_tsquery('simple', :query)
ORDER BY ts_rank(search_vector, websearch_to_tsquery('simple', :query)) DESC
LIMIT :limit
""", nativeQuery = true)
List<Post> searchPosts(@Param("query") String query, @Param("limit") int limit);
QueryDSL 또는 native SQL. JPA 자동 생성 X.
PG 전문 검색 vs Elasticsearch
| PG FTS | Elasticsearch | |
|---|---|---|
| 인프라 | 별도 X | 별도 클러스터 |
| 트랜잭션 | ACID | eventual consistency |
| 외래 키 통합 | ✓ | X |
| 한국어 | mecab-ko 별도 | nori/mecab 내장 |
| 대규모 | ~수백만 | 수억+ |
| 운영 비용 | 낮음 | 높음 |
| 분석·집계 | 약함 | 강력 (Kibana) |
| 학습 곡선 | 낮음 | 중간 |
룰: - 작은~중간 규모 + 단순 검색 = PG FTS - 대규모 + 분석·로그 = Elasticsearch
함정 5가지
(1) GIN 인덱스 누락
to_tsvector 호출이 매 행 = 느림. 무조건 GIN.
(2) 형태소 분석 언어 X
to_tsvector('english', '...') -- 영어
to_tsvector('simple', '...') -- 형태소 분석 없음 (한국어에 자주)
한국어 = simple + pg_trgm 또는 mecab-ko.
(3) tsquery 직접 노출
to_tsquery('user input') -- ❌ ERROR (단어 사이 공백 등)
websearch_to_tsquery('user input') -- ✅
사용자 입력 = websearch_to_tsquery.
(4) Generated 컬럼 안 활용
매번 to_tsvector — Generated 컬럼이 빠름.
(5) PG FTS 만으로 모든 검색
수억 문서·복잡한 분석 = ES 가 정답.
Generated tsvector + GIN + ts_rank + ts_headline + websearch_to_tsquery. 영어는 'english', 한국어는 'simple' + pg_trgm 또는 mecab-ko. 중간 규모는 ES 없이 PG 만으로.
한 줄 정리 — PG 전문 검색 = tsvector·tsquery·@@·GIN. ts_rank 랭킹·ts_headline 하이라이트·websearch_to_tsquery 사용자 친화. 한국어는 pg_trgm/pg_bigm/mecab-ko 확장. 중간 규모는 ES 없이 PG.
시험 직전 한 번 더 — 전문 검색 입문자가 매번 헷갈리는 것
- tsvector = 단어 어근 + 위치 벡터
- tsquery = 검색 쿼리
- @@ = 매칭 연산자
to_tsvector('english', text)= 영어 분석to_tsvector('simple', text)= 형태소 X (한국어 흔함)- GIN 인덱스 무조건
- Generated tsvector 컬럼 = 미리 저장
- setweight = A·B·C·D 가중치
- ts_rank = 매칭 점수
- ts_headline = 하이라이트
- StartSel·StopSel·MaxFragments 옵션
to_tsquery= 정확 문법websearch_to_tsquery= 사용자 친화 (권장)&AND,|OR,!NOT,<->인접- 한국어 = pg_trgm·pg_bigm·mecab-ko
- pg_trgm = 3글자 단위 (
%검색어%빠름) - gin_trgm_ops·gin_bigm_ops
- LIKE + pg_trgm = 부분 매칭 빠름
- mecab-ko = 형태소 분석 (정확)
- PG FTS vs Elasticsearch = 규모·복잡도
- 중간 규모 = PG FTS 충분
- 트랜잭션 통합이 PG 강점
- 게시판 검색 = 한 SQL 매칭+랭킹+하이라이트
- JPA = native SQL 필요
- 한국 회사 = 작은 검색 PG, 큰 검색 ES
시리즈 다른 편
시리즈 다음 글
다음 글(40편)에서는 EXPLAIN — 쿼리 계획 읽기 — 성능 진단의 핵심 도구.
공식 문서: PostgreSQL 18 — Full Text Search에서 더 자세한 사양을 확인할 수 있어요.