blog post cover

Index 및 최적화 및 회고

인덱스를 하나 추가하면 끝날 줄 알았다. 그런데 실제로는 인덱스 + 조건 재작성까지 해야 실행 계획이 바뀌었다.


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로 단축)

메트릭BeforeAfter
실행 시간2683ms829ms
접근 방식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으로 문제를 규정하고, 데이터 분포를 근거로 조건을 정리했을 때 비로소 실행 계획이 바뀌었다. 인덱스는 문제를 해결하는 도구이지, 문제를 정의하는 도구가 아니다.