검색 기능, 생각보다 간단했다
프로젝트에 검색 기능을 추가하려고 하면 자연스럽게 "Elasticsearch 써야겠다"라는 생각이 들었다. 검색엔진이라면 당연히 전문 도구를 써야 한다는 고정관념 때문이었다. 그런데 막상 요구사항을 정리해보니까, 내가 만드는 건 구글이나 네이버가 아니었다. 블로그 글 몇백 개, 제품 몇천 개 수준의 데이터에서 키워드로 검색하는 게 전부였다.
그래서 이미 쓰고 있던 PostgreSQL로 검색을 구현해봤는데, 결론부터 말하면 생각보다 훨씬 강력했다. 별도의 인프라 없이, 이미 있는 DB만으로도 충분히 괜찮은 검색 경험을 만들 수 있었다. Elasticsearch가 필요한 시점이 언젠가는 올 테지만, 그 전까지는 PostgreSQL의 Full-Text Search로 충분하다는 걸 깨달았다.
Aha! LIKE 검색의 한계를 체감하다
처음엔 당연히 LIKE로 시작했다. 검색어가 포함된 글을 찾는 거니까 이렇게만 하면 되지 않을까?
SELECT * FROM posts
WHERE title LIKE '%검색어%' OR content LIKE '%검색어%';
작은 규모에선 괜찮았다. 그런데 데이터가 몇백 개만 넘어가도 느려지기 시작했다. 이유는 간단했다. LIKE '%검색어%'는 인덱스를 전혀 활용할 수 없기 때문이다. 모든 행을 하나씩 읽어서 문자열을 비교해야 한다. 마치 도서관에서 책 제목에 특정 단어가 있는지 확인하려고 모든 책을 일일이 펼쳐보는 것과 같았다.
더 큰 문제는 검색 품질이었다. "데이터베이스"를 검색하면 "DB"는 못 찾았고, "PostgreSQL"을 검색하면 "postgres"는 놓쳤다. 띄어쓰기나 대소문자도 정확히 맞춰야 했다. 이건 검색이 아니라 단순한 문자열 매칭이었다.
그때 알게 된 게 Full-Text Search였다. PostgreSQL은 이미 검색엔진 수준의 기능을 내장하고 있었다.
Deep Dive: tsvector와 tsquery, 검색의 핵심
검색을 위한 특별한 자료구조
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가 등장했다. 이게 검색 결과의 관련성 점수를 계산해준다. 검색어가 여러 번 등장하거나, 중요한 위치(제목 등)에 있으면 점수가 높아진다. 구글 검색처럼 관련도 순으로 정렬할 수 있게 된 거다.
GIN 인덱스로 속도 끌어올리기
이 상태로도 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로 확 떨어졌다. 체감할 수 있는 차이였다.
자동 업데이트: Trigger로 동기화
문제는 새 글을 작성하거나 수정할 때마다 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가 갱신된다. 신경 쓸 필요가 없어졌다.
한글 검색의 함정과 Trigram 해결책
여기까지는 영어 기준이었다. 한글로 바꾸니까 문제가 생겼다.
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 함께 쓰기
결국 나는 둘을 조합했다. 영어나 정확한 단어 매칭은 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를 쓰고 있었는데, 다행히 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에 캡슐화되어 있어서 코드가 깔끔했다.
PostgreSQL vs Elasticsearch, 언제 넘어가야 할까?
Full-Text Search를 쓰면서 "그럼 Elasticsearch는 언제 필요한가?"라는 질문이 계속 들었다. 몇 달 써보니까 기준이 생겼다.
PostgreSQL Full-Text Search로 충분한 경우:
- 데이터가 수만~수십만 건 이하
- 검색이 메인 기능이 아니라 보조 기능
- 관련도 순 정렬 정도의 랭킹만 필요
- 인프라 복잡도를 최소화하고 싶을 때
- 이미 PostgreSQL을 쓰고 있고, 검색을 DB 쿼리로 통합하고 싶을 때
Elasticsearch가 필요한 경우:
- 데이터가 수백만 건 이상
- 검색이 핵심 기능이고, 복잡한 랭킹 알고리즘 필요
- 다국어 형태소 분석, 동의어, 자동완성 등 고급 기능 필요
- 검색 성능이 매우 중요해서 별도 확장이 필요할 때
- 로그나 시계열 데이터 같은 대량 비정형 데이터 검색
내 경우엔 블로그 글과 프로젝트 검색이었고, 데이터가 몇천 건 수준이었다. PostgreSQL로 충분했고, 오히려 DB에 검색 로직이 있으니까 데이터 일관성 유지가 쉬웠다. 나중에 규모가 커지면 Elasticsearch로 마이그레이션하겠지만, 그때까지는 지금 방식으로 간다.
Summary: 이미 가진 도구로 시작하기
검색 기능이 필요하다고 해서 무조건 새로운 도구를 도입할 필요는 없었다. PostgreSQL은 이미 강력한 Full-Text Search를 제공하고 있었고, 대부분의 경우 이걸로 충분했다.
핵심 포인트:
tsvector와tsquery로 검색 가능한 형태로 데이터 변환- GIN 인덱스로 검색 성능 대폭 향상
ts_rank로 관련도 순 정렬 구현- 트리거로 search_vector 자동 갱신
- 한글은 pg_trgm(Trigram)으로 유사도 검색 보완
- Full-Text Search + Trigram 하이브리드로 정확도와 유연성 확보
검색엔진은 복잡한 게 아니라, 이미 있는 DB의 기능을 제대로 쓰는 것부터였다. Elasticsearch가 필요한 시점은 명확히 올 거고, 그때 가서도 늦지 않다. 지금은 이미 쓰고 있는 PostgreSQL로 충분히 괜찮은 검색 경험을 만들 수 있었다.