2026-03-17

블로그 검색에 GIN 인덱스를 적용해봤다 — 정확도도, 속도도 달라졌다

이 블로그에는 검색 기능이 있다. 키워드 검색과 벡터 유사도 검색을 결합한 하이브리드 방식인데, 기존 키워드 검색은 ILIKE로 구현되어 있었다. 글이 44개인 지금은 사실 느리지 않다. 그런데 두 가지 문제가 있었다.

첫째, 검색 결과의 랭킹이 엉망이었다. 'Spring Boot'을 검색하면, 제목에 'Spring Boot'이 정확히 들어간 글과 태그에 'Spring'이 살짝 걸린 글이 똑같은 점수 1.0으로 나왔다. ILIKE는 "매칭됨/안 됨" 두 가지만 구분하기 때문이다.

둘째, 글이 늘어나면 느려질 구조였다. ILIKE는 인덱스를 타지 못하고 매번 테이블 전체를 순차 스캔한다. 44개야 괜찮지만, 수백, 수천 개가 되면?

그래서 PostgreSQL의 Full-text Search와 GIN 인덱스를 적용했다.

기존 방식: ILIKE의 한계

기존 키워드 검색 쿼리는 이렇게 생겼다.

SELECT p.title, p.slug,
  CASE WHEN (
    p.title ILIKE '%Spring Boot%'
    OR p.description ILIKE '%Spring Boot%'
    OR array_to_string(p.tags, ',') ILIKE '%Spring Boot%'
  ) THEN 1.0 ELSE 0.0 END AS keyword_score
FROM posts p
WHERE p.status = 'published';

EXPLAIN ANALYZE를 걸어보면:

Seq Scan on posts p  (cost=0.00..14.03 rows=44 width=369)
  (actual time=0.034..0.169 rows=44 loops=1)
  Filter: ((status)::text = 'published'::text)
  Rows Removed by Filter: 3
Execution Time: 0.189 ms

44개에서 0.189ms. 당연히 빠르다. 하지만 Seq Scan이라는 게 문제다. 테이블 전체를 읽는다는 뜻이고, 데이터가 늘면 선형으로 느려진다.

그리고 결과 품질이 진짜 문제다. 'Spring Boot'을 검색했을 때:

글 제목ILIKE 점수Java 8에서 25까지 — 보일러플레이트 900줄 제거1.0Spring Cloud Config로 MSA 설정 관리 중앙화1.0Kafka 기초: 메시지 큐 vs 이벤트 스트리밍1.0Flix 프로젝트: 개발 기록 - 11.0Flix 프로젝트: 개발 기록 - 21.0

8개 글이 전부 1.0. 어떤 글이 더 관련 있는지 구분이 안 된다.

Full-text Search + GIN 인덱스 적용

tsvector와 ts_rank

PostgreSQL의 Full-text Search는 두 가지를 제공한다.

  • tsvector — 텍스트를 토큰(단어)으로 분해한 검색용 구조. 각 토큰의 위치와 빈도를 저장한다.

  • ts_rank — 쿼리와의 관련도를 0.0~1.0 사이 점수로 계산한다. 단어 빈도, 위치, 커버리지를 고려한다.

ILIKE가 "있다/없다"만 판단한다면, ts_rank는 "얼마나 관련 있는지"를 판단한다.

search_vector 컬럼 + 트리거

처음에는 Generated Column을 쓰려고 했다.

ALTER TABLE posts ADD COLUMN search_vector tsvector
  GENERATED ALWAYS AS (
    to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(description, ''))
  ) STORED;

안 됐다. to_tsvector는 STABLE 함수라서 Generated Column에 사용 불가. 함수형 GIN 인덱스도 IMMUTABLE 요구로 실패했다.

트리거로 우회했다.

-- 컬럼 추가
ALTER TABLE posts ADD COLUMN search_vector tsvector;
 
-- 트리거 함수: INSERT/UPDATE 시 자동 갱신
CREATE OR REPLACE FUNCTION posts_search_vector_update() RETURNS trigger AS $$
BEGIN
  NEW.search_vector := to_tsvector('simple',
    coalesce(NEW.title, '') || ' ' ||
    coalesce(NEW.description, '') || ' ' ||
    array_to_string(coalesce(NEW.tags, ARRAY[]::text[]), ' ')
  );
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;
 
-- title, description, tags 변경 시 자동 실행
CREATE TRIGGER trg_posts_search_vector
  BEFORE INSERT OR UPDATE OF title, description, tags ON posts
  FOR EACH ROW
  EXECUTE FUNCTION posts_search_vector_update();

'simple'을 사용한 이유: Neon DB에는 'korean' 텍스트 검색 설정이 없다. 'simple'은 공백 기준으로 토큰을 분리하기 때문에 한국어+영어 혼합 콘텐츠에서 무난하게 동작한다.

GIN Partial Index

CREATE INDEX idx_posts_search_gin ON posts USING GIN (search_vector)
  WHERE status = 'published';

검색 대상은 항상 published 글뿐이므로 Partial Index로 범위를 한정했다.

검색 쿼리 리라이팅

CTE 기반으로 키워드 검색과 벡터 검색을 분리했다.

