21 KiB
안3. 오래된 원본 기록 아카이빙
개요 문서 | 추천 단계: 3단계 | 난이도: 중 | 위험도: 중간 | 예상 작업량: 2~3일 (+ 첫 이동 작업 며칠 새벽) 전제: 안1 완료. 안2는 선택 (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의 월별 적재량으로 "운영 테이블에 남는 행 수 ≈ 최근 N개월 적재량 합"을 계산해 표를 채운 뒤 결정하세요.
| 보관 기간 | 운영 테이블 예상 행 수 | 장점 | 단점 |
|---|---|---|---|
| 6개월 | (측정) | 가장 작음 | 한 학년 전체 기록 검색 시 매번 아카이브 조회 |
| 13개월 (권장) | (측정) | 한 학년(1년) + 작년 같은 달 비교가 운영 테이블 안에서 가능 | |
| 24개월 | (측정) | 아카이브 조회가 거의 발생하지 않음 | 줄어드는 효과가 작음 |
-- 보관 기간별 운영 테이블에 남을 행 수
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. 테이블 생성
-- 운영 테이블과 같은 컬럼·인덱스(안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/)에 archive_old_records.php를 추가하고 서버 cron으로 매월 1일 새벽에 실행합니다.
MariaDB의
EVENT+ 저장 프로시저로도 가능하지만, 실행 여부와 오류를 확인하기 어렵습니다. 기존 메일 배치처럼 PHP CLI + 로그 파일 방식이 관리하기 쉽습니다.
처리 순서 (테이블별)
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
-- 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 → ranking_record_*.php (항상 현재 날짜) |
영향 없음 | 영향 없음 |
| 랭킹 화면 과거 날짜 탐색 | 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이므로, 시간·일·월 랭킹 구간은 반드시 한쪽 테이블에만 속합니다.
// 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 — 히스토리 보충 조회
-- 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 이전일 때)
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의 바인딩 방식으로 만든 뒤 두 번 사용합니다.
개별 기록 삭제
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의 최고기록 재계산
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 전체를 매일 덤프하고 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 |
-- 이동 전후 합계 확인 (이동 중인 테이블에 새 기록이 계속 쌓이므로 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 (데이터를 되돌린 뒤에) ⑤ 아카이브 테이블 삭제 |
-- 월 단위로 운영 테이블에 되돌리기
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 파일 반영