PostgreSQL 전체 텍스트 검색은 많은 앱에서 충분합니다. 간단한 결정 규칙, 시작 쿼리, 인덱싱 체크리스트로 언제 전용 검색 엔진이 필요한지 판단하세요.

대부분의 사람들은 “전체 텍스트 검색”을 달라고 하진 않습니다. 그들은 빠르게 느껴지고 첫 페이지에서 사용자가 의도한 결과를 찾는 검색 상자를 원합니다. 결과가 느리거나 잡음이 많거나 이상하게 정렬되면, 사용자는 PostgreSQL FTS를 썼는지 별도의 엔진을 썼는지 신경 쓰지 않습니다. 그냥 검색을 믿지 않게 됩니다.
이건 한 가지 결정입니다: 검색을 Postgres 안에 유지할지, 아니면 전용 검색 엔진을 추가할지. 목표는 완벽한 관련도가 아니라, 빠르게 배포할 수 있고 운영하기 쉬우며 실제 앱 사용 방식에 충분히 좋은 탄탄한 기준입니다.
많은 앱에서 PostgreSQL 전체 텍스트 검색은 오랫동안 충분합니다. 제목, 설명, 메모 같은 텍스트 필드가 몇 개 있고, 기본 랭킹과 상태·카테고리 같은 필터가 하나 둘이면 Postgres만으로도 추가 인프라 없이 처리할 수 있습니다. 이동하는 부분이 적고 백업이 단순하며 "앱은 켜져 있는데 검색만 다운된 이유" 같은 사건이 줄어듭니다.
“충분”은 보통 세 가지 목표를 동시에 충족할 수 있다는 뜻입니다:
구체적 예: 사용자가 프로젝트 이름과 메모로 검색하는 SaaS 대시보드. “onboarding checklist” 같은 쿼리가 상위 5개 안에 올바른 프로젝트를 1초 미만에 반환하고, 인덱서나 분석기를 계속 튜닝하거나 재인덱싱하지 않는다면 그건 “충분”입니다. 이런 목표를 복잡도를 높여서만 달성할 수 있다면, "내장 검색 vs 검색 엔진" 문제가 실제로 부상하는 시점입니다.
팀들은 종종 기능으로 검색을 설명하고, 결과가 아니라 요구사항을 이야기합니다. 유용한 접근은 각 기능을 구축하고 튜닝하며 신뢰성 있게 유지하는 데 드는 비용으로 바꾸어 보는 것입니다.
초기 요청은 보통 오타 허용, 페싯과 필터, 하이라이트, “스마트” 랭킹, 자동완성 같은 식으로 들립니다. 첫 버전에서는 필수 항목과 있으면 좋은 항목을 분리하세요. 기본 검색 상자는 일반적으로 관련 항목을 찾고, 일반적인 단어 형태(복수형, 시제)를 처리하고, 단순 필터를 존중하며, 테이블이 커져도 빠르게 유지되는 것만 필요합니다. 여기서 PostgreSQL FTS가 잘 맞습니다.
Postgres는 콘텐츠가 일반 텍스트 필드에 있고 검색을 데이터와 가깝게 두고 싶을 때 빛을 발합니다: 도움말 문서, 블로그 글, 지원 티켓, 내부 문서, 제품 제목과 설명, 고객 기록의 메모 등. 대부분은 “올바른 레코드를 찾아라” 문제지 “검색 제품을 만들어라” 문제는 아닙니다.
있으면 좋은 기능들이 복잡도를 끌어옵니다. 오타 허용과 풍부한 자동완성은 보통 추가 도구로 이끕니다. 페싯은 Postgres에서도 가능하지만, 많은 페싯과 깊은 분석, 거대한 데이터셋에 대한 즉각적인 집계가 필요하면 전용 엔진이 더 매력적으로 보입니다.
숨겨진 비용은 라이선스 비용이 아닌 두 번째 시스템입니다. 검색 엔진을 추가하면 데이터 동기화와 백필(및 그로 인한 버그), 모니터링과 업그레이드, “검색이 오래된 데이터를 보여줘요” 같은 지원 업무, 그리고 두 세트의 관련도 조정 노브가 생깁니다.
확실하지 않다면 Postgres로 시작해서 간단한 것을 배포하고, 필요할 때만 다른 엔진을 추가하세요.
세 가지 검사 규칙을 사용하세요. 세 가지 모두 통과하면 PostgreSQL 전체 텍스트 검색으로 유지하세요. 하나라도 크게 실패하면 전용 검색 엔진을 고려하세요.
관련도 요구: “충분히 좋은” 결과로 괜찮은가, 아니면 오타·동의어·추천 검색·개인화된 결과 등 수많은 엣지 케이스에서 거의 완벽한 순위가 필요한가? 가끔 순서가 완벽하지 않아도 괜찮다면 Postgres가 보통 작동합니다.
쿼리량과 지연: 피크 시 초당 몇 건의 검색을 예상하고 실제 지연 예산은 얼마인가? 검색이 트래픽의 작은 부분이고 적절한 인덱스로 쿼리를 빠르게 유지할 수 있다면 Postgres면 충분합니다. 검색이 주요 워크로드가 되어 핵심 읽기/쓰기와 경쟁하면 경고 신호입니다.
복잡도: 텍스트 필드 한두 개를 검색하는가, 아니면 태그·필터·시간 감쇠·인기도·권한 같은 여러 신호와 다국어를 결합하는가? 로직이 복잡할수록 SQL 내부에서 마찰을 더 느낄 것입니다.
안전한 출발점은 간단합니다: Postgres에 기본을 배포하고 느린 쿼리와 “결과 없음” 검색을 기록한 뒤 결정하세요. 많은 앱은 결코 이를 넘지 않으며 두 번째 시스템을 너무 일찍 운영하고 동기화하는 일을 피할 수 있습니다.
전용 엔진을 가리키는 적신호:
Postgres에 머물기 위한 녹색 신호:
PostgreSQL FTS는 텍스트를 데이터베이스가 빠르게 검색할 수 있는 형태로 바꾸는 내장 방식입니다. 모든 행을 스캔하지 않고 작동하며, 콘텐츠가 이미 Postgres에 있고 예측 가능한 운영으로 빠르고 괜찮은 검색을 원할 때 가장 잘 맞습니다.
알아둘 세 가지 구성요소가 있습니다:
ts_rank(또는 ts_rank_cd)를 사용해 더 관련 있는 행을 먼저 오게 할 수 있습니다.언어 설정은 단어 처리 방식에 영향을 줍니다. 올바른 설정을 쓰면 “running”과 “run”이 일치하도록(stemming) 하거나, 일반적인 불용어를 무시하게 할 수 있습니다. 잘못된 설정이면 평범한 사용자 단어가 색인된 것과 매치되지 않아 검색이 망가진 것처럼 느껴질 수 있습니다.
프리픽스 매칭은 사용자가 “typeahead-ish” 동작을 원할 때 찾는 기능입니다. 예를 들어 “dev”로 “developer”를 매치합니다. Postgres FTS에서는 보통 접미 연산자(term:*)로 수행합니다. 체감 품질을 높일 수 있지만 쿼리당 작업량을 늘리므로 기본값으로 두지 말고 선택적 업그레이드로 취급하세요.
Postgres가 목표로 하지 않는 것: 모든 기능을 갖춘 완전한 검색 플랫폼. 퍼지(spelling) 보정, 고급 자동완성, 필드별 복잡한 분석기, 여러 노드에 분산 인덱싱 같은 기능이 필요하면 내장 편안 영역을 벗어납니다. 그러나 많은 앱에서 PostgreSQL FTS는 이동하는 부분이 훨씬 적으면서도 사용자가 기대하는 대부분을 제공합니다.
검색하려는 콘텐츠에 대한 작고 현실적인 형태는 다음과 같습니다:
-- Minimal example table
CREATE TABLE articles (
id bigserial PRIMARY KEY,
title text NOT NULL,
body text NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
PostgreSQL 전체 텍스트 검색의 좋은 기준은: 사용자가 입력한 것으로부터 쿼리를 만들고, 먼저 행을 필터한 다음(가능하면) 남은 일치 항목을 랭킹하는 것입니다.
-- $1 = user search text, $2 = limit, $3 = offset
WITH q AS (
SELECT websearch_to_tsquery('english', $1) AS query
)
SELECT
a.id,
a.title,
a.updated_at,
ts_rank_cd(
setweight(to_tsvector('english', coalesce(a.title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(a.body, '')), 'B'),
q.query
) AS rank
FROM articles a
CROSS JOIN q
WHERE
a.updated_at \u003e= now() - interval '2 years' -- example safe filter
AND (
setweight(to_tsvector('english', coalesce(a.title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(a.body, '')), 'B')
) @@ q.query
ORDER BY rank DESC, a.updated_at DESC, a.id DESC
LIMIT $2 OFFSET $3;
몇 가지 나중에 시간을 절약해 주는 세부사항:
WHERE에 넣으세요. 더 적은 행을 랭킹하므로 빠릅니다.ORDER BY에 항상 동률 해소용(tie-breaker)을 추가하세요(예: updated_at, 그 다음 id). 이렇게 하면 많은 결과가 같은 랭크를 가질 때 페이징이 안정적입니다.websearch_to_tsquery를 쓰세요. 따옴표와 간단한 연산자를 사용자가 기대하는 방식으로 처리합니다.이 기준이 작동하면 to_tsvector(...) 표현을 저장된 컬럼으로 옮기세요. 이렇게 하면 매 쿼리마다 다시 계산하지 않아도 되고 인덱싱도 간단해집니다.
대부분의 “PostgreSQL FTS가 느리다” 이야기는 한 가지로 귀결됩니다: 데이터베이스가 매 쿼리에서 검색 문서를 만들고 있다는 점입니다. 먼저 tsvector를 미리 저장하고 인덱싱하는 것으로 해결하세요.
tsvector를 저장할까: 생성된 컬럼 vs 트리거?검색 문서가 동일 행의 컬럼에서 만들어진다면 생성된 컬럼(generated column)이 가장 간단한 옵션입니다. 업데이트 시 자동으로 정확하게 유지되며 잊어버리기 어렵습니다.
문서가 관련 테이블을 참조해 결합되어야 하거나(예: 제품 행과 카테고리 이름을 합침) 단일 생성 표현으로 표현하기 어려운 커스텀 로직이 필요하면 트리거로 유지하세요. 트리거는 움직이는 부분을 추가하므로 작게 유지하고 테스트하세요.
tsvector 컬럼에 GIN 인덱스를 만드세요. 이것이 Postgres FTS를 전형적인 앱 검색에서 즉각적으로 느껴지게 하는 기준입니다.
많은 앱에 잘 작동하는 설정:
tsvector를 보관하세요.tsvector에 GIN 인덱스를 추가하세요.to_tsvector(...)를 매번 계산하지 않고 저장된 tsvector에 대해 @@를 사용하도록 하세요.VACUUM (ANALYZE)를 고려해 플래너가 새 인덱스를 이해하게 하세요.벡터를 같은 테이블에 유지하는 것이 보통 더 빠르고 단순합니다. 기본 테이블에 쓰기가 매우 많은 경우나 여러 테이블에 걸친 결합 문서를 인덱싱하고 자체 일정으로 업데이트하려면 별도 검색 테이블이 의미가 있을 수 있습니다.
항목 검색이 특정 행 하위집합(예: status = 'active', 단일 테넌트, 특정 언어)에만 해당하면 부분 인덱스(partial index)가 인덱스 크기를 줄이고 검색을 빠르게 할 수 있습니다. 다만 쿼리가 항상 동일한 필터를 포함할 때만 이점이 있습니다.
관련 규칙을 단순하고 예측 가능하게 유지하면 PostgreSQL FTS로도 놀랍도록 좋은 결과를 얻을 수 있습니다.
가장 쉬운 승리는 필드 가중치입니다: 제목에서의 일치가 본문 깊은 곳의 일치보다 더 중요하게 계산되어야 합니다. 제목에 더 높은 가중치를 주고 본문에는 낮은 가중치를 주는 결합 tsvector를 만들고 ts_rank나 ts_rank_cd로 랭킹하세요.
“신선도”나 “인기” 항목을 위로 띄우려면 신중히 하세요. 작은 보정은 괜찮지만 텍스트 관련도를 완전히 무시해서는 안 됩니다. 실용적 패턴은: 먼저 텍스트로 랭킹하고 동률은 신선도로 깨거나, 캡을 둔 보너스를 더해 관련 없는 새 항목이 완벽한 오래된 항목을 이기지 못하게 하는 것입니다.
동의어와 구문 매칭은 기대치가 자주 엇갈리는 곳입니다. 동의어는 자동으로 생기지 않습니다; 사전이나 커스텀 사전을 추가하거나 쿼리 용어를 직접 확장해야 합니다(예: “auth”를 “authentication”으로 처리). 구문 매칭도 기본값이 아닙니다: 일반 쿼리는 단어를 어디서든 매치하지 특정 구절만 매치하지 않습니다. 사용자가 인용구 구절이나 긴 질문을 입력하면 phraseto_tsquery나 websearch_to_tsquery를 고려하세요.
혼합 언어 콘텐츠는 결정이 필요합니다. 문서별 언어를 알고 있다면 저장하고 해당 구성으로 tsvector를 생성하세요(English, Russian 등). 모르면 안전한 대안은 simple 구성(어간 추출 없음)으로 인덱싱하거나, 알고 있는 경우에는 언어별 벡터와 모든 항목에 대해 simple 벡터 두 개를 유지하는 것입니다.
관련도를 검증하려면 작고 구체적으로 유지하세요:
이 정도면 템플릿, 문서, 프로젝트 같은 앱 검색 상자에서는 보통 충분합니다.
대부분의 “PostgreSQL FTS가 느리거나 관련성이 없다” 이야기는 피할 수 있는 몇 가지 실수에서 비롯됩니다. 이들을 고치는 것이 보통 새 검색 시스템을 추가하는 것보다 간단합니다.
흔한 함정 중 하나는 tsvector를 자체적으로 정확한 계산값으로 취급하는 것입니다. tsvector를 컬럼에 저장했지만 삽입이나 업데이트 시마다 갱신하지 않으면 인덱스가 텍스트와 일치하지 않아 결과가 랜덤하게 보입니다. 쿼리에서 to_tsvector(...)를 매번 계산하면 결과는 정확할 수 있지만 느리고, 전용 인덱스의 이점을 놓칠 수 있습니다.
또 다른 성능 저하 요인은 후보 집합을 좁히기 전에 랭킹을 수행하는 것입니다. ts_rank는 유용하지만 보통 인덱스를 사용해 일치하는 행을 찾은 다음에 실행해야 합니다. 엄청난 부분의 테이블에 대해 랭킹을 계산하거나 먼저 다른 테이블과 조인하면 빠른 검색이 테이블 스캔으로 변할 수 있습니다.
사람들은 또한 “contains” 검색을 LIKE '%term%'처럼 기대합니다. 선행 와일드카드는 FTS와 잘 맞지 않습니다. FTS는 단어(lexemes)를 기반으로 하고 임의의 부분 문자열을 기반으로 하지 않습니다. 제품 코드나 부분 ID에 대해 서브스트링 검색이 필요하면 삼중그램(trigram) 인덱싱 같은 다른 도구를 사용하세요.
성능 문제는 일치가 아닌 결과 처리에서 오는 경우가 많습니다. 주의할 두 가지 패턴:
OFFSET 페이지네이션: 페이지를 넘길수록 더 많은 행을 건너뛰게 만듭니다.운영상 문제도 중요합니다. 많은 업데이트 후 인덱스 팽창(index bloat)이 생기고, 이미 문제가 심해질 때 재인덱싱하면 비용이 큽니다. 변경 전후 실제 쿼리 시간을 측정하고 EXPLAIN ANALYZE를 확인하세요. 수치 없이는 PostgreSQL FTS를 잘못 고쳐서 다른 방식으로 더 나쁘게 만들기 쉽습니다.
PostgreSQL FTS를 탓하기 전에 다음 점검을 수행하세요. 대부분의 문제는 기능 자체가 아닌 기본을 놓친 데서 옵니다.
실제 tsvector를 만드세요: 생성된 컬럼이나 유지되는 컬럼에 저장하되(매 쿼리에서 계산하지 말 것), 올바른 언어 구성(english, simple 등)을 사용하고 필드를 혼합하면 가중치를 적용하세요(title \u003e subtitle \u003e body).
인덱싱할 내용을 정규화하세요: ID, 보일러플레이트, 내비게이션 텍스트 같은 잡음 필드는 tsvector에서 빼고 사용자가 검색하지 않는 큰 블롭은 잘라내세요.
올바른 인덱스를 만드세요: tsvector 컬럼에 GIN 인덱스를 추가하고 EXPLAIN에서 사용되는지 확인하세요. 일부만 검색 대상이면(예: status = 'published') 부분 인덱스가 크기를 줄이고 읽기를 빠르게 할 수 있습니다.
테이블을 건강하게 유지하세요: dead tuple은 인덱스 스캔을 느리게 합니다. 특히 자주 업데이트되는 콘텐츠는 정기적인 vacuum이 중요합니다.
재인덱스 계획을 세우세요: 큰 마이그레이션이나 팽창된 인덱스는 통제된 재인덱스 창이 필요할 수 있습니다.
데이터와 인덱스가 올바르면 쿼리 형태에 집중하세요. PostgreSQL FTS는 먼저 후보 집합을 좁힐 때 빠릅니다.
먼저 필터하고 그다음 랭킹하세요: 테넌트, 언어, 공개 여부, 카테고리 같은 엄격한 필터를 랭킹 전에 적용하세요. 수천 행을 랭킹한 후 버리는 것은 낭비입니다.
안정적인 정렬을 사용하세요: rank 다음에 updated_at 또는 id 같은 동률 해소자를 두어 결과가 새로고침 사이에 튀지 않게 하세요.
“쿼리가 모든 걸 하게” 하지 마세요: 퍼지 매칭이나 오타 허용이 필요하면 의도적으로(그리고 측정하며) 하세요. 실수로 순차 스캔을 강제하지 마세요.
실제 쿼리를 테스트하세요: 상위 20개 검색을 수집하고 수동으로 관련도를 확인하며, 작은 기대 결과 목록을 유지해 회귀를 잡으세요.
느린 경로를 모니터링하세요: 느린 쿼리를 기록하고 EXPLAIN (ANALYZE, BUFFERS)를 검토하며 인덱스 크기와 캐시 히트율을 모니터링해 성장이 동작 변화를 일으킬 때를 포착하세요.
SaaS 도움말 센터는 시작하기 좋은 사례입니다. 목표는 단순합니다: 사용자가 질문에 답하는 한 개의 문서를 찾게 하는 것. 수천 개의 문서가 있고 각 문서에는 제목, 요약, 본문이 있습니다. 대부분 방문자는 “reset password”나 “billing invoice” 같은 2~5단어를 입력합니다.
PostgreSQL FTS로는 이것이 놀랍도록 빨리 완료될 수 있습니다. 결합 필드의 tsvector를 저장하고 GIN 인덱스를 추가하고 관련도로 랭킹하세요. 성공은: 결과가 100ms 미만에 나타나고 상위 3개 결과가 보통 정확하며 시스템을 계속 돌봐줄 필요가 없는 것입니다.
그다음 제품이 성장하면 지원팀은 제품 영역, 플랫폼(iOS, Android, web), 요금제별 필터를 원합니다. 문서 작성자는 동의어, “이렇게 찾으셨나요?”, 오타 처리 개선을 원합니다. 마케팅은 “결과 없음”인 상위 검색 같은 분석을 원합니다. 트래픽이 증가하고 검색이 가장 바쁜 엔드포인트 중 하나가 됩니다.
이 신호들은 전용 검색 엔진이 비용 가치가 있을 수 있음을 알려줍니다:
실용적 마이그레이션 경로는 검색 엔진을 추가한 후에도 Postgres를 진실의 원천(source of truth)으로 유지하는 것입니다. 검색 쿼리와 결과 없음 케이스를 기록하고, 비동기 동기화 작업으로 검색 가능한 필드만 새 인덱스로 복사하세요. 둘을 병행 운영하다가 점진적으로 전환하는 것이 안전합니다.
검색이 주로 “이 단어들을 포함한 문서 찾기”이고 데이터셋이 엄청나지 않다면 PostgreSQL FTS는 보통 충분합니다. 거기서 시작해 작동하게 만들고, Postgres가 잘 못하는 명확한 요구사항이나 확장 문제에 봉착했을 때만 전용 엔진을 추가하세요.
핵심 요약:
tsvector를 저장하고 GIN 인덱스를 추가할 수 있고 랭킹 요구가 기본적이면 Postgres FTS를 사용하세요.실용적 다음 단계: 앞 섹션의 스타터 쿼리와 인덱스를 구현한 뒤 일주일간 간단한 메트릭을 기록하세요. p95 쿼리 시간, 느린 쿼리, 그리고 "검색 → 클릭 → 즉시 이탈 아님" 같은 대략적인 성공 신호를 추적하세요(기본 이벤트 카운터라도 도움이 됩니다). 그러면 더 나은 랭킹이 필요한지 아니면 단순히 더 나은 UX(필터, 하이라이트, 더 나은 스니펫)가 필요한지 빠르게 알 수 있습니다.
빠르게 진행하고 싶다면 Koder.ai (koder.ai)는 검색 UI와 API를 채팅으로 프로토타이핑하고 스냅샷 및 롤백을 이용해 안전하게 반복하면서 실제 사용자를 측정하기에 편리한 방법이 될 수 있습니다.
PostgreSQL full-text search is “enough” when you can hit three things at once:
If you can meet these with a stored tsvector + a GIN index, you’re usually in a great place.
Default to PostgreSQL full-text search first. It ships faster, keeps your data and search in one place, and avoids building and maintaining a separate indexing pipeline.
Move to a dedicated engine when you have a clear requirement Postgres can’t meet well (high-quality typo tolerance, rich autocomplete, heavy faceting, or search load that competes with core database work).
A simple rule is: stay in Postgres if you pass these three checks:
If you fail one badly (especially relevance features like typos/autocomplete, or high search traffic), consider a dedicated engine.
Use Postgres FTS when your search is mostly “find the right record” across a few fields like title/body/notes, with simple filters (tenant, status, category).
It’s a strong fit for help centers, internal docs, tickets, blog/article search, and SaaS dashboards where users search by project names and notes.
A good baseline query usually:
websearch_to_tsquery.Store a prebuilt tsvector and add a GIN index. That avoids recomputing to_tsvector(...) on every request.
Practical setup:
Use a generated column when the search document is built from columns in the same row (simple and hard to break).
Use a trigger-maintained column when your search text depends on related tables or custom logic.
Default choice: generated column first, triggers only when you truly need cross-table composition.
Start with predictable relevance:
Then validate using a small list of real user queries and expected top results.
Postgres FTS is word-based, not substring-based. So it won’t behave like LIKE '%term%' for arbitrary partial strings.
If you need substring search (IDs, codes, fragments), handle that separately (often with trigram indexing) instead of forcing full-text search to do a job it’s not designed for.
Common signals you’ve outgrown Postgres FTS:
A practical path is to keep Postgres as the source of truth and add async indexing when the requirement is clear.
@@ against a stored tsvector.ts_rank/ts_rank_cd plus a stable tie-breaker like updated_at, id.This keeps results relevant, fast, and stable for pagination.
tsvectortsvector_column @@ tsquery.This is the most common fix when search feels slow.