쿼리 튜닝에서 가장 흔한 착각은 “쿼리가 느리다”는 말을 Cardinality / 실세계의 데이터 분포를 배제하고 쓰는 것이다.
생성 스크립트 구성
geo_data_import.py: 시도/시군구/읍면동 적재gym_data_import.py: 헬스장 생성 + 제공 종목 매핑membership_import.py: 헬스장별 회원권 생성user_import.py: 사용자 대량 생성trainer_import.py: 트레이너 생성 + 종목 매핑gym_class_import.py: 수업 생성 + 담당 트레이너 매핑user_membership_import.py: 사용자-회원권 매핑
실제 데이터 규모

user_memberships가 90만 행 이상이기 때문에, 인덱스가 없으면 풀스캔이 거의 확정이다.
분포 특성
날짜 분포
user_memberships는 최근 1년 안에서 시작일이 랜덤이고, 종료일은 시작일 + 기간이다. 중요한 사실 하나는 started_at이 항상 과거라는 점이다. 즉, 이 데이터셋에서는 started_at <= NOW()가 항상 참이다.
그래서 활성 조건은 사실상 아래 한 줄로 정리된다.
ended_at >= NOW()
이 특성이 인덱스 설계의 방향을 결정했다.


헬스장/종목 분포
헬스장마다 제공 종목 수가 1~3개로 랜덤이다. 특정 종목에 편향이 생기며, 그 편향이 조인 결과를 흔든다.
type_count gyms
1 98984
2 98314
3 98808
헬스장별 회원권 수는 현재 데이터에서 모두 동일하다.
membership_count gyms
5 296106
트레이너 분포
헬스장별로 0~20명 사이에서 트레이너가 배정된다. 일부 헬스장에는 트레이너가 없어 수업이 생성되지 않는다. 이 결손이 실제 서비스의 편차를 흉내 낸다.

권장 적재 순서
geo_data_import.pyexercise_types시드 데이터 입력gym_data_import.pymembership_import.pyuser_import.pytrainer_import.pygym_class_import.pyuser_membership_import.py
검증 쿼리
SELECT
(SELECT COUNT(*) FROM users) AS users,
(SELECT COUNT(*) FROM gyms) AS gyms,
(SELECT COUNT(*) FROM memberships) AS memberships,
(SELECT COUNT(*) FROM user_memberships) AS user_memberships;
이제 이 분포를 바탕으로 EXPLAIN을 읽어보자.