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

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