Files
chocomae/doc/plan/db/06-option5-application-layer.md
T

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 검토) HighestRecordFLOATCOALESCE를 쓰면 결과가 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 배열을 받는 경우:

$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.jsphp/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 코드 완료): .length if 블록 삭제 13개 파일(추가 발견 4개 포함), history_record.php 미정의 변수 push 삭제, getHighestRecordArrayForAllWriting() "ii" 수정(함수 유지). 라이선스 타이머 AppID 참조는 유지
  • (측정 후 필요 시) 랭킹 캐시 + 저장 시 무효화 — 이번 작업에서 제외 (2026-09-15, 적용 후 측정해서 재검토)
  • ranking 테이블 처리 결정 (캐시 재활용 또는 삭제) — 랭킹 캐시와 함께 보류