1. 서론: 왜 LIKE '%keyword%'는 재앙이 되는가?
웹 서비스에서 게시글, 상품명, 사용자 문의 등을 검색하는 기능은 필수적입니다. 초기 개발 단계에서는 개발이 가장 간편한 SQL의 LIKE 연산자를 흔히 사용합니다:
SELECT * FROM articles WHERE title LIKE '%스프링%' OR content LIKE '%스프링%';
하지만 데이터가 수만, 수십만 건을 넘어서는 순간, 이 쿼리는 데이터베이스의 심각한 성능 저하를 일으킵니다. LIKE 연산자의 앞부분에 와일드카드(%)가 붙어 있으면 B-Tree 인덱스를 전혀 탈 수 없어, 모든 데이터 블록을 처음부터 끝까지 샅샅이 뒤지는 **Full Table Scan (Sequential Scan)**이 강제되기 때문입니다.
이 문제를 해결하기 위해 많은 팀들이 Elasticsearch나 OpenSearch 같은 외부 전문 검색 엔진 도입을 검토합니다. 하지만 검색 엔진을 별도로 띄우면 인프라 유지비용이 급증하고, 메인 DB와 검색 DB 간의 데이터 동기화(CDC, Logstash, Debezium) 지연 및 정합성 불일치라는 커다란 기술 부채를 떠안게 됩니다.
이때 최고의 실무 대안이 바로 PostgreSQL이 자체 내장한 강력한 ‘Full Text Search(전문 검색, FTS)’ 기능입니다. 별도의 검색 클러스터 없이도 GIN 인덱스를 통해 수백만 건의 데이터를 수 밀리초(ms) 만에 초고속으로 검색할 수 있습니다.
2. PostgreSQL FTS의 핵심 아키텍처와 원리
PostgreSQL의 전문 검색은 텍스트를 형태소 단위의 어휘소(Lexeme)로 분해하고, 이를 역색인(Inverted Index) 구조로 매핑하여 동작합니다.
subgraph Storage ["2. tsvector 데이터 저장"]
TSV["tsvector: 'awesom':3 'capabl':5 'postgresql':1 'search':4"]
Stem --> TSV
end
subgraph Indexing ["3. 역색인 인덱스 (GIN Index)"]
GIN["단어별 Row ID B-Tree 매핑"]
TSV --> GIN
end
subgraph QueryExecution ["4. 질의 매칭 (tsquery)"]
Q["검색어: 'search & postgresql' (tsquery)"]
GIN -.->|초고속 매칭 (@@ 연산자)| Q
end
3. 핵심 데이터 타입과 연산자 정리
1) tsvector (Text Search Vector)
단순한 텍스트 문자열이 아닌, 단어들의 원형(Lexeme)과 문장 내 출현 위치(Position)를 정렬하여 담아두는 전문 검색 전용 바이너리 타입입니다.
SELECT to_tsvector('english', 'The quick brown foxes are jumping over lazy dogs');
-- 결과: 'brown':3 'dog':9 'fox':4 'jump':6 'lazi':8 'quick':2
-- ('The', 'are', 'over' 등 무의미한 불용어는 자동 제거되고, 복수형 foxes -> fox, jumping -> jump로 정규화됨)
2) tsquery (Text Search Query)
사용자가 입력한 검색어를 논리 조건으로 평가하기 위한 타입입니다.
&(AND): 두 단어 모두 포함|(OR): 둘 중 하나 포함!(NOT): 해당 단어 제외<->(인접 연산자): 두 단어가 바로 붙어서 연이어 등장하는 구문(Phrase) 검색
SELECT to_tsquery('english', 'postgresql & (search | index)');
3) 매칭 연산자: @@
tsvector와 tsquery가 일치하는지 평가하는 전용 연산자입니다. 일치하면 true를 반환합니다.
4. 실무 구현: 생성 열(Generated Column)과 GIN 인덱스
매 검색 쿼리마다 to_tsvector() 함수를 실시간으로 실행하면 CPU 부하가 큽니다. 가장 모범적인 실무 패턴은 **저장 시점에 자동으로 계산되는 생성 열(Stored Generated Column)**을 두고 그 위에 GIN 인덱스를 거는 것입니다.
1) 테이블 설계 및 자동 생성 열 선언 (PostgreSQL 12+)
CREATE TABLE articles (
id BIGSERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- 제목과 본문을 결합하여 가중치를 부여한 tsvector 생성 열
search_vector tsvector GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(content, '')), 'B')
) STORED
);
setweight(..., 'A'): 제목(Title)에 가장 높은 가중치(‘A’)를 부여하고 본문(Content)에는 ‘B’를 부여합니다. 검색 랭킹 산출 시 제목에 키워드가 있는 문서가 상단에 노출됩니다.STORED: 디스크에 미리 계산된 벡터를 저장하므로 조회 성능이 극대화됩니다.
2) GIN(Generalized Inverted Index) 인덱스 생성
-- search_vector 컬럼에 GIN 인덱스 생성
CREATE INDEX idx_articles_search_vector ON articles USING gin(search_vector);
[!TIP] 왜 B-Tree가 아닌 GIN 인덱스인가? B-Tree는 전체 컬럼 값의 정렬 순서만 저장하므로 부분 단어 검색에 취약합니다. 반면 **GIN(역색인)**은 각 단어(Lexeme)를 키로 두고 해당 단어가 포함된 레코드 ID 목록을 배열로 저장하므로, 방대한 텍스트 안에서도 특정 단어를 포함한 행을 즉시 색인해 냅니다.
5. 고급 검색 기능: 랭킹(Ranking)과 하이라이트(Highlighting)
1) 관련도 기반 정렬 (ts_rank)
검색 키워드가 얼마나 자주, 그리고 중요한 가중치(‘A’ vs ‘B’) 영역에 등장했는지를 수치화하여 정렬합니다.
SELECT title,
ts_rank(search_vector, query) AS rank_score
FROM articles,
to_tsquery('english', 'spring & performance') query
WHERE search_vector @@ query
ORDER BY rank_score DESC
LIMIT 10;
2) 검색 결과 본문 하이라이트 표시 (ts_headline)
Google 검색 결과처럼, 검색어가 포함된 문맥 주변을 잘라내고 <b> 태그로 감싸주는 기능입니다.
SELECT title,
ts_headline('english', content, query,
'StartSel = <mark>, StopSel = </mark>, MaxWords=35, MinWords=15') AS preview
FROM articles,
to_tsquery('english', 'performance') query
WHERE search_vector @@ query
LIMIT 5;
6. 성능 벤치마크: LIKE vs PostgreSQL FTS
100만 건의 기술 아티클 테이블에서 특정 키워드를 검색했을 때의 성능 차이입니다 (EXPLAIN ANALYZE).
| 쿼리 방식 | 인덱스 사용 | 실행 계획 (Execution Plan) | 평균 소요 시간 |
|---|---|---|---|
LIKE '%performance%' |
불가 | Seq Scan on articles (Full Scan) | 1,450 ms (1.45초) |
| PostgreSQL FTS (GIN) | GIN 적용 | Bitmap Index Scan on idx_articles_fts | 3.8 ms (0.003초) |
LIKE 풀 스캔 대비 무려 380배 이상의 획기적인 속도 향상을 보여주며, 동시 요청이 쏟아져도 데이터베이스 CPU 점유율이 안정적으로 유지됩니다.
7. 한국어 형태소 분석 고려사항 (pg_bigm vs pg_trgm)
PostgreSQL 기본 사전은 영어 및 유럽어 기반 형태소 분해에 최적화되어 있습니다. 공백 구분이 엄격하지 않고 조사가 발달한 한국어 특성상, 다음과 같은 옵션을 고려할 수 있습니다:
pg_trgm(트라이그램) 확장: 3글자씩 잘라 인덱싱하여 오타 교정 및 한국어 부분 일치 검색에 탁월함.pg_bigm(바이그램) 확장: 2글자 단위로 잘라 CJK(한중일) 텍스트 검색에 최적화된 오픈소스 확장.pg_mecab/ 은전한닢 형태소 분석기 플러그인: 네이버 등에서 검증된 메캅 사전을 PostgreSQL 엔진에 직접 결합하여 완벽한 한국어 형태소 분해 지원.
8. 결론
외부 검색 엔진(Elasticsearch) 도입은 클러스터 구축, JVM 메모리 할당, 인덱스 동기화 등 상당한 운영 리소스를 수반합니다.
데이터 규모가 수백만 건 이하이고 RDBMS의 강력한 트랜잭션(ACID)과 즉각적인 데이터 정합성이 중요하다면, PostgreSQL Full Text Search + GIN 인덱스만으로도 상용 서비스에 손색없는 초고속 전문 검색 엔진을 구현할 수 있습니다.