인덱스를 만든 날과 인덱스가 실제로 쓰인 날이 13일 떨어져 있다
- 2026-03-12 — GIN trigram 인덱스 추가
- 2026-03-25 — 인덱스가 쿼리 플랜에 처음 등장
- 그 사이 13일 동안 인덱스는 디스크만 차지
- 이 글의 주제 — 그 13일
문제
- 환경: Spring Boot 3.2 + PostgreSQL 15
- 도메인: B2B 산업용 관리 시스템
- 화면: 장비 목록
- 검색 대상: 장비번호 · 고객명 · 관리번호
-
방식: 부분일치 검색
- 결과 쿼리 형태
WHERE c.name LIKE '%' || #{keyword} || '%'
- 앞에
%가 붙은 LIKE는 B-tree 인덱스 사용 불가 - 이유: 선행 문자열이 고정돼야 범위 스캔 가능
- 결과: 매 검색이 풀스캔
1차 시도: pg_trgm + GIN 인덱스
-
PostgreSQL이 제공하는 도구
-
pg_trgm확장 — 문자열을 3글자 단위(trigram)로 분해 -
gin_trgm_ops연산자 클래스 — GIN 인덱스로%keyword%지원 - 해당 마이그레이션 — 확장 활성화 후 인덱스 생성
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX IF NOT EXISTS idx_device_no_trgm
ON tb_device USING GIN (device_no gin_trgm_ops)
WHERE is_deleted = FALSE AND removal_date IS NULL;
-
부분 인덱스(
WHERE절) 채택은 의도적 - 목록 조회는 항상 삭제되지 않은 행만 조회
- 죽은 행 제외 시 인덱스 크기 감소
- 갱신 비용도 감소
같은 계열 작업:
- 후속 마이그레이션 — 주소 · 장비 식별자 컬럼에 GIN 인덱스 추가
-
LATERAL JOIN이 매 행마다 실행되던 구간에 커버링 인덱스 추가
- 여기까지 작업 후 커밋
- 당시 전제 — 인덱스를 만들었으니 빨라졌을 것
그런데 안 빨라졌다
13일 뒤 다시 들여다봤을 때, 실행 계획에 여전히 Seq Scan이 찍히고 있었다. 인덱스는 분명히 존재하는데 옵티마이저가 쓰지 않았다.
- 원인 위치: 쿼리 쪽
- 매퍼 XML의 실제 조건절
LOWER(c.name) LIKE LOWER('%' || #{keyword} || '%')
- 목적: 대소문자 구분 없는 검색
- 방식: 양쪽에
LOWER()래핑 -
흔한 패턴이고, 그 자체로 틀린 코드도 아님
-
문제의 핵심 — 인덱스는
name에 존재,LOWER(name)에는 부재 - PostgreSQL 입장에서
name과LOWER(name)은 전혀 다른 표현식 - 인덱스 정의와 쿼리 표현식이 문자 그대로 일치해야 후보로 등록
- 함수를 한 번 감싸는 순간 인덱스는 없는 것과 동일
- 선택지 둘
| 방법 | 내용 | 판단 |
|---|---|---|
| 표현식 인덱스로 맞추기 |
GIN (LOWER(name) gin_trgm_ops) 로 재생성 |
인덱스가 커지고, 모든 검색 컬럼마다 별도 인덱스 필요 |
| 쿼리를 인덱스에 맞추기 |
LOWER(x) LIKE LOWER(y) → x ILIKE y
|
인덱스 그대로 사용 가능 |
-
ILIKE— PostgreSQL의 대소문자 무시 LIKE - 의미는
LOWER() LIKE LOWER()와 동일 -
trigram GIN 인덱스가 지원하는 연산자
- 채택: 후자
어떻게 고쳤나
13일 뒤 커밋에서 일괄 전환.
- 대상 패턴:
LOWER(...) LIKE LOWER(...)→ILIKE - 변경 규모: 130개소 남짓
-
조직 관련 매퍼 하나만 373줄 변경
-
기계적 치환처럼 보이나 함정 하나 존재
-
LOWER(a) LIKE LOWER(b)와a ILIKE b는 ASCII 범위에서 동일 - 로케일에 따라 특수 문자에서 결과가 갈릴 수 있음
- 검색 대상이 장비번호 · 고객명 수준이라 실질 위험은 없다고 판단하고 진행
덤: 인덱스가 아예 필요 없어진 케이스
-
같은 커밋에서 제조사 검색 UI 수정
- 기존: 제조사명 텍스트 입력 → 부분일치 검색
- 변경: 드롭다운 선택
- 추가 작업: 제조사 목록 엔드포인트 신설
그 결과:
- 조건절
ILIKE '%...%'→= '...'로 강등 - 정확매칭은 일반 B-tree 인덱스로 충분
- trigram GIN 불필요
가장 빠른 부분일치 검색은 부분일치를 하지 않는 것이다.
- 적용 조건: 검색어 후보 집합이 유한 + 사용자가 그중에서 선택 가능
- 그 경우 자유 텍스트 입력 고수의 근거 없음
남는 교훈
이 일에서 실제로 배운 건 인덱스 문법이 아니다.
인덱스를 만든 것과 인덱스가 쓰이는 것은 별개의 사건이다. 그리고 그 둘 사이에는 아무런 자동 연결이 없다.
-
CREATE INDEX는 성공 여부만 통보 - “이 인덱스는 아무 쿼리도 타지 않습니다”는 알려주지 않음
- 13일 동안 조용했던 이유
DDL을 커밋하기 전에 EXPLAIN으로 대상 쿼리가 실제로 그 인덱스를 타는지 확인했다면 13일이 아니라 13분이면 끝났을 일이다.
- 인덱스 작업의 완료 조건: “인덱스 생성”이 아니라 “플랜에 인덱스가 등장”
-
부수 시사점 — 함수로 감싼 조건절의 조용한 인덱스 무력화
- 주의 대상:
LOWER(),CAST(),COALESCE() - 조건절 좌변에 쓰고 있다면 그 컬럼의 인덱스는 대체로 미사용 상태