464 lines
26 KiB
Markdown
464 lines
26 KiB
Markdown
# 안1. 인덱스 추가 + 쿼리 조건 개선
|
|
|
|
> [개요 문서](01-improvement-overview.md) | 추천 단계: **1단계 (필수)** | 난이도: 하 | 위험도: 낮음 | 예상 작업량: 1~2일
|
|
|
|
## 한 줄 요약
|
|
|
|
실제 쿼리 패턴에 맞는 **복합 인덱스를 추가**하고, 날짜 컬럼에 함수를 씌운 조건을 **"시작 시각 이상 ~ 끝 시각 미만" 범위 조건으로 바꿔** 인덱스를 쓸 수 있게 한다.
|
|
|
|
---
|
|
|
|
## 1. 해결하는 문제
|
|
|
|
| 문제 (선행 문서 번호) | 해결 정도 |
|
|
|---|---|
|
|
| 1. 날짜 컬럼에 `DATE()/HOUR()/YEAR()/MONTH()/DAYOFMONTH()`를 씌운 조건 | ◎ |
|
|
| 2. 복합 인덱스 부재 | ◎ |
|
|
| 4. 게임 종료마다 반복되는 조회 비용 | ○ (조회가 1시간 범위만 읽게 됨) |
|
|
| 6. 관리자 기록 목록 조회 | ○ (날짜 인덱스로 최근 50건을 빠르게 찾음) |
|
|
| (버그) 이달의 랭킹에 작년 기록이 섞임 | ◎ |
|
|
| (정합성) 최고기록 테이블 중복 행 | ◎ (UNIQUE 키) |
|
|
|
|
---
|
|
|
|
## 2. 쉬운 설명
|
|
|
|
- 지금은 "이 선생님의 이 앱에서 오늘 기록"을 찾을 때, 색인이 없어서 **120만 행을 모두 넘겨보며** 날짜를 계산합니다.
|
|
- 인덱스를 `(선생님 → 앱 → 기록 시각)` 순서로 만들면, 색인에서 "선생님 123 → 앱 5 → 2026-09-14 00:00~24:00" 구간으로 바로 이동해 **그날 기록만** 읽습니다.
|
|
- 단, 조건을 `DATE(RecordDateTime) = '2026-09-14'`처럼 쓰면 색인을 못 쓰므로, `RecordDateTime >= '2026-09-14' AND RecordDateTime < '2026-09-15'`로 바꿔야 합니다. **결과는 같습니다.**
|
|
- 인덱스를 추가해도 기존 코드는 그대로 동작합니다. 그래서 "인덱스 먼저 추가 → 코드 나중 배포" 순서로 안전하게 진행할 수 있습니다.
|
|
|
|
---
|
|
|
|
## 3. 변경 내용
|
|
|
|
### 3-1. 인덱스 추가
|
|
|
|
#### 설계 원칙
|
|
|
|
1. `=`로 비교하는 컬럼을 앞에, 범위(`>=`, `<`)로 비교하는 컬럼(`RecordDateTime`)을 뒤에 둔다.
|
|
2. 자주 실행되는 랭킹 쿼리용 인덱스에는 `PlayerID`, 기록 값까지 넣어 **원본 행을 읽지 않고 인덱스만으로** 계산하게 한다(커버링 인덱스).
|
|
3. 인덱스는 쓰기(INSERT/UPDATE) 때마다 함께 갱신되므로 필요한 것만 만든다. 기록 저장은 초당 수 건 수준이라 인덱스 3개 정도는 부담이 작다(예상).
|
|
|
|
#### `best_record`
|
|
|
|
| 인덱스 이름 | 컬럼 순서 | 이 인덱스를 쓰는 쿼리 |
|
|
|---|---|---|
|
|
| `idx_br_maestro_app_time` | `(MaestroID, AppID, RecordDateTime, PlayerID, BestRecord)` | 시간/일간/월간 랭킹 ([app_ranking.php](../../../src/web/server/record/app_ranking.php), [ranking_record_hour.php](../../../src/web/server/record/ranking_record_hour.php), [ranking_record_day.php](../../../src/web/server/record/ranking_record_day.php), [ranking_record_month.php](../../../src/web/server/record/ranking_record_month.php)) — 커버링 |
|
|
| `idx_br_player_app_time` | `(PlayerID, AppID, RecordDateTime)` | 기록 저장 시 "이번 시간 기록" 확인 ([update_result_record.php](../../../src/web/server/record/update_result_record.php)), 히스토리 ([history_record.php](../../../src/web/server/record/history_record.php)), 기록 삭제 후 최고기록 재계산 ([delete_record.php](../../../src/web/server/record/delete_record.php)), 플레이어 삭제 |
|
|
| `idx_br_maestro_time` | `(MaestroID, RecordDateTime)` | 관리자 기록 목록 최신순 조회 ([request_app_player_record_list.php](../../../src/web/server/record/request_app_player_record_list.php)) |
|
|
|
|
> `idx_br_player_app_time`이 `MaestroID`가 아닌 `PlayerID`로 시작하는 이유: `PlayerID`는 한 선생님에게만 속하므로 `PlayerID`만으로도 대상이 충분히 좁혀지고, [delete_test_player_record.php](../../../src/web/server/maestro/delete_test_player_record.php)처럼 `PlayerID`만으로 조회하는 쿼리까지 함께 쓸 수 있습니다. `MaestroID = ?` 조건은 좁혀진 행에서 추가로 확인합니다.
|
|
|
|
#### `typing_exam_record`
|
|
|
|
| 인덱스 이름 | 컬럼 순서 | 이 인덱스를 쓰는 쿼리 |
|
|
|---|---|---|
|
|
| `idx_ter_maestro_writing_time` | `(MaestroID, WritingID, RecordDateTime, PlayerID, Record)` | 긴글 시험 랭킹 6종 ([typing_exam_collection.php](../../../src/web/php/db/typing_exam_collection.php) `getRankingRecord*`, `getRankingMinusRecord*`) — 커버링 |
|
|
| `idx_ter_player_writing_time` | `(PlayerID, WritingID, RecordDateTime)` | `getThisHourRecord()`, `getHistoryRecord()`, 기록 삭제 후 재계산 |
|
|
| `idx_ter_maestro_time` | `(MaestroID, RecordDateTime)` | 관리자 기록 목록 ([request_writing_player_record_list.php](../../../src/web/server/record/request_writing_player_record_list.php)) |
|
|
|
|
#### `license_score` (선택 — 현재 620행이라 효과는 미미, 비용도 미미)
|
|
|
|
| 인덱스 이름 | 컬럼 순서 | 이 인덱스를 쓰는 쿼리 |
|
|
|---|---|---|
|
|
| `idx_ls_maestro_player_time` | `(MaestroID, PlayerID, ScoreDateTime)` | [get_license_score.php](../../../src/web/server/license_timer/get_license_score.php) |
|
|
|
|
#### 적용 SQL
|
|
|
|
```sql
|
|
-- 0) 현재 인덱스 확인 (적용 전후 비교용)
|
|
SHOW INDEX FROM best_record;
|
|
SHOW INDEX FROM typing_exam_record;
|
|
|
|
-- 1) best_record
|
|
ALTER TABLE best_record
|
|
ADD INDEX idx_br_maestro_app_time (MaestroID, AppID, RecordDateTime, PlayerID, BestRecord),
|
|
ADD INDEX idx_br_player_app_time (PlayerID, AppID, RecordDateTime),
|
|
ADD INDEX idx_br_maestro_time (MaestroID, RecordDateTime),
|
|
ALGORITHM=INPLACE, LOCK=NONE;
|
|
|
|
-- 2) typing_exam_record
|
|
ALTER TABLE typing_exam_record
|
|
ADD INDEX idx_ter_maestro_writing_time (MaestroID, WritingID, RecordDateTime, PlayerID, Record),
|
|
ADD INDEX idx_ter_player_writing_time (PlayerID, WritingID, RecordDateTime),
|
|
ADD INDEX idx_ter_maestro_time (MaestroID, RecordDateTime),
|
|
ALGORITHM=INPLACE, LOCK=NONE;
|
|
|
|
-- 3) license_score (선택)
|
|
ALTER TABLE license_score
|
|
ADD INDEX idx_ls_maestro_player_time (MaestroID, PlayerID, ScoreDateTime),
|
|
ALGORITHM=INPLACE, LOCK=NONE;
|
|
|
|
-- 4) 통계 갱신 (옵티마이저가 새 인덱스를 잘 고르도록)
|
|
ANALYZE TABLE best_record, typing_exam_record, license_score;
|
|
```
|
|
|
|
- `ALGORITHM=INPLACE, LOCK=NONE`: 인덱스를 만드는 동안에도 **읽기·쓰기가 계속 가능**합니다. 이 방식이 불가능한 상황이면 MariaDB가 실행하지 않고 오류를 내므로, 모르는 사이 테이블이 잠기는 일은 없습니다.
|
|
- 소요 시간: `best_record` 120만 행 기준 **수십 초~수 분(예상)**. 스테이징에서 먼저 측정하세요.
|
|
- 인덱스 크기: 인덱스 1개당 **수십 MB 수준(예상)**. 적용 후 [개요 7-1](01-improvement-overview.md#7-1-테이블인덱스-크기)의 쿼리로 `index_mb`를 기록하세요. 디스크 여유 공간이 테이블 크기의 2배 이상인지 먼저 확인하세요(`df -h`).
|
|
|
|
### 3-2. 최고기록 테이블 UNIQUE 키 추가 (중복 정리 후)
|
|
|
|
`app_highest_record`는 "(선생님, 플레이어, 앱)당 1행"이어야 하지만 이를 보장하는 장치가 없습니다. UNIQUE 키를 추가하면 DB가 중복을 원천 차단하고, 조회도 해당 인덱스로 빨라집니다.
|
|
|
|
> 앱 105는 **낮을수록 좋은** 앱입니다([util_app.php](../../../src/web/server/lib/util_app.php) `is_highest_record_prefer_app()`). 중복 정리 시 앱 105는 가장 작은 값을, 나머지는 가장 큰 값을 남깁니다.
|
|
|
|
```sql
|
|
-- 1) 안전을 위한 사본
|
|
CREATE TABLE app_highest_record_bak_260914 AS SELECT * FROM app_highest_record;
|
|
CREATE TABLE typing_exam_highest_record_bak_260914 AS SELECT * FROM typing_exam_highest_record;
|
|
|
|
-- 2) 중복 확인 (개요 문서 7-5와 동일). 0건이면 3) 생략
|
|
SELECT COUNT(*) FROM (
|
|
SELECT 1 FROM app_highest_record
|
|
GROUP BY MaestroID, PlayerID, AppID HAVING COUNT(*) > 1
|
|
) d;
|
|
|
|
-- 3) 중복 정리: 같은 조합에서 "더 좋은 기록(b)"이 있는 행(a)을 삭제
|
|
-- 기록이 같으면 ID가 큰(최근) 행을 남김
|
|
DELETE a
|
|
FROM app_highest_record a
|
|
JOIN app_highest_record b
|
|
ON a.MaestroID = b.MaestroID
|
|
AND a.PlayerID = b.PlayerID
|
|
AND a.AppID = b.AppID
|
|
AND a.AppHighestRecordID <> b.AppHighestRecordID
|
|
WHERE
|
|
(a.AppID <> 105 AND (a.HighestRecord < b.HighestRecord
|
|
OR (a.HighestRecord = b.HighestRecord AND a.AppHighestRecordID < b.AppHighestRecordID)))
|
|
OR
|
|
(a.AppID = 105 AND (a.HighestRecord > b.HighestRecord
|
|
OR (a.HighestRecord = b.HighestRecord AND a.AppHighestRecordID < b.AppHighestRecordID)));
|
|
|
|
-- 긴글 시험 최고기록은 모두 "높을수록 좋음"
|
|
DELETE a
|
|
FROM typing_exam_highest_record a
|
|
JOIN typing_exam_highest_record b
|
|
ON a.MaestroID = b.MaestroID
|
|
AND a.PlayerID = b.PlayerID
|
|
AND a.WritingID = b.WritingID
|
|
AND a.TypingExamHighestRecordID <> b.TypingExamHighestRecordID
|
|
WHERE a.HighestRecord < b.HighestRecord
|
|
OR (a.HighestRecord = b.HighestRecord AND a.TypingExamHighestRecordID < b.TypingExamHighestRecordID);
|
|
|
|
-- 4) 중복이 0건인지 다시 확인한 뒤 UNIQUE 키 추가
|
|
ALTER TABLE app_highest_record
|
|
ADD UNIQUE INDEX uk_ahr_maestro_player_app (MaestroID, PlayerID, AppID),
|
|
ALGORITHM=INPLACE, LOCK=NONE;
|
|
|
|
ALTER TABLE typing_exam_highest_record
|
|
ADD UNIQUE INDEX uk_tehr_maestro_player_writing (MaestroID, PlayerID, WritingID),
|
|
ALGORITHM=INPLACE, LOCK=NONE;
|
|
|
|
-- 5) 1~2주 문제 없으면 사본 삭제
|
|
-- DROP TABLE app_highest_record_bak_260914, typing_exam_highest_record_bak_260914;
|
|
```
|
|
|
|
**UNIQUE 키 추가 후 기존 코드 동작**: 현재 코드는 "삭제 후 삽입"이므로 평소에는 그대로 동작합니다. 드물게 두 요청이 동시에 들어오면 한쪽 INSERT가 중복 오류로 실패하는데, 현재 코드는 오류를 무시하므로 화면 오류는 없습니다. 이 경우를 완전히 없애는 UPSERT 방식은 [안2 3-3](03-option2-daily-summary-tables.md)에서 다룹니다.
|
|
|
|
### 3-3. 쿼리 조건 수정 (전/후 비교)
|
|
|
|
#### 변환 규칙
|
|
|
|
| 의미 | 현재 (인덱스 사용 불가) | 변경 (인덱스 사용 가능) |
|
|
|---|---|---|
|
|
| 지금 이 시간 | `DATE(x) = DATE(NOW()) AND HOUR(x) = HOUR(NOW())` | `x >= CAST(DATE_FORMAT(NOW(), '%Y-%m-%d %H:00:00') AS DATETIME)`<br>`AND x < CAST(DATE_FORMAT(NOW(), '%Y-%m-%d %H:00:00') AS DATETIME) + INTERVAL 1 HOUR` |
|
|
| 오늘 | `DATE(x) = DATE(NOW())` | `x >= CURDATE() AND x < CURDATE() + INTERVAL 1 DAY` |
|
|
| 이번 달 | `MONTH(x) = MONTH(NOW())` ← **연도 누락 버그** | `x >= CAST(DATE_FORMAT(CURDATE(), '%Y-%m-01') AS DATE)`<br>`AND x < CAST(DATE_FORMAT(CURDATE(), '%Y-%m-01') AS DATE) + INTERVAL 1 MONTH` |
|
|
| 특정 날짜 `?` | `YEAR(x)=YEAR(?) AND MONTH(x)=MONTH(?) AND DAYOFMONTH(x)=DAYOFMONTH(?)` | `x >= DATE(?) AND x < DATE(?) + INTERVAL 1 DAY` |
|
|
| 특정 날짜 `?`의 특정 시 `?` | 위 + `HOUR(x) = HOUR(?)` | `x >= DATE(?) + INTERVAL HOUR(?) HOUR`<br>`AND x < DATE(?) + INTERVAL (HOUR(?) + 1) HOUR` |
|
|
| 특정 날짜 `?`가 속한 달 | `YEAR(x)=YEAR(?) AND MONTH(x)=MONTH(?)` | `x >= CAST(DATE_FORMAT(?, '%Y-%m-01') AS DATE)`<br>`AND x < CAST(DATE_FORMAT(?, '%Y-%m-01') AS DATE) + INTERVAL 1 MONTH` |
|
|
| 특정 날짜 `?` 이전 전체 | `DATE(x) <= ?` | `x < DATE(?) + INTERVAL 1 DAY` |
|
|
|
|
- 파라미터(`?`)나 `NOW()`에 함수를 쓰는 것은 괜찮습니다. **컬럼(`x`)에만 함수를 쓰지 않으면 됩니다.**
|
|
- 시각 계산은 지금처럼 DB의 `NOW()`를 기준으로 합니다. PHP의 `date()`로 계산하면 PHP와 DB의 타임존이 다를 때 결과가 어긋날 수 있습니다.
|
|
- `WHERE` 안에서 조건의 **순서는 성능과 무관**합니다. 아래 예시는 읽기 쉽게 인덱스 컬럼 순서로 정렬했을 뿐입니다.
|
|
|
|
#### A. [update_result_record.php](../../../src/web/server/record/update_result_record.php) — 게임 종료마다 실행 (가장 자주 실행)
|
|
|
|
`get_best_record()`
|
|
|
|
```sql
|
|
-- 현재
|
|
SELECT BestRecordID, BestRecord
|
|
FROM best_record
|
|
WHERE MaestroID = ? AND AppID = ? AND PlayerID = ? AND DATE(RecordDateTime) = DATE(NOW())
|
|
AND HOUR(RecordDateTime) = HOUR(NOW())
|
|
-- bind_param("iii", $maestro_id, $app_id, $player_id)
|
|
|
|
-- 변경
|
|
SELECT BestRecordID, BestRecord
|
|
FROM best_record
|
|
WHERE PlayerID = ? AND AppID = ? AND MaestroID = ?
|
|
AND RecordDateTime >= CAST(DATE_FORMAT(NOW(), '%Y-%m-%d %H:00:00') AS DATETIME)
|
|
AND RecordDateTime < CAST(DATE_FORMAT(NOW(), '%Y-%m-%d %H:00:00') AS DATETIME) + INTERVAL 1 HOUR
|
|
-- bind_param("iii", $player_id, $app_id, $maestro_id) ← 바인딩 순서 주의
|
|
```
|
|
|
|
`update_best_record()`는 `WHERE BestRecordID = ?`(기본 키)로 이미 1행을 찾으므로 성능 문제는 없습니다. 뒤에 붙은 `DATE()/HOUR()` 조건은 "시간이 바뀌는 순간 방금 조회한 행을 갱신하지 않기 위한" 안전장치이므로 **그대로 두거나** 위의 범위 조건으로 바꿔도 됩니다.
|
|
|
|
#### B. [app_ranking.php](../../../src/web/server/record/app_ranking.php) — 교실 화면 진입 시 3개 쿼리
|
|
|
|
```sql
|
|
-- get_ranking_hour() WHERE 절
|
|
-- 현재
|
|
WHERE BR.PlayerID = P.PlayerID AND DATE(BR.RecordDateTime) = DATE(NOW()) AND HOUR(BR.RecordDateTime) = HOUR(NOW()) AND BR.MaestroID = ? AND BR.AppID = ?
|
|
-- 변경
|
|
WHERE BR.MaestroID = ? AND BR.AppID = ?
|
|
AND BR.RecordDateTime >= CAST(DATE_FORMAT(NOW(), '%Y-%m-%d %H:00:00') AS DATETIME)
|
|
AND BR.RecordDateTime < CAST(DATE_FORMAT(NOW(), '%Y-%m-%d %H:00:00') AS DATETIME) + INTERVAL 1 HOUR
|
|
AND BR.PlayerID = P.PlayerID
|
|
|
|
-- get_ranking_day() WHERE 절
|
|
-- 변경
|
|
WHERE BR.MaestroID = ? AND BR.AppID = ?
|
|
AND BR.RecordDateTime >= CURDATE()
|
|
AND BR.RecordDateTime < CURDATE() + INTERVAL 1 DAY
|
|
AND BR.PlayerID = P.PlayerID
|
|
|
|
-- get_ranking_month() WHERE 절 ※ 연도 누락 버그 수정 포함
|
|
-- 현재
|
|
WHERE BR.PlayerID = P.PlayerID AND MONTH(BR.RecordDateTime) = MONTH(NOW()) AND BR.MaestroID = ? AND BR.AppID = ?
|
|
-- 변경
|
|
WHERE BR.MaestroID = ? AND BR.AppID = ?
|
|
AND BR.RecordDateTime >= CAST(DATE_FORMAT(CURDATE(), '%Y-%m-01') AS DATE)
|
|
AND BR.RecordDateTime < CAST(DATE_FORMAT(CURDATE(), '%Y-%m-01') AS DATE) + INTERVAL 1 MONTH
|
|
AND BR.PlayerID = P.PlayerID
|
|
```
|
|
|
|
세 함수 모두 `bind_param('ii', $maestroID, $appID)`는 변경 없음. `SELECT`, `GROUP BY`, `ORDER BY` 부분도 그대로 둡니다.
|
|
|
|
#### C. [ranking_record_hour.php](../../../src/web/server/record/ranking_record_hour.php) / [ranking_record_day.php](../../../src/web/server/record/ranking_record_day.php) / [ranking_record_month.php](../../../src/web/server/record/ranking_record_month.php)
|
|
|
|
```sql
|
|
-- ranking_record_day.php
|
|
-- 현재
|
|
WHERE BR.MaestroID = ? AND BR.playerID = U.playerID AND YEAR(BR.RecordDateTime) = YEAR(?) AND MONTH(BR.RecordDateTime) = MONTH(?) AND DAYOFMONTH(BR.RecordDateTime) = DAYOFMONTH(?) AND AppID = ?
|
|
-- bind_param('isssi', $maestro_id, $date, $date, $date, $app_id)
|
|
-- 변경
|
|
WHERE BR.MaestroID = ? AND BR.AppID = ?
|
|
AND BR.RecordDateTime >= DATE(?)
|
|
AND BR.RecordDateTime < DATE(?) + INTERVAL 1 DAY
|
|
AND BR.PlayerID = U.PlayerID
|
|
-- bind_param('iiss', $maestro_id, $app_id, $date, $date)
|
|
|
|
-- ranking_record_hour.php
|
|
-- 변경
|
|
WHERE BR.MaestroID = ? AND BR.AppID = ?
|
|
AND BR.RecordDateTime >= DATE(?) + INTERVAL HOUR(?) HOUR
|
|
AND BR.RecordDateTime < DATE(?) + INTERVAL (HOUR(?) + 1) HOUR
|
|
AND BR.PlayerID = U.PlayerID
|
|
-- bind_param('iissss', $maestro_id, $app_id, $date, $time, $date, $time)
|
|
|
|
-- ranking_record_month.php
|
|
-- 변경
|
|
WHERE BR.MaestroID = ? AND BR.AppID = ?
|
|
AND BR.RecordDateTime >= CAST(DATE_FORMAT(?, '%Y-%m-01') AS DATE)
|
|
AND BR.RecordDateTime < CAST(DATE_FORMAT(?, '%Y-%m-01') AS DATE) + INTERVAL 1 MONTH
|
|
AND BR.PlayerID = U.PlayerID
|
|
-- bind_param('iiss', $maestro_id, $app_id, $date, $date)
|
|
```
|
|
|
|
#### D. [history_record.php](../../../src/web/server/record/history_record.php) — 최근 7일 히스토리 (+ AppID 바인딩으로 보안 수정)
|
|
|
|
```sql
|
|
-- 변경 (쿼리 전체)
|
|
SELECT DATE(BR.RecordDateTime) AS Date, MAX(BR.BestRecord) AS HighScore, AA.AppName AS AppName -- 앱 105는 MIN
|
|
FROM best_record BR
|
|
INNER JOIN app AS AA ON BR.AppID = AA.AppID
|
|
WHERE BR.PlayerID = ? AND BR.AppID = ? AND BR.MaestroID = ?
|
|
AND BR.RecordDateTime < DATE(?) + INTERVAL 1 DAY
|
|
GROUP BY DATE(BR.RecordDateTime)
|
|
ORDER BY DATE(BR.RecordDateTime) DESC
|
|
LIMIT 7
|
|
-- bind_param('iiis', $player_id, $app_id, $maestro_id, $date)
|
|
```
|
|
|
|
`SELECT`와 `GROUP BY`의 `DATE()`는 조건(WHERE)이 아니므로 인덱스 사용과 무관합니다. 다만 이 쿼리는 여전히 **해당 플레이어·앱의 전체 기간 기록**을 날짜별로 묶어야 하므로, 한 플레이어의 기록이 수천 행을 넘으면 점점 느려집니다 → [안2](03-option2-daily-summary-tables.md)에서 근본 해결.
|
|
|
|
#### E. [typing_exam_collection.php](../../../src/web/php/db/typing_exam_collection.php) — 긴글 시험
|
|
|
|
```sql
|
|
-- getThisHourRecord()
|
|
-- 변경
|
|
SELECT TypingExamRecordID, Record
|
|
FROM typing_exam_record
|
|
WHERE PlayerID = ? AND WritingID = ? AND MaestroID = ?
|
|
AND RecordDateTime >= CAST(DATE_FORMAT(NOW(), '%Y-%m-%d %H:00:00') AS DATETIME)
|
|
AND RecordDateTime < CAST(DATE_FORMAT(NOW(), '%Y-%m-%d %H:00:00') AS DATETIME) + INTERVAL 1 HOUR
|
|
-- bind_param("iii", $playerID, $writingID, $maestroID)
|
|
|
|
-- getHistoryRecord()
|
|
-- 변경
|
|
SELECT DATE(RecordDateTime), MAX(Record)
|
|
FROM typing_exam_record
|
|
WHERE PlayerID = ? AND WritingID = ? AND MaestroID = ?
|
|
AND RecordDateTime < DATE(?) + INTERVAL 1 DAY
|
|
GROUP BY DATE(RecordDateTime)
|
|
ORDER BY DATE(RecordDateTime) DESC
|
|
LIMIT 7
|
|
-- bind_param('iiis', $playerID, $writingID, $maestroID, $date)
|
|
```
|
|
|
|
랭킹 6개 함수(`getRankingRecordHour/Day/Month`, `getRankingMinusRecordHour/Day/Month`)는 C와 같은 규칙으로 바꿉니다. 예시(`getRankingRecordDay`):
|
|
|
|
```sql
|
|
-- 변경
|
|
SELECT TER.PlayerID AS PlayerID, U.Name AS Name, MAX(TER.Record) AS HighScore
|
|
FROM typing_exam_record TER, player U
|
|
WHERE TER.MaestroID = ? AND TER.WritingID = ?
|
|
AND TER.RecordDateTime >= DATE(?)
|
|
AND TER.RecordDateTime < DATE(?) + INTERVAL 1 DAY
|
|
AND TER.Record >= 0 -- Minus 버전은 TER.Record < 0
|
|
AND TER.PlayerID = U.PlayerID
|
|
GROUP BY TER.PlayerID
|
|
ORDER BY MAX(TER.Record) DESC; -- Minus 버전은 ASC
|
|
-- bind_param('iiss', $maestroID, $writingID, $date, $date)
|
|
```
|
|
|
|
#### F. 수정은 필요 없지만 인덱스 효과를 받는 쿼리
|
|
|
|
| 파일 | 쿼리 | 사용 인덱스 |
|
|
|---|---|---|
|
|
| [delete_record.php](../../../src/web/server/record/delete_record.php) `get_best_record_record_info()` | `WHERE MaestroID=? AND PlayerID=? AND AppID=? ORDER BY BestRecord LIMIT 1` | `idx_br_player_app_time` |
|
|
| [delete_player.php](../../../src/web/server/player/delete_player.php) | `DELETE ... WHERE MaestroID=? AND PlayerID=?` (6개 테이블) | `idx_br_player_app_time` 등 |
|
|
| [delete_test_player_record.php](../../../src/web/server/maestro/delete_test_player_record.php) | `DELETE ... WHERE PlayerID=?` | `idx_br_player_app_time`, `idx_ter_player_writing_time` |
|
|
| [request_*_player_record_list.php](../../../src/web/server/record/request_app_player_record_list.php) | `WHERE MaestroID=? AND 'start' <= RecordDateTime ... ORDER BY RecordDateTime DESC LIMIT 50` | `idx_*_maestro_time` (날짜 조건은 원래 범위 형태) |
|
|
| [app_highest_record.php](../../../src/web/server/lib/app_highest_record.php), [menu_collection.php](../../../src/web/php/db/menu_collection.php), [writing_collection.php](../../../src/web/php/db/writing_collection.php) | 최고기록 단건 조회 | `uk_ahr_*`, `uk_tehr_*` |
|
|
|
|
---
|
|
|
|
## 4. 수정 대상 파일 목록
|
|
|
|
| 파일 | 변경 내용 |
|
|
|---|---|
|
|
| `src/web/server/record/update_result_record.php` | `get_best_record()` 조건 |
|
|
| `src/web/server/record/app_ranking.php` | 시/일/월 랭킹 조건 (+월간 연도 버그) |
|
|
| `src/web/server/record/ranking_record_hour.php` | 조건, 바인딩 |
|
|
| `src/web/server/record/ranking_record_day.php` | 조건, 바인딩 |
|
|
| `src/web/server/record/ranking_record_month.php` | 조건, 바인딩 |
|
|
| `src/web/server/record/history_record.php` | 조건, AppID 바인딩 |
|
|
| `src/web/php/db/typing_exam_collection.php` | `getThisHourRecord`, `getHistoryRecord`, 랭킹 6종 |
|
|
| `src/web/sql/make_db.sql`, `make_db_license_timer.sql` | 신규 설치용 스키마에 인덱스·UNIQUE 반영 |
|
|
| `src/web/sql/migration/YYMMDD_add_record_indexes.sql` (신규) | 운영에 적용한 SQL 기록 |
|
|
|
|
---
|
|
|
|
## 5. 적용 절차
|
|
|
|
| 순서 | 어디서 | 작업 | 확인 |
|
|
|---|---|---|---|
|
|
| 0 | 운영 | [backup-db.sh](../../db/260907-daily-db-backup/backup-db.sh) 수동 실행 | 백업 파일 생성·크기 |
|
|
| 1 | 스테이징 | 최신 백업 복원 | row 수가 운영과 비슷한지 |
|
|
| 2 | 스테이징 | [개요 7-3](01-improvement-overview.md#7-3-대표-쿼리-explain-기준값) 방식으로 대표 쿼리 **개선 전** EXPLAIN/ANALYZE 기록 | `type=ALL`, `rows≈120만` 예상 |
|
|
| 3 | 스테이징 | 3-1 인덱스 추가, 3-2 중복 정리 + UNIQUE 키 | 소요 시간 기록, `SHOW INDEX` |
|
|
| 4 | 스테이징 | 기존 쿼리 그대로 EXPLAIN (인덱스만으로 좋아지는지) | 저장/삭제 쿼리는 `ref`로 개선 |
|
|
| 5 | 스테이징 | 3-3 쿼리 수정 코드 배포, 변경 쿼리 EXPLAIN + **결과 동일성 비교**(아래) | 랭킹 쿼리 `type=range`, `key=idx_br_maestro_app_time` |
|
|
| 6 | 스테이징 | 화면 테스트: 게임 종료 후 기록 저장, 교실 랭킹(시/일/월), 히스토리, 긴글 시험 저장·랭킹, 기록 삭제, 학생 삭제 | 기존과 같은 결과 (월간은 버그 수정분 차이만) |
|
|
| 7 | 운영 (새벽) | 3-1, 3-2 SQL 적용 | 오류 없음, 서비스 정상 |
|
|
| 8 | 운영 | 코드 배포 (`release` 브랜치) | 화면 테스트 6 반복 |
|
|
| 9 | 운영 | 1~2일 슬로우 쿼리 로그 확인, [측정 양식](01-improvement-overview.md#7-6-측정-결과-기록-양식) 채우기 | 1초 이상 기록 쿼리 감소 |
|
|
|
|
**순서가 중요한 이유**: 인덱스는 기존 코드에 영향이 없으므로 먼저 추가합니다. 코드를 먼저 배포하면 인덱스가 없는 동안 새 쿼리도 여전히 느립니다(결과는 같음).
|
|
|
|
### 결과 동일성 비교 방법
|
|
|
|
기존 쿼리와 변경 쿼리의 결과가 같은지 `EXCEPT`(한쪽에만 있는 행 찾기)로 확인합니다. **양방향 모두 0건**이면 같습니다.
|
|
|
|
```sql
|
|
SET @m = 123, @a = 5, @d = '2026-09-10'; -- 7-3에서 찾은 값
|
|
|
|
SELECT COUNT(*) AS only_in_old FROM (
|
|
SELECT BR.PlayerID, MAX(BR.BestRecord) AS rec
|
|
FROM best_record BR
|
|
WHERE BR.MaestroID = @m AND BR.AppID = @a
|
|
AND YEAR(BR.RecordDateTime) = YEAR(@d) AND MONTH(BR.RecordDateTime) = MONTH(@d)
|
|
AND DAYOFMONTH(BR.RecordDateTime) = DAYOFMONTH(@d)
|
|
GROUP BY BR.PlayerID
|
|
EXCEPT
|
|
SELECT BR.PlayerID, MAX(BR.BestRecord) AS rec
|
|
FROM best_record BR
|
|
WHERE BR.MaestroID = @m AND BR.AppID = @a
|
|
AND BR.RecordDateTime >= DATE(@d) AND BR.RecordDateTime < DATE(@d) + INTERVAL 1 DAY
|
|
GROUP BY BR.PlayerID
|
|
) diff;
|
|
-- 두 SELECT의 순서를 바꿔 only_in_new도 확인
|
|
```
|
|
|
|
---
|
|
|
|
## 6. 롤백
|
|
|
|
```sql
|
|
-- 인덱스 제거 (코드를 먼저 되돌린 뒤 실행할 필요는 없음. 새 쿼리도 인덱스 없이 동작함)
|
|
ALTER TABLE best_record
|
|
DROP INDEX idx_br_maestro_app_time,
|
|
DROP INDEX idx_br_player_app_time,
|
|
DROP INDEX idx_br_maestro_time;
|
|
|
|
ALTER TABLE typing_exam_record
|
|
DROP INDEX idx_ter_maestro_writing_time,
|
|
DROP INDEX idx_ter_player_writing_time,
|
|
DROP INDEX idx_ter_maestro_time;
|
|
|
|
ALTER TABLE license_score DROP INDEX idx_ls_maestro_player_time;
|
|
|
|
ALTER TABLE app_highest_record DROP INDEX uk_ahr_maestro_player_app;
|
|
ALTER TABLE typing_exam_highest_record DROP INDEX uk_tehr_maestro_player_writing;
|
|
|
|
-- 중복 정리를 되돌려야 하는 경우 (정리 이후 새로 저장된 최고기록은 사라지므로 신중히)
|
|
-- RENAME TABLE app_highest_record TO app_highest_record_broken,
|
|
-- app_highest_record_bak_260914 TO app_highest_record;
|
|
```
|
|
|
|
코드는 `git revert` 후 `release` 브랜치에 다시 배포합니다.
|
|
|
|
---
|
|
|
|
## 7. 장단점과 위험
|
|
|
|
| 장점 | 단점 / 위험 | 대응 |
|
|
|---|---|---|
|
|
| 데이터를 거의 건드리지 않음 | 인덱스만큼 디스크 사용량 증가 | 적용 전 여유 공간 확인 |
|
|
| 인덱스 추가만으로도 저장·삭제 쿼리 즉시 개선 | 기록 INSERT/UPDATE 시 인덱스 갱신 비용 소폭 증가 | 기록 저장 빈도가 낮아 체감 어려움(예상), 슬로우 로그로 확인 |
|
|
| 롤백이 간단 (`DROP INDEX`) | 바인딩 순서를 잘못 바꾸면 **오류 없이 엉뚱한 결과**가 나옴 | 결과 동일성 비교 쿼리, 화면 테스트 |
|
|
| 월간 랭킹 버그, 보안 이슈(history) 함께 해결 | 옵티마이저가 기대와 다른 인덱스를 고를 수 있음 | `ANALYZE TABLE` 후 EXPLAIN 확인, 필요 시 `FORCE INDEX` |
|
|
| | 히스토리·월간 랭킹은 여전히 넓은 범위를 GROUP BY | 안2 |
|
|
|
|
---
|
|
|
|
## 8. 예상 효과 (측정으로 확인 필요)
|
|
|
|
| 쿼리 | 현재 읽는 행 (예상) | 변경 후 읽는 행 (예상) |
|
|
|---|---|---|
|
|
| 기록 저장 시 이번 시간 기록 확인 | 해당 선생님 또는 플레이어의 모든 기록 ~ 전체 | 0~1행 |
|
|
| 시간 랭킹 | 해당 선생님·앱의 전체 기간 기록 | 그 1시간의 기록 (수~수십 행) |
|
|
| 일간 랭킹 | 〃 | 그날 기록 (수십~수백 행) |
|
|
| 월간 랭킹 | 〃 (+ 다른 해 같은 달) | 그달 기록 (수백~수천 행) |
|
|
| 관리자 기록 목록 최신 50건 | 선생님 기록 전체 후 정렬 | 최근 구간부터 50건 (필터 조건에 따라 달라짐) |
|
|
|
|
---
|
|
|
|
## 9. 한계 → 다음 단계
|
|
|
|
- 히스토리(최근 7일)와 월간 랭킹은 원본 행을 **매번** 묶어서 계산하므로, 데이터가 계속 쌓이면 점점 느려집니다.
|
|
- 원본 테이블 크기 자체는 줄지 않습니다.
|
|
- → [안2. 일별 집계 테이블](03-option2-daily-summary-tables.md), [안3. 아카이빙](04-option3-archiving.md)으로 이어집니다.
|
|
|
|
---
|
|
|
|
## 10. 체크리스트
|
|
|
|
- [ ] 운영 백업 완료, 디스크 여유 공간 확인
|
|
- [ ] 스테이징에 최신 백업 복원
|
|
- [ ] 개선 전 EXPLAIN/ANALYZE 수치 기록
|
|
- [ ] 최고기록 테이블 중복 점검 → 사본 생성 → 정리 → UNIQUE 키 추가
|
|
- [ ] 기록 테이블 인덱스 추가, `ANALYZE TABLE`
|
|
- [ ] 쿼리 수정 (7개 파일), 바인딩 순서 재확인
|
|
- [ ] 결과 동일성 비교 (시/일/월 랭킹, 히스토리)
|
|
- [ ] 화면 테스트 (저장, 랭킹, 히스토리, 긴글 시험, 기록 삭제, 학생 삭제)
|
|
- [ ] 운영 SQL 적용 (새벽) → 코드 배포
|
|
- [ ] 슬로우 쿼리 로그로 1~2일 모니터링, 측정 양식 기록
|
|
- [ ] `make_db.sql` 및 migration 파일 반영
|