Files
chocomae/doc/plan/db/03-option2-daily-summary-tables.md
T
2026-09-15 10:38:05 +09:00

26 KiB

안2. 일별 최고기록 집계 테이블 도입

개요 문서 | 추천 단계: 2단계 | 난이도: 중 | 위험도: 중간 | 예상 작업량: 3~5일 전제: 안1 완료 (특히 최고기록 테이블 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. 측정

-- 집계 테이블이 만들어질 경우의 행 수 (원본 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 검토)
안1 후 히스토리·월간 랭킹이 충분히 빠르고(예: 수십 ms), 아카이빙 계획도 없다 안2 보류, 1년 뒤 재측정
안1 후에도 월간 랭킹·히스토리가 느리다(예: 수백 ms 이상) 안2 진행

4. 변경 내용

4-1. 새 테이블

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_datePlayerID, BestRecord 포함 일간·월간 랭킹을 인덱스만으로 계산 (커버링)
긴글 시험은 MaxPlusRecord, MaxMinusRecord 두 컬럼 현재 랭킹 쿼리가 Record >= 0(플러스 랭킹)과 Record < 0(마이너스 랭킹)을 각각 MAX() 로 계산하므로, 두 값을 따로 저장해야 결과가 똑같이 나옴. 히스토리는 MAX(Record) 전체이므로 COALESCE(MaxPlusRecord, MaxMinusRecord)와 같음
BestRecordDateTime, MaxPlusRecordDateTime 기록 삭제 후 app_highest_record/typing_exam_highest_recordRecordDateTime까지 재계산하기 위함
외래 키 포함 기존 테이블과 같은 규칙. 학생 삭제 시 player보다 먼저 지워야 함(4-5)

4-2. 집계 행 갱신 방식: "그날 원본에서 다시 계산"

집계 행을 갱신하는 방법은 두 가지가 있습니다.

방식 내용 장점 단점
A. 증분 갱신 새 기록이 들어올 때 GREATEST(기존값, 새값)으로 덮어씀 원본을 읽지 않음 기록 삭제 시에는 쓸 수 없음. 긴글 시험처럼 원본 행이 마이너스→플러스로 바뀌는 경우 원본과 어긋날 수 있음
B. 재계산 (권장) 저장·삭제 후 그날 그 플레이어·앱의 원본(최대 24행) 을 읽어 집계 행을 다시 씀 저장·삭제·과거 데이터 채우기에 같은 함수 사용, 원본과 항상 일치 원본을 조금 읽음 (안1 인덱스로 최대 24행이라 부담 없음)

권장 방식 B의 공용 함수 (신규 파일 src/web/server/lib/daily_record.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)도 같은 구조로 작성합니다. 원본 조회 부분만 다음과 같습니다.

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의 "조회 → 삭제 → 삽입" 3단계를 쿼리 1개로 바꿉니다. (안1의 UNIQUE 키 uk_ahr_maestro_player_app 필요)

-- 높을수록 좋은 앱 (앱 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));

⚠️ RecordDateTimeHighestRecord보다 먼저 적어야 합니다. MariaDB는 UPDATE 절을 왼쪽부터 차례로 적용하므로, HighestRecord를 먼저 바꾸면 뒤의 비교가 이미 바뀐 값과 비교하게 되어 RecordDateTime이 갱신되지 않습니다.

typing_exam_highest_record도 같은 방식입니다(0 이상 기록일 때만, 높을수록 좋음). typing_exam_collection.phpgetHighestRecord()addHighestRecord()/updateHighestRecord() 흐름을 UPSERT 1회로 교체합니다.

4-4. 기록 저장 흐름 변경

흐름 파일 변경 전 변경 후
일반 앱 게임 종료 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 이번 시간 기록 조회 → INSERT/UPDATE, 최고기록 조회 → INSERT/UPDATE 기록 저장 후 refresh_daily_typing_exam_record(..., 오늘), 최고기록 UPSERT. TDD용 isPrevHourFlag 경로는 1시간 전 날짜로 갱신
  • 오늘 날짜는 PHP가 아니라 DB 기준으로 맞춥니다. 함수에 $date 대신 "NOW()"를 넘길 수 없으므로, 저장 직후 SELECT CURDATE()로 받은 값을 넘기거나, 함수 안에서 DATE(?) 대신 CURDATE()를 쓰는 오늘 전용 버전을 둡니다.
  • 한 요청 안의 여러 쿼리를 트랜잭션으로 묶으면 중간 실패 시 원본과 집계가 어긋나지 않습니다.
$db_conn->begin_transaction();
// ... best_record 저장, refresh_daily_best_record, app_highest_record UPSERT ...
$db_conn->commit();

4-5. 삭제 흐름 변경

흐름 파일 추가할 처리
개별 기록 삭제 (일반 앱) record/delete_record.php get_best_record_info()에서 RecordDateTime도 조회 → 원본 삭제 → refresh_daily_best_record(그 날짜) → 최고기록 재계산을 원본 대신 집계 테이블에서 수행 (아래 SQL)
개별 기록 삭제 (긴글 시험) 위와 같은 방식, daily_typing_exam_record.MaxPlusRecord 기준
학생 삭제 player/delete_player.php DELETE FROM daily_best_record WHERE MaestroID=? AND PlayerID=?, daily_typing_exam_record도 동일. player 삭제보다 먼저 실행(외래 키)
테스트 계정 기록 초기화 maestro/delete_test_player_record.php DELETE FROM daily_* WHERE PlayerID=? 추가
-- 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

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 getHistoryRecord()

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, app_ranking.php get_ranking_day()

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, get_ranking_month()

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()

-- 플러스 랭킹 (월간 예시)
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·안5에서 처리

5. 과거 데이터 채우기 (backfill)

집계 테이블은 비어 있는 상태로 만들어지므로 기존 120만 행을 한 번 옮겨 계산해야 합니다. MariaDB 10.11의 윈도우 함수(ROW_NUMBER())로 "그날 가장 좋은 기록 1행"을 고릅니다.

-- 월 단위로 나누어 실행 (한 번에 전체를 하면 원본에 오래 잠금이 걸릴 수 있음)
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의 결과를 사용하고, php-cli로 월별 반복 스크립트를 만들거나 수동으로 몇 달씩 실행합니다.
  • daily_typing_exam_recordGROUP BY PlayerID, WritingID, DATE(RecordDateTime)MAX(IF(Record>=0,Record,NULL)), MAX(IF(Record<0,Record,NULL))을 계산하고, MaxPlusRecordDateTime은 같은 윈도우 함수 방식으로 구합니다.

결과 검증

-- 원본에서 계산한 값과 집계 테이블이 다른 행 수 (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일)

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 파일 반영