안동민 개발노트 아이콘

안동민 개발노트

9장 : 인덱스

인덱스 실전

이론을 알았으면 실전으로 넘어갑니다. 인덱스를 생성하고, 실행 계획으로 실제 접근 방식을 확인하며, 인덱스가 기대대로 쓰이지 않는 함정을 피하고, 실무에서 인덱스를 효과적으로 설계하는 방법을 다룹니다.


인덱스 생성

인덱스 생성 (Oracle)
-- 단일 컬럼 인덱스
CREATE INDEX idx_users_email ON users(email);

-- 복합 인덱스 (다중 컬럼)
CREATE INDEX idx_orders_user_date ON orders(user_id, order_date);

-- 유니크 인덱스
CREATE UNIQUE INDEX idx_users_email_uk ON users(email);

-- 인덱스 삭제
DROP INDEX idx_users_email;

-- 인덱스 리빌드 (단편화 해소)
ALTER INDEX idx_orders_user_date REBUILD;

-- 인덱스 리빌드 (온라인 옵션, 지원 에디션/상황 확인 필요)
ALTER INDEX idx_orders_user_date REBUILD ONLINE;

-- 인덱스 상태 확인 (테스트/점검용, 운영 영향 확인 필요)
ANALYZE INDEX idx_orders_user_date VALIDATE STRUCTURE;
SELECT name, height, lf_rows, del_lf_rows,
       ROUND(del_lf_rows / DECODE(lf_rows, 0, 1, lf_rows) * 100) AS frag_pct
FROM index_stats;

Oracle에서 옵티마이저 통계 관리는 보통 DBMS_STATS를 사용합니다. ANALYZE INDEX ... VALIDATE STRUCTUREINDEX_STATS는 구조 점검 성격이 강하고 운영 영향이 있을 수 있으므로, 일상적인 통계 갱신이나 단편화 판단에 무조건 쓰는 도구로 외우면 안 됩니다.

인덱스 생성 (MySQL)
-- 단일 컬럼 인덱스
CREATE INDEX idx_users_email ON users(email);

-- 복합 인덱스
CREATE INDEX idx_orders_user_date ON orders(user_id, order_date);

-- 인덱스 삭제
DROP INDEX idx_users_email ON users;

-- 인덱스 재생성 예시 (온라인 가능 여부는 버전/엔진/작업에 따라 다름)
ALTER TABLE orders DROP INDEX idx_orders_user_date,
                   ADD INDEX idx_orders_user_date(user_id, order_date),
                   ALGORITHM=INPLACE, LOCK=NONE;

-- 인덱스 통계 갱신
ANALYZE TABLE users;
인덱스 생성 (PostgreSQL)
-- 기본 인덱스
CREATE INDEX idx_users_email ON users(email);

-- 서비스 중단 없는 인덱스 생성 (CONCURRENTLY)
CREATE INDEX CONCURRENTLY idx_orders_date
ON orders(order_date);

-- 부분 인덱스 (조건부 인덱스)
CREATE INDEX idx_orders_active
ON orders(user_id, order_date)
WHERE status = 'ACTIVE';

-- 인덱스 재구성 (일반 REINDEX는 잠금 영향이 있을 수 있음)
REINDEX INDEX idx_users_email;

-- PostgreSQL 12+: 잠금 영향을 줄이는 재구성
REINDEX INDEX CONCURRENTLY idx_users_email;

PostgreSQL의 CONCURRENTLY 계열 명령은 일반 인덱스 작업보다 서비스 영향이 작지만 더 오래 걸리고, 트랜잭션 블록 안에서 실행할 수 없습니다. 실패하면 invalid index가 남을 수 있으므로 운영에서는 진행 상태와 실패 정리 절차까지 함께 준비해야 합니다.


EXPLAIN으로 인덱스 사용 확인

인덱스를 만들었다고 반드시 사용되는 것은 아닙니다. 실행 계획을 확인하여 인덱스가 실제로 사용되는지 검증해야 합니다.

실행 계획 확인 (Oracle)
-- 실행 계획 생성
EXPLAIN PLAN FOR
SELECT * FROM users WHERE email = 'test@example.com';

-- 실행 계획 조회
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY);

-- 실제 실행 통계 포함 (Oracle 10g+)
SELECT /*+ GATHER_PLAN_STATISTICS */ *
FROM users WHERE email = 'test@example.com';

SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR(
  NULL, NULL, 'ALLSTATS LAST'));
실행 계획 확인 (MySQL)
-- 기본 실행 계획
EXPLAIN SELECT * FROM users WHERE email = 'test@example.com';

-- 상세 실행 계획 (MySQL 8.0+)
EXPLAIN ANALYZE SELECT * FROM users WHERE email = 'test@example.com';

