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

17 KiB
Raw Blame History

플레이어 성적/기록 테이블 및 쿼리 현황 조사

목적: 서비스 기간이 길어지면서 기록 데이터가 누적되어 조회/저장 속도가 느려질 것으로 예상되는 부분을 사전에 파악하기 위해, 현재 플레이어 성적을 저장하는 테이블과 이를 조회/갱신하는 쿼리를 전수 조사하여 정리한다. 조사 범위: 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_recordget_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)) → 갱신 필요 시 기존 행 DELETEINSERT (매 게임 종료마다 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. 요약 — 데이터가 쌓일수록 느려질 것으로 예상되는 지점

  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부터 이어서 정리했다.