21 KiB
안5. 애플리케이션(PHP/화면) 개선
개요 문서 | 추천 단계: 병행 (보안 항목은 1단계와 함께 우선) | 난이도: 하
중 | 위험도: 낮음 | 예상 작업량: 24일
한 줄 요약
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, request_writing_player_record_list.php, request_license_timer_player_record_list.php
호출 화면: 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만 보내서 문제가 드러나지 않음. 다른 값이 오면 쿼리 오류 |
변경 패턴 (일반 앱 예시)
$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();
// 과목 묶음 → 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으로 확인하고 필요할 때만 적용하세요.
SELECT PlayerID FROM player WHERE MaestroID = ? AND Name LIKE ?;
화면 변경 (maestro_section_record.html)
- "전체 보기" 버튼은 서버 상한(예: 1,000건)을 넘으면 "최근 1,000건만 표시됩니다. 기간을 좁혀 검색하세요." 안내를 표시합니다.
- 더 많은 조회가 필요하면 "다음 50건" 페이지 방식으로 바꿉니다. 관리자 화면 규모에서는
LIMIT ? OFFSET ?방식으로 충분합니다. - 검색 기간 최대치(예: 1년)를 화면과 서버 양쪽에서 제한하는 것도 검토합니다. 안3 아카이빙 기준일과 맞추면 자연스럽습니다.
3-2. 메뉴 화면 N+1 쿼리 제거
대상: menu_active_typing_practice_app_list.php, menu_active_typing_test_app_list.php get_high_score_list(), menu_collection.php getTypingHighestRecordList()
현재
// 한글 앱 목록, 영어 앱 목록 각각에 대해
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회
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에서 채웠습니다.
- ⚠️ (2026-09-15 검토)
get_typing_practice_app_list()와 조건(AppType = ? AND Status = 1)이 같으므로 두 조회를 합쳐 앱 이름까지 한 번에 가져올 수도 있습니다.- 안1의 UNIQUE 키
(MaestroID, PlayerID, AppID)가 있으면LEFT JOIN한 건당 인덱스 1회 탐색입니다.
menu_collection.php처럼 앱 ID 배열을 받는 경우:
$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 getWritingHighestRecord()의 호출부도 같은 방식으로 묶을 수 있습니다.
3-3. 랭킹 결과 캐시 (측정 후 필요할 때)
배경
- 교실 수업에서는 같은 선생님·같은 앱의 학생 수십 명이 거의 동시에 게임을 끝내고, 결과 화면(ranking_board.js)이 시/일/월 랭킹 3개를 한꺼번에 요청합니다.
- 랭킹 화면(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 예시
-- 미사용 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) 해당
(MaestroID, AppID)캐시 행을DELETE하면, 기록이 바뀔 때만 새로 계산하고 조회만 반복될 때는 캐시를 씁니다. - 시간 랭킹은 짧게(예: 30초), 월간 랭킹은 길게(예: 5분) TTL을 달리할 수 있습니다.
- 긴글 시험 랭킹(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 get_ranking_hour/day/month() |
3 |
| ranking_record_hour.php, ranking_record_day.php, ranking_record_month.php | 3 |
typing_exam_collection.php getRankingRecord*, getRankingMinusRecord* |
6 |
정리 방향: 기간 조건만 만들어 주는 함수를 하나 두고, 랭킹 함수는 "기간 종류"와 "정렬 방향"을 인자로 받습니다.
// $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, db_service.js)는 수정할 필요가 없습니다.
3-5. 함께 고칠 작은 버그
| 파일 | 내용 | 수정 |
|---|---|---|
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 | 종료일 당일 기록 누락, 존재하지 않는 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. 체크리스트
- 기록 목록 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곳 모두 인덱스 적용 형태로 이미 수정됨. 회귀 위험 대비 이득이 작음- 메뉴 N+1 제거 — 새 메뉴 경로 (2026-09-15 코드 완료):
menu_collection.php,writing_collection.php,menu_list.php - 메뉴 N+1 제거 — 옛 메뉴
server/app/menu_active_typing_*_app_list.php(범위 제외, 테스트 계정 경로에서만 사용) - 작은 버그 (2026-09-15 코드 완료):
.lengthif 블록 삭제 13개 파일(추가 발견 4개 포함),history_record.php미정의 변수 push 삭제,getHighestRecordArrayForAllWriting()"ii"수정(함수 유지). 라이선스 타이머AppID참조는 유지 - (측정 후 필요 시) 랭킹 캐시 + 저장 시 무효화 — 이번 작업에서 제외 (2026-09-15, 적용 후 측정해서 재검토)
ranking테이블 처리 결정 (캐시 재활용 또는 삭제) — 랭킹 캐시와 함께 보류