
Full-Text Search: DB만으로 검색 기능 구현하기
검색 기능이 필요할 때마다 Elasticsearch를 써야 하나 고민했는데, PostgreSQL의 Full-Text Search만으로도 충분한 경우가 많았다.

검색 기능이 필요할 때마다 Elasticsearch를 써야 하나 고민했는데, PostgreSQL의 Full-Text Search만으로도 충분한 경우가 많았다.
미로를 탈출하는 두 가지 방법. 넓게 퍼져나갈 것인가(BFS), 한 우물만 팔 것인가(DFS). 최단 경로는 누가 찾을까?

DB 설계의 기초. 데이터를 쪼개고 쪼개서 이상 현상(Anomaly)을 방지하는 과정. 제1, 2, 3 정규형을 쉽게 설명합니다.

업다운 게임으로 배우는 이진 탐색 트리. 왜 데이터베이스는 해시 테이블 대신 B-Tree를 쓸까? AVL 트리, 레드블랙 트리, 그리고 Splay Tree까지.

이진 트리는 메모리용입니다. 디스크(SSD/HDD)는 느리니까 트리 키를 낮추고 옆으로 뚱뚱하게 만들어서 디스크 I/O 횟수를 최소화했습니다. B-Tree vs B+Tree 차이와 MySQL 인덱스의 비밀.

