494 lines
26 KiB
Markdown
494 lines
26 KiB
Markdown
# 안2. 일별 최고기록 집계 테이블 도입
|
|
|
|
> [개요 문서](01-improvement-overview.md) | 추천 단계: **2단계** | 난이도: 중 | 위험도: 중간 | 예상 작업량: 3~5일
|
|
> 전제: [안1](02-option1-index-and-query-rewrite.md) 완료 (특히 최고기록 테이블 UNIQUE 키)
|
|
|
|
## 한 줄 요약
|
|
|
|
"플레이어 × 앱(글) × 날짜"당 최고기록 1행만 담는 **작은 집계 테이블**을 만들어 기록 저장·삭제 때 함께 갱신하고, **히스토리(최근 7일)·일간·월간 랭킹은 이 테이블에서 조회**한다. 원본을 옮기거나 지워도(안3) 히스토리 기능이 계속 동작하게 하는 기반이다.
|
|
|
|
---
|
|
|
|
## 1. 해결하는 문제
|
|
|
|
| 문제 (선행 문서 번호) | 해결 정도 | 설명 |
|
|
|---|---|---|
|
|
| 1. 날짜 함수로 묶어 계산하는 쿼리 | ◎ | 히스토리·일간·월간 랭킹에서 `GROUP BY DATE(...)` 자체가 사라짐 |
|
|
| 3. 이력 테이블 무한 증가 | ○ | 조회 기능이 원본에 의존하지 않게 되어 안3(아카이빙)이 가능해짐 |
|
|
| 4. 저장 시 조회+쓰기 비용, 최고기록 delete→insert | ◎ | 최고기록을 UPSERT 1회로 처리 |
|
|
| **요구사항**: 몇 년 전 기록이라도 "마지막으로 플레이한 7일" 히스토리 유지 | ◎ | 집계 테이블은 아카이빙 대상이 아니므로 영구 보존 |
|
|
| (정합성) 기록 삭제 후 최고기록 재계산이 원본 전체를 읽음 | ◎ | 집계 테이블에서 재계산 → 원본을 아카이빙해도 최고기록이 틀어지지 않음 |
|
|
|
|
---
|
|
|
|
## 2. 쉬운 설명
|
|
|
|
- 원본 `best_record`는 **영수증 묶음**입니다. 매시간 1장씩 쌓입니다.
|
|
- 히스토리 화면은 "날짜별 최고 점수 7개"만 필요한데, 지금은 볼 때마다 영수증 묶음 전체를 날짜별로 분류해서 계산합니다.
|
|
- 안2는 **날짜별 요약 장부**(`daily_best_record`)를 따로 두고, 영수증이 들어올 때마다 장부의 그날 칸을 고쳐 적습니다.
|
|
- 히스토리는 장부에서 최근 7칸만 읽으면 끝납니다. 오래된 영수증을 창고로 옮겨도(안3) 장부는 남아 있으므로 히스토리는 그대로 보입니다.
|
|
|
|
---
|
|
|
|
## 3. 안2를 꼭 해야 하는가? (먼저 판단하기)
|
|
|
|
안2는 쓰기 경로를 여러 곳 고쳐야 하므로 안1보다 손이 많이 갑니다. 아래 기준으로 판단하세요.
|
|
|
|
### 3-1. 측정
|
|
|
|
```sql
|
|
-- 집계 테이블이 만들어질 경우의 행 수 (원본 1,206,768행과 비교)
|
|
SELECT COUNT(*) AS daily_rows
|
|
FROM (
|
|
SELECT 1
|
|
FROM best_record
|
|
GROUP BY PlayerID, AppID, DATE(RecordDateTime)
|
|
) t;
|
|
```
|
|
|
|
- 학생들이 하루에 한 시간(한 수업)만 플레이하는 경우가 많다면 `daily_rows`가 원본과 **크게 차이 나지 않을 수 있습니다**(예상). 이 경우 안2의 가치는 "행 수 감소"가 아니라 **"원본 없이도 히스토리·랭킹·최고기록 재계산이 가능해지는 구조"** 입니다.
|
|
- 안1 적용 후 히스토리·월간 랭킹 쿼리를 `ANALYZE`로 측정해 보세요.
|
|
|
|
### 3-2. 판단 기준
|
|
|
|
| 상황 | 권장 |
|
|
|---|---|
|
|
| 안3(원본 아카이빙)을 할 계획이다 | **안2 진행** (또는 [안3 문서 3-2의 대안 B](04-option3-archiving.md) 검토) |
|
|
| 안1 후 히스토리·월간 랭킹이 충분히 빠르고(예: 수십 ms), 아카이빙 계획도 없다 | 안2 보류, 1년 뒤 재측정 |
|
|
| 안1 후에도 월간 랭킹·히스토리가 느리다(예: 수백 ms 이상) | 안2 진행 |
|
|
|
|
---
|
|
|
|
## 4. 변경 내용
|
|
|
|
### 4-1. 새 테이블
|
|
|
|
```sql
|
|
CREATE TABLE daily_best_record (
|
|
DailyBestRecordID INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
|
MaestroID INT UNSIGNED NOT NULL,
|
|
PlayerID INT UNSIGNED NOT NULL,
|
|
AppID INT UNSIGNED NOT NULL,
|
|
RecordDate DATE NOT NULL,
|
|
BestRecord FLOAT NOT NULL, -- 그날 최고기록 (앱 105는 최저값)
|
|
BestRecordDateTime DATETIME NOT NULL, -- 그 최고기록이 저장된 시각
|
|
UpdatedDateTime DATETIME NOT NULL,
|
|
|
|
UNIQUE KEY uk_dbr_player_app_date (PlayerID, AppID, RecordDate),
|
|
KEY idx_dbr_maestro_app_date (MaestroID, AppID, RecordDate, PlayerID, BestRecord),
|
|
|
|
FOREIGN KEY (MaestroID) REFERENCES maestro(MaestroID),
|
|
FOREIGN KEY (AppID) REFERENCES app(AppID),
|
|
FOREIGN KEY (PlayerID) REFERENCES player(PlayerID)
|
|
);
|
|
|
|
CREATE TABLE daily_typing_exam_record (
|
|
DailyTypingExamRecordID INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
|
MaestroID INT UNSIGNED NOT NULL,
|
|
PlayerID INT UNSIGNED NOT NULL,
|
|
WritingID INT UNSIGNED NOT NULL,
|
|
RecordDate DATE NOT NULL,
|
|
MaxPlusRecord FLOAT NULL, -- 그날 0 이상 기록 중 최댓값 (없으면 NULL)
|
|
MaxPlusRecordDateTime DATETIME NULL,
|
|
MaxMinusRecord FLOAT NULL, -- 그날 0 미만 기록 중 최댓값 (없으면 NULL)
|
|
UpdatedDateTime DATETIME NOT NULL,
|
|
|
|
UNIQUE KEY uk_dter_player_writing_date (PlayerID, WritingID, RecordDate),
|
|
KEY idx_dter_maestro_writing_date (MaestroID, WritingID, RecordDate, PlayerID),
|
|
|
|
FOREIGN KEY (MaestroID) REFERENCES maestro(MaestroID),
|
|
FOREIGN KEY (PlayerID) REFERENCES player(PlayerID),
|
|
FOREIGN KEY (WritingID) REFERENCES writing(WritingID)
|
|
);
|
|
```
|
|
|
|
**설계 이유**
|
|
|
|
| 결정 | 이유 |
|
|
|---|---|
|
|
| UNIQUE 키가 `PlayerID`로 시작 | 플레이어는 한 선생님에게만 속함. 저장·삭제·히스토리가 모두 플레이어 기준이라 이 키 하나로 처리 |
|
|
| `idx_dbr_maestro_app_date`에 `PlayerID, BestRecord` 포함 | 일간·월간 랭킹을 인덱스만으로 계산 (커버링) |
|
|
| 긴글 시험은 `MaxPlusRecord`, `MaxMinusRecord` 두 컬럼 | 현재 랭킹 쿼리가 `Record >= 0`(플러스 랭킹)과 `Record < 0`(마이너스 랭킹)을 **각각 `MAX()`** 로 계산하므로, 두 값을 따로 저장해야 결과가 똑같이 나옴. 히스토리는 `MAX(Record)` 전체이므로 `COALESCE(MaxPlusRecord, MaxMinusRecord)`와 같음 |
|
|
| `BestRecordDateTime`, `MaxPlusRecordDateTime` | 기록 삭제 후 `app_highest_record`/`typing_exam_highest_record`의 `RecordDateTime`까지 재계산하기 위함 |
|
|
| 외래 키 포함 | 기존 테이블과 같은 규칙. 학생 삭제 시 `player`보다 먼저 지워야 함(4-5) |
|
|
|
|
### 4-2. 집계 행 갱신 방식: "그날 원본에서 다시 계산"
|
|
|
|
집계 행을 갱신하는 방법은 두 가지가 있습니다.
|
|
|
|
| 방식 | 내용 | 장점 | 단점 |
|
|
|---|---|---|---|
|
|
| A. 증분 갱신 | 새 기록이 들어올 때 `GREATEST(기존값, 새값)`으로 덮어씀 | 원본을 읽지 않음 | 기록 **삭제** 시에는 쓸 수 없음. 긴글 시험처럼 원본 행이 마이너스→플러스로 **바뀌는** 경우 원본과 어긋날 수 있음 |
|
|
| **B. 재계산 (권장)** | 저장·삭제 후 **그날 그 플레이어·앱의 원본(최대 24행)** 을 읽어 집계 행을 다시 씀 | 저장·삭제·과거 데이터 채우기에 **같은 함수** 사용, 원본과 항상 일치 | 원본을 조금 읽음 (안1 인덱스로 최대 24행이라 부담 없음) |
|
|
|
|
권장 방식 B의 공용 함수 (신규 파일 `src/web/server/lib/daily_record.php`):
|
|
|
|
```php
|
|
<?php
|
|
include_once __DIR__ . "/util_app.php";
|
|
|
|
// 해당 날짜의 best_record 원본으로 daily_best_record 1행을 다시 계산한다.
|
|
function refresh_daily_best_record($db_conn, $maestro_id, $player_id, $app_id, $date) {
|
|
$order = is_highest_record_prefer_app($app_id) ? "DESC" : "ASC";
|
|
$query = "
|
|
SELECT BestRecord, RecordDateTime
|
|
FROM best_record
|
|
WHERE PlayerID = ? AND AppID = ? AND MaestroID = ?
|
|
AND RecordDateTime >= DATE(?) AND RecordDateTime < DATE(?) + INTERVAL 1 DAY
|
|
ORDER BY BestRecord " . $order . ", RecordDateTime ASC
|
|
LIMIT 1";
|
|
$stmt = $db_conn->prepare($query);
|
|
$stmt->bind_param("iiiss", $player_id, $app_id, $maestro_id, $date, $date);
|
|
$stmt->execute();
|
|
$best_record = null;
|
|
$best_date_time = null;
|
|
$stmt->bind_result($best_record, $best_date_time);
|
|
$found = $stmt->fetch();
|
|
$stmt->close();
|
|
|
|
if (!$found) {
|
|
$stmt = $db_conn->prepare("
|
|
DELETE FROM daily_best_record
|
|
WHERE PlayerID = ? AND AppID = ? AND RecordDate = DATE(?)");
|
|
$stmt->bind_param("iis", $player_id, $app_id, $date);
|
|
$stmt->execute();
|
|
$stmt->close();
|
|
return;
|
|
}
|
|
|
|
$stmt = $db_conn->prepare("
|
|
INSERT INTO daily_best_record
|
|
(MaestroID, PlayerID, AppID, RecordDate, BestRecord, BestRecordDateTime, UpdatedDateTime)
|
|
VALUES (?, ?, ?, DATE(?), ?, ?, NOW())
|
|
ON DUPLICATE KEY UPDATE
|
|
BestRecord = VALUES(BestRecord),
|
|
BestRecordDateTime = VALUES(BestRecordDateTime),
|
|
UpdatedDateTime = NOW()");
|
|
$stmt->bind_param("iiisds", $maestro_id, $player_id, $app_id, $date, $best_record, $best_date_time);
|
|
$stmt->execute();
|
|
$stmt->close();
|
|
}
|
|
?>
|
|
```
|
|
|
|
긴글 시험용 `refresh_daily_typing_exam_record($db_conn, $maestro_id, $player_id, $writing_id, $date)`도 같은 구조로 작성합니다. 원본 조회 부분만 다음과 같습니다.
|
|
|
|
```sql
|
|
SELECT
|
|
MAX(IF(Record >= 0, Record, NULL)) AS MaxPlusRecord,
|
|
MAX(IF(Record < 0, Record, NULL)) AS MaxMinusRecord,
|
|
COUNT(*) AS cnt
|
|
FROM typing_exam_record
|
|
WHERE PlayerID = ? AND WritingID = ? AND MaestroID = ?
|
|
AND RecordDateTime >= DATE(?) AND RecordDateTime < DATE(?) + INTERVAL 1 DAY;
|
|
-- MaxPlusRecordDateTime은 Record >= 0 인 행 중 ORDER BY Record DESC, RecordDateTime ASC LIMIT 1 로 별도 조회
|
|
-- cnt = 0 이면 집계 행 DELETE
|
|
```
|
|
|
|
> `ORDER BY BestRecord` 뒤에 붙는 `$order`는 코드에서 `"DESC"`/`"ASC"` 두 값 중 하나로만 정해지므로 문자열 연결이어도 안전합니다. 사용자 입력은 모두 `?`로 바인딩합니다.
|
|
|
|
### 4-3. 최고기록 테이블 UPSERT로 교체
|
|
|
|
[lib/app_highest_record.php](../../../src/web/server/lib/app_highest_record.php)의 "조회 → 삭제 → 삽입" 3단계를 쿼리 1개로 바꿉니다. (안1의 UNIQUE 키 `uk_ahr_maestro_player_app` 필요)
|
|
|
|
```sql
|
|
-- 높을수록 좋은 앱 (앱 105 제외 전부)
|
|
INSERT INTO app_highest_record (MaestroID, PlayerID, AppID, HighestRecord, RecordDateTime)
|
|
VALUES (?, ?, ?, ?, NOW())
|
|
ON DUPLICATE KEY UPDATE
|
|
RecordDateTime = IF(VALUES(HighestRecord) > HighestRecord, VALUES(RecordDateTime), RecordDateTime),
|
|
HighestRecord = GREATEST(HighestRecord, VALUES(HighestRecord));
|
|
|
|
-- 낮을수록 좋은 앱 (앱 105)
|
|
INSERT INTO app_highest_record (MaestroID, PlayerID, AppID, HighestRecord, RecordDateTime)
|
|
VALUES (?, ?, ?, ?, NOW())
|
|
ON DUPLICATE KEY UPDATE
|
|
RecordDateTime = IF(VALUES(HighestRecord) < HighestRecord, VALUES(RecordDateTime), RecordDateTime),
|
|
HighestRecord = LEAST(HighestRecord, VALUES(HighestRecord));
|
|
```
|
|
|
|
> ⚠️ **`RecordDateTime`을 `HighestRecord`보다 먼저 적어야 합니다.** MariaDB는 `UPDATE` 절을 왼쪽부터 차례로 적용하므로, `HighestRecord`를 먼저 바꾸면 뒤의 비교가 이미 바뀐 값과 비교하게 되어 `RecordDateTime`이 갱신되지 않습니다.
|
|
|
|
`typing_exam_highest_record`도 같은 방식입니다(0 이상 기록일 때만, 높을수록 좋음). [typing_exam_collection.php](../../../src/web/php/db/typing_exam_collection.php)의 `getHighestRecord()` → `addHighestRecord()`/`updateHighestRecord()` 흐름을 UPSERT 1회로 교체합니다.
|
|
|
|
### 4-4. 기록 저장 흐름 변경
|
|
|
|
| 흐름 | 파일 | 변경 전 | 변경 후 |
|
|
|---|---|---|---|
|
|
| 일반 앱 게임 종료 | [server/record/update_result_record.php](../../../src/web/server/record/update_result_record.php) | ① 이번 시간 `best_record` 조회 → INSERT/UPDATE ② `app_highest_record` 조회 → DELETE → INSERT | ① 그대로(안1 쿼리) ② `refresh_daily_best_record(..., 오늘)` ③ `app_highest_record` UPSERT |
|
|
| 긴글 시험 종료 | [php/record/update_typing_exam_record.php](../../../src/web/php/record/update_typing_exam_record.php) | 이번 시간 기록 조회 → INSERT/UPDATE, 최고기록 조회 → INSERT/UPDATE | 기록 저장 후 `refresh_daily_typing_exam_record(..., 오늘)`, 최고기록 UPSERT. TDD용 `isPrevHourFlag` 경로는 **1시간 전 날짜**로 갱신 |
|
|
|
|
- 오늘 날짜는 PHP가 아니라 DB 기준으로 맞춥니다. 함수에 `$date` 대신 `"NOW()"`를 넘길 수 없으므로, 저장 직후 `SELECT CURDATE()`로 받은 값을 넘기거나, 함수 안에서 `DATE(?)` 대신 `CURDATE()`를 쓰는 오늘 전용 버전을 둡니다.
|
|
- 한 요청 안의 여러 쿼리를 **트랜잭션으로 묶으면** 중간 실패 시 원본과 집계가 어긋나지 않습니다.
|
|
|
|
```php
|
|
$db_conn->begin_transaction();
|
|
// ... best_record 저장, refresh_daily_best_record, app_highest_record UPSERT ...
|
|
$db_conn->commit();
|
|
```
|
|
|
|
### 4-5. 삭제 흐름 변경
|
|
|
|
| 흐름 | 파일 | 추가할 처리 |
|
|
|---|---|---|
|
|
| 개별 기록 삭제 (일반 앱) | [record/delete_record.php](../../../src/web/server/record/delete_record.php) | `get_best_record_info()`에서 `RecordDateTime`도 조회 → 원본 삭제 → `refresh_daily_best_record(그 날짜)` → 최고기록 재계산을 **원본 대신 집계 테이블**에서 수행 (아래 SQL) |
|
|
| 개별 기록 삭제 (긴글 시험) | 〃 | 위와 같은 방식, `daily_typing_exam_record.MaxPlusRecord` 기준 |
|
|
| 학생 삭제 | [player/delete_player.php](../../../src/web/server/player/delete_player.php) | `DELETE FROM daily_best_record WHERE MaestroID=? AND PlayerID=?`, `daily_typing_exam_record`도 동일. **`player` 삭제보다 먼저** 실행(외래 키) |
|
|
| 테스트 계정 기록 초기화 | [maestro/delete_test_player_record.php](../../../src/web/server/maestro/delete_test_player_record.php) | `DELETE FROM daily_* WHERE PlayerID=?` 추가 |
|
|
|
|
```sql
|
|
-- delete_record.php: 최고기록 재계산 (현재 get_best_record_record_info()의 대체)
|
|
SELECT BestRecord, BestRecordDateTime
|
|
FROM daily_best_record
|
|
WHERE PlayerID = ? AND AppID = ? AND MaestroID = ?
|
|
ORDER BY BestRecord DESC -- 앱 105는 ASC
|
|
LIMIT 1;
|
|
|
|
-- 긴글 시험 (현재 get_typing_exam_best_record_info()의 대체)
|
|
SELECT MaxPlusRecord, MaxPlusRecordDateTime
|
|
FROM daily_typing_exam_record
|
|
WHERE PlayerID = ? AND WritingID = ? AND MaestroID = ? AND MaxPlusRecord > 0
|
|
ORDER BY MaxPlusRecord DESC
|
|
LIMIT 1;
|
|
```
|
|
|
|
> 이 변경이 중요한 이유: 안3으로 오래된 원본을 옮긴 뒤에도 원본 기준으로 재계산하면, **옮겨진 기간의 최고기록이 무시되어 최고기록이 낮아지는 오류**가 생깁니다. 집계 테이블은 전체 기간을 갖고 있으므로 안전합니다.
|
|
|
|
### 4-6. 조회 전환
|
|
|
|
#### 히스토리 (최근 7일) — [history_record.php](../../../src/web/server/record/history_record.php)
|
|
|
|
```sql
|
|
SELECT D.RecordDate AS Date, D.BestRecord AS HighScore, A.AppName AS AppName
|
|
FROM daily_best_record D
|
|
INNER JOIN app A ON A.AppID = D.AppID
|
|
WHERE D.PlayerID = ? AND D.AppID = ? AND D.MaestroID = ?
|
|
AND D.RecordDate <= ?
|
|
ORDER BY D.RecordDate DESC
|
|
LIMIT 7;
|
|
-- bind_param('iiis', $player_id, $app_id, $maestro_id, $date)
|
|
```
|
|
|
|
- `GROUP BY`가 없고, UNIQUE 키에서 최근 날짜부터 7행만 읽고 멈춥니다. 기록이 몇 년 전이어도 같은 속도입니다.
|
|
- 앱 105의 MIN/MAX 구분은 집계할 때 이미 반영되어 있어 조회 쿼리는 하나로 충분합니다.
|
|
|
|
긴글 시험 — [typing_exam_collection.php](../../../src/web/php/db/typing_exam_collection.php) `getHistoryRecord()`
|
|
|
|
```sql
|
|
SELECT RecordDate, COALESCE(MaxPlusRecord, MaxMinusRecord) AS HighScore
|
|
FROM daily_typing_exam_record
|
|
WHERE PlayerID = ? AND WritingID = ? AND MaestroID = ?
|
|
AND RecordDate <= ?
|
|
ORDER BY RecordDate DESC
|
|
LIMIT 7;
|
|
```
|
|
|
|
#### 일간 랭킹 — [ranking_record_day.php](../../../src/web/server/record/ranking_record_day.php), [app_ranking.php](../../../src/web/server/record/app_ranking.php) `get_ranking_day()`
|
|
|
|
```sql
|
|
SELECT D.PlayerID AS PlayerID, U.Name AS Name, D.BestRecord AS HighScore
|
|
FROM daily_best_record D
|
|
INNER JOIN player U ON U.PlayerID = D.PlayerID
|
|
WHERE D.MaestroID = ? AND D.AppID = ? AND D.RecordDate = DATE(?) -- app_ranking.php는 CURDATE()
|
|
ORDER BY D.BestRecord DESC; -- 앱 105는 ASC
|
|
```
|
|
|
|
플레이어당 하루 1행이므로 `GROUP BY`가 필요 없습니다.
|
|
|
|
#### 월간 랭킹 — [ranking_record_month.php](../../../src/web/server/record/ranking_record_month.php), `get_ranking_month()`
|
|
|
|
```sql
|
|
SELECT D.PlayerID AS PlayerID, U.Name AS Name, MAX(D.BestRecord) AS HighScore -- 앱 105는 MIN
|
|
FROM daily_best_record D
|
|
INNER JOIN player U ON U.PlayerID = D.PlayerID
|
|
WHERE D.MaestroID = ? AND D.AppID = ?
|
|
AND D.RecordDate >= CAST(DATE_FORMAT(?, '%Y-%m-01') AS DATE)
|
|
AND D.RecordDate < CAST(DATE_FORMAT(?, '%Y-%m-01') AS DATE) + INTERVAL 1 MONTH
|
|
GROUP BY D.PlayerID
|
|
ORDER BY MAX(D.BestRecord) DESC; -- 앱 105는 MIN ... ASC
|
|
```
|
|
|
|
#### 긴글 시험 일간·월간 랭킹 — `getRankingRecordDay/Month()`, `getRankingMinusRecordDay/Month()`
|
|
|
|
```sql
|
|
-- 플러스 랭킹 (월간 예시)
|
|
SELECT D.PlayerID, U.Name, MAX(D.MaxPlusRecord) AS HighScore
|
|
FROM daily_typing_exam_record D
|
|
INNER JOIN player U ON U.PlayerID = D.PlayerID
|
|
WHERE D.MaestroID = ? AND D.WritingID = ?
|
|
AND D.RecordDate >= CAST(DATE_FORMAT(?, '%Y-%m-01') AS DATE)
|
|
AND D.RecordDate < CAST(DATE_FORMAT(?, '%Y-%m-01') AS DATE) + INTERVAL 1 MONTH
|
|
AND D.MaxPlusRecord IS NOT NULL
|
|
GROUP BY D.PlayerID
|
|
ORDER BY MAX(D.MaxPlusRecord) DESC;
|
|
|
|
-- 마이너스 랭킹: MaxPlusRecord → MaxMinusRecord, ORDER BY ... ASC
|
|
```
|
|
|
|
#### 원본에 남는 조회
|
|
|
|
| 조회 | 이유 |
|
|
|---|---|
|
|
| 시간 랭킹 (`ranking_record_hour.php`, `get_ranking_hour()`, `getRanking*Hour()`) | 1시간 단위 정보는 집계 테이블에 없음. 안1 인덱스로 1시간 구간만 읽으므로 충분히 빠름 |
|
|
| 관리자 기록 목록 (`request_*_player_record_list.php`) | 개별 기록(시각 포함)을 보여주고 삭제하는 화면이므로 원본 필요 → [안3](04-option3-archiving.md)·[안5](06-option5-application-layer.md)에서 처리 |
|
|
|
|
---
|
|
|
|
## 5. 과거 데이터 채우기 (backfill)
|
|
|
|
집계 테이블은 비어 있는 상태로 만들어지므로 기존 120만 행을 한 번 옮겨 계산해야 합니다. MariaDB 10.11의 **윈도우 함수**(`ROW_NUMBER()`)로 "그날 가장 좋은 기록 1행"을 고릅니다.
|
|
|
|
```sql
|
|
-- 월 단위로 나누어 실행 (한 번에 전체를 하면 원본에 오래 잠금이 걸릴 수 있음)
|
|
SET @from = '2019-01-01', @to = '2019-02-01';
|
|
|
|
INSERT INTO daily_best_record
|
|
(MaestroID, PlayerID, AppID, RecordDate, BestRecord, BestRecordDateTime, UpdatedDateTime)
|
|
SELECT MaestroID, PlayerID, AppID, RecordDate, BestRecord, RecordDateTime, NOW()
|
|
FROM (
|
|
SELECT MaestroID, PlayerID, AppID,
|
|
DATE(RecordDateTime) AS RecordDate,
|
|
BestRecord, RecordDateTime,
|
|
ROW_NUMBER() OVER (
|
|
PARTITION BY PlayerID, AppID, DATE(RecordDateTime)
|
|
ORDER BY IF(AppID = 105, BestRecord, -BestRecord), RecordDateTime
|
|
) AS rn
|
|
FROM best_record
|
|
WHERE RecordDateTime >= @from AND RecordDateTime < @to
|
|
) t
|
|
WHERE rn = 1
|
|
ON DUPLICATE KEY UPDATE
|
|
BestRecord = VALUES(BestRecord),
|
|
BestRecordDateTime = VALUES(BestRecordDateTime),
|
|
UpdatedDateTime = NOW();
|
|
```
|
|
|
|
- `ORDER BY IF(AppID = 105, BestRecord, -BestRecord)`: 앱 105는 작은 값이, 나머지는 큰 값이 1등(`rn = 1`)이 됩니다.
|
|
- `ON DUPLICATE KEY UPDATE`라서 **같은 달을 여러 번 실행해도 결과가 같습니다**(중단 후 재실행 안전).
|
|
- 월 목록은 [개요 7-2](01-improvement-overview.md#7-2-월별-적재량-증가-속도-파악)의 결과를 사용하고, `php-cli`로 월별 반복 스크립트를 만들거나 수동으로 몇 달씩 실행합니다.
|
|
- `daily_typing_exam_record`는 `GROUP BY PlayerID, WritingID, DATE(RecordDateTime)`로 `MAX(IF(Record>=0,Record,NULL))`, `MAX(IF(Record<0,Record,NULL))`을 계산하고, `MaxPlusRecordDateTime`은 같은 윈도우 함수 방식으로 구합니다.
|
|
|
|
### 결과 검증
|
|
|
|
```sql
|
|
-- 원본에서 계산한 값과 집계 테이블이 다른 행 수 (0이어야 함, 월별로 확인)
|
|
SET @from = '2026-08-01', @to = '2026-09-01';
|
|
|
|
SELECT COUNT(*) AS only_in_source FROM (
|
|
SELECT PlayerID, AppID, DATE(RecordDateTime) AS d,
|
|
IF(AppID = 105, MIN(BestRecord), MAX(BestRecord)) AS r
|
|
FROM best_record
|
|
WHERE RecordDateTime >= @from AND RecordDateTime < @to
|
|
GROUP BY PlayerID, AppID, DATE(RecordDateTime)
|
|
EXCEPT
|
|
SELECT PlayerID, AppID, RecordDate, BestRecord
|
|
FROM daily_best_record
|
|
WHERE RecordDate >= @from AND RecordDate < @to
|
|
) x;
|
|
-- 두 SELECT 순서를 바꿔 only_in_daily도 확인
|
|
```
|
|
|
|
---
|
|
|
|
## 6. 적용 절차 (3단계 배포로 위험 줄이기)
|
|
|
|
**핵심: 쓰기를 먼저 → 과거 채우기 → 조회는 마지막에 전환.** 조회를 바꾸기 전까지는 화면 결과가 기존과 같으므로 문제가 생겨도 사용자 영향이 없습니다.
|
|
|
|
| 순서 | 어디서 | 작업 | 확인 |
|
|
|---|---|---|---|
|
|
| 0 | 운영 | 안1 완료 확인 (UNIQUE 키 포함), 백업 | |
|
|
| 1 | 스테이징 | 4-1 테이블 생성 | `SHOW CREATE TABLE` |
|
|
| 2 | 스테이징 | **쓰기 코드 배포**: `daily_record.php`, 저장 흐름(4-4), 삭제 흐름(4-5), 최고기록 UPSERT(4-3). 조회 코드는 그대로 | 게임 종료·긴글 시험·기록 삭제·학생 삭제 후 집계 행이 생기고/바뀌고/사라지는지 |
|
|
| 3 | 스테이징 | 5장 backfill (월 단위) → **오늘 날짜만 한 번 더** 실행 | 소요 시간 기록, 결과 검증 쿼리 0건 |
|
|
| 4 | 스테이징 | **조회 코드 배포** (4-6) | 게임 화면 히스토리·일간·월간 랭킹이 2단계 이전 화면과 같은지 (월간은 안1의 연도 버그 수정분만 차이) |
|
|
| 5 | 운영 | 1 → 2 (쓰기 배포) | 슬로우 쿼리·오류 로그 |
|
|
| 6 | 운영 (새벽) | 3 (backfill) | 검증 쿼리 0건 |
|
|
| 7 | 운영 | 4 (조회 배포) | 화면 테스트, 1주일간 매일 정합성 점검(아래) |
|
|
|
|
> 3단계에서 "오늘 날짜만 한 번 더"를 하는 이유: backfill이 오늘 데이터를 읽는 사이에 새 기록이 저장되면, backfill이 조금 전 상태로 집계 행을 덮어쓸 수 있습니다. 끝난 뒤 오늘 날짜만 다시 실행하면 최신 상태로 맞춰집니다.
|
|
|
|
### 정기 정합성 점검 (최근 7일)
|
|
|
|
```sql
|
|
SELECT COUNT(*) AS mismatch FROM (
|
|
SELECT PlayerID, AppID, DATE(RecordDateTime) AS d,
|
|
IF(AppID = 105, MIN(BestRecord), MAX(BestRecord)) AS r
|
|
FROM best_record
|
|
WHERE RecordDateTime >= CURDATE() - INTERVAL 7 DAY
|
|
GROUP BY PlayerID, AppID, DATE(RecordDateTime)
|
|
EXCEPT
|
|
SELECT PlayerID, AppID, RecordDate, BestRecord
|
|
FROM daily_best_record
|
|
WHERE RecordDate >= CURDATE() - INTERVAL 7 DAY
|
|
) x;
|
|
```
|
|
|
|
0이 아니면 어떤 쓰기 경로에서 집계 갱신이 빠졌는지 확인하고, 해당 날짜 범위로 5장 backfill을 다시 실행하면 복구됩니다.
|
|
|
|
---
|
|
|
|
## 7. 롤백
|
|
|
|
| 상황 | 방법 |
|
|
|---|---|
|
|
| 조회 결과가 이상함 | 조회 코드만 `git revert` 후 재배포 → 즉시 원본 기준 조회로 복귀. 집계 테이블은 두어도 무방 |
|
|
| 쓰기 경로 오류 | 쓰기 코드 `git revert`. UPSERT로 바꾼 최고기록 로직도 함께 되돌아감 (UNIQUE 키가 있어도 기존 delete→insert 코드는 동작) |
|
|
| 안2 전체 철회 | 코드 되돌린 뒤 `DROP TABLE daily_best_record, daily_typing_exam_record;` |
|
|
|
|
---
|
|
|
|
## 8. 수정 대상 파일
|
|
|
|
| 파일 | 변경 |
|
|
|---|---|
|
|
| `src/web/server/lib/daily_record.php` (신규) | `refresh_daily_best_record()`, `refresh_daily_typing_exam_record()` |
|
|
| `src/web/server/lib/app_highest_record.php` | UPSERT 함수로 교체 |
|
|
| `src/web/server/record/update_result_record.php` | 집계 갱신, UPSERT, 트랜잭션 |
|
|
| `src/web/php/record/update_typing_exam_record.php`, `src/web/php/db/typing_exam_collection.php` | 집계 갱신, 최고기록 UPSERT, 히스토리·일간/월간 랭킹 조회 전환 |
|
|
| `src/web/server/record/delete_record.php` | 삭제 후 집계 갱신, 최고기록 재계산을 집계 테이블 기준으로 |
|
|
| `src/web/server/player/delete_player.php`, `src/web/server/maestro/delete_test_player_record.php` | 집계 행 삭제 추가 (`player` 삭제 전) |
|
|
| `src/web/server/record/history_record.php`, `ranking_record_day.php`, `ranking_record_month.php`, `app_ranking.php` | 조회 전환 |
|
|
| `src/web/sql/make_db.sql`, `src/web/sql/migration/YYMMDD_add_daily_record_tables.sql` | 테이블 정의 |
|
|
|
|
`php/record/*`는 `php/lib/connect_db.php`의 클래스 방식, `server/record/*`는 `server/setup/connect_db.php`의 전역 `$db_conn` 방식을 씁니다. 공용 함수는 **연결 객체를 인자로 받게** 만들어 두 쪽에서 모두 호출할 수 있게 합니다(`TypingExamCollection` 안에서는 `$this->mysqli`를 넘김).
|
|
|
|
---
|
|
|
|
## 9. 장단점과 위험
|
|
|
|
| 장점 | 단점 / 위험 | 대응 |
|
|
|---|---|---|
|
|
| 히스토리가 기록 기간과 무관하게 빠름 | 쓰기 경로 여러 곳 수정 → **한 곳이라도 빠지면 집계가 어긋남** | 공용 함수 1개로 통일, 정기 정합성 점검, 재실행 가능한 backfill |
|
|
| 원본을 아카이빙해도 히스토리·최고기록 재계산이 정상 (안3 가능) | 테이블 2개 추가, 저장 시 쿼리 1~2개 증가 | 증가 쿼리는 인덱스로 최대 24행만 읽음 |
|
|
| 최고기록 UPSERT로 동시성 문제 해소 | `ON DUPLICATE KEY UPDATE` 절의 컬럼 순서 실수 위험 | 4-3 주의사항, 테스트 케이스(더 좋은 기록/나쁜 기록/같은 기록) |
|
|
| 조회 쿼리가 단순해짐 (`GROUP BY` 제거) | 원본과 집계가 둘 다 있어 "진짜 값"이 헷갈릴 수 있음 | **원본이 기준, 집계는 원본에서 다시 만들 수 있는 사본**이라는 원칙을 문서·주석으로 명시 |
|
|
|
|
---
|
|
|
|
## 10. 예상 효과 (측정으로 확인 필요)
|
|
|
|
| 쿼리 | 안1 후 읽는 행 (예상) | 안2 후 읽는 행 (예상) |
|
|
|---|---|---|
|
|
| 히스토리 (최근 7일) | 해당 플레이어·앱의 전체 기간 원본 행 | **7행** |
|
|
| 일간 랭킹 | 그날 원본 행 (플레이어 × 플레이한 시간 수) | 그날 플레이어 수만큼 |
|
|
| 월간 랭킹 | 그달 원본 행 | 그달 (플레이어 × 플레이한 날 수) |
|
|
| 기록 삭제 후 최고기록 재계산 | 플레이어·앱 전체 원본 행 | 플레이어·앱의 플레이한 날 수 |
|
|
| 게임 종료 시 쓰기 | 조회 2 + 쓰기 최대 3 | 조회 2 + 쓰기 2~3 (최고기록 UPSERT 1회) |
|
|
|
|
---
|
|
|
|
## 11. 체크리스트
|
|
|
|
- [ ] 3장 기준으로 안2 진행 여부 결정 (daily_rows 측정, 안1 후 히스토리·월간 랭킹 ms)
|
|
- [ ] 안1의 최고기록 UNIQUE 키 적용 확인
|
|
- [ ] 테이블 생성 SQL 스테이징 적용
|
|
- [ ] `daily_record.php` 공용 함수 작성
|
|
- [ ] 쓰기 경로 반영: 일반 앱 저장 / 긴글 시험 저장(TDD 경로 포함) / 기록 삭제(2종) / 학생 삭제 / 테스트 계정 초기화
|
|
- [ ] 최고기록 UPSERT (컬럼 순서 주의) 및 테스트
|
|
- [ ] backfill 월 단위 실행 → 오늘 날짜 재실행 → 검증 쿼리 0건
|
|
- [ ] 조회 전환: 히스토리 2종, 일간·월간 랭킹 (일반 앱 + 긴글 시험 플러스/마이너스)
|
|
- [ ] 화면 결과 비교
|
|
- [ ] 운영: 쓰기 배포 → backfill(새벽) → 조회 배포
|
|
- [ ] 1주일 정합성 점검, 이후 주 1회 (또는 배치화)
|
|
- [ ] `make_db.sql`, migration 파일 반영
|