이 파트는 “느린 이유를 증명하는 과정”이다.
gym_booking에서 가장 느린 쿼리는 활성 회원 집계였다. 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 NOW() BETWEEN um.started_at AND um.ended_at
GROUP BY g.id, g.name
ORDER BY active_members_count DESC
LIMIT 10;
현재 인덱스 상태
user_memberships:PRIMARY(id),fk_um_user(user_id),fk_um_membership(membership_id)memberships:PRIMARY(id),fk_memberships_gym(gym_id)started_at,ended_at인덱스 없음
실제 EXPLAIN ANALYZE 출력
-> Limit: 10 row(s) (actual time=2683..2683 rows=10 loops=1)
-> Sort: active_members_count DESC, limit input to 10 row(s) per chunk
(actual time=2683..2683 rows=10 loops=1)
-> Table scan on <temporary>
(actual time=2672..2679 rows=49965 loops=1)
-> Aggregate using temporary table
(actual time=2672..2672 rows=49965 loops=1)
-> Nested loop inner join (cost=199355 rows=90802)
(actual time=5.76..2539 rows=56926 loops=1)
-> Nested loop inner join (cost=107714 rows=90802)
(actual time=5.29..1928 rows=56926 loops=1)
-> Filter: (<cache>(now()) between um.started_at
and um.ended_at) (cost=11997 rows=90802)
(actual time=4.76..461 rows=56926 loops=1)
-> Table scan on um (cost=11997 rows=817304)
(actual time=4.45..394 rows=901312 loops=1)
-> Single-row index lookup on m using PRIMARY
(id=um.membership_id) (cost=0.954 rows=1)
(actual time=0.0255..0.0256 rows=1 loops=56926)
-> Single-row index lookup on g using PRIMARY
(id=m.gym_id) (cost=0.909 rows=1)
(actual time=0.0105..0.0105 rows=1 loops=56926)

판단
- 부적절: 핵심 테이블
user_memberships가 풀스캔으로 접근된다. - 근거: “90만 행을 읽어 56,926행만 통과한다.”
- 원인:
started_at/ended_at조건이 인덱스를 타지 못한다. - 힌트: 더미 데이터 특성상
started_at <= NOW()는 항상 참이다. 실질 필터는ended_at >= NOW()하나다.
즉, 인덱스도, 조건도 다시 생각해야 한다.
병목을 찾는 절차
- EXPLAIN ANALYZE 실행
- 실제 시간과 rows가 큰 구간 확인
- 필터가 인덱스를 타는지 확인
- 인덱스/조건 재설계
- 다시 EXPLAIN으로 검증
다음 파트에서 실제로 인덱스를 적용하고, 조건을 바꾸면서 실행 계획을 변화시킨다.