17 KiB
플레이어 성적/기록 테이블 및 쿼리 현황 조사
목적: 서비스 기간이 길어지면서 기록 데이터가 누적되어 조회/저장 속도가 느려질 것으로 예상되는 부분을 사전에 파악하기 위해, 현재 플레이어 성적을 저장하는 테이블과 이를 조회/갱신하는 쿼리를 전수 조사하여 정리한다. 조사 범위:
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 | 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 | 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 | 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 | get_ranking_record_day() |
위와 동일한 패턴에서 HOUR 조건만 빠짐 (일간 랭킹) |
동일 문제 |
| record/ranking_record_month.php | get_ranking_record_month() |
YEAR/MONTH만 비교 (월간 랭킹) |
동일 문제 |
| lib/app_highest_record.php 호출 전 → history_record.php와 유사한 화면에서 사용되는 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 | 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 | 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 | countPlayerRecord() |
SELECT COUNT(PlayerID) FROM best_record WHERE MaestroID=? AND PlayerID=? |
학생 삭제 검증용, 삭제 후 해당 플레이어 기록이 실제로 0건인지 카운트 |
2-2. typing_exam_record 조회
| 파일 | 함수 | 쿼리 요지 | 비고 |
|---|---|---|---|
| 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 | get_player_record_total_count(), get_player_record_list() |
request_app_player_record_list.php와 동일 패턴(카운트+목록 2회 조회, 날짜 범위, 이름 LIKE, 과목 조건 문자열 조합) |
동일 위험 |
| 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 | (직접 COUNT는 없고 DELETE만 수행) | - | - |
2-3. license_score 조회
| 파일 | 함수 | 쿼리 요지 | 비고 |
|---|---|---|---|
| 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 | get_player_record_total_count(), get_player_record_list() |
위 두 기록 목록 쿼리와 동일 패턴(카운트+목록, 날짜 범위, 이름 LIKE) | 동일 위험 |
2-4. app_highest_record / typing_exam_highest_record 조회 (스냅샷 테이블 — 상대적으로 저위험)
| 파일 | 함수 | 쿼리 요지 |
|---|---|---|
| lib/app_highest_record.php | get_app_highest_record() |
WHERE MaestroID=? AND AppID=? AND PlayerID=? (단건) |
| record/request_app_highest_record.php | - | 위 함수를 그대로 호출하여 클라이언트에 반환 |
| server/app/menu_active_typing_practice_app_list.php, menu_active_typing_test_app_list.php | get_high_score_list() |
메뉴에 노출되는 앱 개수만큼 반복문(loop)으로 단건 쿼리를 N번 실행(N+1 조회 패턴) |
| php/db/menu_collection.php | getTypingHighestRecord(), getTypingHighestRecordList() |
동일하게 앱 목록을 순회하며 단건 조회 반복(N+1) |
| php/db/writing_collection.php | getWritingHighestRecord() |
typing_exam_highest_record 단건 조회 |
2-5. license_time 조회 (스냅샷 테이블)
| 파일 | 함수 | 쿼리 요지 |
|---|---|---|
| license_timer/get_license_time_data.php | get_time_data() |
WHERE MaestroID=? AND PlayerID=? (단건) |
3. 성적 저장/갱신/삭제 쿼리 목록
3-1. 기록 저장 흐름 (게임/시험 종료 시)
| 테이블 | 파일 | 로직 |
|---|---|---|
best_record |
record/update_result_record.php | 이번 시간대 기록 존재 조회 → 없으면 INSERT, 있으면 기존 기록보다 좋을 때만 UPDATE (게임 종료마다 SELECT 1회 + INSERT/UPDATE 1회) |
app_highest_record |
〃 (내부에서 lib/app_highest_record.php 함수 사용) | 현재 최고기록 조회(MAX(HighestRecord)) → 갱신 필요 시 기존 행 DELETE 후 INSERT (매 게임 종료마다 SELECT + DELETE + INSERT 발생 가능) |
typing_exam_record |
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 | 조건 없이 항상 INSERT (update/삭제 로직 없음 → 이 테이블이 3개 이력 테이블 중 가장 빠르게, 그리고 가장 무분별하게 증가) |
license_time |
license_timer/update_license_left_time.php, update_license_start_time.php | UPDATE만 수행 (row 자체는 늘지 않음) |
3-2. 기록 삭제(관리자/마에스트로 화면에서 개별 기록 삭제)
| 파일 | 대상 테이블 | 처리 |
|---|---|---|
| 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 | 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 | 마에스트로의 "테스트 계정" 1명에 대해 위와 동일한 6개 테이블을 순차 DELETE (마에스트로가 리셋할 때마다 실행) |
이 삭제 쿼리들은 모두 WHERE PlayerID = ? 또는 WHERE MaestroID = ? AND PlayerID = ? 조건으로, 이력 테이블(best_record, typing_exam_record, license_score)의 경우 특정 플레이어의 누적 데이터가 많을수록 삭제 비용도 함께 증가한다.
4. 요약 — 데이터가 쌓일수록 느려질 것으로 예상되는 지점
- 비sargable 조건(함수로 감싼 날짜 비교):
DATE(),HOUR(),YEAR(),MONTH(),DAYOFMONTH()를RecordDateTime/ScoreDateTime컬럼에 직접 씌우는 쿼리가 매우 많음(랭킹 3종 × 2테이블 = 6개 + 히스토리 2개 + 기록 저장 시 중복 체크 2개). 인덱스가 있어도 활용되지 못해 전체 스캔에 가깝게 동작 → 가장 우선적으로 손봐야 할 지점. - 복합 인덱스 부재:
(MaestroID, AppID, PlayerID),(MaestroID, PlayerID, RecordDateTime)등 실제 조회 패턴에 맞는 인덱스가 스키마에 전혀 없음. - 무한 증가(append-only) 테이블:
best_record,typing_exam_record,license_score세 테이블은 삭제 로직이 미미하거나 없어 시간이 지날수록 계속 커짐. 특히license_score는 update/정리 로직이 전혀 없음. - 게임 종료마다 반복되는 SELECT+INSERT/UPDATE+DELETE 조합:
update_result_record.php가 한 번의 기록 저장에서best_record,app_highest_record두 테이블에 대해 조회 2회 + 쓰기 최대 3회를 수행. 이력 테이블이 커질수록 이 조회 비용도 함께 증가. - N+1 쿼리 패턴: 메뉴 화면(
get_high_score_list,getTypingHighestRecordList)에서 앱/글 목록 개수만큼 반복하여app_highest_record/typing_exam_highest_record를 단건 조회. 이 테이블 자체는 안 커지지만, 앱 종류가 늘어날수록 요청 수가 비례해서 늘어남. - 관리자용 기록 목록 조회의 COUNT+목록 이중 조회 및 앞와일드카드 LIKE 검색:
request_app_player_record_list.php,request_writing_player_record_list.php,request_license_timer_player_record_list.php3곳 모두 동일한 구조로, 이력 테이블 규모가 커질수록 관리 화면 진입이 느려질 가능성이 가장 높음.LIKE '%이름%'검색과 SQL을 문자열로 직접 이어붙이는 방식(바인딩 미적용 구간 포함)도 함께 존재. - 미사용 테이블:
ranking은 실제로 쓰이지 않으므로 정리 대상 후보(성능과는 무관하나 스키마 정리 관점에서 기록).
본 문서는 현황 조사 결과이며, 구체적인 개선 방안은 후속 계획 문서 01-improvement-overview.md부터 이어서 정리했다.