프로젝트에 검색 기능을 추가하려고 하면 자연스럽게 "Elasticsearch 써야겠다"라는 생각이 들었다. 검색엔진이라면 당연히 전문 도구를 써야 한다는 고정관념 때문이었다. 그런데 막상 요구사항을 정리해보니까, 내가 만드는 건 구글이나 네이버가 아니었다. 블로그 글 몇백 개, 제품 몇천 개 수준의 데이터에서 키워드로 검색하는 게 전부였다.
그래서 이미 쓰고 있던 PostgreSQL로 검색을 구현해봤는데, 결론부터 말하면 생각보다 훨씬 강력했다. 별도의 인프라 없이, 이미 있는 DB만으로도 충분히 괜찮은 검색 경험을 만들 수 있었다. Elasticsearch가 필요한 시점이 언젠가는 올 테지만, 그 전까지는 PostgreSQL의 Full-Text Search로 충분하다는 걸 깨달았다.
처음엔 당연히 LIKE로 시작했다. 검색어가 포함된 글을 찾는 거니까 이렇게만 하면 되지 않을까?
SELECT * FROM posts
WHERE title LIKE '%검색어%' OR content LIKE '%검색어%';
작은 규모에선 괜찮았다. 그런데 데이터가 몇백 개만 넘어가도 느려지기 시작했다. 이유는 간단했다. LIKE '%검색어%'는 인덱스를 전혀 활용할 수 없기 때문이다. 모든 행을 하나씩 읽어서 문자열을 비교해야 한다. 마치 도서관에서 책 제목에 특정 단어가 있는지 확인하려고 모든 책을 일일이 펼쳐보는 것과 같았다.
더 큰 문제는 검색 품질이었다. "데이터베이스"를 검색하면 "DB"는 못 찾았고, "PostgreSQL"을 검색하면 "postgres"는 놓쳤다. 띄어쓰기나 대소문자도 정확히 맞춰야 했다. 이건 검색이 아니라 단순한 문자열 매칭이었다.
그때 알게 된 게 Full-Text Search였다. PostgreSQL은 이미 검색엔진 수준의 기능을 내장하고 있었다.
Full-Text Search의 핵심은 tsvector와 tsquery다. 처음 봤을 때 이게 뭔가 싶었는데, 알고 나니까 정말 영리한 설계였다.
tsvector는 문서를 검색 가능한 형태로 변환한 것이다. 원본 텍스트를 그대로 저장하는 게 아니라, 단어들을 추출하고 정규화해서 저장한다. 마치 책의 색인(index)처럼, 어떤 단어들이 있는지 미리 정리해둔다고 보면 된다.
SELECT to_tsvector('english', 'The quick brown fox jumps over the lazy dog');
-- 'brown':3 'dog':9 'fox':4 'jump':5 'lazi':8 'quick':2
여기서 중요한 건 원형으로 변환된다는 점이다. 'jumps'가 'jump'로, 'lazy'가 'lazi'로 바뀌었다. 'the', 'over' 같은 불용어(stopword)는 아예 제거됐다. 그리고 각 단어 옆에 숫자가 붙는데, 이게 문서 내에서 그 단어가 등장한 위치다.
tsquery는 검색어를 표현한 것이다. 사용자가 입력한 검색어를 tsvector와 비교 가능한 형태로 만든다.
SELECT to_tsquery('english', 'fox & dog');
-- 'fox' & 'dog'
SELECT to_tsquery('english', 'quick | slow');
-- 'quick' | 'slow'
&는 AND, |는 OR 연산이다. 이제 이 둘을 매칭하면 검색이 된다.
SELECT to_tsvector('english', 'The quick brown fox') @@ to_tsquery('english', 'fox');
-- true
@@ 연산자가 "매칭되는가?"를 판단한다. 이게 핵심이다.
개념은 알았고, 이제 실제로 적용해봤다. 내 블로그 posts 테이블에 검색 기능을 추가하는 게 목표였다.
먼저 검색용 컬럼을 추가했다.
ALTER TABLE posts
ADD COLUMN search_vector tsvector;
-- 제목과 내용을 합쳐서 tsvector 생성
UPDATE posts
SET search_vector =
to_tsvector('english', coalesce(title, '') || ' ' || coalesce(content, ''));
coalesce는 NULL 처리를 위한 것이다. 제목이나 내용이 비어있을 수도 있으니까.
이제 검색 쿼리는 이렇게 쓴다.
SELECT title, content, ts_rank(search_vector, query) AS rank
FROM posts, to_tsquery('english', 'database & search') query
WHERE search_vector @@ query
ORDER BY rank DESC;
여기서 ts_rank가 등장했다. 이게 검색 결과의 관련성 점수를 계산해준다. 검색어가 여러 번 등장하거나, 중요한 위치(제목 등)에 있으면 점수가 높아진다. 구글 검색처럼 관련도 순으로 정렬할 수 있게 된 거다.
이 상태로도 LIKE보다 훨씬 빠르지만, 데이터가 많아지면 여전히 느려질 수 있다. 그래서 GIN(Generalized Inverted Index) 인덱스를 추가했다.
CREATE INDEX posts_search_idx ON posts USING GIN(search_vector);
GIN 인덱스는 Full-Text Search를 위해 특화된 인덱스 타입이다. 역색인(inverted index) 방식으로, 각 단어가 어느 문서에 등장하는지를 미리 정리해둔다. Elasticsearch도 같은 방식을 쓴다.
인덱스 추가 전후를 비교해봤더니, 1만 개 정도의 레코드에서 검색 속도가 200ms → 15ms로 확 떨어졌다. 체감할 수 있는 차이였다.
문제는 새 글을 작성하거나 수정할 때마다 search_vector를 수동으로 업데이트해야 한다는 거였다. 이건 실수하기 쉽고 불편했다. 그래서 트리거로 자동화했다.
CREATE FUNCTION posts_search_vector_update() RETURNS trigger AS $
BEGIN
NEW.search_vector :=
to_tsvector('english', coalesce(NEW.title, '') || ' ' || coalesce(NEW.content, ''));
RETURN NEW;
END
$ LANGUAGE plpgsql;
CREATE TRIGGER posts_search_vector_trigger
BEFORE INSERT OR UPDATE ON posts
FOR EACH ROW
EXECUTE FUNCTION posts_search_vector_update();
이제 posts 테이블에 INSERT나 UPDATE가 일어나면, 자동으로 search_vector가 갱신된다. 신경 쓸 필요가 없어졌다.
여기까지는 영어 기준이었다. 한글로 바꾸니까 문제가 생겼다.
PostgreSQL의 Full-Text Search는 기본적으로 형태소 분석을 하지 못한다. "데이터베이스"를 검색하면 정확히 "데이터베이스"만 찾고, "데이터"나 "베이스"는 못 찾는다. 영어는 띄어쓰기로 단어가 구분되지만, 한글은 조사가 붙고 합성어가 많아서 훨씬 복잡하다.
전문적인 한글 형태소 분석기를 쓰려면 외부 확장(pg_mecab 등)을 설치해야 하는데, 이건 관리 포인트가 늘어나는 거라 부담스러웠다. 그래서 찾은 게 pg_trgm(Trigram)이다.
Trigram은 문자열을 3글자 단위로 쪼개서 인덱싱한다. "데이터베이스"는 "데이터", "이터베", "터베이", "베이스"로 분해된다. 형태소 분석은 아니지만, 부분 검색이 가능해진다.
CREATE EXTENSION pg_trgm;
CREATE INDEX posts_title_trgm_idx ON posts USING GIN(title gin_trgm_ops);
CREATE INDEX posts_content_trgm_idx ON posts USING GIN(content gin_trgm_ops);
이제 유사도 검색이 가능하다.
SELECT title, similarity(title, '데이터베이스') AS sim
FROM posts
WHERE title % '데이터베이스'
ORDER BY sim DESC;
% 연산자가 유사도를 판단하고, similarity() 함수가 점수를 계산한다. 오타나 띄어쓰기 실수도 어느 정도 커버된다.
결국 나는 둘을 조합했다. 영어나 정확한 단어 매칭은 Full-Text Search로, 한글이나 퍼지 매칭은 Trigram으로 처리하는 하이브리드 방식이다.
SELECT
title,
GREATEST(
ts_rank(search_vector, to_tsquery('english', 'database')),
similarity(title, '데이터베이스') * 0.5
) AS final_rank
FROM posts
WHERE
search_vector @@ to_tsquery('english', 'database')
OR title % '데이터베이스'
ORDER BY final_rank DESC;
두 점수 중 높은 걸 선택해서 정렬한다. Trigram 점수는 가중치를 0.5로 줘서 Full-Text Search보다 우선순위를 낮췄다.
내 프로젝트는 Supabase를 쓰고 있었는데, 다행히 Supabase는 PostgreSQL 기반이라 Full-Text Search를 그대로 쓸 수 있었다.
Supabase의 SQL Editor에서 위에서 만든 컬럼, 인덱스, 트리거를 똑같이 추가했다. 그리고 API로 검색을 노출했다.
// Supabase 클라이언트에서 Full-Text Search 사용
const { data, error } = await supabase
.rpc('search_posts', { search_query: 'database search' });
RPC 함수는 이렇게 만들었다.
CREATE OR REPLACE FUNCTION search_posts(search_query TEXT)
RETURNS TABLE (
id UUID,
title TEXT,
content TEXT,
rank REAL
) AS $
BEGIN
RETURN QUERY
SELECT
posts.id,
posts.title,
posts.content,
ts_rank(posts.search_vector, to_tsquery('english', search_query)) AS rank
FROM posts
WHERE posts.search_vector @@ to_tsquery('english', search_query)
ORDER BY rank DESC
LIMIT 20;
END;
$ LANGUAGE plpgsql;
프론트엔드에선 그냥 함수만 호출하면 됐다. 검색 로직은 DB에 캡슐화되어 있어서 코드가 깔끔했다.
Full-Text Search를 쓰면서 "그럼 Elasticsearch는 언제 필요한가?"라는 질문이 계속 들었다. 몇 달 써보니까 기준이 생겼다.
PostgreSQL Full-Text Search로 충분한 경우:내 경우엔 블로그 글과 프로젝트 검색이었고, 데이터가 몇천 건 수준이었다. PostgreSQL로 충분했고, 오히려 DB에 검색 로직이 있으니까 데이터 일관성 유지가 쉬웠다. 나중에 규모가 커지면 Elasticsearch로 마이그레이션하겠지만, 그때까지는 지금 방식으로 간다.
검색 기능이 필요하다고 해서 무조건 새로운 도구를 도입할 필요는 없었다. PostgreSQL은 이미 강력한 Full-Text Search를 제공하고 있었고, 대부분의 경우 이걸로 충분했다.
핵심 포인트:tsvector와 tsquery로 검색 가능한 형태로 데이터 변환ts_rank로 관련도 순 정렬 구현검색엔진은 복잡한 게 아니라, 이미 있는 DB의 기능을 제대로 쓰는 것부터였다. Elasticsearch가 필요한 시점은 명확히 올 거고, 그때 가서도 늦지 않다. 지금은 이미 쓰고 있는 PostgreSQL로 충분히 괜찮은 검색 경험을 만들 수 있었다.