이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • 인덱스가 있는데도 DB가 사용하지 않는 대표 이유는 무엇인가?
  • 백엔드 코드가 어떤 SQL을 만들 때 인덱스를 망가뜨리는가?
  • 운영에서 인덱스 실패를 어떻게 증거로 확인해야 하는가?

개요

인덱스를 만들었다고 항상 사용되는 것은 아니다. Optimizer는 인덱스 사용 비용과 테이블 스캔 비용을 비교해 계획을 선택한다. 쿼리 작성 방식이 인덱스를 활용하기 어렵게 만들 수도 있고, 데이터 분포상 인덱스를 쓰는 것이 이득이 아닐 수도 있다.

실무에서 중요한 태도는 “인덱스가 있는데 왜 안 타지?”에서 멈추지 않는 것이다. 조건식, 타입, 함수, 선택도, 정렬, 통계, 실행 계획을 차례로 확인해야 한다.

원리

인덱스는 컬럼 값이 정렬된 구조다. 그런데 쿼리가 컬럼 값을 그대로 비교하지 않거나, 너무 많은 행을 대상으로 삼거나, 선행 컬럼 없이 뒤 컬럼만 사용하면 인덱스 장점이 줄어든다.

다음 쿼리는 created_at 인덱스가 있어도 위험하다.

SELECT id, title
FROM posts
WHERE DATE(created_at) = DATE '2026-06-30';

컬럼에 함수를 씌우면 DB가 인덱스의 정렬된 원래 값을 그대로 사용하기 어려울 수 있다. 더 좋은 방식은 범위 조건으로 쓰는 것이다.

SELECT id, title
FROM posts
WHERE created_at >= timestamp '2026-06-30 00:00:00'
  AND created_at < timestamp '2026-07-01 00:00:00';

DBMS별 기능으로 해결할 수도 있다. PostgreSQL은 expression index를 만들 수 있고, MySQL은 generated column이나 functional index 지원 범위를 검토할 수 있다. 다만 먼저 질문해야 할 것은 “함수 인덱스를 만들 것인가”가 아니라 “쿼리를 컬럼 원형 비교로 바꿀 수 있는가”다.

타입 불일치

파라미터 타입이 컬럼 타입과 맞지 않으면 암묵적 형 변환이 생길 수 있다.

-- user_id가 bigint인데 문자열로 비교하는 나쁜 예
SELECT id
FROM orders
WHERE user_id = '123';

DB마다 처리 방식은 다르지만, 컬럼 쪽에 변환이 걸리면 인덱스 활용이 어려워질 수 있다. 백엔드에서는 DTO와 Repository 파라미터 타입을 DB 컬럼 타입과 맞추는 습관이 중요하다.

// 좋지 않은 냄새: DB는 bigint인데 API 내부에서 String으로 계속 다룬다.
List<Order> findByUserId(String userId);
 
// 더 낫다.
List<Order> findByUserId(Long userId);

대소문자 무시 검색도 비슷하다.

-- 흔하지만 인덱스에 불리할 수 있는 패턴
WHERE lower(email) = lower(:email)

PostgreSQL에서는 lower(email) expression index나 citext 같은 선택지가 있고, MySQL에서는 collation 설계가 영향을 준다. 로그인처럼 핵심 경로라면 가입 시 normalize한 값을 별도 컬럼에 저장하고 Unique 제약을 두는 방식도 검토한다.

앞쪽 와일드카드 LIKE

일반 B-Tree 인덱스는 prefix 탐색에는 비교적 잘 맞지만, 앞쪽이 열린 부분 검색에는 약하다.

-- prefix 검색
SELECT id, name
FROM products
WHERE name LIKE 'mac%';
-- 일반 B-Tree 인덱스로 해결하기 어려운 패턴
SELECT id, name
FROM products
WHERE name LIKE '%book%';

부분 검색, 형태소 검색, 랭킹 검색이 필요하면 DB의 full-text index, PostgreSQL trigram, Elasticsearch/OpenSearch 같은 검색 전용 도구를 검토해야 한다. 단순히 name 인덱스만 추가해도 해결되지 않을 수 있다.

낮은 선택도

값 종류가 적고 대부분의 행이 같은 값을 가지면 단독 인덱스 효과가 약할 수 있다.

SELECT id
FROM orders
WHERE status = 'PAID';

전체 주문의 대부분이 PAID라면 DB는 인덱스를 타고 많은 행을 읽는 것보다 테이블을 순차적으로 읽는 편이 낫다고 판단할 수 있다. 하지만 낮은 선택도 컬럼도 복합 인덱스에서는 의미가 달라진다.

SELECT id
FROM orders
WHERE status = 'PAID'
ORDER BY created_at DESC
LIMIT 20;
CREATE INDEX idx_orders_status_created_id
ON orders (status, created_at DESC, id DESC);

이 경우 status로 구간을 잡고 최신순 앞부분만 읽는 것이 목표다.

