인덱스를 하나 추가하면 끝날 줄 알았다. 그런데 실제로는 인덱스 + 조건 재작성까지 해야 실행 계획이 바뀌었다.
1. 기준 쿼리와 결과
SELECT g.id AS gym_id, g.name,
COUNT(*) AS active_members_count
FROM gyms g
JOIN memberships m ON m.gym_id = g.id
JOIN user_memberships um ON um.membership_id = m.id
WHERE um.started_at <= NOW() AND um.ended_at >= NOW()
GROUP BY g.id, g.name
ORDER BY active_members_count DESC
LIMIT 10;
핵심 문제: user_memberships 풀스캔 (약 2683ms 소요)
2. 인덱스 선택
데이터셋에서 started_at <= NOW()가 항상 참이므로, 실제 필터는 ended_at >= NOW()
CREATE INDEX idx_um_active_ended
ON user_memberships (ended_at, membership_id);
ended_at을 선두에 둬서 필터를 먼저 줄임membership_id로 조인을 이어감
3. 인덱스만 적용한 결과
여전히 풀스캔 유지 — 조건 자체가 인덱스를 타기 어려웠다.
WHERE um.started_at <= NOW() AND um.ended_at >= NOW() 에서 started_at 조건이 남아있으면 옵티마이저가 ended_at 인덱스를 선택하지 않는다.
4. 조건 재작성 + EXPLAIN
EXPLAIN ANALYZE
SELECT g.id AS gym_id, g.name,
COUNT(*) AS active_members_count
FROM gyms g
JOIN memberships m ON m.gym_id = g.id
JOIN user_memberships um ON um.membership_id = m.id
WHERE um.ended_at >= NOW()
GROUP BY g.id, g.name
ORDER BY active_members_count DESC
LIMIT 10;
결과: Covering index range scan으로 변경 (약 829ms로 단축)
| 메트릭 | Before | After |
|---|---|---|
| 실행 시간 | 2683ms | 829ms |
| 접근 방식 | Table scan (풀스캔) | Covering index range scan |
| 개선율 | - | 69% |
5. 등가성 확인
SELECT
(SELECT COUNT(*) FROM user_memberships
WHERE NOW() BETWEEN started_at AND ended_at) AS between_count,
(SELECT COUNT(*) FROM user_memberships
WHERE ended_at >= NOW()) AS ended_only;
결과: 56926 = 56926 (동일)
조건을 바꿔도 결과 집합은 완전히 동일하다. 데이터 분포 특성(started_at이 항상 과거)이 이를 보장한다.
6. 회고
핵심은 **“인덱스 추가”가 아니라 “조건 이해”**였다.
EXPLAIN으로 문제를 규정하고, 데이터 분포를 근거로 조건을 정리했을 때 비로소 실행 계획이 바뀌었다. 인덱스는 문제를 해결하는 도구이지, 문제를 정의하는 도구가 아니다.