Files
chocomae/doc/plan/db/04-option3-archiving.md
2026-09-15 10:38:05 +09:00

386 lines
21 KiB
Markdown

# 안3. 오래된 원본 기록 아카이빙
> [개요 문서](01-improvement-overview.md) | 추천 단계: **3단계** | 난이도: 중 | 위험도: 중간 | 예상 작업량: 2~3일 (+ 첫 이동 작업 며칠 새벽)
> 전제: [안1](02-option1-index-and-query-rewrite.md) 완료. [안2](03-option2-daily-summary-tables.md)는 선택 (3-2 참고)
## 한 줄 요약
보관 기간(예: 13개월)이 지난 `best_record`, `typing_exam_record` 원본을 **같은 DB의 보관 테이블(`*_archive`)로 매달 옮겨** 운영 테이블을 일정한 크기로 유지한다. 몇 년 전 기록이라도 "마지막으로 플레이한 7일" 히스토리와 과거 랭킹 조회는 계속 동작하게 한다.
---
## 1. 해결하는 문제
| 문제 (선행 문서 번호) | 해결 정도 | 설명 |
|---|---|---|
| 3. 이력 테이블 무한 증가 | ◎ | 운영 테이블은 "최근 13개월치"만 유지 |
| 6. 관리자 기록 목록 조회 | ○ | 대부분의 검색이 작은 운영 테이블에서 끝남 |
| 인덱스·버퍼풀 효율 | ○ | 자주 읽는 데이터가 메모리에 올라갈 확률 증가 |
| 백업 시간·용량 | △ | 같은 DB라 그대로. 4-5의 백업 분리 적용 시 개선 |
---
## 2. 쉬운 설명
- 운영 테이블은 **사무실 책상**, 아카이브 테이블은 **창고**입니다.
- 매달 1일 새벽, 13개월보다 오래된 영수증을 창고로 옮깁니다. 책상이 늘 가벼우니 일이 빠릅니다.
- 창고에 옮긴 기록이 필요한 화면(과거 날짜 랭킹, 오래 쉬었다 돌아온 학생의 히스토리, 관리자 기록 검색)은 **날짜를 보고 창고도 찾아보도록** 코드를 고칩니다.
- 버리는 것이 아니라 옮기는 것이므로 언제든 되돌릴 수 있습니다.
---
## 3. 방식 선택
### 3-1. 어디로 옮길 것인가
| 방법 | 내용 | 장점 | 단점 | 권장 |
|---|---|---|---|---|
| **A. 같은 DB의 아카이브 테이블** | `best_record_archive` 등 | 쿼리에서 테이블 이름만 바꾸면 조회 가능, 트랜잭션으로 안전하게 이동, 되돌리기 쉬움 | DB 전체 용량은 줄지 않음 | **권장** |
| B. 별도 DB (`chocomae_archive`) | 같은 서버의 다른 데이터베이스 | 백업·권한을 분리하기 쉬움 | DB 간 조회 문법(`chocomae_archive.best_record`), 권한 설정 추가 | A 이후 필요 시 |
| C. 영구 삭제 (백업 파일로만 보존) | 파일로 덤프 후 `DELETE` | DB 용량 실제 감소 | 과거 랭킹·관리자 검색 불가, 복원 번거로움, **3-2 대안 B 사용 불가** | 아카이브 운영 몇 년 뒤 검토 (4-6) |
### 3-2. "최근 7일 히스토리" 요구사항을 만족시키는 두 경로
`history_record.php`는 달력 기준 7일이 아니라 **기록이 있는 최근 7일**을 보여줍니다. 오래 쉬었다 돌아온 학생은 7일 중 일부 또는 전부가 보관 기간 이전일 수 있습니다.
| 항목 | **대안 A: 안2(집계 테이블) 후 아카이빙** | **대안 B: 안2 없이 아카이브 보충 조회** |
|---|---|---|
| 선행 작업 | 안2 (3~5일) | 없음 |
| 히스토리 조회 | 집계 테이블에서 항상 7행 — 아카이빙 영향 없음 | 운영 테이블에서 먼저 조회, **7일이 안 되면 아카이브에서 나머지 조회** |
| 과거 날짜 일간·월간 랭킹 | 집계 테이블 — 영향 없음 | 날짜에 따라 아카이브 테이블 선택 (4-3) |
| 과거 날짜 시간 랭킹 | 날짜에 따라 아카이브 테이블 선택 | 동일 |
| 기록 삭제 후 최고기록 재계산 | 집계 테이블 기준 — 영향 없음 | 운영 + 아카이브를 함께 조회 |
| 관리자 기록 목록, 개별 삭제, 학생 삭제 | 아카이브 대응 필요 | 동일 |
| 코드 수정량 | 안2 범위 + 아카이브 대응 | 아카이브 대응만 (조회 경로가 더 많음) |
| 나중에 영구 삭제(3-1 C)로 확장 | **가능** (히스토리는 집계에 남음) | 불가 (아카이브가 곧 히스토리의 원천) |
| 권장 상황 | 장기 운영, 향후 영구 삭제까지 고려 | 운영 테이블만 빨리 줄이고 싶고 영구 삭제 계획이 없음 |
두 경로 모두 아래 4장의 테이블·배치·대응을 공통으로 사용합니다. 차이는 4-3 표의 "대안 A / 대안 B" 열에 표시했습니다.
### 3-3. 보관 기간
[개요 7-2](01-improvement-overview.md#7-2-월별-적재량-증가-속도-파악)의 월별 적재량으로 "운영 테이블에 남는 행 수 ≈ 최근 N개월 적재량 합"을 계산해 표를 채운 뒤 결정하세요.
| 보관 기간 | 운영 테이블 예상 행 수 | 장점 | 단점 |
|---|---|---|---|
| 6개월 | (측정) | 가장 작음 | 한 학년 전체 기록 검색 시 매번 아카이브 조회 |
| **13개월 (권장)** | (측정) | 한 학년(1년) + 작년 같은 달 비교가 운영 테이블 안에서 가능 | |
| 24개월 | (측정) | 아카이브 조회가 거의 발생하지 않음 | 줄어드는 효과가 작음 |
```sql
-- 보관 기간별 운영 테이블에 남을 행 수
SELECT
SUM(RecordDateTime >= CAST(DATE_FORMAT(CURDATE() - INTERVAL 6 MONTH, '%Y-%m-01') AS DATETIME)) AS keep_6m,
SUM(RecordDateTime >= CAST(DATE_FORMAT(CURDATE() - INTERVAL 13 MONTH, '%Y-%m-01') AS DATETIME)) AS keep_13m,
SUM(RecordDateTime >= CAST(DATE_FORMAT(CURDATE() - INTERVAL 24 MONTH, '%Y-%m-01') AS DATETIME)) AS keep_24m,
COUNT(*) AS total
FROM best_record;
```
**기준 시각은 항상 "매월 1일 00:00:00"** 으로 둡니다. 이렇게 하면 시간·일·월 랭킹의 조회 구간이 **항상 운영 테이블이나 아카이브 테이블 중 한쪽에만** 속하게 되어 조회 코드가 단순해집니다(4-3).
---
## 4. 변경 내용
### 4-1. 테이블 생성
```sql
-- 운영 테이블과 같은 컬럼·인덱스(안1 인덱스 포함)로 생성. 외래 키는 복사되지 않음
CREATE TABLE best_record_archive LIKE best_record;
CREATE TABLE typing_exam_record_archive LIKE typing_exam_record;
-- 어디까지 옮겼는지 기록 (조회 코드가 이 값을 보고 테이블을 고름)
CREATE TABLE archive_status (
TableName VARCHAR(64) NOT NULL PRIMARY KEY, -- 'best_record', 'typing_exam_record'
ArchivedBefore DATETIME NOT NULL, -- 이 시각 이전 기록은 아카이브에 있음
LastRunDateTime DATETIME NOT NULL,
LastMovedRows INT UNSIGNED NOT NULL DEFAULT 0
);
-- 생성 결과 확인: 인덱스는 있고 FOREIGN KEY는 없어야 함
SHOW CREATE TABLE best_record_archive;
```
- `BestRecordID`**원본 값을 그대로** 옮깁니다. 운영과 아카이브에서 ID가 겹치지 않으므로 관리자 화면의 "기록 ID로 삭제"가 두 테이블에서 모두 동작합니다.
- 아카이브 테이블에 외래 키가 없으므로, 학생 삭제 시 **코드에서 명시적으로** 아카이브 행도 지워야 합니다(4-3).
### 4-2. 매월 이동 배치
기존 배치 위치([src/php-cli/batch/](../../../src/php-cli/batch/))에 `archive_old_records.php`를 추가하고 서버 cron으로 **매월 1일 새벽**에 실행합니다.
> MariaDB의 `EVENT` + 저장 프로시저로도 가능하지만, 실행 여부와 오류를 확인하기 어렵습니다. 기존 메일 배치처럼 PHP CLI + 로그 파일 방식이 관리하기 쉽습니다.
#### 처리 순서 (테이블별)
```text
1. cutoff = 13개월 전 달의 1일 00:00:00 (DB에서 계산)
2. 반복:
a. 옮길 대상 중 ID가 가장 작은 5,000행의 마지막 ID(@max_id)를 구함 → 없으면 종료
b. 트랜잭션 시작
c. 아카이브에 복사 (ID <= @max_id AND RecordDateTime < cutoff)
d. 운영에서 삭제 (같은 조건)
e. 커밋
f. 0.5초 쉼 (서비스 쿼리에 양보)
3. archive_status 갱신 (ArchivedBefore = cutoff, 이동 행 수)
4. 로그 기록
```
#### SQL
```sql
-- 1) 기준 시각
SELECT CAST(DATE_FORMAT(CURDATE() - INTERVAL 13 MONTH, '%Y-%m-01') AS DATETIME) AS cutoff;
-- 2-a) 이번 묶음의 마지막 ID
SELECT MAX(BestRecordID) AS max_id
FROM (
SELECT BestRecordID
FROM best_record
WHERE RecordDateTime < ? -- cutoff
ORDER BY BestRecordID
LIMIT 5000
) t;
-- 2-b ~ 2-e) 이동
START TRANSACTION;
INSERT IGNORE INTO best_record_archive
SELECT * FROM best_record
WHERE BestRecordID <= ? AND RecordDateTime < ?; -- max_id, cutoff
DELETE FROM best_record
WHERE BestRecordID <= ? AND RecordDateTime < ?; -- max_id, cutoff
COMMIT;
-- 3) 상태 기록
INSERT INTO archive_status (TableName, ArchivedBefore, LastRunDateTime, LastMovedRows)
VALUES ('best_record', ?, NOW(), ?)
ON DUPLICATE KEY UPDATE
ArchivedBefore = VALUES(ArchivedBefore),
LastRunDateTime = VALUES(LastRunDateTime),
LastMovedRows = VALUES(LastMovedRows);
```
**안전장치 설명**
| 장치 | 이유 |
|---|---|
| 5,000행씩 나눔 | 한 번에 수십만 행을 옮기면 잠금이 길어지고 되돌리기 로그가 커져 서비스가 느려짐 |
| 복사와 삭제를 한 트랜잭션 | 중간에 실패해도 "복사만 되고 삭제 안 됨" 또는 그 반대가 생기지 않음 |
| 복사·삭제에 **같은 조건**(`ID <= max AND 시각 < cutoff`) | 복사한 행과 삭제한 행이 정확히 같음 |
| `INSERT IGNORE` | 어떤 이유로 같은 행이 이미 아카이브에 있어도 오류 없이 계속 진행 (재실행 안전) |
| `ORDER BY BestRecordID` | ID는 시간 순으로 증가하므로 오래된 행이 기본 키 앞쪽에 모여 있어, 별도 인덱스 없이도 빨리 찾음 |
| 보관 기간이 13개월 | "같은 시간대 안에서만 UPDATE"되는 원본 특성상, 옮기는 도중 해당 행이 수정될 일이 없음 |
`typing_exam_record`도 같은 방식입니다(`TypingExamRecordID`).
#### 첫 실행
- 첫 실행에서는 수년 치(예상: 원본의 대부분)를 옮겨야 합니다. 3-3 쿼리로 이동량을 확인하고, 배치에 **1회 최대 이동 행 수 또는 실행 시간 제한**(예: 30분)을 두어 **며칠 새벽에 나누어** 실행하세요. 재실행해도 이어서 진행됩니다.
- 대량 삭제 후에도 InnoDB 파일 크기는 자동으로 줄지 않습니다. 첫 이동이 끝난 뒤 새벽에 `OPTIMIZE TABLE best_record;`로 재구성하면 디스크와 인덱스가 정리됩니다. 테이블 크기만큼 여유 디스크가 필요하며, 스테이징에서 소요 시간을 먼저 측정하세요.
### 4-3. 영향받는 기능과 대응
| 기능 | 파일 | 대안 A (안2 완료) | 대안 B (안2 없음) |
|---|---|---|---|
| 기록 저장 (게임 종료) | `update_result_record.php`, `update_typing_exam_record.php` | 영향 없음 (항상 현재 시각) | 영향 없음 |
| 결과 화면 랭킹 | [ranking_board.js](../../../src/game/result/ranking_board.js) → `ranking_record_*.php` (항상 현재 날짜) | 영향 없음 | 영향 없음 |
| **랭킹 화면 과거 날짜 탐색** | [ranking.js](../../../src/game/ranking/ranking.js)의 이전/다음 날짜 버튼 → `ranking_record_hour/day/month.php`, `get_typing_exam_ranking_record_*.php` | **시간 랭킹만** 날짜로 테이블 선택 | 시간·일간·월간 모두 날짜로 테이블 선택 |
| 히스토리 (최근 7일) | `history_record.php`, `getHistoryRecord()` | 영향 없음 | 운영에서 부족하면 아카이브 보충 (아래 SQL) |
| 기록 삭제 후 최고기록 재계산 | `delete_record.php` | 영향 없음 | 운영 + 아카이브 `UNION ALL` |
| 관리자 기록 목록 | `request_app_player_record_list.php`, `request_writing_player_record_list.php` | 검색 시작일이 cutoff 이전이면 아카이브 포함 | 동일 |
| 관리자 개별 기록 삭제 | `delete_record.php` | 운영에 없으면 아카이브에서 삭제 | 동일 |
| 학생 삭제 | `delete_player.php` | 아카이브 2종도 `DELETE`, 삭제 확인(`countPlayerRecord`)도 아카이브 포함 | 동일 |
| 테스트 계정 기록 초기화 | `delete_test_player_record.php` | 아카이브 2종도 `DELETE` | 동일 |
| 자격증 기록 | `license_score` | 대상 아님 (620행) | 대상 아님 |
#### 조회할 테이블 고르기 (공용 함수)
기준 시각이 항상 월초 00:00이므로, 시간·일·월 랭킹 구간은 반드시 한쪽 테이블에만 속합니다.
```php
// archive_status에서 기준 시각 조회 (아카이빙 전이면 null)
function get_archived_before($db_conn, $table_name) { /* SELECT ArchivedBefore FROM archive_status WHERE TableName = ? */ }
// 구간 끝이 기준 시각 이하이면 아카이브, 아니면 운영 테이블.
// 반환값은 코드에 고정된 두 이름 중 하나이므로 쿼리 문자열에 넣어도 안전하다.
function pick_record_table($base_table, $archived_before, $range_end) {
if ($archived_before !== null && strtotime($range_end) <= strtotime($archived_before))
return $base_table . "_archive";
return $base_table;
}
```
구간 끝(`$range_end`)은 DB에서 계산해 받는 것이 안전합니다. 예: `SELECT DATE(?) + INTERVAL 1 DAY`.
#### 대안 B — 히스토리 보충 조회
```sql
-- 1단계: 운영 테이블
SELECT DATE(RecordDateTime) AS d, MAX(BestRecord) AS HighScore -- 앱 105는 MIN
FROM best_record
WHERE PlayerID = ? AND AppID = ? AND MaestroID = ?
AND RecordDateTime < DATE(?) + INTERVAL 1 DAY
GROUP BY d
ORDER BY d DESC
LIMIT 7;
-- 1단계 결과가 n행(n < 7)이고 archive_status가 있으면 2단계: 아카이브에서 (7 - n)행
SELECT DATE(RecordDateTime) AS d, MAX(BestRecord) AS HighScore
FROM best_record_archive
WHERE PlayerID = ? AND AppID = ? AND MaestroID = ?
AND RecordDateTime < LEAST(DATE(?) + INTERVAL 1 DAY, ?) -- ? = ArchivedBefore
GROUP BY d
ORDER BY d DESC
LIMIT ?; -- 7 - n
```
- 기준 시각이 자정이므로 같은 날짜가 두 테이블에 나뉘어 있을 수 없어, 두 결과를 이어 붙이기만 하면 됩니다.
- 2단계는 "최근 13개월 동안 7일도 플레이하지 않은 학생"에게만 실행되므로 드뭅니다.
- 긴글 시험(`getHistoryRecord()`)도 같은 방식입니다.
#### 관리자 기록 목록 (검색 시작일이 cutoff 이전일 때)
```sql
SELECT Id, Date, Time, Name, Subject, Record FROM (
SELECT BR.BestRecordID AS Id, DATE(BR.RecordDateTime) AS Date, TIME(BR.RecordDateTime) AS Time,
P.Name, A.KoreanName AS Subject, BR.BestRecord AS Record, BR.RecordDateTime AS SortKey
FROM best_record BR, player P, app A
WHERE /* 안5 3-1의 바인딩 조건 */
UNION ALL
SELECT BR.BestRecordID, DATE(BR.RecordDateTime), TIME(BR.RecordDateTime),
P.Name, A.KoreanName, BR.BestRecord, BR.RecordDateTime
FROM best_record_archive BR, player P, app A
WHERE /* 같은 조건 (바인딩 값을 한 번 더 넘김) */
) t
ORDER BY SortKey DESC
LIMIT ?;
```
- 검색 시작일이 cutoff 이후면 기존처럼 운영 테이블만 조회합니다.
- 조건 조합은 [안5 3-1](06-option5-application-layer.md)의 바인딩 방식으로 만든 뒤 두 번 사용합니다.
#### 개별 기록 삭제
```sql
DELETE FROM best_record WHERE BestRecordID = ?;
-- 영향 행 수가 0이면
DELETE FROM best_record_archive WHERE BestRecordID = ?;
```
현재 코드는 `$stmt->execute()`의 성공 여부(`true/false`)만 확인하므로, **`$stmt->affected_rows`** 로 실제 삭제 여부를 판단하도록 바꿔야 합니다. 삭제 대상 조회(`get_best_record_info()`)도 두 테이블을 확인합니다.
### 4-4. 대안 B의 최고기록 재계산
```sql
SELECT BestRecord, RecordDateTime FROM (
SELECT BestRecord, RecordDateTime FROM best_record
WHERE PlayerID = ? AND AppID = ? AND MaestroID = ?
UNION ALL
SELECT BestRecord, RecordDateTime FROM best_record_archive
WHERE PlayerID = ? AND AppID = ? AND MaestroID = ?
) t
ORDER BY BestRecord DESC -- 앱 105는 ASC
LIMIT 1;
```
아카이브를 빼고 운영 테이블만 보면, 오래전에 세운 최고기록이 무시되어 **최고기록이 낮아지는 오류**가 생깁니다.
### 4-5. 백업 분리 (선택)
현재 [backup-db.sh](../../db/260907-daily-db-backup/backup-db.sh)는 DB 전체를 매일 덤프하고 28일간 보관합니다. 아카이브 테이블은 한 달에 한 번만 바뀌므로:
- 매일 백업: `--ignore-table=chocomae.best_record_archive --ignore-table=chocomae.typing_exam_record_archive` 추가
- 매월 배치 직후: 아카이브 테이블만 별도 덤프, **보관 기간을 28일보다 길게**(예: 12개월) 설정
> ⚠️ 매일 백업에서 아카이브를 제외하면, 매일 백업 파일만으로는 아카이브를 복원할 수 없습니다. 월간 아카이브 백업이 정상 생성되는지 반드시 확인한 뒤 적용하세요.
### 4-6. 나중에 영구 삭제로 확장하려면
- **대안 A(안2 완료)일 때만** 가능합니다.
- 아카이브에서 N년 이상 지난 행을 월 단위로 파일 덤프(`mariadb-dump --where="RecordDateTime < '...'"`) → 파일 확인 → `DELETE`
- 영향: 그 기간의 과거 시간 랭킹, 관리자 기록 검색이 불가능해집니다. 화면의 이전 날짜 버튼·검색 기간에 하한을 두세요.
---
## 5. 적용 절차
| 순서 | 어디서 | 작업 | 확인 |
|---|---|---|---|
| 0 | - | 3-2 경로(대안 A/B)와 3-3 보관 기간 결정 | 결정 기록 |
| 1 | 운영 | 백업 | |
| 2 | 스테이징 | 4-1 테이블 생성 | `SHOW CREATE TABLE` |
| 3 | 스테이징 | **아카이브 대응 코드 먼저 배포** (4-3). `archive_status`가 비어 있으면 기존과 동일하게 동작 | 기존 화면 결과 변화 없음 |
| 4 | 스테이징 | 배치 수동 실행 (소량 → 전체) | 이동 전후 `운영 + 아카이브` 행 수 합계가 같음 (아래 SQL) |
| 5 | 스테이징 | 화면 테스트: 랭킹 화면 이전 날짜 버튼으로 cutoff 이전·이후 날짜, 오래 쉰 학생 히스토리, 관리자 목록(cutoff 전후 기간), 아카이브 기록 삭제, 학생 삭제 | 이동 전과 같은 결과 |
| 6 | 운영 | 3 → 코드 배포 | |
| 7 | 운영 (새벽 여러 번) | 첫 이동 배치, 완료 후 `OPTIMIZE TABLE` | 행 수 합계, 서비스 지연 여부 |
| 8 | 운영 | cron 등록 (매월 1일 새벽), 로그 확인 | `archive_status.LastRunDateTime` |
```sql
-- 이동 전후 합계 확인 (이동 중인 테이블에 새 기록이 계속 쌓이므로 cutoff 이전만 비교)
SELECT
(SELECT COUNT(*) FROM best_record WHERE RecordDateTime < ?) AS in_main,
(SELECT COUNT(*) FROM best_record_archive WHERE RecordDateTime < ?) AS in_archive;
-- 이동 전 in_main 값 = 이동 후 in_main(0) + in_archive 여야 함 (그 사이 관리자 삭제가 없었다면)
```
---
## 6. 롤백
| 상황 | 방법 |
|---|---|
| 배치에 문제 | cron 해제. 이미 옮긴 데이터는 아카이브에 안전하게 있고, 대응 코드가 조회하므로 서비스 영향 없음 |
| 아카이빙 자체를 철회 | ① cron 해제 ② 아래 SQL로 월 단위 되돌리기 ③ `DELETE FROM archive_status;` ④ 대응 코드 `git revert` (**데이터를 되돌린 뒤에**) ⑤ 아카이브 테이블 삭제 |
```sql
-- 월 단위로 운영 테이블에 되돌리기
START TRANSACTION;
INSERT IGNORE INTO best_record
SELECT * FROM best_record_archive
WHERE RecordDateTime >= ? AND RecordDateTime < ?;
DELETE FROM best_record_archive
WHERE RecordDateTime >= ? AND RecordDateTime < ?;
COMMIT;
```
운영 테이블에는 외래 키가 있으므로, 되돌리는 기록의 플레이어·앱·선생님이 존재해야 합니다. 학생 삭제 시 아카이브도 함께 지우도록(4-3) 했다면 문제없습니다.
---
## 7. 장단점과 위험
| 장점 | 단점 / 위험 | 대응 |
|---|---|---|
| 운영 테이블 크기가 일정하게 유지됨 | 조회 경로마다 "아카이브도 봐야 하는가"를 처리해야 함 → **빠뜨리면 과거 기록이 안 보임** | 4-3 표를 체크리스트로 사용, `pick_record_table()` 공용 함수 |
| 버리지 않으므로 되돌리기 가능 | DB 전체 용량은 줄지 않음 | 4-5 백업 분리, 장기적으로 4-6 |
| 매월 소량 이동이라 부담 작음 | 첫 이동은 대량 | 며칠 새벽에 나누어 실행 |
| 파티셔닝(안4)과 달리 외래 키·기본 키 변경 없음 | 배치가 멈춰도 알아채기 어려움 | `archive_status.LastRunDateTime` 확인, 배치 실패 시 메일(기존 PHPMailer 활용) |
---
## 8. 예상 효과 (측정으로 확인 필요)
- 운영 `best_record` 행 수: 120만 → **최근 13개월 적재량**(3-3 쿼리의 `keep_13m`)
- 운영 테이블 인덱스·데이터 크기가 같은 비율로 감소 → 버퍼풀 적중률 향상
- 관리자 기록 목록(최근 기간 검색): 작은 테이블에서 조회
- 시간이 지나도 운영 테이블 크기가 거의 일정 (매월 들어오는 만큼 나감)
---
## 9. 체크리스트
- [ ] 경로 결정: 대안 A(안2 후) / 대안 B
- [ ] 보관 기간 결정 (3-3 측정)
- [ ] `*_archive`, `archive_status` 테이블 생성, 외래 키 없음 확인
- [ ] 대응 코드: 랭킹 과거 날짜(일반 앱·긴글 시험), 히스토리(대안 B), 최고기록 재계산(대안 B), 관리자 목록 2종, 개별 삭제(`affected_rows`), 학생 삭제, 테스트 계정 초기화
- [ ] `archive_status`가 비어 있을 때 기존과 동일하게 동작하는지 확인
- [ ] 배치 작성: 5,000행 단위, 트랜잭션, 실행 시간 제한, 로그, 실패 알림
- [ ] 스테이징 전체 리허설 + 행 수 합계 검증 + 화면 테스트
- [ ] 운영: 코드 배포 → 첫 이동(나누어) → `OPTIMIZE TABLE` → cron 등록
- [ ] (선택) 백업 분리, 월간 아카이브 백업 확인
- [ ] `make_db.sql`, migration 파일 반영