-- FORMAT=JSON으로 비용 정보 확인
EXPLAIN FORMAT=JSON SELECT * FROM users WHERE email = 'test@example.com';

인덱스가 안 타는 경우

인덱스를 만들어 놓고도 옵티마이저가 인덱스를 무시하는 경우가 있습니다. 이를 인지하고 피하는 것이 인덱스 실전의 핵심입니다.

원인예시대응 방향
컬럼에 함수 적용WHERE UPPER(name) = 'KIM'표현식 인덱스 또는 저장값 정규화
묵시적 타입 변환WHERE phone = 01012345678컬럼 타입과 비교 값 타입 일치
NOT, <> 조건WHERE status <> 'DELETED'선택도 확인, 긍정 조건/부분 인덱스
OR 조건WHERE a = 1 OR b = 2index merge/bitmap/UNION 비교
LIKE 앞쪽 와일드카드WHERE name LIKE '%철수'전문 검색 또는 trigram 계열 검토
NULL 비교WHERE column IS NULLDBMS별 인덱스 저장 규칙 확인
낮은 선택도WHERE gender = 'M' (50%)Full Scan/bitmap/복합 조건 비교
산술 연산WHERE salary * 12 > 50000000컬럼 쪽 연산 제거

각 원인 상세 분석

4. OR 조건과 UNION ALL
인덱스 비효율
  SELECT * FROM users
  WHERE department_id = 10 OR location_id = 20;
  → 두 조건이 서로 다른 인덱스 → Full Scan 가능

해결: UNION ALL로 분리
  SELECT * FROM users WHERE department_id = 10
  UNION ALL
  SELECT * FROM users WHERE location_id = 20
    AND (department_id <> 10 OR department_id IS NULL);  -- 중복 방지
  → 각 쿼리가 각 인덱스를 사용 ✓

선택도(Selectivity)와 인덱스 효과

인덱스가 있어도 선택도가 낮으면(대부분의 행이 조건을 만족하면) 옵티마이저가 의도적으로 Full Scan을 선택할 수 있습니다. 이는 실패가 아니라, 많은 row lookup보다 순차 읽기가 더 싸다고 판단한 결과일 수 있습니다.


커버링 인덱스

쿼리에 필요한 모든 컬럼이 인덱스에 포함되어 있으면, 테이블 접근을 줄이거나 생략할 수 있습니다. 이를 커버링 인덱스(Covering Index)라고 합니다. DBMS와 MVCC 상태에 따라 실제 테이블 접근이 완전히 사라지지 않을 수 있습니다.

커버링 여부는 실행 계획에서 DBMS별로 다르게 확인합니다. MySQL은 Using index, PostgreSQL은 Index Only ScanHeap Fetches, SQL Server는 key lookup 유무를 함께 봐야 합니다. 특히 PostgreSQL은 visibility map 상태에 따라 Index Only Scan이어도 heap fetch가 남을 수 있습니다.


복합 인덱스 컬럼 순서 결정

복합 인덱스에서 컬럼 순서는 성능에 결정적 영향을 미칩니다.

첫 범위 조건 뒤의 컬럼은 효율적인 탐색이나 전역 정렬 활용이 제한될 수 있습니다. 따라서 WHERE user_id = ? AND order_date >= ? ORDER BY status처럼 범위와 정렬 요구가 섞인 쿼리는 (user_id, order_date, status)(user_id, status, order_date) 후보를 실행 계획과 실제 실행 시간으로 비교해야 합니다.


인덱스 관리와 모니터링

인덱스를 만들고 방치하면 안 됩니다. 정기적인 관리가 필요합니다.

인덱스 모니터링 (MySQL)
──── 사용되지 않는 인덱스 찾기 (MySQL 8.0+) ────
SELECT * FROM sys.schema_unused_indexes
WHERE object_schema = 'mydb';

──── 인덱스 중복 확인 ────
SELECT * FROM sys.schema_redundant_indexes
WHERE table_schema = 'mydb';

──── 인덱스 사용 통계 ────
SELECT index_name,
       count_star,
       count_read,
       count_write,
       count_fetch,
       count_insert,
       count_update,
       count_delete
FROM performance_schema.table_io_waits_summary_by_index_usage
WHERE object_schema = 'mydb'
  AND object_name = 'orders';

사용량 통계는 수집 설정, 서버 재시작, 통계 reset, 관측 기간의 영향을 받습니다. 사용되지 않음으로 보이는 인덱스라도 유니크 제약, 외래 키 보조, 월말 배치, 장애 대응 쿼리처럼 드물게 필요한 경로가 있는지 확인한 뒤 삭제해야 합니다.


특수 인덱스

B-Tree 외에도 특수한 상황에 적합한 인덱스 유형이 존재합니다.

