Files
chocomae/doc/plan/db/player-record-tables-and-queries.md
T
2026-09-15 10:38:05 +09:00

136 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 플레이어 성적/기록 테이블 및 쿼리 현황 조사
> 목적: 서비스 기간이 길어지면서 기록 데이터가 누적되어 조회/저장 속도가 느려질 것으로 예상되는 부분을 사전에 파악하기 위해, 현재 플레이어 성적을 저장하는 테이블과 이를 조회/갱신하는 쿼리를 전수 조사하여 정리한다.
> 조사 범위: `src/web/sql/*.sql`(스키마), `src/web/server/**/*.php`, `src/web/php/db/**/*.php`
---
## 1. 성적/기록 저장 테이블 목록
### 1-1. 시간이 지날수록 계속 쌓이는 테이블 (이력/히스토리 테이블) — 성능 저하 위험이 가장 큰 그룹
| 테이블 | 정의 위치 | 설명 | 적재 빈도 |
|---|---|---|---|
| `best_record` | `src/web/sql/make_db.sql` | 일반 앱(마우스/타자연습 게임)의 기록. **시간(hour) 단위로 1행씩 계속 insert**되며, 같은 시간대 안에서는 update로 갱신 | 플레이어 × 앱 × 시간(hour)마다 1행 |
| `typing_exam_record` | `src/web/sql/make_db.sql` | 긴글쓰기(시험) 기록. `best_record`와 동일한 패턴(시간 단위 1행) | 플레이어 × 글(writing) × 시간(hour)마다 1행 |
| `license_score` | `src/web/sql/make_db_license_timer.sql` | 자격증 타이머 채점 결과. **매 채점마다 무조건 insert만 발생**(update/삭제 로직 없음 → 사실상 무한 append) | 채점할 때마다 1행 |
이 세 테이블은 서비스 기간이 늘어날수록 **row 수가 선형적으로 계속 증가**하는 구조이며, 아래 2절의 조회 쿼리들이 대부분 이 테이블들을 대상으로 한다. 성능 저하가 가장 먼저 체감될 지점이다.
### 1-2. 플레이어당 최신/최고 기록 1건만 유지하는 테이블 (스냅샷 테이블) — 상대적으로 안전
| 테이블 | 정의 위치 | 설명 |
|---|---|---|
| `app_highest_record` | `make_db.sql` | 앱별 역대 최고 기록. 갱신 시 기존 행을 delete 후 insert (또는 update) 하는 방식으로 **플레이어 × 앱 조합당 항상 1행만 유지** |
| `typing_exam_highest_record` | `make_db.sql` | 긴글쓰기(시험)의 역대 최고 기록. `app_highest_record`와 동일한 패턴 |
| `license_time` | `make_db_license_timer.sql` | 자격증 타이머 진행 시간(현재 상태). 플레이어당 1행, update로만 갱신 |
이 테이블들은 row 수가 "플레이어 수 × 앱(또는 글감) 수"에 비례하므로 회원이 크게 늘지 않는 한 증가 폭이 제한적이다.
### 1-3. 정의되어 있지만 사용되지 않는 테이블
| 테이블 | 비고 |
|---|---|
| `ranking` | `make_db.sql`에 CREATE TABLE만 존재하고, 코드 전체에서 INSERT/SELECT/UPDATE/DELETE 어디에서도 참조되지 않음 (죽은 테이블로 추정). 랭킹 조회는 실제로는 `best_record`/`typing_exam_record`를 그때그때 집계(GROUP BY)해서 계산함 |
### 1-4. 인덱스 현황
`make_db.sql`, `make_db_license_timer.sql`을 확인한 결과, 위 테이블들에는 **PRIMARY KEY(자동 증가 ID)와 FOREIGN KEY 컬럼 외에 별도의 복합 인덱스가 전혀 정의되어 있지 않다.** (`CREATE INDEX` 구문 없음) FK 컬럼(MaestroID, AppID, PlayerID, WritingID)에는 InnoDB가 자동으로 단일 컬럼 인덱스를 생성하지만, 실제 쿼리들은 대부분 `(MaestroID, AppID, PlayerID)` 또는 `(MaestroID, PlayerID, RecordDateTime)`처럼 여러 컬럼을 동시에 조건으로 사용하므로 복합 인덱스 없이는 최적 경로로 조회되지 않는다.
---
## 2. 성적 조회 쿼리 목록
### 2-1. `best_record` 조회
| 파일 | 함수 | 쿼리 요지 | 비고 |
|---|---|---|---|
| [record/update_result_record.php](../../../src/web/server/record/update_result_record.php) | `get_best_record()` | `WHERE MaestroID=? AND AppID=? AND PlayerID=? AND DATE(RecordDateTime)=DATE(NOW()) AND HOUR(RecordDateTime)=HOUR(NOW())` | **기록 저장(= 게임 종료)마다 매번 실행.** `DATE()`, `HOUR()` 함수를 컬럼에 직접 씌워서 비교(sargable하지 않음) → 인덱스가 있어도 활용 불가, 해당 조건에 맞는 후보를 찾기 위해 전체(또는 해당 플레이어의 전체) 스캔에 가까워짐 |
| [record/history_record.php](../../../src/web/server/record/history_record.php) | `get_history_record()` | `WHERE MaestroID=? AND PlayerID=? AND DATE(RecordDateTime)<=? AND AppID=? GROUP BY DATE(RecordDateTime) ORDER BY DATE(RecordDateTime) DESC LIMIT 7` | 최근 7일 기록 조회. `GROUP BY DATE(...)`, `ORDER BY DATE(...)` 모두 함수 기반이라 인덱스 미활용, 대상 데이터가 쌓일수록 그룹핑 대상 row 수 증가 |
| [record/ranking_record_hour.php](../../../src/web/server/record/ranking_record_hour.php) | `get_ranking_record_hour()` | `WHERE MaestroID=? AND YEAR(RecordDateTime)=YEAR(?) AND MONTH(...)=MONTH(?) AND DAYOFMONTH(...)=DAYOFMONTH(?) AND HOUR(...)=HOUR(?) AND AppID=? GROUP BY PlayerID` | 특정 시간대 랭킹. `YEAR/MONTH/DAYOFMONTH/HOUR` 4중 함수 비교 → 완전 비sargable, 매 조회마다 해당 Maestro/App의 **모든 이력**을 함수로 걸러야 함 |
| [record/ranking_record_day.php](../../../src/web/server/record/ranking_record_day.php) | `get_ranking_record_day()` | 위와 동일한 패턴에서 `HOUR` 조건만 빠짐 (일간 랭킹) | 동일 문제 |
| [record/ranking_record_month.php](../../../src/web/server/record/ranking_record_month.php) | `get_ranking_record_month()` | `YEAR/MONTH`만 비교 (월간 랭킹) | 동일 문제 |
| [lib/app_highest_record.php](../../../src/web/server/lib/app_highest_record.php) 호출 전 → [history_record.php](../../../src/web/server/record/history_record.php)와 유사한 화면에서 사용되는 [record/app_ranking.php](../../../src/web/server/record/app_ranking.php) | `get_ranking_hour/day/month()` | 시/일/월 랭킹을 `DATE(RecordDateTime)=DATE(NOW())`, `HOUR(RecordDateTime)=HOUR(NOW())`, `MONTH(RecordDateTime)=MONTH(NOW())` 조건으로 각각 조회 후 `GROUP BY PlayerID` | 교실 홈 화면 진입 시마다 3개 쿼리 동시 실행. 역시 함수 기반 조건 |
| [record/request_app_player_record_list.php](../../../src/web/server/record/request_app_player_record_list.php) | `get_player_record_total_count()`, `get_player_record_list()` | 관리자/마에스트로용 기록 목록. `BR.RecordDateTime BETWEEN 시작~종료`, 플레이어 이름 `LIKE '%...%'`, 과목 조건을 문자열 조합으로 생성 후 `ORDER BY RecordDateTime DESC` | **COUNT(*) 쿼리와 목록 쿼리를 각각 실행(2회 스캔)**. `LIKE '%...%'`(앞와일드카드)는 인덱스 사용 불가. 조건절이 PHP 문자열 결합으로 생성되어 SQL Injection 잠재 위험도 있음(바인딩 미적용 구간) |
| [record/delete_record.php](../../../src/web/server/record/delete_record.php) | `get_best_record_info()`, `get_best_record_record_info()` | 기록 삭제 후 재계산을 위해 `WHERE MaestroID=? AND PlayerID=? AND AppID=? ORDER BY BestRecord [ASC|DESC] LIMIT 1` 등 | 삭제 시마다 재조회 |
| [player/delete_player.php](../../../src/web/server/player/delete_player.php) | `countPlayerRecord()` | `SELECT COUNT(PlayerID) FROM best_record WHERE MaestroID=? AND PlayerID=?` | 학생 삭제 검증용, 삭제 후 해당 플레이어 기록이 실제로 0건인지 카운트 |
### 2-2. `typing_exam_record` 조회
| 파일 | 함수 | 쿼리 요지 | 비고 |
|---|---|---|---|
| [php/db/typing_exam_collection.php](../../../src/web/php/db/typing_exam_collection.php) | `getThisHourRecord()` | `WHERE MaestroID=? AND PlayerID=? AND WritingID=? AND DATE(RecordDateTime)=DATE(NOW()) AND HOUR(RecordDateTime)=HOUR(NOW())` | 시험 기록 저장 시마다 실행. `best_record``get_best_record()`와 동일한 문제 |
| 〃 | `getHistoryRecord()` | `WHERE ... DATE(RecordDateTime)<=? AND WritingID=? GROUP BY DATE(RecordDateTime) ORDER BY RecordDateTime DESC LIMIT 7` | `history_record.php`와 동일 패턴 |
| 〃 | `getRankingRecordHour/Day/Month()` (플러스 기록) | `YEAR/MONTH/DAYOFMONTH/HOUR` 함수 비교 + `Record >= 0` + `GROUP BY PlayerID` | 오타 게임처럼 "감점형이 아닌" 시험용 |
| 〃 | `getRankingMinusRecordHour/Day/Month()` (마이너스 기록) | 위와 동일 구조에서 `Record < 0` | 오타 개수 등 "적을수록 좋은" 시험용, 총 6개의 랭킹 쿼리가 사실상 중복 로직 |
| [record/request_writing_player_record_list.php](../../../src/web/server/record/request_writing_player_record_list.php) | `get_player_record_total_count()`, `get_player_record_list()` | `request_app_player_record_list.php`와 동일 패턴(카운트+목록 2회 조회, 날짜 범위, 이름 LIKE, 과목 조건 문자열 조합) | 동일 위험 |
| [record/delete_record.php](../../../src/web/server/record/delete_record.php) | `get_typing_exam_record_info()`, `get_typing_exam_best_record_info()` | 삭제 대상 조회 및 재계산용 `ORDER BY Record DESC LIMIT 1` | 삭제 시마다 재조회 |
| [player/delete_player.php](../../../src/web/server/player/delete_player.php) | (직접 COUNT는 없고 DELETE만 수행) | - | - |
### 2-3. `license_score` 조회
| 파일 | 함수 | 쿼리 요지 | 비고 |
|---|---|---|---|
| [license_timer/get_license_score.php](../../../src/web/server/license_timer/get_license_score.php) | `get_score_list()` | `WHERE MaestroID=? AND PlayerID=? ORDER BY ScoreDateTime DESC` | 플레이어별 전체 채점 이력을 매번 전부 조회(페이징/LIMIT 없음) → 누적될수록 응답 느려짐 |
| [record/request_license_timer_player_record_list.php](../../../src/web/server/record/request_license_timer_player_record_list.php) | `get_player_record_total_count()`, `get_player_record_list()` | 위 두 기록 목록 쿼리와 동일 패턴(카운트+목록, 날짜 범위, 이름 LIKE) | 동일 위험 |
### 2-4. `app_highest_record` / `typing_exam_highest_record` 조회 (스냅샷 테이블 — 상대적으로 저위험)
| 파일 | 함수 | 쿼리 요지 |
|---|---|---|
| [lib/app_highest_record.php](../../../src/web/server/lib/app_highest_record.php) | `get_app_highest_record()` | `WHERE MaestroID=? AND AppID=? AND PlayerID=?` (단건) |
| [record/request_app_highest_record.php](../../../src/web/server/record/request_app_highest_record.php) | - | 위 함수를 그대로 호출하여 클라이언트에 반환 |
| [server/app/menu_active_typing_practice_app_list.php](../../../src/web/server/app/menu_active_typing_practice_app_list.php), [menu_active_typing_test_app_list.php](../../../src/web/server/app/menu_active_typing_test_app_list.php) | `get_high_score_list()` | 메뉴에 노출되는 **앱 개수만큼 반복문(loop)으로 단건 쿼리를 N번 실행**(N+1 조회 패턴) |
| [php/db/menu_collection.php](../../../src/web/php/db/menu_collection.php) | `getTypingHighestRecord()`, `getTypingHighestRecordList()` | 동일하게 앱 목록을 순회하며 단건 조회 반복(N+1) |
| [php/db/writing_collection.php](../../../src/web/php/db/writing_collection.php) | `getWritingHighestRecord()` | `typing_exam_highest_record` 단건 조회 |
### 2-5. `license_time` 조회 (스냅샷 테이블)
| 파일 | 함수 | 쿼리 요지 |
|---|---|---|
| [license_timer/get_license_time_data.php](../../../src/web/server/license_timer/get_license_time_data.php) | `get_time_data()` | `WHERE MaestroID=? AND PlayerID=?` (단건) |
---
## 3. 성적 저장/갱신/삭제 쿼리 목록
### 3-1. 기록 저장 흐름 (게임/시험 종료 시)
| 테이블 | 파일 | 로직 |
|---|---|---|
| `best_record` | [record/update_result_record.php](../../../src/web/server/record/update_result_record.php) | 이번 시간대 기록 존재 조회 → 없으면 `INSERT`, 있으면 기존 기록보다 좋을 때만 `UPDATE` (게임 종료마다 SELECT 1회 + INSERT/UPDATE 1회) |
| `app_highest_record` | 〃 (내부에서 [lib/app_highest_record.php](../../../src/web/server/lib/app_highest_record.php) 함수 사용) | 현재 최고기록 조회(`MAX(HighestRecord)`) → 갱신 필요 시 기존 행 `DELETE``INSERT` (매 게임 종료마다 SELECT + DELETE + INSERT 발생 가능) |
| `typing_exam_record` | [php/db/typing_exam_collection.php](../../../src/web/php/db/typing_exam_collection.php) `addRecord()`/`updateRecord()` | `best_record`와 동일 패턴 |
| `typing_exam_highest_record` | 〃 `addHighestRecord()`/`updateHighestRecord()` | 조회 후 존재 여부에 따라 INSERT/UPDATE (delete_record.php 쪽에서는 delete 후 insert 방식도 사용) |
| `license_score` | [license_timer/add_license_score.php](../../../src/web/server/license_timer/add_license_score.php) | 조건 없이 항상 `INSERT` (update/삭제 로직 없음 → 이 테이블이 3개 이력 테이블 중 가장 빠르게, 그리고 가장 무분별하게 증가) |
| `license_time` | [license_timer/update_license_left_time.php](../../../src/web/server/license_timer/update_license_left_time.php), [update_license_start_time.php](../../../src/web/server/license_timer/update_license_start_time.php) | `UPDATE`만 수행 (row 자체는 늘지 않음) |
### 3-2. 기록 삭제(관리자/마에스트로 화면에서 개별 기록 삭제)
| 파일 | 대상 테이블 | 처리 |
|---|---|---|
| [record/delete_record.php](../../../src/web/server/record/delete_record.php) | `best_record`, `app_highest_record`, `typing_exam_record`, `typing_exam_highest_record`, `license_score` | 앱 종류에 따라 분기하여 해당 이력 1건 `DELETE` 후, 남은 기록 중 최고기록을 재조회하여 `app_highest_record`/`typing_exam_highest_record`를 재계산(UPDATE/INSERT/DELETE) |
### 3-3. 학생(플레이어) 삭제 시 연쇄 삭제
| 파일 | 대상 테이블 |
|---|---|
| [player/delete_player.php](../../../src/web/server/player/delete_player.php) | `app_highest_record`, `best_record`, `typing_exam_highest_record`, `typing_exam_record`, `license_score`, `license_time`, `player` (총 6개 기록성 테이블을 순차 DELETE 후 `player` 삭제) |
| [maestro/delete_test_player_record.php](../../../src/web/server/maestro/delete_test_player_record.php) | 마에스트로의 "테스트 계정" 1명에 대해 위와 동일한 6개 테이블을 순차 DELETE (마에스트로가 리셋할 때마다 실행) |
이 삭제 쿼리들은 모두 `WHERE PlayerID = ?` 또는 `WHERE MaestroID = ? AND PlayerID = ?` 조건으로, 이력 테이블(`best_record`, `typing_exam_record`, `license_score`)의 경우 특정 플레이어의 누적 데이터가 많을수록 삭제 비용도 함께 증가한다.
---
## 4. 요약 — 데이터가 쌓일수록 느려질 것으로 예상되는 지점
1. **비sargable 조건(함수로 감싼 날짜 비교)**: `DATE()`, `HOUR()`, `YEAR()`, `MONTH()`, `DAYOFMONTH()``RecordDateTime`/`ScoreDateTime` 컬럼에 직접 씌우는 쿼리가 매우 많음(랭킹 3종 × 2테이블 = 6개 + 히스토리 2개 + 기록 저장 시 중복 체크 2개). 인덱스가 있어도 활용되지 못해 전체 스캔에 가깝게 동작 → **가장 우선적으로 손봐야 할 지점**.
2. **복합 인덱스 부재**: `(MaestroID, AppID, PlayerID)`, `(MaestroID, PlayerID, RecordDateTime)` 등 실제 조회 패턴에 맞는 인덱스가 스키마에 전혀 없음.
3. **무한 증가(append-only) 테이블**: `best_record`, `typing_exam_record`, `license_score` 세 테이블은 삭제 로직이 미미하거나 없어 시간이 지날수록 계속 커짐. 특히 `license_score`는 update/정리 로직이 전혀 없음.
4. **게임 종료마다 반복되는 SELECT+INSERT/UPDATE+DELETE 조합**: `update_result_record.php`가 한 번의 기록 저장에서 `best_record`, `app_highest_record` 두 테이블에 대해 조회 2회 + 쓰기 최대 3회를 수행. 이력 테이블이 커질수록 이 조회 비용도 함께 증가.
5. **N+1 쿼리 패턴**: 메뉴 화면(`get_high_score_list`, `getTypingHighestRecordList`)에서 앱/글 목록 개수만큼 반복하여 `app_highest_record`/`typing_exam_highest_record`를 단건 조회. 이 테이블 자체는 안 커지지만, 앱 종류가 늘어날수록 요청 수가 비례해서 늘어남.
6. **관리자용 기록 목록 조회의 COUNT+목록 이중 조회 및 앞와일드카드 LIKE 검색**: `request_app_player_record_list.php`, `request_writing_player_record_list.php`, `request_license_timer_player_record_list.php` 3곳 모두 동일한 구조로, 이력 테이블 규모가 커질수록 관리 화면 진입이 느려질 가능성이 가장 높음. `LIKE '%이름%'` 검색과 SQL을 문자열로 직접 이어붙이는 방식(바인딩 미적용 구간 포함)도 함께 존재.
7. **미사용 테이블**: `ranking`은 실제로 쓰이지 않으므로 정리 대상 후보(성능과는 무관하나 스키마 정리 관점에서 기록).
> 본 문서는 현황 조사 결과이며, 구체적인 개선 방안은 후속 계획 문서 [01-improvement-overview.md](01-improvement-overview.md)부터 이어서 정리했다.