Product 도메인 Entity를 구현하다가 @Table의 indexes 속성을 마주쳤다. 인덱스가 성능에 좋다는 건 알고 있었지만, 막상 어떤 컬럼에 걸어야 하는지, 걸면 무조건 빠른 건지 명확하게 정리된 적이 없었다. 그래서 이번에 인덱스에 대해서 찾아보았다.
책의 목차와 같다. 목차 없이 책에서 내용을 찾으려면 처음부터 끝까지 전부 읽어야 하지만, 목차가 있으면 해당 페이지로 바로 이동할 수 있다. DB도 마찬가지로 인덱스가 없으면 조건에 맞는 행을 찾기 위해 테이블 전체를 스캔(Full Table Scan)한다.
-- 인덱스 없을 때: products 10만 건 전부 스캔
SELECT * FROM products WHERE status = 'ON_SALE';
-- 인덱스 있을 때: status 인덱스에서 ON_SALE인 행만 바로 탐색
SELECT * FROM products WHERE status = 'ON_SALE';
@Table(
name = "products",
indexes = {
// 단일 인덱스: 컬럼 하나
@Index(name = "idx_products_status", columnList = "status"),
// 복합 인덱스: 컬럼 두 개를 하나의 인덱스로 묶음
@Index(name = "idx_products_status_created", columnList = "status, created_at"),
}
)
@Index는 Hibernate가 DDL을 생성할 때 CREATE INDEX 구문을 자동으로 만들어 준다. 즉 인덱스 생성 자체는 JPA가 해주고, 실제 쿼리에서 인덱스를 쓸지 말지는 DB 옵티마이저가 판단한다.
처음엔 단순하게 컬럼마다 인덱스를 하나씩 걸면 되지 않나 생각했다. 그런데 단일 인덱스 여러 개가 있어도 DB는 하나의 인덱스만 선택해서 사용한다.
// 단일 인덱스 2개
@Index(name = "idx_products_status", columnList = "status")
@Index(name = "idx_products_created_at", columnList = "created_at")
-- 이 쿼리에서 두 인덱스가 동시에 사용될 것 같지만
SELECT * FROM products
WHERE status = 'ON_SALE'
ORDER BY created_at DESC;
-- 실제로는 옵티마이저가 status 인덱스 하나만 선택
-- → ON_SALE인 행을 찾은 뒤 created_at으로 별도 정렬 (추가 비용 발생)
이 경우 (status, created_at) 복합 인덱스가 더 효율적이다. 복합 인덱스는 이미 두 컬럼 기준으로 정렬된 상태로 저장되기 때문에 정렬 비용이 따로 들지 않는다.
// 복합 인덱스: WHERE + ORDER BY를 인덱스 하나로 처리
@Index(name = "idx_products_status_created", columnList = "status, created_at")
결론: WHERE와 ORDER BY에 항상 함께 쓰이는 컬럼 조합이 있다면 복합 인덱스가 유리하다.
복합 인덱스는 왼쪽 컬럼부터 순서대로 사용해야 효과가 있다.
// (seller_id, status) 복합 인덱스
@Index(name = "idx_products_seller_status", columnList = "seller_id, status")
-- ✅ 인덱스 사용됨
WHERE seller_id = 7
WHERE seller_id = 7 AND status = 'ON_SALE'
-- ❌ 인덱스 사용 안됨 (선두 컬럼인 seller_id 없음)
WHERE status = 'ON_SALE'
인덱스를 걸어도 쿼리 작성 방식에 따라 옵티마이저가 인덱스를 무시하는 경우가 있다. 이 부분이 가장 중요한 포인트였다.
1. 인덱스 컬럼에 함수나 연산을 적용한 경우
-- ❌ 컬럼을 가공하면 인덱스 무효
WHERE YEAR(created_at) = 2026
WHERE sale_price * 0.9 > 100000
-- ✅ 컬럼을 그대로 비교해야 인덱스 사용
WHERE created_at >= '2026-01-01'
WHERE sale_price > 111111
2. LIKE 앞에 와일드카드가 붙은 경우
-- ❌ 앞에 % 붙으면 인덱스 무효 (어디서 시작하는지 모르니 풀스캔)
WHERE name LIKE '%스니커즈'
-- ✅ 뒤에만 % 붙으면 인덱스 사용 가능
WHERE name LIKE '스니커즈%'
3. 데이터 분포가 너무 고를 때
-- status = 'ON_SALE'인 데이터가 전체의 90%라면
-- "어차피 대부분이니 풀스캔이 낫겠다"고 옵티마이저가 판단
WHERE status = 'ON_SALE' -- 인덱스 있어도 사용 안 할 수 있음
인덱스는 선택도(Selectivity) 가 높을수록 효과가 크다. 조건에 맞는 데이터가 전체 중 적은 비율일수록 인덱스 효과가 극대화된다.
인덱스를 걸었다고 끝이 아니라, 실제로 사용되는지 검증해야 한다.
방법 1 — application.yml SQL 로그
spring:
jpa:
show-sql: true
properties:
hibernate:
format_sql: true
logging:
level:
org.hibernate.SQL: DEBUG
방법 2 — MySQL EXPLAIN
EXPLAIN SELECT * FROM products
WHERE status = 'ON_SALE'
ORDER BY created_at DESC;
| 컬럼 | 의미 |
|---|---|
key | 실제 사용된 인덱스명 (NULL이면 인덱스 미사용) |
type: ALL | 풀 테이블 스캔 (인덱스 미사용, 가장 느림) |
type: range | 범위 탐색 (인덱스 사용, 양호) |
type: ref | 특정 값 탐색 (인덱스 사용, 좋음) |
type: const | PK·Unique로 단 1건 탐색 (가장 빠름) |
인덱스가 조회 성능을 높이는 대신 쓰기(INSERT·UPDATE·DELETE) 시 인덱스도 함께 갱신해야 하므로 쓰기 성능이 약간 저하된다. 모든 컬럼에 인덱스를 다는 건 오히려 역효과다.
| 인덱스 있을 때 | 인덱스 없을 때 | |
|---|---|---|
SELECT | 빠름 ✅ | 느림 ❌ |
INSERT / UPDATE / DELETE | 약간 느림 ⚠️ | 빠름 ✅ |
인덱스를 걸기 좋은 컬럼
WHERE 조건에 자주 등장하는 컬럼 (status, seller_id)ORDER BY에 자주 등장하는 컬럼 (created_at, sale_price)인덱스를 걸지 않아도 되는 컬럼
Boolean처럼 값의 종류가 2가지뿐인 컬럼Product 도메인에 아래와 같이 인덱스를 정리했다. 단일 인덱스 4개에서 실제 쿼리 패턴을 분석해서 복합 인덱스로 재구성했다.
@Table(
name = "products",
indexes = {
// 상품 목록 조회: status 필터 + created_at 정렬이 항상 함께 사용
@Index(name = "idx_products_status_created",
columnList = "status, created_at"),
// 상품 목록 조회: status 필터 + 가격 정렬
@Index(name = "idx_products_status_price",
columnList = "status, sale_price"),
// 판매자 본인 상품 조회: seller_id + status 조합
@Index(name = "idx_products_seller_status",
columnList = "seller_id, status"),
}
)
인덱스는 "걸면 빠르다"가 아니라 "올바른 쿼리 패턴과 조합될 때 빠르다" 는 게 핵심이었다. 특히 복합 인덱스의 선두 컬럼 규칙과, 인덱스 컬럼에 함수를 쓰면 무효가 된다는 점은 모르고 지나쳤으면 나중에 성능 문제가 생겼을 때 원인을 찾기 어려웠을 것 같다. API 구현 후 EXPLAIN으로 실행 계획을 확인하는 습관을 들여야겠다.