비트맵 인덱스 (Oracle)
-- 카디널리티가 낮은 컬럼에 적합
CREATE BITMAP INDEX idx_orders_status ON orders(status);

-- 비트맵 인덱스 장점: 분석성 쿼리에서 여러 조건의 AND/OR 연산이 유리
SELECT COUNT(*) FROM orders
WHERE status = 'COMPLETED' AND payment_type = 'CARD';
-- → 두 비트맵 인덱스의 비트 AND 연산 후보

-- 주의: DML이 빈번한 OLTP에는 부적합
-- → 행 잠금이 아닌 비트맵 세그먼트 잠금 → 동시성 저하
부분 인덱스 (PostgreSQL)
-- 활성 주문만 인덱싱 (전체의 10%가 ACTIVE라면)
CREATE INDEX idx_orders_active_user
ON orders(user_id, order_date)
WHERE status = 'ACTIVE';

-- 효과: 조건 밖 행은 인덱스에 없어 크기와 갱신 비용 감소
-- 조건: 쿼리 조건이 부분 인덱스 조건을 만족함을 옵티마이저가 알아야 함

인덱스 설계 실습

쇼핑몰 DB를 기준으로 실무적인 인덱스를 설계합니다.

쇼핑몰 인덱스 설계
-- 1. 주문 조회: 특정 사용자의 최근 주문
--    WHERE user_id = ? ORDER BY order_date DESC
CREATE INDEX idx_orders_user_date
ON orders(user_id, order_date DESC);

-- 2. 상품 검색: 카테고리별 가격순
--    WHERE category_id = ? ORDER BY price
CREATE INDEX idx_products_cat_price
ON products(category_id, price);

-- 3. 주문 상세: 주문별 상품 목록
--    WHERE order_id = ?
CREATE INDEX idx_oitems_order ON order_items(order_id);

-- 4. 리뷰: 상품별 최신 리뷰
--    WHERE product_id = ? ORDER BY created_at DESC
CREATE INDEX idx_reviews_prod_date
ON reviews(product_id, created_at DESC);

-- 5. 사용자 로그인: 이메일로 조회
--    WHERE email = ?
CREATE UNIQUE INDEX idx_users_email ON users(email);

-- 6. 주문 상태 조회: 특정 상태의 주문 목록
--    WHERE status = ? AND order_date >= ?
CREATE INDEX idx_orders_status_date
ON orders(status, order_date);

-- 7. 커버링 인덱스: 주문 목록 API
--    SELECT order_id, order_date, total, status
--    WHERE user_id = ? ORDER BY order_date DESC LIMIT 20
--    InnoDB는 보조 인덱스에 PK가 자동 포함될 수 있지만,
--    PostgreSQL/SQL Server는 INCLUDE 절도 후보가 됨
CREATE INDEX idx_orders_cover
ON orders(user_id, order_date DESC, order_id, total, status);
쿼리 패턴인덱스이유
사용자별 주문 목록(user_id, order_date DESC)복합 인덱스로 정렬까지 커버
카테고리별 상품(category_id, price)필터 + 정렬 한 번에
이메일 로그인(email) UNIQUE등호 검색, 유일성 보장
상품별 리뷰(product_id, created_at DESC)최신순 정렬 포함
주문 목록 API(user_id, order_date DESC, order_id, total, status)커버링 인덱스

커버링 목적의 인덱스는 모든 목록 API마다 만들면 쓰기 비용과 저장 공간이 빠르게 늘어납니다. 조회 빈도, p95/p99 지연, 반환 컬럼의 크기, DML 비율을 함께 보고 유지할 인덱스를 고르는 것이 안전합니다.


인덱스 설계 시 주의사항


DBMS별 인덱스 특성 비교

항목OracleMySQL (InnoDB)PostgreSQL
기본 인덱스B-tree 계열B-tree 계열, clustered PKB-tree access method
PK 클러스터링IOT 선택 가능기본 키가 clustered index힙 테이블 기본
온라인 생성ONLINE 옵션Online DDL 지원 범위 확인CONCURRENTLY
표현식 인덱스함수 기반표현식/생성 컬럼 기반표현식 인덱스
부분 인덱스직접 지원 제한직접 지원 제한Partial Index
비트맵 인덱스지원미지원실행 계획의 Bitmap Scan
GIN/GiST별도 도메인 인덱스공간/전문검색 별도 기능지원
통계 갱신DBMS_STATSANALYZE TABLEANALYZE

인덱스 실전 종합 정리

인덱스는 자주 쓰는 쿼리 패턴에 맞춰 설계해야 합니다. 다음 장에서는 데이터 일관성의 핵심인 트랜잭션을 다루겠습니다.