# 안5. 애플리케이션(PHP/화면) 개선 > [개요 문서](01-improvement-overview.md) | 추천 단계: **병행** (보안 항목은 1단계와 함께 우선) | 난이도: 하~중 | 위험도: 낮음 | 예상 작업량: 2~4일 ## 한 줄 요약 DB 구조는 그대로 두고 PHP 코드의 비효율과 위험을 고친다: **기록 목록 API의 SQL Injection 제거와 불필요한 COUNT 제거**, **메뉴 화면 N+1 쿼리를 1회 조회로 통합**, 필요 시 **랭킹 결과 짧은 캐시**, **중복된 랭킹 코드 정리**. --- ## 1. 해결하는 문제 | 문제 (선행 문서 번호) | 해결 정도 | 항목 | |---|---|---| | 5. 메뉴 화면 N+1 쿼리 | ◎ | 3-2 | | 6. 관리자 기록 목록: COUNT+목록 이중 조회, `LIKE`, 문자열 SQL | ◎ | 3-1 | | 7. 미사용 `ranking` 테이블 | ○ | 3-3 (캐시 테이블로 대체 또는 삭제) | | (보안) 기록 목록 API SQL Injection | ◎ | 3-1 | | 같은 교실 학생들이 동시에 랭킹을 열 때 반복 조회 | ○ | 3-3 | | 랭킹 쿼리 코드 중복 (안1·안2 수정 시 여러 파일을 똑같이 고쳐야 함) | ○ | 3-4 | --- ## 2. 쉬운 설명 - **SQL Injection**: 검색창에 이름 대신 SQL 조각을 넣으면, 지금 코드는 그 조각을 쿼리에 그대로 붙여 실행합니다. `?` 자리표시자(바인딩)를 쓰면 DB가 입력값을 "값"으로만 취급해 안전합니다. - **N+1**: 앱이 16개면 "앱 목록 1번 + 앱마다 최고기록 1번 = 17번" DB에 왕복합니다. 한 번에 "이 앱들 최고기록 전부"를 요청하면 1번이면 됩니다. - **불필요한 COUNT**: 기록 목록 API는 "전체 건수(COUNT)"와 "목록"을 따로 조회하는데, 조사 결과 **화면은 전체 건수를 사용하지 않습니다**(`RecordTotalCount`를 참조하는 JS/HTML 없음, 화면의 "총 n개 검색"은 받은 목록 길이). 같은 조건으로 테이블을 두 번 읽고 있는 셈입니다. --- ## 3. 변경 내용 ### 3-1. 기록 목록 API — 보안 + 성능 (우선순위 높음) 대상: [request_app_player_record_list.php](../../../src/web/server/record/request_app_player_record_list.php), [request_writing_player_record_list.php](../../../src/web/server/record/request_writing_player_record_list.php), [request_license_timer_player_record_list.php](../../../src/web/server/record/request_license_timer_player_record_list.php) 호출 화면: [maestro_section_record.html](../../../src/web/module/maestro_section_record.html) `getPlayerRecordList(limitCount)` (기본 50건, "전체 보기"는 `LimitCount=0`) #### 현재 문제 | 문제 | 코드 위치 | 영향 | |---|---|---| | 시작일·종료일·이름·AppID를 SQL 문자열에 직접 연결 | `makeDateStatement()`, `makePlayerNameStatement()`, `makeSubjectSentence()`, `LIMIT ".$limit_count` | SQL Injection (다른 선생님 기록 조회·삭제 가능성) | | `get_player_record_total_count()` + `get_player_record_list()` 이중 조회 | 각 파일 31~32행 | 같은 조건으로 두 번 읽음. 결과 `RecordTotalCount`는 화면에서 미사용 | | "전체 보기"(`LimitCount=0`)에 상한 없음 | `if($limit_count > 0)` | 검색 기간이 길면 수만 행을 한 번에 응답 | | 종료일 처리 불일치 | 일반 앱/긴글: `<= DATE_ADD(end, INTERVAL 1 DAY)` (다음날 00:00:00 포함) / 자격증: `< 'end'` (**종료일 당일 기록 누락**) | 화면마다 검색 결과 범위가 다름 | | 자격증 목록의 과목 조건이 `license_score`에 없는 `AppID`를 참조 | `makeSubjectSentence()` | 현재 화면은 3000만 보내서 문제가 드러나지 않음. 다른 값이 오면 쿼리 오류 | #### 변경 패턴 (일반 앱 예시) ```php $maestro_id = (int)$_POST["MaestroID"]; $start_date = $_POST["StartDate"]; $end_date = $_POST["EndDate"]; $player_name = trim($_POST["PlayerNameList"]); $app_id = (int)$_POST["AppID"]; $limit_count = (int)$_POST["LimitCount"]; $MAX_LIMIT = 1000; if ($limit_count <= 0 || $limit_count > $MAX_LIMIT) $limit_count = $MAX_LIMIT; // WHERE 조각은 코드에 고정된 문자열만 사용하고, 값은 전부 ? 로 바인딩한다. $where = array( "BR.MaestroID = ?", "BR.RecordDateTime >= DATE(?)", "BR.RecordDateTime < DATE(?) + INTERVAL 1 DAY", "BR.PlayerID = P.PlayerID", "BR.AppID = A.AppID", ); $types = "iss"; $params = array($maestro_id, $start_date, $end_date); if (strlen($player_name) > 0) { $where[] = "P.Name LIKE ?"; $types .= "s"; $params[] = "%" . $player_name . "%"; } $range = get_subject_app_range($app_id); if ($range !== null) { $where[] = "BR.AppID BETWEEN ? AND ?"; $types .= "ii"; $params[] = $range[0]; $params[] = $range[1]; } $query = " SELECT BR.BestRecordID, DATE(BR.RecordDateTime), TIME(BR.RecordDateTime), P.Name, A.KoreanName, BR.BestRecord FROM best_record BR, player P, app A WHERE " . implode(" AND ", $where) . " ORDER BY BR.RecordDateTime DESC LIMIT ?"; $types .= "i"; $params[] = $limit_count; $stmt = $db_conn->prepare($query); $stmt->bind_param($types, ...$params); $stmt->execute(); ``` ```php // 과목 묶음 → AppID 범위 (기존 makeSubjectSentence()의 조건을 그대로 옮김) function get_subject_app_range($app_id) { switch ($app_id) { case 0: return null; // 전체 case 1000: return array(1, 8); // 한글 연습 (기존: A.AppID < 9) case 1001: return array(11, 18); // 영어 연습 (기존: 10 < A.AppID < 19) case 1002: return array(21, 30); // 한글 테스트 (기존: 20 < A.AppID < 31) case 1003: return array(31, 40); // 영어 테스트 (기존: 30 < A.AppID < 41) default: return array($app_id, $app_id); } } ``` - `get_player_record_total_count()` 호출과 `set_data("RecordTotalCount", …)`를 **삭제**합니다. 나중에 "더 있음" 표시가 필요하면 `LIMIT 51`로 조회해 51번째 행 존재 여부로 판단합니다(COUNT 불필요). - 종료일은 세 API 모두 `< DATE(?) + INTERVAL 1 DAY`(종료일 하루 전체 포함, 다음날 0시 제외)로 통일합니다. - 긴글 시험 API는 `WR.Language = ?`('korean'/'english') 또는 `WR.WritingID = ?`를, 자격증 API는 과목 조건 없이(3000) 같은 패턴으로 바꿉니다. - 안1의 `idx_br_maestro_time (MaestroID, RecordDateTime)` 인덱스가 있으면 최근 기록부터 읽다가 `LIMIT`만큼 모이면 멈춥니다. #### 이름 검색(`LIKE '%이름%'`)은 괜찮은가? - 앞에 `%`가 있는 `LIKE`는 인덱스를 쓸 수 없지만, 이 조건은 **`player` 테이블(한 선생님의 학생 수십~수백 명)** 에만 적용됩니다. - 옵티마이저가 기록 테이블부터 읽는 비효율 계획을 고르면, 학생 ID를 먼저 조회한 뒤 `BR.PlayerID IN (?, ?, …)`로 넘기는 2단계 방식으로 바꿉니다. 먼저 EXPLAIN으로 확인하고 필요할 때만 적용하세요. ```sql SELECT PlayerID FROM player WHERE MaestroID = ? AND Name LIKE ?; ``` #### 화면 변경 ([maestro_section_record.html](../../../src/web/module/maestro_section_record.html)) - "전체 보기" 버튼은 서버 상한(예: 1,000건)을 넘으면 "최근 1,000건만 표시됩니다. 기간을 좁혀 검색하세요." 안내를 표시합니다. - 더 많은 조회가 필요하면 "다음 50건" 페이지 방식으로 바꿉니다. 관리자 화면 규모에서는 `LIMIT ? OFFSET ?` 방식으로 충분합니다. - 검색 기간 최대치(예: 1년)를 화면과 서버 양쪽에서 제한하는 것도 검토합니다. 안3 아카이빙 기준일과 맞추면 자연스럽습니다. ### 3-2. 메뉴 화면 N+1 쿼리 제거 대상: [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()`, [menu_collection.php](../../../src/web/php/db/menu_collection.php) `getTypingHighestRecordList()` #### 현재 ```php // 한글 앱 목록, 영어 앱 목록 각각에 대해 for ($i = 0; $i < $count; $i++) { // 앱마다 한 번씩 SELECT MAX(AHR.HighestRecord) FROM app AS A INNER JOIN app_highest_record AS AHR ON A.AppID = ? AND A.AppID = AHR.AppID AND AHR.MaestroID = ? AND AHR.PlayerID = ? } ``` 메뉴를 열 때마다 `앱 목록 2회 + 활성 앱 2회 + (한글 앱 수 + 영어 앱 수)회` 쿼리가 실행됩니다. #### 변경 — 앱 종류(한글/영어)당 1회 ```sql SELECT A.AppID, COALESCE(MAX(AHR.HighestRecord), 0) AS AppHighestRecord FROM app A LEFT JOIN app_highest_record AHR ON AHR.AppID = A.AppID AND AHR.MaestroID = ? AND AHR.PlayerID = ? WHERE A.AppType = ? AND A.Status = 1 GROUP BY A.AppID ORDER BY A.AppID; -- bind_param('iii', $maestro_id, $player_id, $appType) ``` - 기존 코드는 기록이 없는 앱도 `AppHighestRecord = 0`으로 응답하므로 `LEFT JOIN` + `COALESCE(…, 0)`으로 **응답 형식을 그대로** 유지합니다. - ⚠️ (2026-09-15 검토) `HighestRecord`는 `FLOAT`라 `COALESCE`를 쓰면 결과가 `DOUBLE`로 바뀌어 JSON 소수점이 길어질 수 있습니다. 실제 구현은 `MAX(...) GROUP BY`로 조회하고 0은 PHP에서 채웠습니다. - `get_typing_practice_app_list()`와 조건(`AppType = ? AND Status = 1`)이 같으므로 두 조회를 합쳐 앱 이름까지 한 번에 가져올 수도 있습니다. - 안1의 UNIQUE 키 `(MaestroID, PlayerID, AppID)`가 있으면 `LEFT JOIN` 한 건당 인덱스 1회 탐색입니다. `menu_collection.php`처럼 앱 ID 배열을 받는 경우: ```php $placeholders = implode(",", array_fill(0, count($app_ids), "?")); $query = " SELECT AppID, HighestRecord FROM app_highest_record WHERE MaestroID = ? AND PlayerID = ? AND AppID IN (" . $placeholders . ")"; $types = "ii" . str_repeat("i", count($app_ids)); $params = array_merge(array($maestroID, $playerID), $app_ids); $stmt = $this->mysqli->prepare($query); $stmt->bind_param($types, ...$params); // 결과에 없는 AppID는 0으로 채워 기존 반환 형식 유지 ``` `typing_exam_highest_record`를 글(writing)마다 조회하는 [writing_collection.php](../../../src/web/php/db/writing_collection.php) `getWritingHighestRecord()`의 호출부도 같은 방식으로 묶을 수 있습니다. ### 3-3. 랭킹 결과 캐시 (측정 후 필요할 때) #### 배경 - 교실 수업에서는 **같은 선생님·같은 앱**의 학생 수십 명이 거의 동시에 게임을 끝내고, 결과 화면([ranking_board.js](../../../src/game/result/ranking_board.js))이 시/일/월 랭킹 3개를 한꺼번에 요청합니다. - **랭킹 화면([ranking.js](../../../src/game/ranking/ranking.js))은 열려 있는 동안 5초마다 랭킹을 다시 요청합니다**(`Ranking.REFRESH_TIME_SEC = 5`, `game.time.events.loop`). 교실 앞 화면에 랭킹을 띄워 두거나 여러 학생이 열어 두면, 기록이 바뀌지 않아도 1분에 12회씩 같은 쿼리가 반복됩니다. - 결과는 모두 같은데 쿼리는 "화면 수 × 요청 횟수"만큼 실행됩니다. - 안1·안2 적용 후에도 수업 시간대에 랭킹 쿼리가 슬로우 로그에 남는다면 캐시를 도입합니다. **먼저 측정하고, 필요 없으면 하지 않습니다.** #### 방법 비교 | 방법 | 내용 | 장점 | 단점 | |---|---|---|---| | **A. DB 캐시 테이블 (권장)** | 랭킹 결과 JSON을 `(MaestroID, AppID, RankingType)`별로 저장, N초 이내면 재사용 | 추가 설치 없음, 여러 서버에서도 동작 | 캐시 조회도 DB 왕복 1회 | | B. PHP APCu | PHP 메모리 캐시 | 가장 빠름 | Docker 이미지에 확장 설치 필요, 컨테이너 재시작 시 초기화 | | C. 파일 캐시 | `/tmp`에 JSON 파일 저장 | 설치 없음 | 동시 쓰기·권한·정리 관리 필요 | #### 방법 A 예시 ```sql -- 미사용 ranking 테이블은 백업 후 삭제하고, 용도에 맞는 새 테이블 생성 CREATE TABLE ranking_cache ( MaestroID INT UNSIGNED NOT NULL, AppID INT UNSIGNED NOT NULL, RankingType CHAR(10) NOT NULL, -- 'hour' / 'day' / 'month' CachedDateTime DATETIME NOT NULL, Payload MEDIUMTEXT NOT NULL, -- 랭킹 배열 JSON PRIMARY KEY (MaestroID, AppID, RankingType) ); -- 조회: 30초 이내 캐시가 있으면 사용 SELECT Payload FROM ranking_cache WHERE MaestroID = ? AND AppID = ? AND RankingType = ? AND CachedDateTime >= NOW() - INTERVAL 30 SECOND; -- 없으면 랭킹 쿼리 실행 후 저장 INSERT INTO ranking_cache (MaestroID, AppID, RankingType, CachedDateTime, Payload) VALUES (?, ?, ?, NOW(), ?) ON DUPLICATE KEY UPDATE CachedDateTime = NOW(), Payload = VALUES(Payload); ``` - **사용자 경험 주의**: 방금 좋은 기록을 낸 학생이 랭킹에서 자기 기록을 바로 못 보면 혼란스럽습니다. 기록 저장 시([update_result_record.php](../../../src/web/server/record/update_result_record.php)) 해당 `(MaestroID, AppID)` 캐시 행을 `DELETE`하면, 기록이 바뀔 때만 새로 계산하고 조회만 반복될 때는 캐시를 씁니다. - 시간 랭킹은 짧게(예: 30초), 월간 랭킹은 길게(예: 5분) TTL을 달리할 수 있습니다. - 긴글 시험 랭킹([db_service.js](../../../src/game/lib/db_service.js) → `php/record/get_typing_exam_ranking_record_*.php`)도 같은 방식으로 적용할 수 있습니다. #### `ranking` 테이블 처리 | 선택 | 방법 | |---|---| | 캐시 도입 | 위처럼 `ranking` 삭제 후 `ranking_cache` 생성 (기존 구조는 "순위별 PlayerID"라 JSON 캐시에 맞지 않음) | | 캐시 미도입 | `ranking`이 비어 있는지 `SELECT COUNT(*) FROM ranking;` 확인 → 백업 후 `DROP TABLE ranking;`, `make_db.sql`에서도 제거 | ### 3-4. 중복 랭킹 코드 정리 (안1·안2와 함께 하면 효율적) 현재 같은 형태의 랭킹 쿼리가 여러 곳에 복사되어 있어, 안1·안2에서 조건을 바꿀 때 **모든 복사본을 똑같이 고쳐야** 합니다. | 위치 | 복사본 수 | |---|---| | [app_ranking.php](../../../src/web/server/record/app_ranking.php) `get_ranking_hour/day/month()` | 3 | | [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) | 3 | | [typing_exam_collection.php](../../../src/web/php/db/typing_exam_collection.php) `getRankingRecord*`, `getRankingMinusRecord*` | 6 | 정리 방향: 기간 조건만 만들어 주는 함수를 하나 두고, 랭킹 함수는 "기간 종류"와 "정렬 방향"을 인자로 받습니다. ```php // $period: 'hour' | 'day' | 'month', 반환: array(SQL 조각, 바인딩 타입, 바인딩 값 배열) function build_period_condition($column, $period, $date, $time) { switch ($period) { case 'hour': return array("$column >= DATE(?) + INTERVAL HOUR(?) HOUR AND $column < DATE(?) + INTERVAL (HOUR(?) + 1) HOUR", "ssss", array($date, $time, $date, $time)); case 'day': return array("$column >= DATE(?) AND $column < DATE(?) + INTERVAL 1 DAY", "ss", array($date, $date)); case 'month': return array("$column >= CAST(DATE_FORMAT(?, '%Y-%m-01') AS DATE) AND $column < CAST(DATE_FORMAT(?, '%Y-%m-01') AS DATE) + INTERVAL 1 MONTH", "ss", array($date, $date)); } return null; } ``` `$column`은 코드에서 `"BR.RecordDateTime"` 같은 고정 문자열로만 넘깁니다(사용자 입력 금지). 기존 PHP 엔드포인트 파일과 응답 형식은 그대로 두고 **내부 구현만** 공용 함수를 호출하게 바꾸면, 게임 클라이언트([db_connect_manager.js](../../../src/game/lib/db_connect_manager.js), [db_service.js](../../../src/game/lib/db_service.js))는 수정할 필요가 없습니다. ### 3-5. 함께 고칠 작은 버그 | 파일 | 내용 | 수정 | |---|---|---| | [typing_exam_collection.php](../../../src/web/php/db/typing_exam_collection.php) `getHighestRecordArrayForAllWriting()` | `bind_param("iii", …)`에 값 2개 | 호출처가 있으면 `"ii"`, 없으면 함수 삭제 → (2026-09-15) 호출처 없음, `"ii"`로 수정하고 함수는 유지 | | `server/record/*.php` 여러 파일 | `if($replyJSON.length === 0)` (PHP에서는 항상 거짓) | **if 블록 삭제**. `count(...) === 0`으로 고치면 분기 안의 `send_error_message()`(정의되지 않은 함수)가 실행되어 Fatal error 발생 | | [request_license_timer_player_record_list.php](../../../src/web/server/record/request_license_timer_player_record_list.php) | 종료일 당일 기록 누락, 존재하지 않는 `LS.AppID` 참조 | 3-1 패턴으로 재작성 | --- ## 4. 수정 대상 파일 | 파일 | 항목 | |---|---| | `src/web/server/record/request_app_player_record_list.php` | 3-1 | | `src/web/server/record/request_writing_player_record_list.php` | 3-1 | | `src/web/server/record/request_license_timer_player_record_list.php` | 3-1, 3-5 | | `src/web/module/maestro_section_record.html` | 3-1 (상한 안내, 페이지) | | `src/web/server/app/menu_active_typing_practice_app_list.php`, `menu_active_typing_test_app_list.php` | 3-2 | | `src/web/php/db/menu_collection.php`, `src/web/php/db/writing_collection.php` | 3-2 | | `src/web/server/record/app_ranking.php`, `update_result_record.php` + `src/web/sql/make_db.sql` | 3-3 (선택) | | `src/web/server/lib/` 공용 함수 (신규), 랭킹 파일들 | 3-4 | | `src/web/php/db/typing_exam_collection.php` 등 | 3-5 | --- ## 5. 적용 순서 | 순서 | 작업 | 이유 | |---|---|---| | 1 | **3-1 기록 목록 API** (안1과 같은 시기) | 보안 이슈, 코드 변경만으로 즉시 효과 | | 2 | 3-4 랭킹 코드 정리 | 안1의 쿼리 조건 변경을 **공용 함수 한 곳**에서 하게 되어 실수 감소. 안1 코드 작업 전에 하거나 함께 진행 | | 3 | 3-2 N+1 제거 | 독립적, 언제든 가능 | | 4 | 3-5 작은 버그 | 해당 파일 수정 시 함께 | | 5 | 3-3 랭킹 캐시 | 안1·안2 후 **측정해서 필요할 때만** | 각 항목 공통: 스테이징 배포 → 화면 테스트 → 운영 배포. DB 구조 변경이 없는 항목(3-1, 3-2, 3-4, 3-5)은 `git revert`만으로 롤백됩니다. ### 테스트 항목 - 기록 목록: 과목 전체/묶음(1000~1005)/개별 앱, 이름 검색 있음/없음, 기간 1일/1개월, "전체 보기", 종료일 당일 기록 포함 여부, 자격증 목록 - 보안: 이름 칸에 `' OR '1'='1` 입력 시 결과가 비거나 해당 이름 검색으로만 동작하는지 - 메뉴: 기록이 있는 앱/없는 앱의 최고기록 표시가 변경 전과 같은지, 한글·영어 연습/테스트 메뉴 모두 - 랭킹(캐시 적용 시): 기록 저장 직후 랭킹에 반영되는지, 다른 선생님 교실과 섞이지 않는지 --- ## 6. 장단점과 위험 | 장점 | 단점 / 위험 | 대응 | |---|---|---| | DB 구조 변경 없음, 롤백 쉬움 | 기록 목록 조건을 다시 짜면서 **기존과 검색 결과가 달라질 수 있음** (특히 과목 묶음 범위, 종료일) | 변경 전후 같은 조건으로 결과 건수 비교 | | SQL Injection 제거 | `bind_param(..., ...$params)`의 타입 문자열과 값 개수 불일치 시 오류 | 조건 추가할 때 `$types`, `$params`를 **항상 같은 줄 묶음에서** 함께 추가 | | COUNT 제거로 기록 목록 조회 비용 약 절반 (예상) | "전체 보기" 상한 도입으로 사용 방식 변화 | 안내 문구, 기간 좁히기 유도 | | 메뉴 쿼리 수가 앱 개수와 무관 | | | | 랭킹 캐시로 수업 시간대 부하 감소 | 캐시 무효화 누락 시 오래된 랭킹 표시 | 저장 시 해당 캐시 삭제, TTL 짧게 | --- ## 7. 예상 효과 (측정으로 확인 필요) | 화면 / API | 변경 전 | 변경 후 (예상) | |---|---|---| | 관리자 기록 목록 | COUNT 1회 + 목록 1회, 상한 없음 | 목록 1회, 최대 1,000건 | | 연습/테스트 메뉴 진입 | 4 + 앱 수(예: 16)회 = 약 20회 쿼리 | 약 6회 (앱 목록·활성 앱·최고기록 × 한글/영어) | | 결과 화면 랭킹 (학생 30명 동시, 캐시 적용 시) | 90회 쿼리 | 캐시 만료 시에만 3회 + 캐시 조회 | | 랭킹 화면 1개를 1시간 열어 둠 (캐시 적용 시) | 720회 쿼리 (5초마다) | 기록이 바뀌었거나 TTL 만료 시에만 실제 계산 | --- ## 8. 체크리스트 - [x] **기록 목록 API 3종: 바인딩 전환, COUNT 유지, LIMIT 상한 없이, 종료일 통일** (2026-09-15 완료) - request_app_player_record_list.php ✅ - request_writing_player_record_list.php ✅ - request_license_timer_player_record_list.php ✅ - [ ] 기록 목록 화면: 상한 안내 / 페이지 (생략) - [ ] 변경 전후 검색 결과 건수 비교, SQL Injection 입력 테스트 - [ ] ~~랭킹 기간 조건 공용 함수 (안1 작업과 함께)~~ — **보류** (2026-09-15): 날짜 조건은 커밋 629c8ff에서 12곳 모두 인덱스 적용 형태로 이미 수정됨. 회귀 위험 대비 이득이 작음 - [x] **메뉴 N+1 제거 — 새 메뉴 경로** (2026-09-15 코드 완료): `menu_collection.php`, `writing_collection.php`, `menu_list.php` - [ ] 메뉴 N+1 제거 — 옛 메뉴 `server/app/menu_active_typing_*_app_list.php` (범위 제외, 테스트 계정 경로에서만 사용) - [x] **작은 버그** (2026-09-15 코드 완료): `.length` if 블록 삭제 13개 파일(추가 발견 4개 포함), `history_record.php` 미정의 변수 push 삭제, `getHighestRecordArrayForAllWriting()` `"ii"` 수정(함수 유지). 라이선스 타이머 `AppID` 참조는 유지 - [ ] (측정 후 필요 시) 랭킹 캐시 + 저장 시 무효화 — **이번 작업에서 제외** (2026-09-15, 적용 후 측정해서 재검토) - [ ] `ranking` 테이블 처리 결정 (캐시 재활용 또는 삭제) — 랭킹 캐시와 함께 보류