OR 조건과 복잡한 조건

OR 조건은 인덱스 사용을 복잡하게 만든다.

SELECT id
FROM tickets
WHERE assignee_id = 10
   OR requester_id = 10;

DB는 두 인덱스를 각각 사용해 합치는 계획을 선택할 수도 있지만, 데이터 분포와 DB에 따라 테이블 스캔을 선택할 수도 있다. 호출 빈도가 높고 데이터가 많다면 쿼리를 분리해 UNION을 검토할 수 있다.

SELECT id
FROM tickets
WHERE assignee_id = 10
UNION
SELECT id
FROM tickets
WHERE requester_id = 10;

항상 UNION이 정답은 아니다. 실행 계획으로 확인해야 한다.

UNION은 기본적으로 중복 제거 비용을 만들 수 있다. 중복이 도메인상 불가능하거나 중복 허용 후 애플리케이션에서 처리할 수 있다면 UNION ALL이 더 나을 수 있지만, 결과 의미가 달라지므로 조심해야 한다.

실행 계획으로 확인하기

PostgreSQL에서는 다음처럼 확인한다.

EXPLAIN (ANALYZE, BUFFERS)
SELECT id, title
FROM posts
WHERE created_at >= timestamp '2026-06-30'
  AND created_at < timestamp '2026-07-01'
ORDER BY created_at DESC
LIMIT 20;

MySQL에서는 다음처럼 시작할 수 있다.

EXPLAIN
SELECT id, title
FROM posts
WHERE created_at >= '2026-06-30'
  AND created_at < '2026-07-01'
ORDER BY created_at DESC
LIMIT 20;

확인할 것은 단순히 인덱스 이름이 보이는지가 아니다. 예상 rows, 실제 rows, 정렬 여부, 필터 후 버려진 행, 임시 테이블, 버퍼 읽기까지 함께 봐야 한다.

PostgreSQL에서는 Rows Removed by Filter, Heap Fetches, Buffers가 힌트를 준다. MySQL에서는 Using where, Using filesort, Using temporary, rows, filtered를 함께 본다. 표현은 달라도 질문은 같다. “인덱스로 얼마나 좁혔고, 그 뒤 얼마나 버렸는가?”

실전 팁

  • 컬럼에 함수를 씌우지 말고 비교값을 가공한다.
  • 타입을 DB 컬럼과 애플리케이션 코드에서 일치시킨다.
  • %keyword% 검색은 검색 요구사항으로 분리해서 설계한다.
  • 낮은 선택도 컬럼은 단독보다 복합 인덱스 문맥에서 본다.
  • OR 조건, 동적 조건, 관리자 검색은 실제 실행 계획을 여러 파라미터 값으로 확인한다.
  • 함수 인덱스나 generated column은 쿼리 변경이 어려울 때 쓰는 선택지로 보고, 쓰기 비용과 이식성을 함께 검토한다.
  • 대소문자 무시 검색은 DB collation, normalize 컬럼, expression index 중 어떤 책임 경계가 맞는지 정한다.

위험 신호!

  • 운영 slow query에 WHERE DATE(column) 패턴이 많다.
  • Repository 파라미터 타입이 DB 컬럼 타입과 다르다.
  • 검색 요구사항이 커졌는데 여전히 LIKE '%keyword%'만 사용한다.
  • 인덱스가 있는데도 실행 계획의 rows가 너무 크다.
  • 통계 갱신이나 데이터 분포 변화 가능성을 보지 않는다.
  • UNION으로 바꾸면서 중복 제거 비용과 결과 의미 변화를 확인하지 않는다.
  • 대소문자 무시 검색을 위해 모든 로그인 요청에서 lower(column)을 계산한다.

확인 질문

  • 컬럼에 함수를 씌운 조건이 인덱스에 불리할 수 있는 이유는 무엇인가?
    • 인덱스에는 원래 컬럼 값이 정렬되어 있는데, 함수 적용 결과를 기준으로 비교하면 그 정렬 구조를 바로 활용하기 어려울 수 있기 때문이다.
  • 앞쪽 와일드카드 LIKE '%keyword%'가 일반 B-Tree 인덱스와 잘 맞지 않는 이유는 무엇인가?
    • 검색어 앞부분이 고정되어 있지 않아 정렬된 인덱스에서 시작 위치를 좁히기 어렵기 때문이다.
  • 인덱스 실패를 확인할 때 무엇을 봐야 하는가?
    • 실행 계획의 접근 방식, 사용 인덱스, 예상 rows와 실제 rows, 정렬과 임시 작업, 필터로 버려진 행 수를 봐야 한다.
  • 대소문자 무시 이메일 검색을 설계할 때 검토할 선택지는 무엇인가?
    • 입력 normalize 저장, DB collation, PostgreSQL expression index나 citext, MySQL generated/functional index 지원 등을 비교하고 Unique 제약까지 함께 설계해야 한다.

참고 문서