DB 개선 계획 작성

This commit is contained in:
2026-09-15 10:38:05 +09:00
parent a15ea560d3
commit 72663ac6e9
16 changed files with 4576 additions and 0 deletions
@@ -0,0 +1,135 @@
# 플레이어 성적/기록 테이블 및 쿼리 현황 조사
> 목적: 서비스 기간이 길어지면서 기록 데이터가 누적되어 조회/저장 속도가 느려질 것으로 예상되는 부분을 사전에 파악하기 위해, 현재 플레이어 성적을 저장하는 테이블과 이를 조회/갱신하는 쿼리를 전수 조사하여 정리한다.
> 조사 범위: `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)부터 이어서 정리했다.