WITH keyword_results AS (
  SELECT p.id, p.title, p.slug, p.description, p.tags, p.created_at,
         ts_rank(p.search_vector, to_tsquery('simple', 'Spring & Boot')) AS keyword_score
  FROM posts p
  WHERE p.status = 'published'
    AND p.search_vector @@ to_tsquery('simple', 'Spring & Boot')
),
vector_results AS (
  SELECT DISTINCT ON (c.post_id)
    c.post_id,
    1 - (c.embedding <=> $1::vector) AS similarity
  FROM post_chunks c
  ORDER BY c.post_id, c.embedding <=> $1::vector
)
SELECT p.title, p.slug, p.description, p.tags, p.created_at,
  coalesce(vr.similarity, 0) AS vector_score,
  coalesce(kr.keyword_score, 0) AS keyword_score
FROM posts p
LEFT JOIN keyword_results kr ON kr.id = p.id
LEFT JOIN vector_results vr ON vr.post_id = p.id
WHERE p.status = 'published'
  AND (kr.id IS NOT NULL OR vr.similarity > 0.3)
ORDER BY (0.4 * coalesce(kr.keyword_score, 0) + 0.6 * coalesce(vr.similarity, 0)) DESC
LIMIT 20;

실측 결과

44개 글: 속도 차이는 미미하다

현재 블로그의 실제 데이터(44개 published 글)에서 EXPLAIN ANALYZE를 돌려봤다.

방식SQL 실행시간스캔 방식ILIKE0.189msSeq ScanGIN + ts_rank0.076msSeq Scan (옵티마이저 선택)

둘 다 1ms 미만이고, GIN을 만들어놨는데도 PostgreSQL 옵티마이저가 Seq Scan을 선택했다. 44개면 인덱스를 거치는 오버헤드보다 그냥 다 읽는 게 빠르기 때문이다. 옵티마이저는 생각보다 똑똑하다.

6,000개 글: 여기서 차이가 난다

그래서 더미 데이터 6,000개를 넣고 벤치마크를 돌려봤다. 선택도가 높은(매칭되는 행이 적은) 검색어로 테스트했다.

검색어ILIKE (Seq Scan)GIN + ts_rank개선Kafka7.136ms1.108ms6.4배pgvector7.609ms1.091ms7.0배

ILIKE는 6,000개 행을 전부 읽고 패턴 매칭을 했다(Seq Scan, 7ms). GIN + ts_rank는 인덱스로 매칭되는 행만 빠르게 찾아서 1ms 안에 끝냈다.

44개에서는 차이가 없지만, 6,000개에서는 7배 차이. 데이터가 더 늘어나면 격차는 벌어진다. ILIKE는 행 수에 비례해서 느려지지만, GIN Index Scan은 거의 일정하기 때문이다.

정확도: 이것도 확실히 달라졌다

'Spring Boot' 검색 결과의 ts_rank 점수:

글 제목ILIKEts_rankJava 8에서 25까지 — 보일러플레이트 900줄 제거1.00.4160Spring Cloud Config로 MSA 설정 관리 중앙화1.00.1993Kafka 기초: 메시지 큐 vs 이벤트 스트리밍1.00.1677Flix 프로젝트: 개발 기록 - 71.00.0991Flix 프로젝트: 아키텍처 소개 & 개발 기록 - 11.00.0991

ILIKE에서는 전부 1.0이었던 점수가, ts_rank에서는 0.0991에서 0.4160까지 4배 이상 차이가 난다. 'Spring Boot'과 직접 관련 있는 Java 마이그레이션 글이 가장 높고, 태그에만 걸린 글은 낮다.

하이브리드 검색에서 키워드 점수에 40% 가중치를 주고 있으니, 이 차별화가 최종 랭킹에 직접 영향을 준다. ILIKE 시절에는 키워드 40%가 사실상 무의미했는데, 이제 비로소 키워드 매칭과 의미 유사도가 진짜로 조합되는 하이브리드 검색이 됐다.

삽질 기록

순탄하지만은 않았다.

  1. Generated Column 실패to_tsvector가 STABLE 함수라서 불가. PostgreSQL의 함수 분류(IMMUTABLE/STABLE/VOLATILE)를 이해하고 있어야 당황하지 않는다.

  2. 함수형 인덱스도 실패 — 같은 이유. IMMUTABLE이 아니면 인덱스 표현식에 사용 불가.

  3. Korean config 없음 — Neon DB에는 'korean' 텍스트 검색 설정이 없다. 'simple'로 타협.

  4. 44개에서 GIN 안 탐 — 옵티마이저가 Seq Scan을 선택. 인덱스를 만들었다고 무조건 타는 게 아니다.

정리

  • 작은 데이터에서는 속도 차이가 없다. 하지만 구조를 미리 잡아두면 데이터가 늘어도 성능이 유지된다.

  • 큰 데이터에서는 확실히 빠르다. 6,000개 기준 7배 차이, 데이터가 늘수록 격차는 벌어진다.

  • 속도보다 더 큰 수확은 정확도다. ILIKE의 0/1 매칭이 ts_rank의 관련도 점수로 바뀌면서 검색 랭킹 품질이 눈에 띄게 좋아졌다.

  • PostgreSQL 옵티마이저를 믿어라. 인덱스를 만들어도 데이터가 적으면 안 탄다. 그게 오히려 효율적이기 때문이다.

'Spring Boot'을 검색했을 때 Java 마이그레이션 글이 1등으로 올라오는 걸 보면, 이 정도면 충분히 가치 있는 작업이었다.

관련 글

벡터 유사도 기반