DB 개선 계획 작성

This commit is contained in:
2026-09-15 10:38:05 +09:00
parent a15ea560d3
commit 72663ac6e9
16 changed files with 4576 additions and 0 deletions
+306
View File
@@ -0,0 +1,306 @@
# 플레이어 기록 DB 성능 개선 방안 — 개요
> - 선행 문서: [player-record-tables-and-queries.md](player-record-tables-and-queries.md) (기록 테이블·쿼리 현황 조사)
> - 대상 DB: 운영 MariaDB 10.11.13
> - 작성일: 2026-09-14
> - 이 문서는 **계획 문서**입니다. 소스 코드와 DB는 아직 변경하지 않았습니다.
## 문서 구성
| 순서 | 문서 | 내용 |
|---|---|---|
| 1 | **이 문서** | 현재 상황, 쉬운 원리 설명, 용어 사전, 5개 안 비교, 추천 로드맵, 사전 측정 방법 |
| 2 | [02-option1-index-and-query-rewrite.md](02-option1-index-and-query-rewrite.md) | 안1. 인덱스 추가 + 쿼리 조건 개선 |
| 3 | [03-option2-daily-summary-tables.md](03-option2-daily-summary-tables.md) | 안2. 일별 최고기록 집계 테이블 도입 |
| 4 | [04-option3-archiving.md](04-option3-archiving.md) | 안3. 오래된 원본 기록 아카이빙 |
| 5 | [05-option4-partitioning.md](05-option4-partitioning.md) | 안4. 테이블 파티셔닝 (보류 권장) |
| 6 | [06-option5-application-layer.md](06-option5-application-layer.md) | 안5. 애플리케이션(PHP/화면) 개선 |
| 7 | [07-alternative-approaches.md](07-alternative-approaches.md) | 다른 방향의 해결책 연구: 학교별 테이블 분할, Redis·MongoDB·Kafka, DB 실행 환경 변경, 폴링·PHP 환경 |
처음이라면 이 문서의 **2장(원리)****4장(비교표)****6장(로드맵)** 순서로 읽고, 실제 작업할 안의 문서를 열어보는 것을 권장합니다.
---
## 1. 현재 상황 한눈에 보기
운영 DB에서 측정한 row 수 (2026-09 기준):
| 테이블 | row 수 | 성격 | 비고 |
|---|---:|---|---|
| `best_record` | **1,206,768** | 이력(계속 증가) | **가장 큰 개선 대상.** `COUNT(*)`만 0.93초 |
| `app_highest_record` | 228,930 | 스냅샷(플레이어×앱당 1행) | UNIQUE 키가 없어 중복 행 존재 여부 점검 필요 |
| `typing_exam_record` | 127,955 | 이력(계속 증가) | `best_record`와 같은 구조·같은 문제 |
| `typing_exam_highest_record` | 20,346 | 스냅샷 | |
| `license_score` | 620 | 이력 | 현재 규모로는 문제 없음 (예방 차원만) |
| `license_time` | 1 | 스냅샷 | 사실상 미사용 기능 |
`SELECT COUNT(*) FROM best_record`가 0.93초 걸렸다는 것은, DB가 120만 행을 처음부터 끝까지 훑었다는 뜻입니다. 현재 랭킹·히스토리·기록 저장 쿼리도 대부분 비슷한 방식으로 동작하므로, **게임이 끝날 때마다, 랭킹 화면을 열 때마다** 이 비용이 반복됩니다. 기록은 매시간 쌓이므로 시간이 갈수록 더 느려집니다.
---
## 2. 왜 느려지는가 — 쉬운 설명
### 2-1. 인덱스 = 책 뒤의 "찾아보기(색인)"
- 두꺼운 책에서 "파티셔닝"이라는 단어를 찾을 때, 첫 페이지부터 읽으면 오래 걸립니다(= **전체 스캔, Full Table Scan**).
- 책 뒤의 색인에서 "ㅍ" 항목을 찾아 페이지 번호로 바로 가면 빠릅니다(= **인덱스 탐색**).
- 현재 기록 테이블에는 "MaestroID 색인", "PlayerID 색인"처럼 **한 가지 기준짜리 색인만** 있습니다(외래 키를 만들 때 자동 생성된 것).
### 2-2. 복합 인덱스 = 전화번호부 (성 → 이름 순서)
- 전화번호부는 "성"으로 먼저 정렬하고, 같은 성 안에서 "이름"으로 정렬합니다.
- "김철수"는 빨리 찾지만, "성은 모르고 이름이 철수인 사람"은 전부 뒤져야 합니다.
- 인덱스도 같습니다. `(MaestroID, AppID, RecordDateTime)` 인덱스는 "이 선생님의 → 이 앱의 → 이 시간대 기록"을 순서대로 좁혀서 바로 찾습니다. **컬럼 순서가 중요**합니다. 보통 `=`로 비교하는 컬럼을 앞에, 범위(`>=`, `<`)로 비교하는 컬럼을 뒤에 둡니다.
### 2-3. 컬럼에 함수를 씌우면 색인을 쓸 수 없다
`RecordDateTime` 인덱스는 `2026-09-14 13:25:10` 같은 **전체 시각 순서**로 정렬되어 있습니다.
```sql
-- 현재 방식: "시(hour)가 13인 기록"
WHERE DATE(RecordDateTime) = DATE(NOW()) AND HOUR(RecordDateTime) = 13
```
DB 입장에서는 각 행의 `RecordDateTime``DATE()`, `HOUR()`**계산해 봐야** 조건에 맞는지 알 수 있으므로, 색인이 있어도 모든 행을 확인합니다.
```sql
-- 개선 방식: "13:00 이상 14:00 미만"
WHERE RecordDateTime >= '2026-09-14 13:00:00' AND RecordDateTime < '2026-09-14 14:00:00'
```
이 조건은 색인 안에서 **연속된 한 구간**이므로 그 구간만 읽습니다. 이렇게 인덱스를 활용할 수 있는 조건을 **sargable**하다고 부릅니다. 결과는 완전히 같고 방식만 다릅니다.
### 2-4. 매시간 1행씩 영원히 쌓이는 구조
`best_record`는 "플레이어 × 앱 × 1시간"마다 1행이 생깁니다. 삭제하는 로직이 사실상 없으므로, 인덱스를 잘 만들어도 **히스토리·월간 랭킹처럼 넓은 범위를 묶어 계산(GROUP BY)하는 쿼리**는 데이터가 늘수록 결국 느려집니다. 이 문제는 "미리 날짜별로 계산해 둔 작은 테이블(집계 테이블)"과 "오래된 원본 분리(아카이빙)"로 해결합니다.
---
## 3. 용어 사전
| 용어 | 뜻 | 이 프로젝트에서의 예 |
|---|---|---|
| 인덱스 (Index) | 원하는 행을 빨리 찾기 위한 정렬된 색인 | `best_record``PlayerID` 색인 |
| 복합 인덱스 | 여러 컬럼을 순서대로 묶은 인덱스 | `(MaestroID, AppID, RecordDateTime)` |
| 커버링 인덱스 | 쿼리에 필요한 컬럼이 전부 인덱스에 들어 있어 원본 행을 읽지 않아도 되는 인덱스 | 랭킹용 인덱스에 `PlayerID, BestRecord`까지 포함 |
| 전체 스캔 (Full Scan) | 테이블의 모든 행을 처음부터 끝까지 읽음 | 현재 랭킹/히스토리 쿼리 |
| sargable | 인덱스를 활용할 수 있는 형태의 조건 | `RecordDateTime >= ? AND RecordDateTime < ?` |
| EXPLAIN | 쿼리를 실행하지 않고 "어떻게 실행할지" 계획을 보여주는 명령 | `EXPLAIN SELECT ...` |
| ANALYZE | 쿼리를 **실제로 실행**하고 계획과 실제 소요를 함께 보여줌 | `ANALYZE FORMAT=JSON SELECT ...` |
| 집계(요약) 테이블 | 원본을 미리 묶어 계산해 둔 작은 테이블 | 안2의 `daily_best_record` (플레이어×앱×날짜당 1행) |
| UPSERT | "없으면 INSERT, 있으면 UPDATE"를 쿼리 한 번으로 처리 | `INSERT ... ON DUPLICATE KEY UPDATE` |
| UNIQUE 키 | 같은 값 조합이 두 번 들어가지 못하게 막는 인덱스 | `app_highest_record (MaestroID, PlayerID, AppID)` |
| 아카이빙 | 오래된 데이터를 별도 보관 테이블로 옮겨 운영 테이블을 작게 유지 | `best_record_archive` |
| 파티셔닝 | 한 테이블을 내부적으로 연/월 단위 "서랍"으로 나누어 저장 | `RecordDateTime` 연도별 파티션 |
| 온라인 DDL | 서비스 중에도 테이블 구조(인덱스 등)를 변경하는 방식 | `ALTER TABLE ... ALGORITHM=INPLACE, LOCK=NONE` |
| N+1 쿼리 | 목록 1번 조회 후 항목마다 쿼리를 1번씩 더 실행하는 비효율 패턴 | 메뉴 화면의 앱별 최고기록 조회 |
| 슬로우 쿼리 로그 | 기준 시간보다 오래 걸린 쿼리를 기록하는 DB 기능 | `long_query_time = 1` |
---
## 4. 5개 개선안 요약 비교
| | 안1. 인덱스 + 쿼리 조건 | 안2. 일별 집계 테이블 | 안3. 원본 아카이빙 | 안4. 파티셔닝 | 안5. 애플리케이션 개선 |
|---|---|---|---|---|---|
| 핵심 아이디어 | 알맞은 색인을 만들고, 색인을 쓸 수 있게 조건을 바꾼다 | 날짜별 최고기록을 미리 계산해 두고 거기서 조회한다 | 오래된 원본을 보관 테이블로 옮긴다 | 테이블을 연/월 서랍으로 나눈다 | PHP 코드의 비효율(N+1, 이중 조회, 문자열 SQL)을 고친다 |
| 효과 | **즉시 큼** | **장기적으로 매우 큼** | 장기 용량 관리 | 장기 용량 관리 | 중간 (특정 화면) |
| 난이도 | 하 | 중 | 중 | 상 | 하~중 |
| 위험도 | 낮음 | 중간 | 중간 | 높음 | 낮음 |
| 예상 작업량 | 1~2일 | 3~5일 | 2~3일 | 3~5일 + 서비스 점검 시간 | 2~4일 |
| DB 구조 변경 | 인덱스 추가, UNIQUE 키 | 새 테이블 2개 | 보관 테이블 2개 + 배치 | PK 변경, FK 제거, 테이블 재구성 | 없음 |
| 코드 수정 범위 | 쿼리 조건 (약 7개 파일) | 기록 저장·삭제·조회 경로 | 관리자 기록 목록, 삭제 경로 | 적음 (쿼리 조건은 안1 필요) | 메뉴·기록 목록 API·화면 |
| 기존 데이터 변경 | 최고기록 중복 행 정리만 | 없음 (새 테이블에 복사) | 원본 행 이동 | 테이블 재구성 | 없음 |
| 추천 | **1단계 (필수)** | **2단계** | 3단계 | **보류** | 병행 |
각 안은 서로 배타적이지 않습니다. **안1 → 안2 → 안3은 순서대로 쌓아 올리는 구조**이고, 안5는 언제든 병행할 수 있으며, 안4는 안3과 목적이 겹쳐 현재는 권장하지 않습니다.
---
## 5. 문제점 × 개선안 매트릭스
선행 문서 4장의 문제 1~7이 각 안으로 얼마나 해결되는지 정리했습니다.
(◎ 근본 해결 / ○ 상당 부분 개선 / △ 일부 도움 / - 무관)
| # | 문제점 | 안1 | 안2 | 안3 | 안4 | 안5 |
|---|---|:---:|:---:|:---:|:---:|:---:|
| 1 | 날짜 컬럼에 함수를 씌운 조건 (인덱스 무력화) | ◎ | ◎ | - | △ (안1이 선행되어야 효과) | - |
| 2 | 복합 인덱스 부재 | ◎ | ○ (새 테이블은 처음부터 인덱스 설계) | - | △ | - |
| 3 | 이력 테이블 무한 증가 | - | ○ (조회가 원본에 의존하지 않게 됨) | ◎ | ◎ | - |
| 4 | 기록 저장 시 반복되는 조회+쓰기 비용, 최고기록 delete→insert | ○ | ◎ (UPSERT) | △ | - | - |
| 5 | 메뉴 화면 N+1 쿼리 | - | - | - | - | ◎ |
| 6 | 관리자 기록 목록: COUNT+목록 이중 조회, `LIKE '%…%'`, 문자열 SQL | ○ (날짜 인덱스) | - | ○ (조회 대상 축소) | △ | ◎ |
| 7 | 미사용 `ranking` 테이블 | - | - | - | - | ○ (재활용 또는 삭제 결정) |
---
## 6. 추천 조합과 단계별 로드맵
| 단계 | 내용 | 목표 / 완료 조건 | 참고 문서 |
|---|---|---|---|
| **0단계. 측정** (반나절) | 테이블 크기, 대표 쿼리 EXPLAIN 기준값, 슬로우 쿼리 로그, 최고기록 중복 점검 | 개선 전 수치를 표로 남김 (7장) | 이 문서 7장 |
| **1단계. 안1** | 인덱스 추가 → 쿼리 조건 sargable 변환 → 월간 랭킹 버그 수정 | 랭킹·기록 저장 쿼리 EXPLAIN에서 `type=ALL`(전체 스캔)이 사라짐 | 안1 |
| **1단계 병행. 안5 일부** | 기록 목록 API의 SQL Injection 제거 (보안 이슈라 우선) | 모든 조건이 `?` 바인딩 | 안5 |
| **2단계. 안2** | 일별 집계 테이블 생성 → 저장 로직에 반영 → 과거 데이터 채우기 → 조회 전환 | 히스토리/일간·월간 랭킹이 집계 테이블에서 조회됨, 기존 결과와 동일 | 안2 |
| **3단계. 안3** | 보관 기간 결정 → 아카이브 테이블·배치 도입 | 운영 `best_record`가 보관 기간 이내 데이터만 유지, "최근 7일 히스토리"는 계속 정상 | 안3 |
| **수시. 안5 나머지** | N+1 제거, 기록 목록 페이징, 랭킹 캐시, 중복 코드 정리 | 메뉴 화면 쿼리 수가 앱 개수와 무관해짐 | 안5 |
| **보류. 안4** | 재검토 조건 충족 시에만 검토 | 원본 1,000만 행 초과 등 | 안4 |
**왜 이 순서인가**
- 안1은 **기존 데이터를 거의 건드리지 않고**, 인덱스는 추가해도 기존 코드가 그대로 동작하므로 가장 안전하게 큰 효과를 얻습니다.
- 안2는 "몇 년 전 기록이라도 플레이어가 마지막으로 플레이한 7일은 보여준다"는 요구사항을 **원본 테이블 없이도** 만족시키는 장치입니다. 따라서 **안3(원본 분리)보다 반드시 먼저** 해야 합니다.
- 안3은 안2가 끝나야 안전하게 오래된 원본을 옮길 수 있습니다.
**안2를 건너뛰는 경로도 있습니다**
- 학생들이 하루 한 수업(한 시간)만 플레이하는 경우가 많으면, 집계 테이블 행 수가 원본과 크게 다르지 않을 수 있습니다. 이때 안2의 가치는 속도보다 "원본 없이도 히스토리가 동작하는 구조"입니다.
- 안1 후 히스토리·월간 랭킹이 충분히 빠르다면, **안2 없이 안3의 "대안 B(히스토리를 아카이브 테이블에서 보충 조회)"** 로 요구사항을 만족시키는 더 단순한 경로를 택할 수 있습니다.
- 판단 기준과 측정 방법: [안2 3장](03-option2-daily-summary-tables.md), 두 경로 비교: [안3 3-2](04-option3-archiving.md)
---
## 7. 사전 측정 절차 (0단계)
> 운영 DB에서 무거운 쿼리를 반복 실행하면 서비스에 영향이 갈 수 있으므로, 가능하면 **최신 백업을 스테이징 DB(`mariadb.jisangs.com`)에 복원해서** 측정하세요. 운영에서 실행할 때는 사용량이 적은 시간대에 실행하세요.
### 7-1. 테이블·인덱스 크기
```sql
SELECT TABLE_NAME,
TABLE_ROWS AS approx_rows,
ROUND(DATA_LENGTH / 1024 / 1024, 1) AS data_mb,
ROUND(INDEX_LENGTH / 1024 / 1024, 1) AS index_mb
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'chocomae'
ORDER BY DATA_LENGTH DESC;
```
- `TABLE_ROWS`는 추정치입니다(정확한 값은 `COUNT(*)`).
- 함께 확인: `SELECT @@innodb_buffer_pool_size / 1024 / 1024 AS buffer_pool_mb;` — 데이터+인덱스 크기가 이 값보다 훨씬 크면 디스크 읽기가 많아져 느려집니다.
### 7-2. 월별 적재량 (증가 속도 파악)
```sql
SELECT DATE_FORMAT(RecordDateTime, '%Y-%m') AS ym, COUNT(*) AS cnt
FROM best_record
GROUP BY ym
ORDER BY ym;
```
이 결과로 "보관 기간을 13개월로 하면 운영 테이블에 몇 행이 남는지"(안3), "1,000만 행에 언제 도달하는지"(안4 재검토 시점)를 계산할 수 있습니다.
### 7-3. 대표 쿼리 EXPLAIN 기준값
1) 기록이 많은 선생님·앱 조합을 찾습니다.
```sql
SELECT MaestroID, AppID, COUNT(*) AS cnt
FROM best_record
WHERE RecordDateTime >= NOW() - INTERVAL 30 DAY
GROUP BY MaestroID, AppID
ORDER BY cnt DESC
LIMIT 5;
```
2) 그 값으로 현재 형태의 일간 랭킹 쿼리 계획을 확인합니다. (`123`, `5`는 위 결과로 교체)
```sql
EXPLAIN
SELECT BR.PlayerID, U.Name, MAX(BR.BestRecord) AS HighScore
FROM best_record BR, player U
WHERE BR.MaestroID = 123 AND BR.PlayerID = U.PlayerID
AND YEAR(BR.RecordDateTime) = YEAR('2026-09-10')
AND MONTH(BR.RecordDateTime) = MONTH('2026-09-10')
AND DAYOFMONTH(BR.RecordDateTime) = DAYOFMONTH('2026-09-10')
AND BR.AppID = 5
GROUP BY BR.PlayerID
ORDER BY MAX(BR.BestRecord) DESC;
```
3) EXPLAIN 결과 읽는 법
| 컬럼 | 볼 것 | 좋은 값 / 나쁜 값 |
|---|---|---|
| `type` | 어떻게 찾는가 | `ref`, `range`, `eq_ref` 좋음 / **`ALL`(전체 스캔) 나쁨** |
| `key` | 사용한 인덱스 | 기대한 인덱스 이름이 보이면 좋음 / `NULL`이면 인덱스 미사용 |
| `rows` | 읽을 것으로 예상하는 행 수 | 작을수록 좋음 (120만 근처면 전체 스캔) |
| `Extra` | 추가 작업 | `Using index`(커버링) 좋음 / `Using temporary; Using filesort`는 대상 행이 많을 때 부담 |
4) 실제 소요 시간까지 보려면 `EXPLAIN` 대신 `ANALYZE FORMAT=JSON`을 사용합니다. **이 명령은 쿼리를 실제로 실행**하므로 SELECT에만 사용하세요. 결과의 `r_total_time_ms`가 실제 소요 시간(ms)입니다.
### 7-4. 슬로우 쿼리 로그 켜기 (일시적)
```sql
SET GLOBAL slow_query_log = 1;
SET GLOBAL long_query_time = 1; -- 1초 이상 걸린 쿼리 기록
SET GLOBAL log_output = 'TABLE'; -- mysql.slow_log 테이블에 기록
-- 하루 정도 서비스 후 확인
SELECT start_time, query_time, rows_examined, LEFT(sql_text, 200) AS sql_head
FROM mysql.slow_log
ORDER BY query_time DESC
LIMIT 30;
-- 측정이 끝나면 끄기
SET GLOBAL slow_query_log = 0;
```
`SET GLOBAL`은 DB 컨테이너가 재시작되면 초기화됩니다. 계속 켜 두려면 Docker의 MariaDB 설정 파일(`my.cnf`)에 추가해야 합니다.
### 7-5. 최고기록 테이블 중복 점검
```sql
-- 같은 (선생님, 플레이어, 앱) 조합이 2행 이상인 경우
SELECT MaestroID, PlayerID, AppID, COUNT(*) AS cnt
FROM app_highest_record
GROUP BY MaestroID, PlayerID, AppID
HAVING cnt > 1
ORDER BY cnt DESC
LIMIT 50;
SELECT MaestroID, PlayerID, WritingID, COUNT(*) AS cnt
FROM typing_exam_highest_record
GROUP BY MaestroID, PlayerID, WritingID
HAVING cnt > 1
ORDER BY cnt DESC
LIMIT 50;
```
결과가 있으면 안1의 UNIQUE 키 추가 전에 정리가 필요합니다(안1 문서 3-2 참고).
### 7-6. 측정 결과 기록 양식
| 측정 항목 | 개선 전 | 안1 후 | 안2 후 | 안3 후 |
|---|---|---|---|---|
| `best_record` data_mb / index_mb | | | | |
| 일간 랭킹 쿼리 `rows` / 실제 ms | | | | |
| 시간 랭킹 쿼리 `rows` / 실제 ms | | | | |
| 히스토리(최근 7일) 쿼리 `rows` / 실제 ms | | | | |
| 기록 저장 시 중복 확인 쿼리 `rows` / 실제 ms | | | | |
| 관리자 기록 목록(50건) 실제 ms | | | | |
| 1초 이상 슬로우 쿼리 수 (1일) | | | | |
| 일일 백업 파일 크기 | | | | |
---
## 8. 조사 중 발견한 버그·위험 (개선 작업 때 함께 수정 권장)
| 우선순위 | 파일 | 내용 | 영향 | 조치 |
|---|---|---|---|---|
| **높음 (보안)** | [request_app_player_record_list.php](../../../src/web/server/record/request_app_player_record_list.php), [request_writing_player_record_list.php](../../../src/web/server/record/request_writing_player_record_list.php), [request_license_timer_player_record_list.php](../../../src/web/server/record/request_license_timer_player_record_list.php) | 시작일·종료일·학생 이름·AppID를 SQL 문자열에 그대로 이어붙임 | 요청 값을 조작하면 다른 선생님의 기록 조회 등 **SQL Injection 가능** | 안5: 전부 `?` 바인딩으로 변경 |
| 높음 (보안) | [history_record.php](../../../src/web/server/record/history_record.php) | `AppID`를 쿼리 문자열에 직접 연결 | 위와 동일 | 안1 쿼리 수정 시 바인딩으로 변경 |
| 중간 (정확성) | [app_ranking.php](../../../src/web/server/record/app_ranking.php) `get_ranking_month()` | `MONTH()`만 비교하고 연도(`YEAR`)를 비교하지 않음 | **작년·재작년 같은 달 기록까지 이달의 랭킹에 섞임** | 안1: 월 범위 조건으로 변경하며 자연히 해결 |
| 중간 (정합성) | `app_highest_record`, `typing_exam_highest_record` | UNIQUE 키 없이 "삭제 후 삽입"으로 갱신 | 동시에 기록이 저장되면 같은 조합이 2행 이상 생길 수 있음 | 안1: 중복 정리 + UNIQUE 키 / 안2: UPSERT |
| 낮음 (버그) | [typing_exam_collection.php](../../../src/web/php/db/typing_exam_collection.php) `getHighestRecordArrayForAllWriting()` | `bind_param("iii", …)`에 값은 2개만 전달 | 이 함수 호출 시 오류 | 호출처 확인 후 `"ii"`로 수정 또는 미사용이면 삭제 |
| 낮음 (코드) | `record/*.php` 여러 파일 | `if($replyJSON.length === 0)`는 PHP에서 "문자열 연결"로 해석되어 항상 거짓 | 빈 결과 시 에러 응답 분기가 동작하지 않음 (현재는 빈 배열로 응답되어 큰 문제는 없음) | 필요 시 `count($replyJSON) === 0`으로 정리 |
---
## 9. 적용 시 공통 안전 수칙
1. **백업 먼저**: 작업 직전에 [backup-db.sh](../../db/260907-daily-db-backup/backup-db.sh)를 수동 실행하고 백업 파일 크기를 확인합니다.
2. **스테이징 먼저**: 최신 백업을 스테이징 DB에 복원해 같은 작업을 먼저 해보고, 소요 시간과 결과를 기록합니다.
3. **사용량이 적은 시간대**: 운영 DB 구조 변경은 새벽에 진행합니다.
4. **한 번에 하나씩**: 인덱스 추가 → 확인 → 코드 배포 → 확인 순서로, 여러 변경을 한꺼번에 하지 않습니다.
5. **롤백 SQL을 미리 준비**: 각 안 문서의 "롤백" 절을 작업 전에 복사해 둡니다.
6. **스키마 변경 이력 남기기**: 운영 DB에 적용한 SQL은 `src/web/sql/migration/YYMMDD_설명.sql` 같은 파일로 저장소에 남기고, 신규 설치용 [make_db.sql](../../../src/web/sql/make_db.sql)에도 반영해 운영 DB와 스키마 파일이 달라지지 않게 합니다.
@@ -0,0 +1,463 @@
# 안1. 인덱스 추가 + 쿼리 조건 개선
> [개요 문서](01-improvement-overview.md) | 추천 단계: **1단계 (필수)** | 난이도: 하 | 위험도: 낮음 | 예상 작업량: 1~2일
## 한 줄 요약
실제 쿼리 패턴에 맞는 **복합 인덱스를 추가**하고, 날짜 컬럼에 함수를 씌운 조건을 **"시작 시각 이상 ~ 끝 시각 미만" 범위 조건으로 바꿔** 인덱스를 쓸 수 있게 한다.
---
## 1. 해결하는 문제
| 문제 (선행 문서 번호) | 해결 정도 |
|---|---|
| 1. 날짜 컬럼에 `DATE()/HOUR()/YEAR()/MONTH()/DAYOFMONTH()`를 씌운 조건 | ◎ |
| 2. 복합 인덱스 부재 | ◎ |
| 4. 게임 종료마다 반복되는 조회 비용 | ○ (조회가 1시간 범위만 읽게 됨) |
| 6. 관리자 기록 목록 조회 | ○ (날짜 인덱스로 최근 50건을 빠르게 찾음) |
| (버그) 이달의 랭킹에 작년 기록이 섞임 | ◎ |
| (정합성) 최고기록 테이블 중복 행 | ◎ (UNIQUE 키) |
---
## 2. 쉬운 설명
- 지금은 "이 선생님의 이 앱에서 오늘 기록"을 찾을 때, 색인이 없어서 **120만 행을 모두 넘겨보며** 날짜를 계산합니다.
- 인덱스를 `(선생님 → 앱 → 기록 시각)` 순서로 만들면, 색인에서 "선생님 123 → 앱 5 → 2026-09-14 00:00~24:00" 구간으로 바로 이동해 **그날 기록만** 읽습니다.
- 단, 조건을 `DATE(RecordDateTime) = '2026-09-14'`처럼 쓰면 색인을 못 쓰므로, `RecordDateTime >= '2026-09-14' AND RecordDateTime < '2026-09-15'`로 바꿔야 합니다. **결과는 같습니다.**
- 인덱스를 추가해도 기존 코드는 그대로 동작합니다. 그래서 "인덱스 먼저 추가 → 코드 나중 배포" 순서로 안전하게 진행할 수 있습니다.
---
## 3. 변경 내용
### 3-1. 인덱스 추가
#### 설계 원칙
1. `=`로 비교하는 컬럼을 앞에, 범위(`>=`, `<`)로 비교하는 컬럼(`RecordDateTime`)을 뒤에 둔다.
2. 자주 실행되는 랭킹 쿼리용 인덱스에는 `PlayerID`, 기록 값까지 넣어 **원본 행을 읽지 않고 인덱스만으로** 계산하게 한다(커버링 인덱스).
3. 인덱스는 쓰기(INSERT/UPDATE) 때마다 함께 갱신되므로 필요한 것만 만든다. 기록 저장은 초당 수 건 수준이라 인덱스 3개 정도는 부담이 작다(예상).
#### `best_record`
| 인덱스 이름 | 컬럼 순서 | 이 인덱스를 쓰는 쿼리 |
|---|---|---|
| `idx_br_maestro_app_time` | `(MaestroID, AppID, RecordDateTime, PlayerID, BestRecord)` | 시간/일간/월간 랭킹 ([app_ranking.php](../../../src/web/server/record/app_ranking.php), [ranking_record_hour.php](../../../src/web/server/record/ranking_record_hour.php), [ranking_record_day.php](../../../src/web/server/record/ranking_record_day.php), [ranking_record_month.php](../../../src/web/server/record/ranking_record_month.php)) — 커버링 |
| `idx_br_player_app_time` | `(PlayerID, AppID, RecordDateTime)` | 기록 저장 시 "이번 시간 기록" 확인 ([update_result_record.php](../../../src/web/server/record/update_result_record.php)), 히스토리 ([history_record.php](../../../src/web/server/record/history_record.php)), 기록 삭제 후 최고기록 재계산 ([delete_record.php](../../../src/web/server/record/delete_record.php)), 플레이어 삭제 |
| `idx_br_maestro_time` | `(MaestroID, RecordDateTime)` | 관리자 기록 목록 최신순 조회 ([request_app_player_record_list.php](../../../src/web/server/record/request_app_player_record_list.php)) |
> `idx_br_player_app_time`이 `MaestroID`가 아닌 `PlayerID`로 시작하는 이유: `PlayerID`는 한 선생님에게만 속하므로 `PlayerID`만으로도 대상이 충분히 좁혀지고, [delete_test_player_record.php](../../../src/web/server/maestro/delete_test_player_record.php)처럼 `PlayerID`만으로 조회하는 쿼리까지 함께 쓸 수 있습니다. `MaestroID = ?` 조건은 좁혀진 행에서 추가로 확인합니다.
#### `typing_exam_record`
| 인덱스 이름 | 컬럼 순서 | 이 인덱스를 쓰는 쿼리 |
|---|---|---|
| `idx_ter_maestro_writing_time` | `(MaestroID, WritingID, RecordDateTime, PlayerID, Record)` | 긴글 시험 랭킹 6종 ([typing_exam_collection.php](../../../src/web/php/db/typing_exam_collection.php) `getRankingRecord*`, `getRankingMinusRecord*`) — 커버링 |
| `idx_ter_player_writing_time` | `(PlayerID, WritingID, RecordDateTime)` | `getThisHourRecord()`, `getHistoryRecord()`, 기록 삭제 후 재계산 |
| `idx_ter_maestro_time` | `(MaestroID, RecordDateTime)` | 관리자 기록 목록 ([request_writing_player_record_list.php](../../../src/web/server/record/request_writing_player_record_list.php)) |
#### `license_score` (선택 — 현재 620행이라 효과는 미미, 비용도 미미)
| 인덱스 이름 | 컬럼 순서 | 이 인덱스를 쓰는 쿼리 |
|---|---|---|
| `idx_ls_maestro_player_time` | `(MaestroID, PlayerID, ScoreDateTime)` | [get_license_score.php](../../../src/web/server/license_timer/get_license_score.php) |
#### 적용 SQL
```sql
-- 0) 현재 인덱스 확인 (적용 전후 비교용)
SHOW INDEX FROM best_record;
SHOW INDEX FROM typing_exam_record;
-- 1) best_record
ALTER TABLE best_record
ADD INDEX idx_br_maestro_app_time (MaestroID, AppID, RecordDateTime, PlayerID, BestRecord),
ADD INDEX idx_br_player_app_time (PlayerID, AppID, RecordDateTime),
ADD INDEX idx_br_maestro_time (MaestroID, RecordDateTime),
ALGORITHM=INPLACE, LOCK=NONE;
-- 2) typing_exam_record
ALTER TABLE typing_exam_record
ADD INDEX idx_ter_maestro_writing_time (MaestroID, WritingID, RecordDateTime, PlayerID, Record),
ADD INDEX idx_ter_player_writing_time (PlayerID, WritingID, RecordDateTime),
ADD INDEX idx_ter_maestro_time (MaestroID, RecordDateTime),
ALGORITHM=INPLACE, LOCK=NONE;
-- 3) license_score (선택)
ALTER TABLE license_score
ADD INDEX idx_ls_maestro_player_time (MaestroID, PlayerID, ScoreDateTime),
ALGORITHM=INPLACE, LOCK=NONE;
-- 4) 통계 갱신 (옵티마이저가 새 인덱스를 잘 고르도록)
ANALYZE TABLE best_record, typing_exam_record, license_score;
```
- `ALGORITHM=INPLACE, LOCK=NONE`: 인덱스를 만드는 동안에도 **읽기·쓰기가 계속 가능**합니다. 이 방식이 불가능한 상황이면 MariaDB가 실행하지 않고 오류를 내므로, 모르는 사이 테이블이 잠기는 일은 없습니다.
- 소요 시간: `best_record` 120만 행 기준 **수십 초~수 분(예상)**. 스테이징에서 먼저 측정하세요.
- 인덱스 크기: 인덱스 1개당 **수십 MB 수준(예상)**. 적용 후 [개요 7-1](01-improvement-overview.md#7-1-테이블인덱스-크기)의 쿼리로 `index_mb`를 기록하세요. 디스크 여유 공간이 테이블 크기의 2배 이상인지 먼저 확인하세요(`df -h`).
### 3-2. 최고기록 테이블 UNIQUE 키 추가 (중복 정리 후)
`app_highest_record`는 "(선생님, 플레이어, 앱)당 1행"이어야 하지만 이를 보장하는 장치가 없습니다. UNIQUE 키를 추가하면 DB가 중복을 원천 차단하고, 조회도 해당 인덱스로 빨라집니다.
> 앱 105는 **낮을수록 좋은** 앱입니다([util_app.php](../../../src/web/server/lib/util_app.php) `is_highest_record_prefer_app()`). 중복 정리 시 앱 105는 가장 작은 값을, 나머지는 가장 큰 값을 남깁니다.
```sql
-- 1) 안전을 위한 사본
CREATE TABLE app_highest_record_bak_260914 AS SELECT * FROM app_highest_record;
CREATE TABLE typing_exam_highest_record_bak_260914 AS SELECT * FROM typing_exam_highest_record;
-- 2) 중복 확인 (개요 문서 7-5와 동일). 0건이면 3) 생략
SELECT COUNT(*) FROM (
SELECT 1 FROM app_highest_record
GROUP BY MaestroID, PlayerID, AppID HAVING COUNT(*) > 1
) d;
-- 3) 중복 정리: 같은 조합에서 "더 좋은 기록(b)"이 있는 행(a)을 삭제
-- 기록이 같으면 ID가 큰(최근) 행을 남김
DELETE a
FROM app_highest_record a
JOIN app_highest_record b
ON a.MaestroID = b.MaestroID
AND a.PlayerID = b.PlayerID
AND a.AppID = b.AppID
AND a.AppHighestRecordID <> b.AppHighestRecordID
WHERE
(a.AppID <> 105 AND (a.HighestRecord < b.HighestRecord
OR (a.HighestRecord = b.HighestRecord AND a.AppHighestRecordID < b.AppHighestRecordID)))
OR
(a.AppID = 105 AND (a.HighestRecord > b.HighestRecord
OR (a.HighestRecord = b.HighestRecord AND a.AppHighestRecordID < b.AppHighestRecordID)));
-- 긴글 시험 최고기록은 모두 "높을수록 좋음"
DELETE a
FROM typing_exam_highest_record a
JOIN typing_exam_highest_record b
ON a.MaestroID = b.MaestroID
AND a.PlayerID = b.PlayerID
AND a.WritingID = b.WritingID
AND a.TypingExamHighestRecordID <> b.TypingExamHighestRecordID
WHERE a.HighestRecord < b.HighestRecord
OR (a.HighestRecord = b.HighestRecord AND a.TypingExamHighestRecordID < b.TypingExamHighestRecordID);
-- 4) 중복이 0건인지 다시 확인한 뒤 UNIQUE 키 추가
ALTER TABLE app_highest_record
ADD UNIQUE INDEX uk_ahr_maestro_player_app (MaestroID, PlayerID, AppID),
ALGORITHM=INPLACE, LOCK=NONE;
ALTER TABLE typing_exam_highest_record
ADD UNIQUE INDEX uk_tehr_maestro_player_writing (MaestroID, PlayerID, WritingID),
ALGORITHM=INPLACE, LOCK=NONE;
-- 5) 1~2주 문제 없으면 사본 삭제
-- DROP TABLE app_highest_record_bak_260914, typing_exam_highest_record_bak_260914;
```
**UNIQUE 키 추가 후 기존 코드 동작**: 현재 코드는 "삭제 후 삽입"이므로 평소에는 그대로 동작합니다. 드물게 두 요청이 동시에 들어오면 한쪽 INSERT가 중복 오류로 실패하는데, 현재 코드는 오류를 무시하므로 화면 오류는 없습니다. 이 경우를 완전히 없애는 UPSERT 방식은 [안2 3-3](03-option2-daily-summary-tables.md)에서 다룹니다.
### 3-3. 쿼리 조건 수정 (전/후 비교)
#### 변환 규칙
| 의미 | 현재 (인덱스 사용 불가) | 변경 (인덱스 사용 가능) |
|---|---|---|
| 지금 이 시간 | `DATE(x) = DATE(NOW()) AND HOUR(x) = HOUR(NOW())` | `x >= CAST(DATE_FORMAT(NOW(), '%Y-%m-%d %H:00:00') AS DATETIME)`<br>`AND x < CAST(DATE_FORMAT(NOW(), '%Y-%m-%d %H:00:00') AS DATETIME) + INTERVAL 1 HOUR` |
| 오늘 | `DATE(x) = DATE(NOW())` | `x >= CURDATE() AND x < CURDATE() + INTERVAL 1 DAY` |
| 이번 달 | `MONTH(x) = MONTH(NOW())`**연도 누락 버그** | `x >= CAST(DATE_FORMAT(CURDATE(), '%Y-%m-01') AS DATE)`<br>`AND x < CAST(DATE_FORMAT(CURDATE(), '%Y-%m-01') AS DATE) + INTERVAL 1 MONTH` |
| 특정 날짜 `?` | `YEAR(x)=YEAR(?) AND MONTH(x)=MONTH(?) AND DAYOFMONTH(x)=DAYOFMONTH(?)` | `x >= DATE(?) AND x < DATE(?) + INTERVAL 1 DAY` |
| 특정 날짜 `?`의 특정 시 `?` | 위 + `HOUR(x) = HOUR(?)` | `x >= DATE(?) + INTERVAL HOUR(?) HOUR`<br>`AND x < DATE(?) + INTERVAL (HOUR(?) + 1) HOUR` |
| 특정 날짜 `?`가 속한 달 | `YEAR(x)=YEAR(?) AND MONTH(x)=MONTH(?)` | `x >= CAST(DATE_FORMAT(?, '%Y-%m-01') AS DATE)`<br>`AND x < CAST(DATE_FORMAT(?, '%Y-%m-01') AS DATE) + INTERVAL 1 MONTH` |
| 특정 날짜 `?` 이전 전체 | `DATE(x) <= ?` | `x < DATE(?) + INTERVAL 1 DAY` |
- 파라미터(`?`)나 `NOW()`에 함수를 쓰는 것은 괜찮습니다. **컬럼(`x`)에만 함수를 쓰지 않으면 됩니다.**
- 시각 계산은 지금처럼 DB의 `NOW()`를 기준으로 합니다. PHP의 `date()`로 계산하면 PHP와 DB의 타임존이 다를 때 결과가 어긋날 수 있습니다.
- `WHERE` 안에서 조건의 **순서는 성능과 무관**합니다. 아래 예시는 읽기 쉽게 인덱스 컬럼 순서로 정렬했을 뿐입니다.
#### A. [update_result_record.php](../../../src/web/server/record/update_result_record.php) — 게임 종료마다 실행 (가장 자주 실행)
`get_best_record()`
```sql
-- 현재
SELECT BestRecordID, BestRecord
FROM best_record
WHERE MaestroID = ? AND AppID = ? AND PlayerID = ? AND DATE(RecordDateTime) = DATE(NOW())
AND HOUR(RecordDateTime) = HOUR(NOW())
-- bind_param("iii", $maestro_id, $app_id, $player_id)
-- 변경
SELECT BestRecordID, BestRecord
FROM best_record
WHERE PlayerID = ? AND AppID = ? AND MaestroID = ?
AND RecordDateTime >= CAST(DATE_FORMAT(NOW(), '%Y-%m-%d %H:00:00') AS DATETIME)
AND RecordDateTime < CAST(DATE_FORMAT(NOW(), '%Y-%m-%d %H:00:00') AS DATETIME) + INTERVAL 1 HOUR
-- bind_param("iii", $player_id, $app_id, $maestro_id) ← 바인딩 순서 주의
```
`update_best_record()``WHERE BestRecordID = ?`(기본 키)로 이미 1행을 찾으므로 성능 문제는 없습니다. 뒤에 붙은 `DATE()/HOUR()` 조건은 "시간이 바뀌는 순간 방금 조회한 행을 갱신하지 않기 위한" 안전장치이므로 **그대로 두거나** 위의 범위 조건으로 바꿔도 됩니다.
#### B. [app_ranking.php](../../../src/web/server/record/app_ranking.php) — 교실 화면 진입 시 3개 쿼리
```sql
-- get_ranking_hour() WHERE 절
-- 현재
WHERE BR.PlayerID = P.PlayerID AND DATE(BR.RecordDateTime) = DATE(NOW()) AND HOUR(BR.RecordDateTime) = HOUR(NOW()) AND BR.MaestroID = ? AND BR.AppID = ?
-- 변경
WHERE BR.MaestroID = ? AND BR.AppID = ?
AND BR.RecordDateTime >= CAST(DATE_FORMAT(NOW(), '%Y-%m-%d %H:00:00') AS DATETIME)
AND BR.RecordDateTime < CAST(DATE_FORMAT(NOW(), '%Y-%m-%d %H:00:00') AS DATETIME) + INTERVAL 1 HOUR
AND BR.PlayerID = P.PlayerID
-- get_ranking_day() WHERE 절
-- 변경
WHERE BR.MaestroID = ? AND BR.AppID = ?
AND BR.RecordDateTime >= CURDATE()
AND BR.RecordDateTime < CURDATE() + INTERVAL 1 DAY
AND BR.PlayerID = P.PlayerID
-- get_ranking_month() WHERE 절 ※ 연도 누락 버그 수정 포함
-- 현재
WHERE BR.PlayerID = P.PlayerID AND MONTH(BR.RecordDateTime) = MONTH(NOW()) AND BR.MaestroID = ? AND BR.AppID = ?
-- 변경
WHERE BR.MaestroID = ? AND BR.AppID = ?
AND BR.RecordDateTime >= CAST(DATE_FORMAT(CURDATE(), '%Y-%m-01') AS DATE)
AND BR.RecordDateTime < CAST(DATE_FORMAT(CURDATE(), '%Y-%m-01') AS DATE) + INTERVAL 1 MONTH
AND BR.PlayerID = P.PlayerID
```
세 함수 모두 `bind_param('ii', $maestroID, $appID)`는 변경 없음. `SELECT`, `GROUP BY`, `ORDER BY` 부분도 그대로 둡니다.
#### C. [ranking_record_hour.php](../../../src/web/server/record/ranking_record_hour.php) / [ranking_record_day.php](../../../src/web/server/record/ranking_record_day.php) / [ranking_record_month.php](../../../src/web/server/record/ranking_record_month.php)
```sql
-- ranking_record_day.php
-- 현재
WHERE BR.MaestroID = ? AND BR.playerID = U.playerID AND YEAR(BR.RecordDateTime) = YEAR(?) AND MONTH(BR.RecordDateTime) = MONTH(?) AND DAYOFMONTH(BR.RecordDateTime) = DAYOFMONTH(?) AND AppID = ?
-- bind_param('isssi', $maestro_id, $date, $date, $date, $app_id)
-- 변경
WHERE BR.MaestroID = ? AND BR.AppID = ?
AND BR.RecordDateTime >= DATE(?)
AND BR.RecordDateTime < DATE(?) + INTERVAL 1 DAY
AND BR.PlayerID = U.PlayerID
-- bind_param('iiss', $maestro_id, $app_id, $date, $date)
-- ranking_record_hour.php
-- 변경
WHERE BR.MaestroID = ? AND BR.AppID = ?
AND BR.RecordDateTime >= DATE(?) + INTERVAL HOUR(?) HOUR
AND BR.RecordDateTime < DATE(?) + INTERVAL (HOUR(?) + 1) HOUR
AND BR.PlayerID = U.PlayerID
-- bind_param('iissss', $maestro_id, $app_id, $date, $time, $date, $time)
-- ranking_record_month.php
-- 변경
WHERE BR.MaestroID = ? AND BR.AppID = ?
AND BR.RecordDateTime >= CAST(DATE_FORMAT(?, '%Y-%m-01') AS DATE)
AND BR.RecordDateTime < CAST(DATE_FORMAT(?, '%Y-%m-01') AS DATE) + INTERVAL 1 MONTH
AND BR.PlayerID = U.PlayerID
-- bind_param('iiss', $maestro_id, $app_id, $date, $date)
```
#### D. [history_record.php](../../../src/web/server/record/history_record.php) — 최근 7일 히스토리 (+ AppID 바인딩으로 보안 수정)
```sql
-- 변경 (쿼리 전체)
SELECT DATE(BR.RecordDateTime) AS Date, MAX(BR.BestRecord) AS HighScore, AA.AppName AS AppName -- 앱 105는 MIN
FROM best_record BR
INNER JOIN app AS AA ON BR.AppID = AA.AppID
WHERE BR.PlayerID = ? AND BR.AppID = ? AND BR.MaestroID = ?
AND BR.RecordDateTime < DATE(?) + INTERVAL 1 DAY
GROUP BY DATE(BR.RecordDateTime)
ORDER BY DATE(BR.RecordDateTime) DESC
LIMIT 7
-- bind_param('iiis', $player_id, $app_id, $maestro_id, $date)
```
`SELECT``GROUP BY``DATE()`는 조건(WHERE)이 아니므로 인덱스 사용과 무관합니다. 다만 이 쿼리는 여전히 **해당 플레이어·앱의 전체 기간 기록**을 날짜별로 묶어야 하므로, 한 플레이어의 기록이 수천 행을 넘으면 점점 느려집니다 → [안2](03-option2-daily-summary-tables.md)에서 근본 해결.
#### E. [typing_exam_collection.php](../../../src/web/php/db/typing_exam_collection.php) — 긴글 시험
```sql
-- getThisHourRecord()
-- 변경
SELECT TypingExamRecordID, Record
FROM typing_exam_record
WHERE PlayerID = ? AND WritingID = ? AND MaestroID = ?
AND RecordDateTime >= CAST(DATE_FORMAT(NOW(), '%Y-%m-%d %H:00:00') AS DATETIME)
AND RecordDateTime < CAST(DATE_FORMAT(NOW(), '%Y-%m-%d %H:00:00') AS DATETIME) + INTERVAL 1 HOUR
-- bind_param("iii", $playerID, $writingID, $maestroID)
-- getHistoryRecord()
-- 변경
SELECT DATE(RecordDateTime), MAX(Record)
FROM typing_exam_record
WHERE PlayerID = ? AND WritingID = ? AND MaestroID = ?
AND RecordDateTime < DATE(?) + INTERVAL 1 DAY
GROUP BY DATE(RecordDateTime)
ORDER BY DATE(RecordDateTime) DESC
LIMIT 7
-- bind_param('iiis', $playerID, $writingID, $maestroID, $date)
```
랭킹 6개 함수(`getRankingRecordHour/Day/Month`, `getRankingMinusRecordHour/Day/Month`)는 C와 같은 규칙으로 바꿉니다. 예시(`getRankingRecordDay`):
```sql
-- 변경
SELECT TER.PlayerID AS PlayerID, U.Name AS Name, MAX(TER.Record) AS HighScore
FROM typing_exam_record TER, player U
WHERE TER.MaestroID = ? AND TER.WritingID = ?
AND TER.RecordDateTime >= DATE(?)
AND TER.RecordDateTime < DATE(?) + INTERVAL 1 DAY
AND TER.Record >= 0 -- Minus 버전은 TER.Record < 0
AND TER.PlayerID = U.PlayerID
GROUP BY TER.PlayerID
ORDER BY MAX(TER.Record) DESC; -- Minus 버전은 ASC
-- bind_param('iiss', $maestroID, $writingID, $date, $date)
```
#### F. 수정은 필요 없지만 인덱스 효과를 받는 쿼리
| 파일 | 쿼리 | 사용 인덱스 |
|---|---|---|
| [delete_record.php](../../../src/web/server/record/delete_record.php) `get_best_record_record_info()` | `WHERE MaestroID=? AND PlayerID=? AND AppID=? ORDER BY BestRecord LIMIT 1` | `idx_br_player_app_time` |
| [delete_player.php](../../../src/web/server/player/delete_player.php) | `DELETE ... WHERE MaestroID=? AND PlayerID=?` (6개 테이블) | `idx_br_player_app_time` 등 |
| [delete_test_player_record.php](../../../src/web/server/maestro/delete_test_player_record.php) | `DELETE ... WHERE PlayerID=?` | `idx_br_player_app_time`, `idx_ter_player_writing_time` |
| [request_*_player_record_list.php](../../../src/web/server/record/request_app_player_record_list.php) | `WHERE MaestroID=? AND 'start' <= RecordDateTime ... ORDER BY RecordDateTime DESC LIMIT 50` | `idx_*_maestro_time` (날짜 조건은 원래 범위 형태) |
| [app_highest_record.php](../../../src/web/server/lib/app_highest_record.php), [menu_collection.php](../../../src/web/php/db/menu_collection.php), [writing_collection.php](../../../src/web/php/db/writing_collection.php) | 최고기록 단건 조회 | `uk_ahr_*`, `uk_tehr_*` |
---
## 4. 수정 대상 파일 목록
| 파일 | 변경 내용 |
|---|---|
| `src/web/server/record/update_result_record.php` | `get_best_record()` 조건 |
| `src/web/server/record/app_ranking.php` | 시/일/월 랭킹 조건 (+월간 연도 버그) |
| `src/web/server/record/ranking_record_hour.php` | 조건, 바인딩 |
| `src/web/server/record/ranking_record_day.php` | 조건, 바인딩 |
| `src/web/server/record/ranking_record_month.php` | 조건, 바인딩 |
| `src/web/server/record/history_record.php` | 조건, AppID 바인딩 |
| `src/web/php/db/typing_exam_collection.php` | `getThisHourRecord`, `getHistoryRecord`, 랭킹 6종 |
| `src/web/sql/make_db.sql`, `make_db_license_timer.sql` | 신규 설치용 스키마에 인덱스·UNIQUE 반영 |
| `src/web/sql/migration/YYMMDD_add_record_indexes.sql` (신규) | 운영에 적용한 SQL 기록 |
---
## 5. 적용 절차
| 순서 | 어디서 | 작업 | 확인 |
|---|---|---|---|
| 0 | 운영 | [backup-db.sh](../../db/260907-daily-db-backup/backup-db.sh) 수동 실행 | 백업 파일 생성·크기 |
| 1 | 스테이징 | 최신 백업 복원 | row 수가 운영과 비슷한지 |
| 2 | 스테이징 | [개요 7-3](01-improvement-overview.md#7-3-대표-쿼리-explain-기준값) 방식으로 대표 쿼리 **개선 전** EXPLAIN/ANALYZE 기록 | `type=ALL`, `rows≈120만` 예상 |
| 3 | 스테이징 | 3-1 인덱스 추가, 3-2 중복 정리 + UNIQUE 키 | 소요 시간 기록, `SHOW INDEX` |
| 4 | 스테이징 | 기존 쿼리 그대로 EXPLAIN (인덱스만으로 좋아지는지) | 저장/삭제 쿼리는 `ref`로 개선 |
| 5 | 스테이징 | 3-3 쿼리 수정 코드 배포, 변경 쿼리 EXPLAIN + **결과 동일성 비교**(아래) | 랭킹 쿼리 `type=range`, `key=idx_br_maestro_app_time` |
| 6 | 스테이징 | 화면 테스트: 게임 종료 후 기록 저장, 교실 랭킹(시/일/월), 히스토리, 긴글 시험 저장·랭킹, 기록 삭제, 학생 삭제 | 기존과 같은 결과 (월간은 버그 수정분 차이만) |
| 7 | 운영 (새벽) | 3-1, 3-2 SQL 적용 | 오류 없음, 서비스 정상 |
| 8 | 운영 | 코드 배포 (`release` 브랜치) | 화면 테스트 6 반복 |
| 9 | 운영 | 1~2일 슬로우 쿼리 로그 확인, [측정 양식](01-improvement-overview.md#7-6-측정-결과-기록-양식) 채우기 | 1초 이상 기록 쿼리 감소 |
**순서가 중요한 이유**: 인덱스는 기존 코드에 영향이 없으므로 먼저 추가합니다. 코드를 먼저 배포하면 인덱스가 없는 동안 새 쿼리도 여전히 느립니다(결과는 같음).
### 결과 동일성 비교 방법
기존 쿼리와 변경 쿼리의 결과가 같은지 `EXCEPT`(한쪽에만 있는 행 찾기)로 확인합니다. **양방향 모두 0건**이면 같습니다.
```sql
SET @m = 123, @a = 5, @d = '2026-09-10'; -- 7-3에서 찾은 값
SELECT COUNT(*) AS only_in_old FROM (
SELECT BR.PlayerID, MAX(BR.BestRecord) AS rec
FROM best_record BR
WHERE BR.MaestroID = @m AND BR.AppID = @a
AND YEAR(BR.RecordDateTime) = YEAR(@d) AND MONTH(BR.RecordDateTime) = MONTH(@d)
AND DAYOFMONTH(BR.RecordDateTime) = DAYOFMONTH(@d)
GROUP BY BR.PlayerID
EXCEPT
SELECT BR.PlayerID, MAX(BR.BestRecord) AS rec
FROM best_record BR
WHERE BR.MaestroID = @m AND BR.AppID = @a
AND BR.RecordDateTime >= DATE(@d) AND BR.RecordDateTime < DATE(@d) + INTERVAL 1 DAY
GROUP BY BR.PlayerID
) diff;
-- 두 SELECT의 순서를 바꿔 only_in_new도 확인
```
---
## 6. 롤백
```sql
-- 인덱스 제거 (코드를 먼저 되돌린 뒤 실행할 필요는 없음. 새 쿼리도 인덱스 없이 동작함)
ALTER TABLE best_record
DROP INDEX idx_br_maestro_app_time,
DROP INDEX idx_br_player_app_time,
DROP INDEX idx_br_maestro_time;
ALTER TABLE typing_exam_record
DROP INDEX idx_ter_maestro_writing_time,
DROP INDEX idx_ter_player_writing_time,
DROP INDEX idx_ter_maestro_time;
ALTER TABLE license_score DROP INDEX idx_ls_maestro_player_time;
ALTER TABLE app_highest_record DROP INDEX uk_ahr_maestro_player_app;
ALTER TABLE typing_exam_highest_record DROP INDEX uk_tehr_maestro_player_writing;
-- 중복 정리를 되돌려야 하는 경우 (정리 이후 새로 저장된 최고기록은 사라지므로 신중히)
-- RENAME TABLE app_highest_record TO app_highest_record_broken,
-- app_highest_record_bak_260914 TO app_highest_record;
```
코드는 `git revert``release` 브랜치에 다시 배포합니다.
---
## 7. 장단점과 위험
| 장점 | 단점 / 위험 | 대응 |
|---|---|---|
| 데이터를 거의 건드리지 않음 | 인덱스만큼 디스크 사용량 증가 | 적용 전 여유 공간 확인 |
| 인덱스 추가만으로도 저장·삭제 쿼리 즉시 개선 | 기록 INSERT/UPDATE 시 인덱스 갱신 비용 소폭 증가 | 기록 저장 빈도가 낮아 체감 어려움(예상), 슬로우 로그로 확인 |
| 롤백이 간단 (`DROP INDEX`) | 바인딩 순서를 잘못 바꾸면 **오류 없이 엉뚱한 결과**가 나옴 | 결과 동일성 비교 쿼리, 화면 테스트 |
| 월간 랭킹 버그, 보안 이슈(history) 함께 해결 | 옵티마이저가 기대와 다른 인덱스를 고를 수 있음 | `ANALYZE TABLE` 후 EXPLAIN 확인, 필요 시 `FORCE INDEX` |
| | 히스토리·월간 랭킹은 여전히 넓은 범위를 GROUP BY | 안2 |
---
## 8. 예상 효과 (측정으로 확인 필요)
| 쿼리 | 현재 읽는 행 (예상) | 변경 후 읽는 행 (예상) |
|---|---|---|
| 기록 저장 시 이번 시간 기록 확인 | 해당 선생님 또는 플레이어의 모든 기록 ~ 전체 | 0~1행 |
| 시간 랭킹 | 해당 선생님·앱의 전체 기간 기록 | 그 1시간의 기록 (수~수십 행) |
| 일간 랭킹 | 〃 | 그날 기록 (수십~수백 행) |
| 월간 랭킹 | 〃 (+ 다른 해 같은 달) | 그달 기록 (수백~수천 행) |
| 관리자 기록 목록 최신 50건 | 선생님 기록 전체 후 정렬 | 최근 구간부터 50건 (필터 조건에 따라 달라짐) |
---
## 9. 한계 → 다음 단계
- 히스토리(최근 7일)와 월간 랭킹은 원본 행을 **매번** 묶어서 계산하므로, 데이터가 계속 쌓이면 점점 느려집니다.
- 원본 테이블 크기 자체는 줄지 않습니다.
- → [안2. 일별 집계 테이블](03-option2-daily-summary-tables.md), [안3. 아카이빙](04-option3-archiving.md)으로 이어집니다.
---
## 10. 체크리스트
- [ ] 운영 백업 완료, 디스크 여유 공간 확인
- [ ] 스테이징에 최신 백업 복원
- [ ] 개선 전 EXPLAIN/ANALYZE 수치 기록
- [ ] 최고기록 테이블 중복 점검 → 사본 생성 → 정리 → UNIQUE 키 추가
- [ ] 기록 테이블 인덱스 추가, `ANALYZE TABLE`
- [ ] 쿼리 수정 (7개 파일), 바인딩 순서 재확인
- [ ] 결과 동일성 비교 (시/일/월 랭킹, 히스토리)
- [ ] 화면 테스트 (저장, 랭킹, 히스토리, 긴글 시험, 기록 삭제, 학생 삭제)
- [ ] 운영 SQL 적용 (새벽) → 코드 배포
- [ ] 슬로우 쿼리 로그로 1~2일 모니터링, 측정 양식 기록
- [ ] `make_db.sql` 및 migration 파일 반영
@@ -0,0 +1,493 @@
# 안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 파일 반영
+385
View File
@@ -0,0 +1,385 @@
# 안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 파일 반영
+202
View File
@@ -0,0 +1,202 @@
# 안4. 테이블 파티셔닝 (현재는 보류 권장)
> [개요 문서](01-improvement-overview.md) | 추천 단계: **보류** (재검토 기준 충족 시 검토) | 난이도: 상 | 위험도: 높음 | 예상 작업량: 3~5일 + 서비스 점검 시간
## 한 줄 요약
`best_record`, `typing_exam_record``RecordDateTime` 기준 **연도(또는 월)별 파티션으로 나누어**, 날짜 범위 조회 시 해당 파티션만 읽고 오래된 파티션은 통째로 떼어낼 수 있게 한다. 효과는 크지만 **외래 키 제거·기본 키 변경·테이블 재구성**이 필요해 현재 규모와 운영 여건에서는 권장하지 않는다.
---
## 1. 파티셔닝이란 — 쉬운 설명
- 지금 `best_record`는 **서랍 하나**에 몇 년 치 기록이 모두 들어 있습니다.
- 파티셔닝은 겉보기엔 테이블 하나지만 내부적으로 **"2019년 서랍, 2020년 서랍, …, 2026년 서랍"** 으로 나누어 저장합니다.
- "2026년 9월 기록"을 찾으면 DB가 **2026년 서랍만** 엽니다(= **파티션 프루닝, partition pruning**).
- 2019년 기록을 정리할 때는 행을 하나씩 지우지 않고 **2019년 서랍을 통째로 빼냅니다**. 수백만 행이라도 거의 즉시 끝납니다.
---
## 2. 해결하는 문제
| 문제 (선행 문서 번호) | 해결 정도 | 설명 |
|---|---|---|
| 3. 이력 테이블 무한 증가 | ◎ | 오래된 파티션을 즉시 분리·삭제 |
| 1. 함수로 감싼 날짜 조건 | △ | **안1의 범위 조건이 먼저 적용되어야** 프루닝이 동작. 파티셔닝만으로는 해결되지 않음 |
| 2. 복합 인덱스 부재 | △ | 파티션마다 인덱스가 작아짐. 복합 인덱스 자체는 여전히 필요 |
| 6. 관리자 기록 목록 | △ | 날짜 범위가 좁으면 일부 파티션만 읽음 |
---
## 3. 적용 모습 (예시)
> 아래 SQL은 **이해를 돕기 위한 예시**입니다. 실제 적용 시에는 스테이징에서 충분히 검증해야 합니다.
### 3-1. 사전 확인: 외래 키 이름
```sql
SELECT CONSTRAINT_NAME, TABLE_NAME, REFERENCED_TABLE_NAME
FROM information_schema.REFERENTIAL_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA = 'chocomae'
AND TABLE_NAME IN ('best_record', 'typing_exam_record');
-- 반대로, 이 두 테이블을 참조하는 외래 키가 없는지도 확인 (현재 스키마 기준 없음)
SELECT CONSTRAINT_NAME, TABLE_NAME
FROM information_schema.REFERENTIAL_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA = 'chocomae'
AND REFERENCED_TABLE_NAME IN ('best_record', 'typing_exam_record');
```
현재 [make_db.sql](../../../src/web/sql/make_db.sql) 기준 `best_record``maestro`, `app`, `player`를 참조하는 외래 키 3개, `typing_exam_record``maestro`, `player`, `writing`을 참조하는 외래 키 3개가 있습니다.
### 3-2. 방법 A — 기존 테이블을 직접 변경 (간단하지만 쓰기 잠금 발생)
```sql
-- 1) 외래 키 제거 (이름은 3-1 결과로 교체)
ALTER TABLE best_record
DROP FOREIGN KEY best_record_ibfk_1,
DROP FOREIGN KEY best_record_ibfk_2,
DROP FOREIGN KEY best_record_ibfk_3;
-- 2) 기본 키에 파티션 기준 컬럼 포함
ALTER TABLE best_record
DROP PRIMARY KEY,
ADD PRIMARY KEY (BestRecordID, RecordDateTime);
-- 3) 연도별 파티션 생성 (테이블 전체 재작성)
ALTER TABLE best_record
PARTITION BY RANGE COLUMNS (RecordDateTime) (
PARTITION p2019 VALUES LESS THAN ('2020-01-01'),
PARTITION p2020 VALUES LESS THAN ('2021-01-01'),
PARTITION p2021 VALUES LESS THAN ('2022-01-01'),
PARTITION p2022 VALUES LESS THAN ('2023-01-01'),
PARTITION p2023 VALUES LESS THAN ('2024-01-01'),
PARTITION p2024 VALUES LESS THAN ('2025-01-01'),
PARTITION p2025 VALUES LESS THAN ('2026-01-01'),
PARTITION p2026 VALUES LESS THAN ('2027-01-01'),
PARTITION pmax VALUES LESS THAN (MAXVALUE)
);
```
- 2), 3)은 **테이블 전체를 다시 쓰는 작업**이라 진행 중 기록 저장이 막힙니다. 120만 행 기준 **수 분(예상)** 의 서비스 점검 시간이 필요합니다.
- 첫 파티션 연도는 [개요 7-2](01-improvement-overview.md#7-2-월별-적재량-증가-속도-파악) 결과의 가장 오래된 연도로 맞춥니다.
### 3-3. 방법 B — 새 테이블로 복사 후 교체 (점검 시간 최소화, 작업은 복잡)
1. 파티션이 적용된 `best_record_new`를 생성 (외래 키 없음, 기본 키 `(BestRecordID, RecordDateTime)`, 안1의 인덱스 포함)
2. 오래된 데이터부터 월 단위로 `INSERT INTO best_record_new SELECT * FROM best_record WHERE RecordDateTime >= ? AND RecordDateTime < ?` 반복 복사
3. 짧은 점검 시간에 마지막 구간 복사 후 `RENAME TABLE best_record TO best_record_old, best_record_new TO best_record;`
4. 복사 도중 발생한 **기록 수정·삭제**(같은 시간대 UPDATE, 관리자 삭제)를 놓치지 않도록 마지막 수 시간 구간은 반드시 점검 시간에 다시 복사
5. 문제 없으면 `best_record_old` 삭제
### 3-4. 운영 중 해야 하는 일
```sql
-- 매년 말: 새 연도 파티션 추가 (pmax를 쪼갬)
ALTER TABLE best_record REORGANIZE PARTITION pmax INTO (
PARTITION p2027 VALUES LESS THAN ('2028-01-01'),
PARTITION pmax VALUES LESS THAN (MAXVALUE)
);
-- 오래된 파티션을 별도 테이블로 분리 (MariaDB 10.7 이상 지원)
ALTER TABLE best_record CONVERT PARTITION p2019 TO TABLE best_record_2019;
-- 또는 영구 삭제
ALTER TABLE best_record DROP PARTITION p2019;
-- 쿼리가 필요한 파티션만 읽는지 확인 (partitions 컬럼)
EXPLAIN PARTITIONS
SELECT PlayerID, MAX(BestRecord) FROM best_record
WHERE MaestroID = 123 AND AppID = 5
AND RecordDateTime >= '2026-09-01' AND RecordDateTime < '2026-10-01'
GROUP BY PlayerID;
```
`pmax`에 데이터가 쌓인 뒤 `REORGANIZE`하면 그만큼 재작성 비용이 커지므로, **연말 전에 자동으로 다음 해 파티션을 만드는 배치**가 필요합니다.
---
## 4. MariaDB 파티셔닝 제약 (중요)
| 제약 | 이 프로젝트에 미치는 영향 |
|---|---|
| **파티션된 InnoDB 테이블은 외래 키(FOREIGN KEY)를 가질 수 없음** | 기존 외래 키 6개 제거 필요. 이후 "없는 플레이어의 기록"이 생겨도 DB가 막아주지 않음 |
| 모든 PRIMARY KEY / UNIQUE 키에 파티션 기준 컬럼이 포함되어야 함 | 기본 키를 `(BestRecordID, RecordDateTime)`으로 변경. 향후 안2처럼 원본에 UNIQUE 키를 만들 때도 `RecordDateTime` 포함 필요 |
| 프루닝은 **파티션 기준 컬럼에 대한 범위/등호 조건**이 있을 때만 동작 | 안1의 범위 조건 변환이 선행되어야 함. `DATE(RecordDateTime)=...` 형태는 모든 파티션을 읽음 |
| 날짜 조건이 없는 쿼리는 **모든 파티션을 각각 탐색** | 기록 저장 후 최고기록 재계산(`WHERE MaestroID=? AND PlayerID=? AND AppID=? ORDER BY BestRecord`), 학생 삭제(`WHERE MaestroID=? AND PlayerID=?`)는 파티션 수만큼 인덱스를 탐색 → **파티션이 많으면 오히려 느려질 수 있음** |
| 파티션 구조 변경(`ALTER ... PARTITION BY`, PK 변경)은 테이블 재작성 | 서비스 점검 시간 필요 |
| 파티션 수가 많으면 열린 파일 수·메모리 사용 증가 | 월 단위보다 **연 단위**가 이 프로젝트 규모에 적합 |
---
## 5. 장단점
| 장점 | 단점 |
|---|---|
| 오래된 데이터 분리·삭제가 즉시 끝남 (행 단위 DELETE 불필요) | 외래 키 제거로 데이터 무결성 보호 약화 |
| 날짜 범위 조회 시 해당 파티션만 읽음 | 기본 키 변경, 테이블 재작성, 서비스 점검 시간 필요 |
| 파티션별 인덱스가 작아 캐시 효율이 좋아짐 | 날짜 조건 없는 쿼리는 오히려 느려질 수 있음 |
| 코드 수정은 적음 (안1 적용 전제) | 매년 파티션 추가 배치 등 운영 작업이 늘어남 |
| | 문제가 생겼을 때 원인 파악과 복구가 어려움 (DB 경험이 필요) |
---
## 6. 현재 보류를 권장하는 이유
1. **규모가 아직 크지 않음**: 120만 행은 안1의 복합 인덱스로 "필요한 구간만 읽는" 상태가 되면 파티셔닝의 추가 이득이 작습니다.
2. **안3과 목적이 겹침**: 오래된 데이터를 운영 테이블에서 빼는 목적은 안3(아카이빙)으로도 달성할 수 있고, 안3은 외래 키·기본 키를 건드리지 않습니다.
3. **외래 키 제거의 부작용**: 학생 삭제 순서가 어긋나거나 코드 버그가 생기면 고아 기록이 쌓여도 DB가 막아주지 않습니다.
4. **저장·삭제 경로가 날짜 조건 없이 동작**: 이 서비스의 가장 잦은 쓰기 흐름(기록 저장 → 최고기록 재계산, 학생 삭제)은 `PlayerID` 기준이라 프루닝 혜택을 받지 못합니다.
5. **운영 부담 대비 경험 수준**: 파티션 추가 자동화, 재작성 작업, 장애 시 복구는 DB 운영 경험이 필요한 영역입니다.
---
## 7. 재검토 기준
아래 중 하나라도 해당하면 파티셔닝을 다시 검토합니다.
| 기준 | 확인 방법 |
|---|---|
| 안3 적용 후에도 운영 `best_record`**1,000만 행** 초과 (또는 곧 초과 예상) | [개요 7-2](01-improvement-overview.md#7-2-월별-적재량-증가-속도-파악)의 월별 적재량 × 보관 개월 수 |
| 안3 아카이빙 배치의 삭제 작업이 **수십 분 이상** 걸리거나 서비스 지연을 일으킴 | 배치 로그, 슬로우 쿼리 로그 |
| 기록 테이블 데이터+인덱스 크기가 `innodb_buffer_pool_size`를 크게 초과해 디스크 읽기가 잦음 | [개요 7-1](01-improvement-overview.md#7-1-테이블인덱스-크기) |
| 서버 이전·DB 재구성 등으로 **어차피 테이블을 재작성**해야 하는 시점이 옴 | 인프라 계획 |
```sql
-- 최근 12개월 월별 증가량으로 1,000만 행 도달 시점 추정
SELECT DATE_FORMAT(RecordDateTime, '%Y-%m') AS ym, COUNT(*) AS cnt
FROM best_record
WHERE RecordDateTime >= CURDATE() - INTERVAL 12 MONTH
GROUP BY ym
ORDER BY ym;
```
---
## 8. 만약 적용한다면 순서 (요약)
1. 안1(범위 조건), 안2(집계 테이블) 선행 완료
2. 날짜 조건 없는 쿼리 목록 재점검 → 필요 시 날짜 조건 추가 또는 집계 테이블로 전환
3. 스테이징에서 방법 B로 리허설, 점검 시간 측정
4. 파티션 자동 추가 배치 작성 및 테스트
5. 운영 점검 공지 → 적용 → EXPLAIN PARTITIONS로 프루닝 확인
6. 외래 키 대신 정합성 점검 쿼리를 정기 실행 (예: `player`에 없는 `PlayerID`를 가진 기록 수)
```sql
-- 고아 기록 점검 (외래 키 제거 후 정기 실행)
SELECT COUNT(*) FROM best_record BR
LEFT JOIN player P ON BR.PlayerID = P.PlayerID
WHERE P.PlayerID IS NULL;
```
---
## 9. 체크리스트 (재검토 시)
- [ ] 재검토 기준 중 무엇에 해당하는지 수치로 기록
- [ ] 안1·안2·안3 적용 상태 확인
- [ ] 외래 키 제거에 대한 대체 점검 방안 합의
- [ ] 날짜 조건 없는 쿼리 목록과 성능 영향 측정
- [ ] 스테이징 리허설 (방법 B), 점검 시간 산정
- [ ] 파티션 자동 추가 배치
- [ ] 롤백 계획 (`best_record_old` 보존 기간)
+352
View File
@@ -0,0 +1,352 @@
# 안5. 애플리케이션(PHP/화면) 개선
> [개요 문서](01-improvement-overview.md) | 추천 단계: **병행** (보안 항목은 1단계와 함께 우선) | 난이도: 하~중 | 위험도: 낮음 | 예상 작업량: 2~4일
## 한 줄 요약
DB 구조는 그대로 두고 PHP 코드의 비효율과 위험을 고친다: **기록 목록 API의 SQL Injection 제거와 불필요한 COUNT 제거**, **메뉴 화면 N+1 쿼리를 1회 조회로 통합**, 필요 시 **랭킹 결과 짧은 캐시**, **중복된 랭킹 코드 정리**.
---
## 1. 해결하는 문제
| 문제 (선행 문서 번호) | 해결 정도 | 항목 |
|---|---|---|
| 5. 메뉴 화면 N+1 쿼리 | ◎ | 3-2 |
| 6. 관리자 기록 목록: COUNT+목록 이중 조회, `LIKE`, 문자열 SQL | ◎ | 3-1 |
| 7. 미사용 `ranking` 테이블 | ○ | 3-3 (캐시 테이블로 대체 또는 삭제) |
| (보안) 기록 목록 API SQL Injection | ◎ | 3-1 |
| 같은 교실 학생들이 동시에 랭킹을 열 때 반복 조회 | ○ | 3-3 |
| 랭킹 쿼리 코드 중복 (안1·안2 수정 시 여러 파일을 똑같이 고쳐야 함) | ○ | 3-4 |
---
## 2. 쉬운 설명
- **SQL Injection**: 검색창에 이름 대신 SQL 조각을 넣으면, 지금 코드는 그 조각을 쿼리에 그대로 붙여 실행합니다. `?` 자리표시자(바인딩)를 쓰면 DB가 입력값을 "값"으로만 취급해 안전합니다.
- **N+1**: 앱이 16개면 "앱 목록 1번 + 앱마다 최고기록 1번 = 17번" DB에 왕복합니다. 한 번에 "이 앱들 최고기록 전부"를 요청하면 1번이면 됩니다.
- **불필요한 COUNT**: 기록 목록 API는 "전체 건수(COUNT)"와 "목록"을 따로 조회하는데, 조사 결과 **화면은 전체 건수를 사용하지 않습니다**(`RecordTotalCount`를 참조하는 JS/HTML 없음, 화면의 "총 n개 검색"은 받은 목록 길이). 같은 조건으로 테이블을 두 번 읽고 있는 셈입니다.
---
## 3. 변경 내용
### 3-1. 기록 목록 API — 보안 + 성능 (우선순위 높음)
대상: [request_app_player_record_list.php](../../../src/web/server/record/request_app_player_record_list.php), [request_writing_player_record_list.php](../../../src/web/server/record/request_writing_player_record_list.php), [request_license_timer_player_record_list.php](../../../src/web/server/record/request_license_timer_player_record_list.php)
호출 화면: [maestro_section_record.html](../../../src/web/module/maestro_section_record.html) `getPlayerRecordList(limitCount)` (기본 50건, "전체 보기"는 `LimitCount=0`)
#### 현재 문제
| 문제 | 코드 위치 | 영향 |
|---|---|---|
| 시작일·종료일·이름·AppID를 SQL 문자열에 직접 연결 | `makeDateStatement()`, `makePlayerNameStatement()`, `makeSubjectSentence()`, `LIMIT ".$limit_count` | SQL Injection (다른 선생님 기록 조회·삭제 가능성) |
| `get_player_record_total_count()` + `get_player_record_list()` 이중 조회 | 각 파일 31~32행 | 같은 조건으로 두 번 읽음. 결과 `RecordTotalCount`는 화면에서 미사용 |
| "전체 보기"(`LimitCount=0`)에 상한 없음 | `if($limit_count > 0)` | 검색 기간이 길면 수만 행을 한 번에 응답 |
| 종료일 처리 불일치 | 일반 앱/긴글: `<= DATE_ADD(end, INTERVAL 1 DAY)` (다음날 00:00:00 포함) / 자격증: `< 'end'` (**종료일 당일 기록 누락**) | 화면마다 검색 결과 범위가 다름 |
| 자격증 목록의 과목 조건이 `license_score`에 없는 `AppID`를 참조 | `makeSubjectSentence()` | 현재 화면은 3000만 보내서 문제가 드러나지 않음. 다른 값이 오면 쿼리 오류 |
#### 변경 패턴 (일반 앱 예시)
```php
$maestro_id = (int)$_POST["MaestroID"];
$start_date = $_POST["StartDate"];
$end_date = $_POST["EndDate"];
$player_name = trim($_POST["PlayerNameList"]);
$app_id = (int)$_POST["AppID"];
$limit_count = (int)$_POST["LimitCount"];
$MAX_LIMIT = 1000;
if ($limit_count <= 0 || $limit_count > $MAX_LIMIT)
$limit_count = $MAX_LIMIT;
// WHERE 조각은 코드에 고정된 문자열만 사용하고, 값은 전부 ? 로 바인딩한다.
$where = array(
"BR.MaestroID = ?",
"BR.RecordDateTime >= DATE(?)",
"BR.RecordDateTime < DATE(?) + INTERVAL 1 DAY",
"BR.PlayerID = P.PlayerID",
"BR.AppID = A.AppID",
);
$types = "iss";
$params = array($maestro_id, $start_date, $end_date);
if (strlen($player_name) > 0) {
$where[] = "P.Name LIKE ?";
$types .= "s";
$params[] = "%" . $player_name . "%";
}
$range = get_subject_app_range($app_id);
if ($range !== null) {
$where[] = "BR.AppID BETWEEN ? AND ?";
$types .= "ii";
$params[] = $range[0];
$params[] = $range[1];
}
$query = "
SELECT BR.BestRecordID, DATE(BR.RecordDateTime), TIME(BR.RecordDateTime),
P.Name, A.KoreanName, BR.BestRecord
FROM best_record BR, player P, app A
WHERE " . implode(" AND ", $where) . "
ORDER BY BR.RecordDateTime DESC
LIMIT ?";
$types .= "i";
$params[] = $limit_count;
$stmt = $db_conn->prepare($query);
$stmt->bind_param($types, ...$params);
$stmt->execute();
```
```php
// 과목 묶음 → AppID 범위 (기존 makeSubjectSentence()의 조건을 그대로 옮김)
function get_subject_app_range($app_id) {
switch ($app_id) {
case 0: return null; // 전체
case 1000: return array(1, 8); // 한글 연습 (기존: A.AppID < 9)
case 1001: return array(11, 18); // 영어 연습 (기존: 10 < A.AppID < 19)
case 1002: return array(21, 30); // 한글 테스트 (기존: 20 < A.AppID < 31)
case 1003: return array(31, 40); // 영어 테스트 (기존: 30 < A.AppID < 41)
default: return array($app_id, $app_id);
}
}
```
- `get_player_record_total_count()` 호출과 `set_data("RecordTotalCount", …)`를 **삭제**합니다. 나중에 "더 있음" 표시가 필요하면 `LIMIT 51`로 조회해 51번째 행 존재 여부로 판단합니다(COUNT 불필요).
- 종료일은 세 API 모두 `< DATE(?) + INTERVAL 1 DAY`(종료일 하루 전체 포함, 다음날 0시 제외)로 통일합니다.
- 긴글 시험 API는 `WR.Language = ?`('korean'/'english') 또는 `WR.WritingID = ?`를, 자격증 API는 과목 조건 없이(3000) 같은 패턴으로 바꿉니다.
- 안1의 `idx_br_maestro_time (MaestroID, RecordDateTime)` 인덱스가 있으면 최근 기록부터 읽다가 `LIMIT`만큼 모이면 멈춥니다.
#### 이름 검색(`LIKE '%이름%'`)은 괜찮은가?
- 앞에 `%`가 있는 `LIKE`는 인덱스를 쓸 수 없지만, 이 조건은 **`player` 테이블(한 선생님의 학생 수십~수백 명)** 에만 적용됩니다.
- 옵티마이저가 기록 테이블부터 읽는 비효율 계획을 고르면, 학생 ID를 먼저 조회한 뒤 `BR.PlayerID IN (?, ?, …)`로 넘기는 2단계 방식으로 바꿉니다. 먼저 EXPLAIN으로 확인하고 필요할 때만 적용하세요.
```sql
SELECT PlayerID FROM player WHERE MaestroID = ? AND Name LIKE ?;
```
#### 화면 변경 ([maestro_section_record.html](../../../src/web/module/maestro_section_record.html))
- "전체 보기" 버튼은 서버 상한(예: 1,000건)을 넘으면 "최근 1,000건만 표시됩니다. 기간을 좁혀 검색하세요." 안내를 표시합니다.
- 더 많은 조회가 필요하면 "다음 50건" 페이지 방식으로 바꿉니다. 관리자 화면 규모에서는 `LIMIT ? OFFSET ?` 방식으로 충분합니다.
- 검색 기간 최대치(예: 1년)를 화면과 서버 양쪽에서 제한하는 것도 검토합니다. 안3 아카이빙 기준일과 맞추면 자연스럽습니다.
### 3-2. 메뉴 화면 N+1 쿼리 제거
대상: [menu_active_typing_practice_app_list.php](../../../src/web/server/app/menu_active_typing_practice_app_list.php), [menu_active_typing_test_app_list.php](../../../src/web/server/app/menu_active_typing_test_app_list.php) `get_high_score_list()`, [menu_collection.php](../../../src/web/php/db/menu_collection.php) `getTypingHighestRecordList()`
#### 현재
```php
// 한글 앱 목록, 영어 앱 목록 각각에 대해
for ($i = 0; $i < $count; $i++) {
// 앱마다 한 번씩
SELECT MAX(AHR.HighestRecord)
FROM app AS A INNER JOIN app_highest_record AS AHR
ON A.AppID = ? AND A.AppID = AHR.AppID AND AHR.MaestroID = ? AND AHR.PlayerID = ?
}
```
메뉴를 열 때마다 `앱 목록 2회 + 활성 앱 2회 + (한글 앱 수 + 영어 앱 수)회` 쿼리가 실행됩니다.
#### 변경 — 앱 종류(한글/영어)당 1회
```sql
SELECT A.AppID, COALESCE(MAX(AHR.HighestRecord), 0) AS AppHighestRecord
FROM app A
LEFT JOIN app_highest_record AHR
ON AHR.AppID = A.AppID AND AHR.MaestroID = ? AND AHR.PlayerID = ?
WHERE A.AppType = ? AND A.Status = 1
GROUP BY A.AppID
ORDER BY A.AppID;
-- bind_param('iii', $maestro_id, $player_id, $appType)
```
- 기존 코드는 기록이 없는 앱도 `AppHighestRecord = 0`으로 응답하므로 `LEFT JOIN` + `COALESCE(…, 0)`으로 **응답 형식을 그대로** 유지합니다.
- `get_typing_practice_app_list()`와 조건(`AppType = ? AND Status = 1`)이 같으므로 두 조회를 합쳐 앱 이름까지 한 번에 가져올 수도 있습니다.
- 안1의 UNIQUE 키 `(MaestroID, PlayerID, AppID)`가 있으면 `LEFT JOIN` 한 건당 인덱스 1회 탐색입니다.
`menu_collection.php`처럼 앱 ID 배열을 받는 경우:
```php
$placeholders = implode(",", array_fill(0, count($app_ids), "?"));
$query = "
SELECT AppID, HighestRecord
FROM app_highest_record
WHERE MaestroID = ? AND PlayerID = ? AND AppID IN (" . $placeholders . ")";
$types = "ii" . str_repeat("i", count($app_ids));
$params = array_merge(array($maestroID, $playerID), $app_ids);
$stmt = $this->mysqli->prepare($query);
$stmt->bind_param($types, ...$params);
// 결과에 없는 AppID는 0으로 채워 기존 반환 형식 유지
```
`typing_exam_highest_record`를 글(writing)마다 조회하는 [writing_collection.php](../../../src/web/php/db/writing_collection.php) `getWritingHighestRecord()`의 호출부도 같은 방식으로 묶을 수 있습니다.
### 3-3. 랭킹 결과 캐시 (측정 후 필요할 때)
#### 배경
- 교실 수업에서는 **같은 선생님·같은 앱**의 학생 수십 명이 거의 동시에 게임을 끝내고, 결과 화면([ranking_board.js](../../../src/game/result/ranking_board.js))이 시/일/월 랭킹 3개를 한꺼번에 요청합니다.
- **랭킹 화면([ranking.js](../../../src/game/ranking/ranking.js))은 열려 있는 동안 5초마다 랭킹을 다시 요청합니다**(`Ranking.REFRESH_TIME_SEC = 5`, `game.time.events.loop`). 교실 앞 화면에 랭킹을 띄워 두거나 여러 학생이 열어 두면, 기록이 바뀌지 않아도 1분에 12회씩 같은 쿼리가 반복됩니다.
- 결과는 모두 같은데 쿼리는 "화면 수 × 요청 횟수"만큼 실행됩니다.
- 안1·안2 적용 후에도 수업 시간대에 랭킹 쿼리가 슬로우 로그에 남는다면 캐시를 도입합니다. **먼저 측정하고, 필요 없으면 하지 않습니다.**
#### 방법 비교
| 방법 | 내용 | 장점 | 단점 |
|---|---|---|---|
| **A. DB 캐시 테이블 (권장)** | 랭킹 결과 JSON을 `(MaestroID, AppID, RankingType)`별로 저장, N초 이내면 재사용 | 추가 설치 없음, 여러 서버에서도 동작 | 캐시 조회도 DB 왕복 1회 |
| B. PHP APCu | PHP 메모리 캐시 | 가장 빠름 | Docker 이미지에 확장 설치 필요, 컨테이너 재시작 시 초기화 |
| C. 파일 캐시 | `/tmp`에 JSON 파일 저장 | 설치 없음 | 동시 쓰기·권한·정리 관리 필요 |
#### 방법 A 예시
```sql
-- 미사용 ranking 테이블은 백업 후 삭제하고, 용도에 맞는 새 테이블 생성
CREATE TABLE ranking_cache (
MaestroID INT UNSIGNED NOT NULL,
AppID INT UNSIGNED NOT NULL,
RankingType CHAR(10) NOT NULL, -- 'hour' / 'day' / 'month'
CachedDateTime DATETIME NOT NULL,
Payload MEDIUMTEXT NOT NULL, -- 랭킹 배열 JSON
PRIMARY KEY (MaestroID, AppID, RankingType)
);
-- 조회: 30초 이내 캐시가 있으면 사용
SELECT Payload FROM ranking_cache
WHERE MaestroID = ? AND AppID = ? AND RankingType = ?
AND CachedDateTime >= NOW() - INTERVAL 30 SECOND;
-- 없으면 랭킹 쿼리 실행 후 저장
INSERT INTO ranking_cache (MaestroID, AppID, RankingType, CachedDateTime, Payload)
VALUES (?, ?, ?, NOW(), ?)
ON DUPLICATE KEY UPDATE CachedDateTime = NOW(), Payload = VALUES(Payload);
```
- **사용자 경험 주의**: 방금 좋은 기록을 낸 학생이 랭킹에서 자기 기록을 바로 못 보면 혼란스럽습니다. 기록 저장 시([update_result_record.php](../../../src/web/server/record/update_result_record.php)) 해당 `(MaestroID, AppID)` 캐시 행을 `DELETE`하면, 기록이 바뀔 때만 새로 계산하고 조회만 반복될 때는 캐시를 씁니다.
- 시간 랭킹은 짧게(예: 30초), 월간 랭킹은 길게(예: 5분) TTL을 달리할 수 있습니다.
- 긴글 시험 랭킹([db_service.js](../../../src/game/lib/db_service.js) → `php/record/get_typing_exam_ranking_record_*.php`)도 같은 방식으로 적용할 수 있습니다.
#### `ranking` 테이블 처리
| 선택 | 방법 |
|---|---|
| 캐시 도입 | 위처럼 `ranking` 삭제 후 `ranking_cache` 생성 (기존 구조는 "순위별 PlayerID"라 JSON 캐시에 맞지 않음) |
| 캐시 미도입 | `ranking`이 비어 있는지 `SELECT COUNT(*) FROM ranking;` 확인 → 백업 후 `DROP TABLE ranking;`, `make_db.sql`에서도 제거 |
### 3-4. 중복 랭킹 코드 정리 (안1·안2와 함께 하면 효율적)
현재 같은 형태의 랭킹 쿼리가 여러 곳에 복사되어 있어, 안1·안2에서 조건을 바꿀 때 **모든 복사본을 똑같이 고쳐야** 합니다.
| 위치 | 복사본 수 |
|---|---|
| [app_ranking.php](../../../src/web/server/record/app_ranking.php) `get_ranking_hour/day/month()` | 3 |
| [ranking_record_hour.php](../../../src/web/server/record/ranking_record_hour.php), [ranking_record_day.php](../../../src/web/server/record/ranking_record_day.php), [ranking_record_month.php](../../../src/web/server/record/ranking_record_month.php) | 3 |
| [typing_exam_collection.php](../../../src/web/php/db/typing_exam_collection.php) `getRankingRecord*`, `getRankingMinusRecord*` | 6 |
정리 방향: 기간 조건만 만들어 주는 함수를 하나 두고, 랭킹 함수는 "기간 종류"와 "정렬 방향"을 인자로 받습니다.
```php
// $period: 'hour' | 'day' | 'month', 반환: array(SQL 조각, 바인딩 타입, 바인딩 값 배열)
function build_period_condition($column, $period, $date, $time) {
switch ($period) {
case 'hour':
return array("$column >= DATE(?) + INTERVAL HOUR(?) HOUR AND $column < DATE(?) + INTERVAL (HOUR(?) + 1) HOUR",
"ssss", array($date, $time, $date, $time));
case 'day':
return array("$column >= DATE(?) AND $column < DATE(?) + INTERVAL 1 DAY",
"ss", array($date, $date));
case 'month':
return array("$column >= CAST(DATE_FORMAT(?, '%Y-%m-01') AS DATE) AND $column < CAST(DATE_FORMAT(?, '%Y-%m-01') AS DATE) + INTERVAL 1 MONTH",
"ss", array($date, $date));
}
return null;
}
```
`$column`은 코드에서 `"BR.RecordDateTime"` 같은 고정 문자열로만 넘깁니다(사용자 입력 금지). 기존 PHP 엔드포인트 파일과 응답 형식은 그대로 두고 **내부 구현만** 공용 함수를 호출하게 바꾸면, 게임 클라이언트([db_connect_manager.js](../../../src/game/lib/db_connect_manager.js), [db_service.js](../../../src/game/lib/db_service.js))는 수정할 필요가 없습니다.
### 3-5. 함께 고칠 작은 버그
| 파일 | 내용 | 수정 |
|---|---|---|
| [typing_exam_collection.php](../../../src/web/php/db/typing_exam_collection.php) `getHighestRecordArrayForAllWriting()` | `bind_param("iii", …)`에 값 2개 | 호출처가 있으면 `"ii"`, 없으면 함수 삭제 |
| `server/record/*.php` 여러 파일 | `if($replyJSON.length === 0)` (PHP에서는 항상 거짓) | `count(...) === 0`으로 수정하거나 불필요하면 삭제 |
| [request_license_timer_player_record_list.php](../../../src/web/server/record/request_license_timer_player_record_list.php) | 종료일 당일 기록 누락, 존재하지 않는 `LS.AppID` 참조 | 3-1 패턴으로 재작성 |
---
## 4. 수정 대상 파일
| 파일 | 항목 |
|---|---|
| `src/web/server/record/request_app_player_record_list.php` | 3-1 |
| `src/web/server/record/request_writing_player_record_list.php` | 3-1 |
| `src/web/server/record/request_license_timer_player_record_list.php` | 3-1, 3-5 |
| `src/web/module/maestro_section_record.html` | 3-1 (상한 안내, 페이지) |
| `src/web/server/app/menu_active_typing_practice_app_list.php`, `menu_active_typing_test_app_list.php` | 3-2 |
| `src/web/php/db/menu_collection.php`, `src/web/php/db/writing_collection.php` | 3-2 |
| `src/web/server/record/app_ranking.php`, `update_result_record.php` + `src/web/sql/make_db.sql` | 3-3 (선택) |
| `src/web/server/lib/` 공용 함수 (신규), 랭킹 파일들 | 3-4 |
| `src/web/php/db/typing_exam_collection.php` 등 | 3-5 |
---
## 5. 적용 순서
| 순서 | 작업 | 이유 |
|---|---|---|
| 1 | **3-1 기록 목록 API** (안1과 같은 시기) | 보안 이슈, 코드 변경만으로 즉시 효과 |
| 2 | 3-4 랭킹 코드 정리 | 안1의 쿼리 조건 변경을 **공용 함수 한 곳**에서 하게 되어 실수 감소. 안1 코드 작업 전에 하거나 함께 진행 |
| 3 | 3-2 N+1 제거 | 독립적, 언제든 가능 |
| 4 | 3-5 작은 버그 | 해당 파일 수정 시 함께 |
| 5 | 3-3 랭킹 캐시 | 안1·안2 후 **측정해서 필요할 때만** |
각 항목 공통: 스테이징 배포 → 화면 테스트 → 운영 배포. DB 구조 변경이 없는 항목(3-1, 3-2, 3-4, 3-5)은 `git revert`만으로 롤백됩니다.
### 테스트 항목
- 기록 목록: 과목 전체/묶음(1000~1005)/개별 앱, 이름 검색 있음/없음, 기간 1일/1개월, "전체 보기", 종료일 당일 기록 포함 여부, 자격증 목록
- 보안: 이름 칸에 `' OR '1'='1` 입력 시 결과가 비거나 해당 이름 검색으로만 동작하는지
- 메뉴: 기록이 있는 앱/없는 앱의 최고기록 표시가 변경 전과 같은지, 한글·영어 연습/테스트 메뉴 모두
- 랭킹(캐시 적용 시): 기록 저장 직후 랭킹에 반영되는지, 다른 선생님 교실과 섞이지 않는지
---
## 6. 장단점과 위험
| 장점 | 단점 / 위험 | 대응 |
|---|---|---|
| DB 구조 변경 없음, 롤백 쉬움 | 기록 목록 조건을 다시 짜면서 **기존과 검색 결과가 달라질 수 있음** (특히 과목 묶음 범위, 종료일) | 변경 전후 같은 조건으로 결과 건수 비교 |
| SQL Injection 제거 | `bind_param(..., ...$params)`의 타입 문자열과 값 개수 불일치 시 오류 | 조건 추가할 때 `$types`, `$params`**항상 같은 줄 묶음에서** 함께 추가 |
| COUNT 제거로 기록 목록 조회 비용 약 절반 (예상) | "전체 보기" 상한 도입으로 사용 방식 변화 | 안내 문구, 기간 좁히기 유도 |
| 메뉴 쿼리 수가 앱 개수와 무관 | | |
| 랭킹 캐시로 수업 시간대 부하 감소 | 캐시 무효화 누락 시 오래된 랭킹 표시 | 저장 시 해당 캐시 삭제, TTL 짧게 |
---
## 7. 예상 효과 (측정으로 확인 필요)
| 화면 / API | 변경 전 | 변경 후 (예상) |
|---|---|---|
| 관리자 기록 목록 | COUNT 1회 + 목록 1회, 상한 없음 | 목록 1회, 최대 1,000건 |
| 연습/테스트 메뉴 진입 | 4 + 앱 수(예: 16)회 = 약 20회 쿼리 | 약 6회 (앱 목록·활성 앱·최고기록 × 한글/영어) |
| 결과 화면 랭킹 (학생 30명 동시, 캐시 적용 시) | 90회 쿼리 | 캐시 만료 시에만 3회 + 캐시 조회 |
| 랭킹 화면 1개를 1시간 열어 둠 (캐시 적용 시) | 720회 쿼리 (5초마다) | 기록이 바뀌었거나 TTL 만료 시에만 실제 계산 |
---
## 8. 체크리스트
- [ ] 기록 목록 API 3종: 바인딩 전환, COUNT 제거, 상한, 종료일 통일
- [ ] 기록 목록 화면: 상한 안내 / 페이지
- [ ] 변경 전후 검색 결과 건수 비교, SQL Injection 입력 테스트
- [ ] 랭킹 기간 조건 공용 함수 (안1 작업과 함께)
- [ ] 메뉴 N+1 제거 (연습·테스트, `menu_collection.php`, `writing_collection.php`)
- [ ] 작은 버그 3종
- [ ] (측정 후 필요 시) 랭킹 캐시 + 저장 시 무효화
- [ ] `ranking` 테이블 처리 결정 (캐시 재활용 또는 삭제)
+524
View File
@@ -0,0 +1,524 @@
# 기록 DB 성능 문제의 다른 해결 방법 연구
> [개요 문서](01-improvement-overview.md) | 작성일: 2026-09-14
> 안1~안5(MariaDB 안에서 인덱스·집계·아카이빙·파티셔닝·코드 개선)와 **전혀 다른 방향**의 해결책을 검토한 문서입니다.
> 검토 대상: 학교(선생님) 단위 테이블 분할, 다른 저장소(Redis·MongoDB·Kafka·분석 DB·S3), DB 실행 환경 변경, 애플리케이션·제품 정책 변경
---
## 0. 결론 먼저
| 방법 | 성능에 도움? | 이 프로젝트 적합도 | 한 줄 평가 |
|---|---|:---:|---|
| **MariaDB 설정 튜닝** (buffer pool 등) | 클 가능성 높음 | ★★★★★ | 설정이 기본값(128MB)이면 가장 싸고 빠른 개선. **측정부터** |
| **랭킹 5초 폴링 방식 변경** | 큼 (반복 조회 제거) | ★★★★★ | DB를 바꾸지 않고 요청 수 자체를 줄임 |
| **PHP 실행 환경 점검** (연결 재사용, OPcache) | 중간 | ★★★★ | 요청마다 DB 새 연결 + 추가 쿼리 발생 중 |
| **서버 사양·스토리지 점검** (CPU 크레딧, gp3) | 상황에 따라 큼 | ★★★★ | 수업 시간대만 느리다면 1순위 의심 |
| Redis로 랭킹 처리 | 랭킹 화면에 한해 큼 | ★★★ | 랭킹 자료구조와 딱 맞지만 운영 대상이 하나 늘어남 |
| RDS(관리형 MariaDB)로 이전 | 성능보다는 운영 편의 | ★★★ | "DB 관리 부담을 줄이고 싶다"가 목적일 때 |
| DB 서버 분리 / 읽기 복제본 | 중간 | ★★ | 웹·DB가 자원을 두고 경쟁할 때만 |
| 오래된 기록을 S3 파일로 보관 | 용량에 도움 | ★★ | 과거 기록 실시간 조회가 필요 없어질 때 |
| 학교(선생님) 단위 테이블 분할 | 인덱스와 거의 같은 효과 | ★ | **인덱스가 같은 일을 훨씬 싸게 함.** 테이블 수 폭증 문제 큼 |
| MongoDB로 기록 이전 | 거의 없음 | ★ | 같은 인덱스·집계 작업이 필요하고, 이전 비용·DB 2개 운영 |
| Kafka 도입 | 없음 (읽기 문제엔 무관) | ☆ | 쓰기 폭주용 도구. 방금 저장한 기록이 랭킹에 늦게 보이는 부작용 |
| 분석 전용 DB (ClickHouse 등) | 현재 기능엔 없음 | ☆ | 대규모 통계 대시보드가 생길 때 검토 |
**핵심 판단**: 현재 문제는 **데이터가 많아서가 아니라, 데이터를 읽는 방식 때문**입니다. `best_record` 120만 행은 MariaDB 기준으로 작은 편이며, 적절한 인덱스가 있으면 수억 행에서도 같은 종류의 조회가 빠르게 동작합니다. 다른 기술로 옮겨도 "필요한 범위만 읽게 하기(인덱스)"와 "미리 계산해 두기(집계)"는 똑같이 필요하므로, **새 기술은 문제를 옮길 뿐 없애지 않습니다.** 반면 운영할 서버·백업·장애 지점은 늘어납니다.
그래서 추천 조합은 다음과 같습니다.
1. **환경 측정 → MariaDB 설정 튜닝** (4장, 비용 거의 0)
2. **안1 (인덱스 + 쿼리 조건)**
3. **랭킹 폴링 개선 + PHP 연결 점검** (5장, 6장)
4. 그래도 부족하거나 규모가 커지면 → Redis 랭킹 또는 RDS 이전 검토
---
## 1. 판단 기준 — 이 프로젝트의 현실
| 항목 | 현재 상황 | 의미 |
|---|---|---|
| 데이터 규모 | `best_record` 1,206,768행, 서비스 약 7년 (2019년 데이터 존재) → 연 약 17만 행 증가(예상) | DB 입장에서는 **소규모**. 10년 뒤에도 수백만 행 수준 |
| 쓰기 부하 | 게임 1판 종료 시 기록 1건 저장 | 쓰기가 병목일 가능성 낮음 |
| 읽기 부하 | 게임 결과 화면 랭킹 3종, **랭킹 화면 5초마다 재조회**(`Ranking.REFRESH_TIME_SEC = 5`), 메뉴 N+1 | **읽기 방식이 병목** |
| 부하 패턴 | 수업 시간(학교 시간표)에 몰림 | 평균이 아니라 **수업 시간대 최대치** 기준으로 봐야 함 |
| 서버 구성 | 운영 DB 설정이 `localhost` → Apache·PHP·MariaDB가 **같은 EC2**에서 동작하는 것으로 추정 | 웹과 DB가 CPU·메모리를 나눠 씀 |
| 운영 인력 | 1인, DB 최적화 경험 적음 | **운영할 구성 요소가 늘어나는 것 자체가 큰 비용** |
| 코드 구조 | PHP 7 절차형 코드, composer 없음, 요청마다 `new mysqli` | 외부 라이브러리 도입 시 설치·배포 방식부터 바꿔야 함 |
**평가 기준**: ① 실제로 느린 원인을 해결하는가 ② 추가로 운영해야 할 것이 늘어나는가 ③ 문제가 생겼을 때 혼자 복구할 수 있는가 ④ 비용
---
## 2. 학교(선생님) 단위로 기록 테이블 쪼개기
### 2-1. 전제: 이 스키마의 "학교"
현재 스키마에는 **학교 테이블이 없고**, 학생(`player`)은 선생님(`maestro`)에게 속합니다. 따라서 "학교 단위 분할"은 실제로는 **선생님 단위 분할**입니다.
```text
현재: best_record (모든 선생님의 기록 120만 행)
분할: best_record_m1, best_record_m2, ..., best_record_m{MaestroID}
```
### 2-2. 성능에 도움이 될까?
**도움이 되는 부분**
- 인덱스가 없는 지금 상태에서는 "선생님 123의 오늘 랭킹"을 구할 때 120만 행이 아니라 **그 선생님 테이블만** 훑으므로 빨라집니다.
**하지만 인덱스가 같은 효과를 이미 준다**
- `(MaestroID, AppID, RecordDateTime)` 인덱스는 "선생님 123 → 앱 5 → 오늘" 구간으로 바로 이동합니다. 즉 **인덱스는 DB가 자동으로 관리해 주는 "선생님별 칸막이"** 입니다.
- 테이블 크기에 따른 차이는 인덱스 트리 깊이 정도입니다. InnoDB는 한 페이지(16KB)에 수백 개의 키가 들어가므로, 120만 행이어도 **3단계 정도**면 원하는 위치에 도달합니다(예상). 작은 테이블과 비교해 **페이지 1~2개를 더 읽는 차이**이며, 이 페이지들은 대부분 메모리(buffer pool)에 올라가 있습니다.
- 결론: **인덱스를 만든 뒤에는 테이블 분할의 추가 성능 이득이 거의 없습니다.** 인덱스 없이 분할만 하면, 큰 학교의 테이블은 여전히 전체를 훑습니다.
### 2-3. 학교 수만큼 테이블이 늘어날 때의 문제점
선생님 수를 먼저 확인해 보세요.
```sql
SELECT COUNT(*) AS maestro_count FROM maestro;
SELECT COUNT(DISTINCT MaestroID) AS maestro_with_records FROM best_record;
-- 기록이 한쪽에 몰려 있는지 (상위 10명의 비중)
SELECT MaestroID, COUNT(*) AS cnt,
ROUND(COUNT(*) * 100 / (SELECT COUNT(*) FROM best_record), 1) AS pct
FROM best_record
GROUP BY MaestroID
ORDER BY cnt DESC
LIMIT 10;
```
선생님이 N명이고 기록 테이블이 2종(`best_record`, `typing_exam_record`)이면 테이블은 **2N개**, 최고기록·집계 테이블까지 쪼개면 **4~6N개**가 됩니다.
| 영역 | 문제 | 구체적 증상 |
|---|---|---|
| **DB 서버 자원** | 테이블마다 파일(`.ibd`)과 메타데이터가 생김 | `table_open_cache`, `table_definition_cache`, `open_files_limit` 한도에 걸리면 테이블을 열고 닫기를 반복해 **오히려 느려짐**. 데이터 사전 메모리 증가 |
| **스키마 변경** | 인덱스 1개 추가 = 테이블 수만큼 `ALTER TABLE` | 수천 번 실행 중 일부만 실패하면 **테이블마다 구조가 달라지는 상태**(schema drift)가 생기고, 어떤 테이블이 다른지 추적해야 함 |
| **보안** | 테이블 이름은 `?` 바인딩이 불가능 | `"SELECT ... FROM best_record_m" . $maestro_id` 같은 문자열 연결이 모든 기록 쿼리에 생김 → **SQL Injection 위험 지점이 오히려 늘어남**. 숫자 검증·화이트리스트가 필수 |
| **권한** | 선생님 가입 시 `CREATE TABLE` 필요 | 웹 서비스 DB 계정에 DDL 권한을 줘야 함 (해킹 시 피해 범위 확대). 생성 실패 시 가입 처리 꼬임 |
| **전체 조회 불가** | 여러 테이블을 한 번에 조회하려면 `UNION ALL`을 테이블 수만큼 | 관리자 전체 통계, 앱별 전체 이용량, "전국 랭킹" 같은 기능이 사실상 불가능 |
| **데이터 이동** | 학생이 다른 선생님으로 옮기거나 선생님 계정을 합칠 때 | 테이블 간 행 이동 코드가 필요 |
| **외래 키** | 테이블마다 외래 키를 달아야 함 | 누락되면 무결성 보호가 테이블마다 제각각 |
| **백업·도구** | `mariadb-dump`, `information_schema` 조회, phpMyAdmin 목록 | 테이블 수에 비례해 느려지고 사람이 보기 어려워짐 |
| **불균형** | 기록의 대부분이 소수의 큰 학교에 몰려 있으면 | 느린 선생님의 테이블은 여전히 크고, 작은 테이블 수천 개만 늘어남 |
| **코드 전체 수정** | 기록 테이블을 쓰는 모든 PHP 파일 | [현황 문서](player-record-tables-and-queries.md)의 쿼리 전부를 동적 테이블 이름으로 바꿔야 함 |
**장점도 있습니다**
- 선생님 계정 삭제·백업·복원이 테이블 단위로 쉬움 (현재 [선생님 단위 백업/삭제 스크립트](../../db/260907-backup-delete-maestro/backup-maestro.sh)가 하는 일이 단순해짐)
- 계약상 "학교 데이터를 물리적으로 분리해 달라"는 요구가 있으면 설명하기 쉬움
### 2-4. 변형안 비교
| 변형 | 방식 | 테이블 수 문제 | 동적 이름·전체 조회 문제 | 평가 |
|---|---|---|---|---|
| A. 선생님마다 테이블 | `best_record_m123` | **매우 큼** | 있음 | 비권장 |
| B. 선생님 그룹(해시)별 고정 개수 | `best_record_00` ~ `_15` (MaestroID % 16) | 없음 (16개 고정) | 있음 | 인덱스보다 나은 점이 거의 없음 |
| C. MariaDB 내장 파티셔닝 (선생님 기준) | `PARTITION BY KEY(MaestroID) PARTITIONS 16` | 없음 (DB가 관리) | **없음** (테이블 이름 하나) | 가장 나은 형태지만, [안4](05-option4-partitioning.md)의 제약(외래 키 불가, 기본 키 변경) 동일. `PlayerID`만으로 조회하는 쿼리는 모든 파티션 탐색 |
| D. 선생님(학교)마다 DB 분리 | `chocomae_school_123` 데이터베이스 | 매우 큼 (DB 단위) | 있음 + 연결 전환 | 대형 기관 전용 서비스(SaaS 격리 요구)일 때만 |
| E. 연도별 테이블 | `best_record_2025` | 작음 (연 1개) | 있음 (연도 넘는 조회) | [안3 아카이빙](04-option3-archiving.md)이 같은 목적을 더 안전하게 달성 |
### 2-5. 결론
- **현재 규모와 운영 여건에서는 비권장**합니다. 인덱스가 같은 효과를 거의 비용 없이 주고, 테이블 수 증가로 생기는 문제(스키마 변경, 보안, 전체 조회 불가)가 훨씬 큽니다.
- **재검토 조건**: 교육청·대형 학교와 "학교별 데이터 물리적 격리 및 삭제 증명"을 계약해야 할 때 → 테이블 분할이 아니라 **D(학교별 DB) 또는 학교별 DB 서버**를 별도 서비스 구조로 설계.
---
## 3. 다른 저장소를 함께 쓰기
### 3-1. Redis — 랭킹에 잘 맞는 자료구조
#### 무엇인가
메모리에 데이터를 저장하는 초고속 키-값 저장소입니다. 특히 **Sorted Set**(점수 순으로 자동 정렬되는 집합)이 랭킹과 정확히 맞습니다.
#### 적용 모습
```text
키: rank:{MaestroID}:{AppID}:hour:2026091413
rank:{MaestroID}:{AppID}:day:20260914
rank:{MaestroID}:{AppID}:month:202609
멤버: PlayerID
점수: 기록
```
```text
# 기록 저장 시 (MariaDB 저장 후) — 기존 점수보다 높을 때만 갱신 (Redis 6.2+의 GT 옵션)
ZADD rank:123:5:hour:2026091413 GT 15460 "9876"
ZADD rank:123:5:day:20260914 GT 15460 "9876"
ZADD rank:123:5:month:202609 GT 15460 "9876"
EXPIRE rank:123:5:hour:2026091413 172800 # 2일 뒤 자동 삭제
# 낮을수록 좋은 앱(105)은 LT 옵션, 조회 방향도 반대
# 랭킹 조회 — 상위 30명
ZREVRANGE rank:123:5:day:20260914 0 29 WITHSCORES
```
- 조회는 MariaDB 쿼리 없이 메모리에서 끝나므로, 랭킹 화면의 **5초 폴링**이 수십 개 열려 있어도 부담이 거의 없습니다.
- 랭킹 키에 들어가는 학생 수가 작아 메모리는 **수십 MB 수준**(예상)입니다.
#### 문제점
| 문제 | 설명 | 대응 |
|---|---|---|
| **이중 쓰기 정합성** | MariaDB 저장은 성공, Redis 저장은 실패하면 랭킹이 틀어짐 | MariaDB를 원본으로 두고 Redis는 "다시 만들 수 있는 사본"으로 취급. 재구축 스크립트 필요 |
| 재시작 시 데이터 | 메모리 저장이라 설정에 따라 재시작 시 사라짐 | RDB/AOF 영속화 설정 또는 시작 시 재구축 |
| **과거 날짜 랭킹** | [ranking.js](../../../src/game/ranking/ranking.js)의 이전 날짜 버튼으로 오래된 랭킹을 볼 수 있음. 만료된 키는 없음 | 오래된 날짜는 MariaDB로 조회 → **조회 경로가 2개** 유지 |
| **기록 삭제** | 관리자가 기록을 지우면, Sorted Set에는 "그 다음으로 좋은 기록"이 없음 | 해당 기간을 MariaDB에서 다시 계산해 Redis 갱신 |
| 학생 이름 | 멤버는 PlayerID만 저장 | 이름은 MariaDB에서 조회하거나 Redis Hash에 별도 저장 |
| PHP 연동 | `phpredis` 확장 설치(Docker/서버 이미지 수정) 또는 Predis 라이브러리(현재 composer 미사용) | 배포 방식 변경 필요 |
| 운영 | 서비스 1개 추가, 메모리 제한, 비밀번호 설정, **외부 포트 노출 금지** | AWS ElastiCache를 쓰면 운영 부담은 줄지만 비용 증가 |
#### 평가
- **랭킹 화면 반복 조회**라는 한 가지 문제에는 매우 효과적입니다.
- 하지만 [안5의 랭킹 캐시](06-option5-application-layer.md)(DB 캐시 테이블)나 5장의 **폴링 개선**으로 비슷한 효과를 추가 구성 요소 없이 얻을 수 있습니다.
- **검토 시점**: 동시 접속 학급 수가 크게 늘어 캐시 테이블 조회조차 부담이 될 때, 또는 세션·실시간 기능 등 Redis를 쓸 다른 이유가 함께 생길 때.
### 3-2. MongoDB — 문서형 DB로 기록 이전
#### 무엇인가
표(행·열) 대신 JSON 형태 문서를 저장하는 DB입니다. 스키마를 유연하게 바꿀 수 있고, 시계열 데이터용 **Time Series Collection**(5.0+)도 있습니다.
#### 적용 모습
```javascript
// 방법 1: 학생 문서에 기록 배열을 계속 추가 → ❌ 안티패턴
{ playerId: 9876, records: [ {app:5, rec:15460, at:"..."}, ... 수천 ] }
// 문서 1개 최대 16MB 제한, 배열이 커질수록 수정이 느려짐
// 방법 2: 학생·앱·날짜별 묶음 문서 (버킷 패턴)
{ maestroId: 123, playerId: 9876, appId: 5, date: "2026-09-14",
best: 15460, hours: [ {h:13, best:15460}, {h:14, best:14200} ] }
```
방법 2는 사실상 **[안2의 일별 집계 테이블](03-option2-daily-summary-tables.md)과 같은 구조**입니다.
#### 성능에 도움이 될까?
- 랭킹 = "선생님·앱·기간 범위 조회 → 학생별 최고값 → 정렬". MongoDB에서도 `{maestroId, appId, date}` **복합 인덱스**와 aggregation `$group`, `$sort`가 필요합니다. **해야 하는 일이 같습니다.**
- 즉 빨라지는 이유는 MongoDB라서가 아니라 인덱스·미리 집계 때문이며, 그건 MariaDB에서도 똑같이 됩니다.
#### 문제점
| 문제 | 설명 |
|---|---|
| **DB 간 조인 불가** | 학생 이름(`player`)은 MariaDB에 있음 → PHP에서 두 DB 결과를 합쳐야 함 |
| **트랜잭션 분리** | 기록 저장(MongoDB)과 학생 삭제(MariaDB)를 한 트랜잭션으로 묶을 수 없음 → 고아 기록 |
| 이전 작업 | 120만+13만 행 이관, 기록 관련 PHP 코드 전면 재작성 |
| PHP 연동 | `mongodb` 확장 + 공식 라이브러리(composer) 필요 |
| 자원 | 기본 캐시가 메모리를 많이 씀 → 같은 EC2에서 MariaDB와 메모리 경쟁 |
| 운영 | 백업·복원·모니터링 체계를 하나 더 익히고 유지 |
| 라이선스·비용 | 자체 설치는 SSPL 라이선스, 관리형(Atlas)은 유료 |
#### 평가
**비권장.** 문제를 해결하는 핵심(인덱스·집계)은 그대로 필요하고, 비용과 위험만 추가됩니다. MongoDB가 빛나는 경우(구조가 제각각인 데이터, 대량 분산 쓰기)와 이 서비스의 특성이 맞지 않습니다.
### 3-3. Kafka — 이벤트 스트리밍
#### 무엇인가
대량의 이벤트를 순서대로 받아 여러 소비자에게 전달하는 메시지 시스템입니다. "초당 수만 건의 쓰기를 받아 두었다가 천천히 처리"하는 데 강합니다.
#### 적용 모습
```text
게임 종료 → PHP가 "기록 이벤트" 발행 → Kafka
├→ 소비자 1: MariaDB에 저장
├→ 소비자 2: 랭킹 집계 갱신
└→ 소비자 3: 통계
```
#### 성능에 도움이 될까?
- **아니오.** 이 서비스의 병목은 **읽기**(랭킹·히스토리 조회)입니다. Kafka는 쓰기를 흡수하는 도구이며 읽기 쿼리를 빠르게 하지 않습니다.
#### 문제점
| 문제 | 설명 |
|---|---|
| **방금 저장한 기록이 늦게 보임** | 저장이 비동기가 되어, 결과 화면이 랭킹을 조회할 때 아직 반영되지 않았을 수 있음. 현재 결과 화면은 기록 저장 후 **0.5초만 기다렸다가**(`RecordBoard.DELAY_UPDATING_RESULT_RECORD_MS = 500`) 랭킹을 조회하므로, 처리가 조금만 밀려도 "방금 낸 기록이 랭킹에 없음"이 바로 드러남 |
| 자원 | JVM 기반, 브로커에 수 GB 메모리 권장 → 단일 EC2에 부담 |
| 운영 복잡도 | 토픽·파티션·소비자 그룹·재처리·중복 처리(exactly-once) 이해 필요 |
| PHP 연동 | `rdkafka` 확장 설치 |
| 장애 지점 | Kafka가 멈추면 기록 저장 전체가 멈춤 |
#### 더 가벼운 대안 (나중에 비동기 처리가 필요해지면)
- MariaDB 테이블을 작업 대기열로 쓰고 cron이 처리
- Redis Streams (Redis를 이미 도입했다면)
#### 평가
**부적합.** 규모·문제 유형·운영 여건 모두 맞지 않습니다.
### 3-4. 분석 전용 DB (ClickHouse, MariaDB ColumnStore, DuckDB)
#### 무엇인가
데이터를 열(column) 단위로 저장해 **수억 행 집계를 초 단위로** 처리하는 DB입니다.
#### 평가
- 현재 기능(선생님별 랭킹, 학생별 히스토리, 기록 1건 저장·삭제)은 **소량을 자주 읽고 쓰는 작업(OLTP)** 이라 맞지 않습니다. 열 저장 DB는 1건 수정·삭제가 느리고 작은 쿼리에 비효율적입니다.
- **검토 시점**: "전국 학생 월별 타자 속도 변화", "앱별 연간 이용 통계" 같은 **관리자 분석 대시보드**를 만들 때. 이때도 운영 DB는 그대로 두고, 밤마다 복사한 데이터를 DuckDB(설치 없이 파일 하나로 동작) 등으로 분석하는 방식이 가장 가볍습니다.
### 3-5. 오래된 기록을 S3 파일로 보관
#### 무엇인가
[안3 아카이빙](04-option3-archiving.md)의 변형입니다. 오래된 기록을 DB 테이블이 아니라 **S3에 파일(CSV/Parquet)로 저장**하고 운영 DB에서 삭제합니다. 필요하면 Amazon Athena로 SQL 조회합니다(조회량 기준 과금).
| 장점 | 단점 |
|---|---|
| DB 용량·백업 크기가 실제로 줄어듦 | 과거 날짜 랭킹, 오래 쉰 학생의 히스토리를 **즉시** 보여줄 수 없음 (Athena는 초~수십 초, PHP 연동 복잡) |
| 저장 비용이 매우 저렴 | AWS 권한·버킷 관리, 파일 형식·경로 규칙 설계 |
| 수년 치 보관에 적합 | "최근 7일 히스토리 유지" 요구사항은 [안2 집계 테이블](03-option2-daily-summary-tables.md)이 선행되어야 만족 |
**검토 시점**: 안2·안3을 적용해 몇 년 운영한 뒤, 아카이브 테이블 자체가 부담이 되고 "N년 이전 기록은 화면에서 안 보여도 된다"는 정책이 정해졌을 때.
---
## 4. MariaDB 실행 환경 변경
> 인덱스·쿼리를 고치기 전에 **서버가 제대로 설정되어 있는지** 확인하는 것이 가장 먼저입니다. 설정이 기본값이면, 코드 수정 없이 크게 좋아질 수 있습니다.
### 4-1. 먼저 측정할 것
#### 서버 (EC2에 SSH 접속 후)
```bash
nproc
```
```bash
free -h
```
```bash
df -h
```
```bash
docker stats --no-stream
```
```bash
TOKEN=$(curl -s -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 60") && curl -s -H "X-aws-ec2-metadata-token: $TOKEN" http://169.254.169.254/latest/meta-data/instance-type
```
- 인스턴스 타입이 `t2`/`t3`/`t4g` 계열이면 AWS 콘솔 → CloudWatch → EC2 → **`CPUCreditBalance`** 그래프를 수업 시간대 기준으로 확인합니다.
- AWS 콘솔 → EC2 → 볼륨에서 EBS 타입(`gp2`/`gp3`)과 크기를 확인합니다.
- MariaDB가 Docker 컨테이너라면 `docker inspect <컨테이너> | grep -i memory`로 메모리 제한이 걸려 있는지 확인합니다.
#### MariaDB
```sql
SELECT @@version, @@innodb_buffer_pool_size / 1024 / 1024 AS buffer_pool_mb,
@@max_connections, @@tmp_table_size / 1024 / 1024 AS tmp_table_mb,
@@max_heap_table_size / 1024 / 1024 AS heap_table_mb,
@@table_open_cache, @@innodb_flush_log_at_trx_commit;
-- buffer pool 적중률: 디스크에서 읽은 비율이 1%를 넘으면 메모리 부족 의심
SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read%';
-- Innodb_buffer_pool_reads(디스크) / Innodb_buffer_pool_read_requests(전체)
-- GROUP BY 등에서 디스크 임시 테이블이 많이 만들어지는지
SHOW GLOBAL STATUS LIKE 'Created_tmp%';
-- 연결 상황
SHOW GLOBAL STATUS LIKE 'Threads_connected';
SHOW GLOBAL STATUS LIKE 'Max_used_connections';
SHOW GLOBAL STATUS LIKE 'Connections';
```
- 데이터+인덱스 크기는 [개요 7-1](01-improvement-overview.md#7-1-테이블인덱스-크기) 쿼리로 확인합니다.
### 4-2. 설정 튜닝 (서버는 그대로)
| 설정 | MariaDB 기본값 | 의미 | 조정 방향 |
|---|---|---|---|
| **`innodb_buffer_pool_size`** | **128MB** | 테이블·인덱스를 메모리에 올려두는 공간 | 전체 데이터+인덱스 크기보다 크게. DB 전용 서버면 RAM의 50~70%, **웹과 같은 서버면** Apache/PHP 사용량을 빼고 여유 있게 (예: RAM 4GB면 1~1.5GB 수준에서 시작, 예상) |
| `tmp_table_size`, `max_heap_table_size` | 16MB | `GROUP BY`·정렬 임시 테이블을 메모리에서 처리할 크기 | `Created_tmp_disk_tables`가 많으면 둘을 같이 올림 (예: 64MB) |
| `innodb_log_file_size` | 96MB | 쓰기 로그 크기 | 쓰기가 적어 대개 그대로 |
| `innodb_flush_log_at_trx_commit` | 1 | 커밋마다 디스크 동기화 (가장 안전) | 2로 바꾸면 쓰기가 빨라지지만 **서버 전원 장애 시 최근 1초 기록 유실 가능** → 게임 기록 특성상 고려 가능하나, 쓰기가 병목이 아니므로 우선순위 낮음 |
| `max_connections` | 151 | 동시 연결 수 | `Max_used_connections`가 가까우면 조정. 너무 크면 메모리 과다 |
| `slow_query_log`, `long_query_time` | 꺼짐 | 느린 쿼리 기록 | 켜두고 주기적으로 확인 |
- **buffer pool이 기본값 128MB인데 기록 테이블+인덱스가 그보다 크다면**, 현재 느린 원인의 상당 부분이 "매번 디스크에서 읽기"일 수 있습니다. 이 경우 설정 한 줄로 큰 개선이 가능합니다.
- MariaDB 10.11은 `SET GLOBAL innodb_buffer_pool_size = ...`**재시작 없이** 크기를 바꿀 수 있습니다. 확인 후 설정 파일(`my.cnf` 또는 Docker 볼륨의 설정)에도 반영해야 재시작 후 유지됩니다.
- 메모리를 너무 크게 잡으면 Apache/PHP와 경쟁해 **서버 전체가 스왑을 쓰며 더 느려집니다.** `free -h`로 여유를 확인하며 단계적으로 올리세요.
### 4-3. 서버 사양·스토리지
| 점검 항목 | 문제가 되는 경우 | 해결 |
|---|---|---|
| **CPU 크레딧 (t 계열)** | 평소엔 괜찮다가 **수업 시간에만** 느림, `CPUCreditBalance`가 0 근처 | t 계열 "무제한(Unlimited)" 모드, 한 단계 큰 인스턴스, 또는 크레딧 없는 계열(m/c 계열) |
| **메모리** | buffer pool을 충분히 줄 수 없음, 스왑 사용 | 메모리 큰 인스턴스로 변경 (EC2는 중지 → 타입 변경 → 시작으로 수 분 내 가능) |
| **EBS gp2** | 볼륨이 작으면 기본 IOPS가 낮고 크레딧 방식 | **gp3로 온라인 변경**(재시작 없음). gp3는 크기와 무관하게 기본 3,000 IOPS 제공, 일반적으로 gp2보다 저렴 |
| 디스크 여유 | 안1 인덱스 추가, `OPTIMIZE TABLE`에 공간 필요 | 볼륨 크기 온라인 확장 |
| ARM(Graviton, t4g/m7g) | 같은 성능에 저렴한 경우 많음 | Docker 이미지·PHP 확장의 arm64 지원 확인 필요 → 이전 작업이 따름 |
인스턴스·스토리지 변경은 **코드 수정이 없고 되돌리기 쉬운** 해결책이지만, 비용이 매달 발생합니다. 정확한 금액은 AWS 요금 계산기로 확인하세요.
### 4-4. DB를 별도 서버로 분리
| 장점 | 단점 |
|---|---|
| 웹(Apache/PHP)과 DB가 CPU·메모리를 두고 경쟁하지 않음 | 서버 비용 추가 |
| DB 서버 메모리를 buffer pool에 넉넉히 할당 | 네트워크 왕복 추가 (같은 가용 영역이면 1ms 미만, 예상). 메뉴 N+1처럼 **쿼리 수가 많은 화면은 누적 지연이 커짐** → 안5와 함께 |
| 웹 서버를 늘리거나 교체하기 쉬움 | 보안 그룹·연결 설정·백업 대상 변경 |
**검토 시점**: 4-1 측정에서 수업 시간대 CPU·메모리가 웹과 DB 모두 높게 나올 때.
### 4-5. Amazon RDS for MariaDB (관리형 DB)로 이전
| 장점 | 단점 |
|---|---|
| **자동 백업 + 특정 시점 복구(PITR)**: 현재 NAS 백업 스크립트 대체 가능 | 비용 (같은 사양 EC2보다 비쌈) |
| 보안 패치·버전 업그레이드 자동화 | `SUPER` 권한 없음, 설정은 파라미터 그룹으로만 |
| Performance Insights로 **느린 쿼리를 그래프로 확인** (DB 경험이 적을 때 큰 도움) | 이전 작업: 덤프·복원 중 점검 시간 또는 AWS DMS 설정 |
| 스토리지 자동 확장, Multi-AZ(장애 시 자동 전환), 읽기 복제본 클릭 생성 | RDS MariaDB 10.11 지원 여부·마이너 버전 확인 필요 |
| DB 서버 분리 효과(4-4) 포함 | 배포 스크립트의 DB 호스트 변경, 연결 암호화 설정 |
- **Aurora는 MySQL/PostgreSQL 호환**이며 MariaDB 호환이 아닙니다. MariaDB 전용 문법을 쓰는 곳이 있으면 수정이 필요하므로, 이 프로젝트에는 **RDS for MariaDB**가 자연스럽습니다.
- RDS 자체가 느린 쿼리를 빠르게 해주지는 않습니다. **목적은 성능보다 운영 부담 감소**입니다. 인덱스 없는 쿼리는 RDS에서도 느립니다.
**검토 시점**: 백업·복구·패치·모니터링을 혼자 챙기기 부담스러울 때, 또는 서버 이전 계획이 있을 때.
### 4-6. 읽기 복제본 (Read Replica)
- 기록 저장은 원본 DB, 랭킹·관리자 조회는 복제본으로 보내 부하를 나눕니다.
- **복제 지연**(보통 1초 미만이지만 부하 시 늘어남) 때문에 방금 저장한 기록이 랭킹에 늦게 보일 수 있습니다 → 결과 화면 랭킹은 원본, 랭킹 화면 폴링·관리자 목록만 복제본으로 보내는 식의 구분이 필요합니다.
- PHP에서 연결을 2개로 나누는 코드 수정이 필요합니다.
- **현재 규모에서는 과함.** 인덱스와 폴링 개선이 먼저입니다.
### 4-7. DB 버전·제품 변경
| 선택 | 이 문제와의 관계 | 평가 |
|---|---|---|
| MariaDB 11.x로 업그레이드 | 옵티마이저 비용 모델 개선 등 전반적 향상. 하지만 **컬럼에 함수를 씌운 조건은 버전을 올려도 인덱스를 못 씀** | 문제 해결책은 아님. 장기 지원 버전 계획에 따라 |
| MySQL 8.0으로 이전 | 8.0.13+의 **함수 인덱스**(`INDEX ((DATE(RecordDateTime)))`)로 코드 수정 없이 일부 조건에 인덱스 사용 가능. 단 `DATE`, `HOUR`, `YEAR`, `MONTH` 조합마다 인덱스가 필요하고 식이 정확히 일치해야 함 | 이전 비용·호환성 위험이 안1 쿼리 수정보다 훨씬 큼. 비권장 |
| MariaDB 10.11 가상 컬럼 인덱스 | `RecordDate DATE AS (DATE(RecordDateTime)) VIRTUAL` + 인덱스. 쿼리가 `RecordDate` 컬럼을 직접 조건에 써야 함 | 결국 쿼리 수정 필요 → 안1의 범위 조건이 더 단순 |
| PostgreSQL | BRIN 인덱스(시간순 대용량에 매우 작은 인덱스), 부분 인덱스 등 강력 | 전면 이전. 현재 규모에서 이득 대비 비용 과다 |
---
## 5. 애플리케이션·제품 관점의 해결책
DB를 바꾸지 않고 **요청 자체를 줄이거나 가볍게** 만드는 방법입니다.
### 5-1. 랭킹 화면 5초 폴링 개선 (추천)
현재 [ranking.js](../../../src/game/ranking/ranking.js)는 화면이 열려 있는 동안 **기록이 바뀌지 않아도 5초마다** 랭킹 전체를 다시 요청합니다(`game.time.events.loop`). 화면 1개를 1시간 열어 두면 720회입니다.
| 방법 | 내용 | 효과 | 난이도 |
|---|---|---|---|
| A. 주기 늘리기 | 5초 → 15~30초 | 요청 3~6배 감소 | 매우 쉬움 (상수 1개) |
| B. 화면이 안 보일 때 멈춤 | 브라우저 탭이 숨겨지면(`document.hidden`) 요청 중단 | 열어두고 방치한 화면 요청 제거 | 쉬움 |
| **C. 변경 확인 후 가져오기** | "이 선생님·앱의 마지막 기록 저장 시각"만 가벼운 API로 확인 → 바뀌었을 때만 랭킹 조회 | 수업 중이 아닐 때 랭킹 쿼리 거의 0 | 중간 |
| D. 서버 푸시 (SSE/WebSocket) | 기록 저장 시 서버가 화면에 알림 | 가장 즉각적 | 어려움 (Apache+PHP 구조와 맞지 않음) → 비권장 |
C의 예시: 마지막 저장 시각은 [안2 집계 테이블](03-option2-daily-summary-tables.md)의 `UpdatedDateTime`이나, 선생님·앱별 "마지막 기록 시각" 1행을 관리하는 작은 테이블로 확인합니다.
```sql
SELECT MAX(UpdatedDateTime) FROM daily_best_record
WHERE MaestroID = ? AND AppID = ? AND RecordDate = CURDATE();
```
클라이언트는 이전 값과 같으면 랭킹 요청을 건너뜁니다. A+B만 해도 코드 몇 줄로 반복 조회가 크게 줄어듭니다.
### 5-2. 랭킹 결과를 파일로 미리 만들어 두기
- 기록 저장 시 해당 선생님·앱의 랭킹 JSON 파일을 다시 만들고, 화면은 **Apache가 파일을 그대로** 내려줍니다(PHP·DB 실행 없음).
- 빠르지만 파일 쓰기 권한, 동시 쓰기 충돌, 오래된 파일 정리, 배포 시 파일 보존 문제가 생깁니다. [안5 캐시 테이블](06-option5-application-layer.md)이 더 관리하기 쉽습니다.
### 5-3. PHP 실행 환경
| 점검 | 현재 | 개선 | 효과 |
|---|---|---|---|
| DB 연결 | [connect_db.php](../../../src/web/server/setup/connect_db.php)가 요청마다 `new mysqli` + `USE chocomae` 쿼리 추가 실행 | 이미 DB 이름을 지정해 연결하므로 `USE` 쿼리 삭제. 영속 연결(호스트에 `p:` 접두사) 검토 | 요청마다 연결·왕복 1회씩 절약. 5초 폴링처럼 **작은 요청이 많을 때** 체감 |
| OPcache | 확인 필요 (`php -i \| grep opcache.enable`) | 켜져 있지 않으면 활성화 | PHP 파일 해석 비용 제거 |
| Apache MPM | 확인 필요 | prefork + mod_php는 요청당 메모리가 큼 → PHP-FPM 전환 검토 | 같은 메모리로 더 많은 동시 요청, DB에 메모리 양보 |
영속 연결은 연결 수가 `max_connections`에 가깝게 쌓일 수 있고, 트랜잭션·임시 변수가 다음 요청으로 넘어갈 수 있으므로 **스테이징에서 먼저 확인**하세요.
### 5-4. 기능 정책 조정
가장 강력한 최적화는 **필요 없는 일을 하지 않는 것**입니다.
| 정책 | 효과 |
|---|---|
| 랭킹 화면 과거 날짜 탐색 범위를 최근 1년으로 제한 | 오래된 기록 조회 경로 제거 → 안3 아카이빙이 단순해짐 |
| 관리자 기록 목록 "전체 보기" 제거 또는 최대 기간 제한 | 대량 조회 제거 |
| 시간 랭킹을 "최근 1주일"만 제공 | 원본 테이블의 조회 범위 축소 |
| 일정 기간 미사용 선생님 계정의 기록 정리 정책 (이용약관·안내 필요) | 데이터 총량 감소 |
선생님(사용자)에게 실제로 필요한 기능인지 확인한 뒤 결정하세요.
---
## 6. 종합 비교
| 방법 | 해결하는 것 | 예상 효과 | 추가 운영 부담 | 월 비용 | 난이도 | 되돌리기 |
|---|---|---|---|---|---|---|
| MariaDB 설정 튜닝 | 디스크 읽기, 임시 테이블 | 중~대 (측정에 따라) | 없음 | 0 | 하 | 쉬움 |
| 랭킹 폴링 개선 (A+B, C) | 반복 조회 | 대 (랭킹 부하) | 없음 | 0 | 하~중 | 쉬움 |
| PHP 연결·OPcache | 요청당 오버헤드 | 소~중 | 없음 | 0 | 하 | 쉬움 |
| 인스턴스·gp3 점검 | CPU 크레딧, IOPS | 상황에 따라 대 | 없음 | 증가 가능 | 하 | 쉬움 |
| 안1 (인덱스+쿼리) | 전체 스캔 | 대 | 없음 | 0 | 하 | 쉬움 |
| Redis 랭킹 | 랭킹 조회 | 대 (랭킹만) | Redis 운영 | 소 (자체) / 중 (ElastiCache) | 중 | 중간 |
| DB 서버 분리 | 자원 경쟁 | 중 | 서버 1대 | 증가 | 중 | 중간 |
| RDS 이전 | 운영 부담 (백업·패치·모니터링) | 성능은 소 | **감소** | 증가 | 중 | 어려움 |
| 읽기 복제본 | 읽기 분산 | 중 | 복제 관리 | 증가 | 중~상 | 중간 |
| S3 보관 | 장기 용량 | 용량 대 | AWS 관리 | 매우 소 | 중~상 | 중간 |
| 학교별 테이블 | (인덱스와 같은 효과) | 인덱스 대비 거의 0 | **매우 큼** | 0 | 상 | 매우 어려움 |
| MongoDB | (인덱스·집계와 같은 효과) | 거의 0 | 큼 | 증가 | 상 | 매우 어려움 |
| Kafka | 쓰기 흡수 (현재 문제 아님) | 0 | 매우 큼 | 증가 | 상 | 어려움 |
| 분석 DB | 대규모 통계 (현재 기능 없음) | 0 | 큼 | 증가 | 상 | 중간 |
---
## 7. 추천 실행 순서 (안1~안5와 통합)
| 순서 | 작업 | 이유 |
|---|---|---|
| 1 | **4-1 환경 측정** + [개요 7장](01-improvement-overview.md#7-사전-측정-절차-0단계) 쿼리 측정 | 원인이 서버 설정·사양인지, 쿼리인지 먼저 구분 |
| 2 | **4-2 설정 튜닝** (buffer pool이 기본값이면 즉시), **4-3** CPU 크레딧·gp3 확인 | 코드 수정 없이 효과, 되돌리기 쉬움 |
| 3 | **5-1 A+B** 폴링 주기·숨김 시 중단, **5-3** `USE` 쿼리 제거·OPcache 확인 | 코드 몇 줄 |
| 4 | [안1](02-option1-index-and-query-rewrite.md) + [안5 보안 항목](06-option5-application-layer.md) | 쿼리 자체의 근본 개선 |
| 5 | 재측정 → 부족하면 5-1 C 또는 [안5 랭킹 캐시](06-option5-application-layer.md) | 반복 조회가 여전히 많을 때 |
| 6 | [안2](03-option2-daily-summary-tables.md)·[안3](04-option3-archiving.md) | 장기 성장 대비 |
| 7 | (선택) RDS 이전 | 운영 부담을 줄이고 싶을 때 |
| 8 | (조건부) Redis, DB 분리 | 8장 조건 충족 시 |
---
## 8. 다른 기술을 다시 검토할 시점
| 신호 | 검토할 방법 |
|---|---|
| 안1·튜닝 후에도 수업 시간대 DB CPU가 계속 높고, 슬로우 쿼리 대부분이 랭킹 | Redis 랭킹, 폴링 방식 C |
| 웹과 DB가 같은 서버에서 메모리 부족·스왑 발생 | DB 서버 분리 또는 RDS |
| 백업 실패·복구 경험 부족이 걱정됨, 장애 대응을 혼자 하기 어려움 | RDS (자동 백업·PITR·모니터링) |
| 동시 접속 학급이 현재의 10배 이상으로 증가 | 읽기 복제본, Redis, 웹 서버 여러 대 |
| 교육청·대형 기관과 학교별 데이터 격리 계약 | 학교별 DB(2-4 D) 구조 설계 |
| 관리자용 대규모 통계·리포트 기능 기획 | 분석 DB(DuckDB/ClickHouse)로 야간 복사 |
| 수년 치 아카이브가 부담되고 과거 기록 화면 조회 불필요 정책 확정 | S3 보관 |
---
## 9. 조사 중 확인한 운영 환경 관련 점검 사항
| 항목 | 내용 | 권장 |
|---|---|---|
| DB 포트 외부 접근 | 일일 백업 스크립트가 외부(NAS)에서 운영 DB 3306 포트로 직접 접속 | AWS 보안 그룹에서 3306 포트를 **NAS의 공인 IP로만** 허용하는지 확인. 가능하면 SSH 터널 사용 |
| 요청마다 `USE chocomae` | [connect_db.php](../../../src/web/server/setup/connect_db.php) | 5-3 참고, 불필요한 쿼리 |
| DB 계정 정보가 코드에 포함 | `connect_db.php`에 설정 파일이 없을 때 쓰는 기본 계정 정보가 들어 있음 | 저장소에서 제거하고 운영 비밀번호가 같다면 변경 |
| **개인 키 파일이 저장소에 포함** | 저장소 루트의 `jinaju.pem`(RSA 개인 키)이 git으로 추적되고 있음 | 서버 접속용 키라면 **새 키로 교체 후 기존 키 폐기**, 저장소에서 제거하고 `.gitignore`에 추가. git 이력에도 남아 있으므로 원격 저장소 접근 권한자 확인 |
@@ -0,0 +1,644 @@
# Synology 읽기 복제본 구축 가이드
> 실시간 MariaDB Replication으로 Synology NAS를 운영 DB의 최신 사본으로 유지
> - 작성일: 2026-09-15
> - 대상 환경: AWS EC2 (운영 MariaDB 10.11.13) ↔ Synology NAS (MariaDB 10)
> - 이 문서는 **계획 문서**입니다. 실제 구현은 아직 하지 않았습니다.
---
## 1. 개념: MariaDB Replication
### 마스터-슬레이브 구조
```
┌─────────────────────────────┐
│ AWS EC2 (마스터) │
│ MariaDB 10.11.13 │
│ - 모든 쓰기 실행 │
│ - Binlog 기록 │
│ :3306 │
└──────────────┬──────────────┘
│ 바이너리 로그 복제
│ (지속적, 초단위)
┌─────────────────────────────┐
│ Synology NAS (슬레이브) │
│ MariaDB 10 │
│ - 로그 수신 & 적용 │
│ - 읽기만 가능 │
│ mariadb.jisangs.com:3306 │
└─────────────────────────────┘
```
**동작 원리:**
1. 마스터에서 모든 변경(INSERT/UPDATE/DELETE)을 **바이너리 로그**에 기록
2. 슬레이브가 지속적으로 마스터의 로그를 읽음
3. 슬레이브가 같은 명령을 로컬에서 재실행 (replay)
4. 결과적으로 마스터와 슬레이브의 데이터가 항상 동일 (보통 < 1초 지연)
**현재 시스템과의 비교:**
| 항목 | 현재 (매일 덤프) | 변경 후 (실시간 복제) |
|---|---|---|
| 네트워크 | NAS가 마스터 SELECT | 마스터가 NAS에 쓰기 푸시 |
| 용량 | SQL 파일 (매일 수백 MB) | 바이너리 로그 (변경분만) |
| 복원 속도 | 수십 분 | 초 단위 |
| 운영 DB 부하 | 매일 높음 (SELECT * 시간) | 거의 없음 |
| 백업 용도 | 덤프 파일 자체 | 복제본 + 덤프 (필요시) |
| 분석 쿼리 영향 | 운영 DB 느려짐 | 복제본이므로 무관 |
---
## 2. 단계별 구현
### 단계 1: 운영 DB 설정 (AWS EC2)
#### 2-1. Binlog 활성화
`mariadb-dump``mariadb-restore`를 쓰는 현재 시스템과 달리, 복제를 위해서는 바이너리 로그가 계속 켜져 있어야 합니다.
**Docker 컨테이너에서 확인:**
```bash
# EC2 인스턴스에서
docker exec <container_name> mariadb -e "SHOW VARIABLES LIKE 'log_bin%';"
# 결과 예: log_bin = ON
```
**만약 OFF라면:**
```bash
# Docker 실행 시 또는 my.cnf에 추가
[mysqld]
log_bin = mysql-bin
binlog_format = ROW
binlog_expire_logs_days = 14 # 14일 후 자동 삭제
server_id = 1 # 마스터는 1, 슬레이브는 2+
```
**Docker 컨테이너 재시작 필요** (또는 운영 설정 파일 재로드)
**온라인 확인 및 임시 활성화** (재시작 없음):
```sql
-- 연결: mysql -h chocomae.jinaju.com -u root -p
SET GLOBAL binlog_format = 'ROW';
SET GLOBAL log_bin = ON;
-- 확인
SHOW VARIABLES LIKE 'binlog%';
SHOW MASTER STATUS; -- File='mysql-bin.000001', Position=XXX 나와야 함
```
> **중요**: 컨테이너를 재시작하면 `SET GLOBAL`은 초기화됩니다. 영구 적용은 Docker 설정 파일에서 해야 합니다.
#### 2-2. 복제 계정 생성
Synology(슬레이브)에서 마스터의 로그를 읽기 위한 전용 계정:
```sql
-- 운영 DB에서 (EC2, root 권한)
CREATE USER 'replication'@'mariadb.jisangs.com' IDENTIFIED BY '복제_비밀번호_여기_입력';
GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'replication'@'mariadb.jisangs.com';
FLUSH PRIVILEGES;
-- 확인
SHOW GRANTS FOR 'replication'@'mariadb.jisangs.com';
```
> **보안**: 비밀번호는 강력하게. Synology의 접속 파일(`.chocomae_replication.cnf` 같은)에 저장하되, 권한을 `600` (읽기 전용)으로 제한합니다.
#### 2-3. 현재 로그 위치 기록
초기 데이터 복사(2-5단계)를 시작하기 전에 **현재의 마스터 로그 상태**를 기록해야 합니다. 그 이후의 변경분부터 복제하므로:
```sql
SHOW MASTER STATUS;
```
**출력 예:**
```
File Position Binlog_Do_DB Binlog_Ignore_DB
mysql-bin.000001 154
```
이 값(`mysql-bin.000001`, `154`)을 메모해 두세요. 슬레이브 설정(2-7단계)에서 사용합니다.
---
### 단계 2: Synology 초기화
#### 2-4. 현재 스테이징 DB 확인
Synology의 MariaDB 상태 확인:
```bash
# Synology SSH에서
mariadb -u root -p -e "SELECT @@version, @@datadir;"
```
현재 `mariadb.jisangs.com:3306`에 기존 `chocomae` DB가 있습니다 (스테이징/테스트용).
#### 2-5. 초기 데이터 복사 (Binlog 이전까지)
**방법 A: 현재 backup-db.sh 스크립트 활용 (추천)**
이미 작성된 [backup-db.sh](../db/260907-daily-db-backup/backup-db.sh)는 `mariadb-dump --single-transaction`을 사용합니다. 이것이 바로 복제용 초기 데이터 복사의 좋은 도구입니다.
1. 운영 DB에서 현재 상태의 덤프를 받습니다 (위에서 메모한 로그 위치 이후의 변경분만 복제되므로 OK).
2. Synology에서 그 덤프를 복원합니다.
**구체적 커맨드:**
```bash
# Synology SSH에서
# 1) 기존 테스트 DB를 백업 (선택, 필요하면)
mariadb-dump -u root -p chocomae > /volume1/backup/chocomae_before_replication.sql
# 2) 운영 DB에서 현재 상태의 덤프를 받기 (스테이징의 backup-db.sh 스크립트 참고)
# 또는 EC2에서 Synology로 직접 파이프
mariadb-dump -h chocomae.jinaju.com -u backup -p \
--single-transaction \
--default-character-set=utf8mb4 \
chocomae | mariadb -u root -p chocomae
# (또는) 파일로 저장했다면
mariadb -u root -p chocomae < /volume1/backup/chocomae_latest.sql
```
이제 Synology의 `chocomae` DB가 운영 DB와 동일한 상태입니다 (위에서 기록한 로그 위치 시점의).
#### 2-6. Synology MariaDB 설정
슬레이브를 위한 추가 설정. Synology의 MariaDB 설정 파일 (보통 `/etc/my.cnf` 또는 `/var/packages/MariaDB10/target/etc/my.cnf`):
```ini
[mysqld]
server_id = 2 # 슬레이브는 2 이상 (마스터와 다른 ID)
skip_slave_start = OFF # 시작 시 자동으로 복제 시작 (선택)
# 보통은 ON으로 두고 수동 START SLAVE 권장
relay_log = mysql-relay-bin
relay_log_index = mysql-relay-bin.index
log_slave_updates = ON # 슬레이브도 로그 기록 (선택, 슬레이브의 슬레이브 필요시)
read_only = ON # 슬레이브에서의 쓰기 금지 (권장)
```
Synology의 MariaDB 재시작 또는 SSH에서 `SET GLOBAL`로 설정:
```sql
SET GLOBAL server_id = 2;
SET GLOBAL read_only = ON; # ()
```
---
### 단계 3: 복제 시작 (Synology)
#### 2-7. CHANGE MASTER TO
2-3단계에서 메모한 운영 DB의 로그 위치를 사용:
```sql
-- Synology의 MariaDB에서 (root 또는 관리자)
-- 먼저 기존 복제 설정 확인
SHOW SLAVE STATUS;
-- 아무 것도 없으면 OK, 뭔가 있으면 아래 항목 먼저 실행:
-- STOP SLAVE;
-- RESET SLAVE;
-- 복제 설정
CHANGE MASTER TO
MASTER_HOST = 'chocomae.jinaju.com',
MASTER_PORT = 3306,
MASTER_USER = 'replication',
MASTER_PASSWORD = '복제_비밀번호',
MASTER_LOG_FILE = 'mysql-bin.000001', -- 2-3단계의 File 값
MASTER_LOG_POS = 154; -- 2-3단계의 Position 값
```
> **타임존 고려**: MariaDB는 일반적으로 UTC 기반이므로 `MASTER_CONNECT_RETRY` 같은 추가 설정은 대개 불필요합니다. 네트워크 재연결 시간은 기본값(60초)이 적절합니다.
#### 2-8. 복제 시작
```sql
START SLAVE;
-- 몇 초 기다린 뒤 상태 확인
SHOW SLAVE STATUS\G
-- 확인할 항목:
-- Slave_IO_Running: Yes
-- Slave_SQL_Running: Yes
-- Seconds_Behind_Master: 0 (또는 작은 숫자)
-- Last_Error: (비어 있음)
```
**복제가 정상 동작하면:**
- `Slave_IO_Running: Yes` — 마스터의 바이너리 로그를 계속 읽는 중
- `Slave_SQL_Running: Yes` — 읽은 로그를 Synology에서 재실행 중
- `Seconds_Behind_Master: 0` — 지연 없음 (또는 1~2초 이내)
**문제가 있으면:**
- `Slave_IO_Running: Connecting` — 네트워크 문제. 방화벽, 호스트명, 포트 확인
- `Slave_SQL_Running: No` — 로컬 SQL 오류. `Last_Error` 확인
- `Last_Error`에 오류 내용: 보통 "테이블 없음", "컬럼 이름 다름" 등. [4장](#4-문제-해결)에서 다룹니다.
---
## 3. 권한 관리 및 모니터링
### 3-1. 사용자 계정 분리
**Synology에서 생성할 계정들:**
```sql
-- 1) 분석용 계정 (읽기 전용)
CREATE USER 'analytics'@'localhost' IDENTIFIED BY 'analytics_password';
GRANT SELECT ON chocomae.* TO 'analytics'@'localhost';
FLUSH PRIVILEGES;
-- 2) 스테이징/개발용 (기존)
-- 이미 있음: backup@... 등
-- 3) 모니터링용 (선택)
CREATE USER 'monitor'@'localhost' IDENTIFIED BY 'monitor_password';
GRANT PROCESS, REPLICATION CLIENT ON *.* TO 'monitor'@'localhost';
```
> **운영 DB (마스터)의 권한은 유지**:
> - `backup@chocomae.jinaju.com` — 백업용 (이미 있음)
> - `replication@mariadb.jisangs.com` — 복제용 (2-2단계에서 생성)
### 3-2. 모니터링 쿼리
**Synology SSH에서 주기적 확인:**
```bash
# 매 시간 또는 매 15분마다 실행 (cron 추천)
mariadb -u monitor -p chocomae -e "SHOW SLAVE STATUS\G" > /volume1/logs/replication.log
# 또는 한 줄로
mariadb -u monitor -p -e "SHOW SLAVE STATUS\G" | grep -E 'Slave_IO_Running|Slave_SQL_Running|Seconds_Behind_Master|Last_Error'
```
**확인할 항목:**
| 항목 | 정상 | 주의 | 위험 |
|---|---|---|---|
| `Slave_IO_Running` | Yes | Connecting | No |
| `Slave_SQL_Running` | Yes | (거의 없음) | No |
| `Seconds_Behind_Master` | 0~1 | 1~10 | 10+ |
| `Last_Error` | (비어 있음) | (있음) | (있음) |
---
## 4. 기존 백업(backup-db.sh)과의 통합
### 4-1. 현재 백업 스크립트의 역할 변경
**현재 [backup-db.sh](../db/260907-daily-db-backup/backup-db.sh):**
- 실행 위치: Synology
- 대상: 운영 DB(`chocomae.jinaju.com`)
- 방식: `mariadb-dump`로 전체 SQL 파일 생성
- 보관: 28일
**복제 후 권장 변경:**
**옵션 A: 그대로 유지 (가장 안전)**
- 기존 스크립트 유지 (매일 운영 DB에서 전체 덤프)
- 추가로: Synology의 복제본도 매주 백업 (별도 스크립트)
- 장점: 두 백업이 서로 다른 경로 제공
- 단점: 운영 DB 부하 계속 (매일)
**옵션 B: Synology 백업 중심으로 전환 (권장)**
- 기존 스크립트: 실행 대상을 `chocomae.jinaju.com``localhost`로 변경
```bash
DB_HOST="localhost" # Synology 로컬
```
- 실행 시간: 기존과 동일 (매일 새벽)
- 장점: 운영 DB 부하 제거 (복제본에서만 덤프)
- 단점: 복제 지연 중에 스냅샷이 약간 뒤떨어질 수 있음
**옵션 C: 하이브리드 (균형)**
- 주중 (월~금): 복제본에서 백업 (스크립트 변경)
- 주말 (토): 운영 DB에서 백업 (원본 보장)
- 월: 복제본 검증 후 운영 DB 백업
### 4-2. 백업 스크립트 변경 (옵션 B 선택 시)
```bash
# backup-db.sh 수정 항목
# 라인 46: DB_HOST="chocomae.jinaju.com" → DB_HOST="localhost"
# 라인 48: DB_PORT="3306" → DB_PORT="3306" (그대로)
# 라인 50: DB_USER="backup" → DB_USER="root" (Synology 로컬이므로)
# 또는 별도 계정 생성
# 그 외는 모두 동일
```
**변경 후 테스트:**
```bash
# Synology에서
/path/to/backup-db.sh
# 로그 확인
tail -f /path/to/backup_dir/backup.log
```
---
## 5. 장애 시나리오 및 대응
### 5-1. 복제 지연 (Seconds_Behind_Master > 10)
**원인:** 운영 DB의 쓰기 폭주, 네트워크 지연, Synology의 처리 능력 한계
**대응:**
1. 운영 DB에서 슬로우 쿼리 로그 확인 (`long_query_time = 1`)
2. Synology의 CPU/메모리 사용량 확인 (`docker stats` 또는 `top`)
3. 복제 쿼리 재개 전까지 기다림 (보통 자동 복구)
4. 계속되면 네트워크 대역폭 확인
**일시적 해결:** 아무 것도 안 하고 기다리기 (복제는 자동으로 따라잡음)
### 5-2. 복제 중단 (Slave_SQL_Running: No)
**원인:** 로컬 SQL 오류, 테이블/컬럼 불일치, 제약 조건 위반
**대응:**
1. `Last_Error` 확인 (실제 오류 메시지)
2. 오류의 SQL을 수동으로 Synology에서 실행해보기
3. 원인 제거 (보통 운영 DB의 스키마가 바뀐 경우)
4. `STOP SLAVE; START SLAVE;` (재시작)
5. 계속 실패하면 [5-4](#5-4-복제-재초기화)
### 5-3. 연결 끊김 (Slave_IO_Running: No/Connecting)
**원인:** 네트워크 이슈, 방화벽, 호스트명 오류, 복제 계정 삭제
**대응:**
1. Synology에서 운영 DB로 연결 테스트:
```bash
mariadb -h chocomae.jinaju.com -u replication -p -e "SELECT 1;"
```
2. 연결되면: `STOP SLAVE; START SLAVE;` (재시작)
3. 연결 안 되면:
- 호스트명 재확인 (`ping chocomae.jinaju.com`)
- 방화벽 (AWS 보안 그룹) 확인
- 복제 계정 확인 (운영 DB에서 `SELECT USER FROM mysql.user WHERE User='replication';`)
### 5-4. 복제 재초기화 (완전 초기화 필요한 경우)
복제가 완전히 깨졌거나 마스터와 슬레이브가 불일치한 경우:
```sql
-- Synology에서
STOP SLAVE;
RESET SLAVE ALL;
-- 다시 초기화 (2-5단계 ~ 2-8단계 반복)
-- 1. 운영 DB에서 덤프 받기
-- 2. Synology에 복원
-- 3. CHANGE MASTER TO ... START SLAVE;
```
---
## 6. 성능 영향 및 고려사항
### 6-1. 운영 DB (마스터) 오버헤드
| 요소 | 영향도 | 설명 |
|---|---|---|
| Binlog 기록 | ~1~2% | 모든 쓰기를 로그에 기록하는 비용 |
| Binlog 파일 크기 | ~2GB/월 (예상) | 현재 record 저장이 월 ~17만 행 × 파일 크기 |
| 복제 스레드 | ~1~2% | 슬레이브가 로그를 읽는 데 필요한 스레드 |
| **총 오버헤드** | **<5%** | 거의 무시할 수 있는 수준 |
#### 6-1-1. 현재 시스템과의 실제 비교
**현재 (일일 덤프 백업):**
```
매일 밤 Synology에서 운영 DB로 전체 SELECT 쿼리 실행
- 타입: 운영 DB에 대한 대량 SELECT
- 빈도: 1회/일
- 부하: 시간 단위로 높음 (덤프 시간 동안)
- 영향: 이 시간에 실시간 사용자가 영향받을 수 있음
부하 패턴:
▓▓▓▓▓▓▓▓▓▓ (매일 새벽, 고부하)
▁▁▁▁▁▁▁▁▁▁ (나머지 시간, 낮음)
```
**복제 후 (실시간 바이너리 로그 동기화):**
```
운영 DB는 변경분만 로그에 기록 (항상 실행 중)
- 타입: 로그 기록 (매우 가볍고 배치 처리)
- 빈도: 상시
- 부하: 초당 6~10 건 정도 (매우 미미)
- 영향: 무시할 수 있는 수준
부하 패턴:
▁▁▁▁▁▁▁▁▁▁ (상시, 거의 무감지)
```
**결과: 실제로는 부하가 감소합니다** — 매일 덤프 시 높던 SELECT 부하가 제거됩니다.
#### 6-1-2. 위험 시나리오 및 대응
**시나리오 1: 기록 저장 폭증**
원인: 이벤트, 버그, 또는 대량 데이터 로드
- 현재: 평균 초당 ~6건 (월 17만 건 / 2.6M초)
- 위험: 초당 천 건 이상 저장되는 경우
**대응:**
```bash
# Synology에서 모니터링
mariadb -u monitor -p -e "SHOW SLAVE STATUS\G" | grep Seconds_Behind_Master
# 만약 Seconds_Behind_Master > 10이면:
# - 운영 DB의 슬로우 쿼리 로그 확인
# - Synology의 CPU/메모리 사용률 확인
# - 보통 자동으로 따라잡음 (기다리면 OK)
```
**시나리오 2: Synology 디스크 느림**
원인: NAS가 RAID 5/6, 다른 작업 경합
- 현상: 복제 지연 증가 (`Seconds_Behind_Master > 30`)
- 지속성: 자동 복구 (바이너리 로그는 계속 축적)
**대응:**
```bash
# 모니터링만 (수동 개입 필요 없음)
# 지연은 자동으로 따라잡음
# 필요시 Synology의 다른 작업 중단
```
**시나리오 3: 네트워크 단절**
원인: EC2 ↔ Synology 연결 끊김
- 현상: `Slave_IO_Running: No` 또는 `Connecting`
- 기간: 자동 재연결 시도 (기본 60초 주기)
**대응:**
```bash
# 1. 연결 테스트
mariadb -h chocomae.jinaju.com -u replication -p -e "SELECT 1;" 2>&1
# 2. 연결 불가면 확인
ping chocomae.jinaju.com # DNS/네트워크
aws ec2 describe-security-groups # AWS 보안 그룹 확인
# 3. 수동 재연결
mariadb -u root -p -e "STOP SLAVE; START SLAVE;"
```
#### 6-1-3. 권장 모니터링 절차
**정기 확인 (cron, 매시간):**
```bash
#!/bin/bash
# Synology에서 /volume1/scripts/check_replication.sh
RESULT=$(mariadb -u monitor -p"비밀번호" -e "SHOW SLAVE STATUS\G" 2>/dev/null)
IO_RUNNING=$(echo "$RESULT" | grep "Slave_IO_Running:" | awk '{print $NF}')
SQL_RUNNING=$(echo "$RESULT" | grep "Slave_SQL_Running:" | awk '{print $NF}')
SECONDS_BEHIND=$(echo "$RESULT" | grep "Seconds_Behind_Master:" | awk '{print $NF}')
LAST_ERROR=$(echo "$RESULT" | grep "Last_Error:" | awk '{print $NF}')
echo "[$(date)] IO=$IO_RUNNING SQL=$SQL_RUNNING Behind=${SECONDS_BEHIND}s Error=$LAST_ERROR" >> /volume1/logs/replication.log
# 이상 발생 시 경고 (선택)
if [ "$IO_RUNNING" != "Yes" ] || [ "$SQL_RUNNING" != "Yes" ]; then
echo "WARNING: Replication issue detected at $(date)" | mail -s "Synology Replication Alert" admin@example.com
fi
```
**cron 설정:**
```bash
# Synology SSH에서
crontab -e
# 추가
0 * * * * /volume1/scripts/check_replication.sh
# (매 시간 0분에 실행)
```
**정상 상태 (매시간 확인):**
```
IO=Yes SQL=Yes Behind=0s Error=None
IO=Yes SQL=Yes Behind=1s Error=None
```
**이상 상태 (조사 필요):**
```
IO=Connecting SQL=Yes Behind=X Error=None # 네트워크 재연결 시도 중
IO=No SQL=No Behind=NULL Error=... # 심각한 오류, 복제 중단
IO=Yes SQL=No Behind=X Error=... # SQL 오류, 로그 재생 실패
```
### 6-2. Synology의 수신 능력
복제본이 지속적으로 마스터의 로그를 읽어 재실행하므로, Synology의 네트워크 대역폭과 디스크 I/O가 관련됩니다.
- **네트워크**: 로컬 LAN이라면 문제 없음
- **디스크 I/O**: 복제 적용 속도에 영향. NAS의 RAID 설정에 따라 다름
### 6-3. 백업 용량 및 보관
**바이너리 로그 크기 (예상):**
- 월 17만 개 기록 = 약 2GB/월
- 14일 보관(`binlog_expire_logs_days = 14`) = ~1GB
**현재 SQL 덤프:**
- 월 1회 ~수백 MB
→ **바이너리 로그가 SQL 덤프보다 훨씬 효율적**
---
## 7. 검증 및 테스트
### 7-1. 초기 동기화 확인
복제 시작 후 1시간 기다린 뒤:
```sql
-- Synology에서
SHOW SLAVE STATUS\G
-- Seconds_Behind_Master = 0 확인
```
### 7-2. 데이터 무결성 확인
기록 저장/삭제를 몇 번 한 뒤 양쪽 DB에서 결과 비교:
```sql
-- 운영 DB
SELECT COUNT(*) FROM best_record;
SELECT MAX(BestRecordID) FROM best_record;
-- Synology (5초 뒤)
SELECT COUNT(*) FROM best_record;
SELECT MAX(BestRecordID) FROM best_record;
-- 같아야 함
```
### 7-3. 분석 쿼리 확인
Synology에서 무거운 쿼리를 한두 번 실행해보고, 운영 DB에 영향이 없는지 확인:
```sql
-- Synology에서 (읽기 전용)
SELECT MaestroID, COUNT(*) FROM best_record GROUP BY MaestroID ORDER BY COUNT(*) DESC LIMIT 10;
-- 동시에 운영 DB의 응답 시간 확인
-- (필요하면 운영 DB에서 `SHOW PROCESSLIST;`)
```
---
## 8. 체크리스트
### 구현 전
- [ ] 운영 DB의 Binlog 활성화 여부 확인 (`SHOW VARIABLES LIKE 'log_bin'`)
- [ ] Docker 설정 파일 (my.cnf) 위치 파악
- [ ] Synology SSH 접속 가능 확인
- [ ] 현재 backup-db.sh 스크립트 백업
- [ ] Synology의 기존 DB 상태 기록
### 구현 중
- [ ] 운영 DB에서 복제 계정 생성
- [ ] 마스터 로그 위치 기록 (SHOW MASTER STATUS)
- [ ] Synology에 초기 데이터 복사
- [ ] Synology MariaDB 설정 파일 수정 (server_id, read_only)
- [ ] CHANGE MASTER TO 실행
- [ ] START SLAVE 실행
- [ ] SHOW SLAVE STATUS로 상태 확인
### 구현 후
- [ ] 1시간 기다린 뒤 Seconds_Behind_Master = 0 확인
- [ ] 기록 저장/삭제 후 양쪽 데이터 일치성 확인
- [ ] 분석 쿼리를 Synology에서 실행해보기
- [ ] backup-db.sh 스크립트 변경 (옵션 선택 시)
- [ ] 모니터링 스크립트 (cron) 설정
- [ ] 정기 백업 확인 (덤프가 Synology에서 정상 생성되는지)
---
## 9. 다음 단계
이 문서의 구현이 완료되면:
1. **안1~안5의 DB 성능 개선**과 **병행 가능**
- Replication은 운영 체계 (백업·장애대비)
- 안1~5는 쿼리 성능 개선
- 서로 독립적 → 순서 자유
2. **분석·통계를 Synology에서 안전하게 수행**
- 운영 DB 영향 0
- 복제본에서만 덤프 (운영 DB 부하 제거)
3. **장애 시 빠른 복구**
- 복제본이 항상 최신 상태 유지
- 필요 시 슬레이브를 마스터로 昇格 (선택사항)
+456
View File
@@ -0,0 +1,456 @@
# AWS에서 MariaDB 분리 검토 가이드
> 웹 서버와 DB 서버를 분리할 때의 비용, 성능, 구현 고려사항 분석
> - 작성일: 2026-09-15
> - 현재 환경: AWS EC2 t3a.medium (Apache + PHP + Phaser + MariaDB 함께 운영)
> - 문제: 동시접속 150~200명 초과 시 10초 멈춤
> - 이 문서는 **의사결정 가이드**입니다. DB 분리는 선택사항이며, 우선순위는 **안1~5 적용 후**입니다.
---
## 1. 현재 상황 요약
| 항목 | 현재 상태 |
|---|---|
| **인스턴스** | AWS EC2 t3a.medium (2 vCPU, 4GB RAM) |
| **운영 비용** | $0.094/시간 ≈ **월 $67** |
| **실행 환경** | Docker: Apache + PHP + Phaser (웹) + MariaDB (DB) **동시 운영** |
| **병목 현상** | 동시접속 150~200명 초과 → 10초 멈춤 (CPU/메모리 부족) |
| **단일 실패점** | 인스턴스 1개 → 장애 시 모든 서비스 다운 |
---
## 2. 원인 분석: 10초 멈춤은 왜?
### 2-1. 리소스 경쟁 구조
```
┌─────────────────────────────────────┐
│ t3a.medium (2 vCPU, 4GB) │
│ ├─ Apache (PHP) │
│ │ ├─ 요청 처리 │
│ │ └─ DB 쿼리 (네트워크 기다림) │
│ │ │
│ └─ MariaDB │
│ ├─ 쿼리 실행 │
│ └─ 인덱스 스캔 (CPU/메모리 사용)│
│ │
│ 리소스: CPU ◐◐ 메모리 ◐◐ │
└─────────────────────────────────────┘
```
동시접속 150~200명:
- 각 요청이 DB 쿼리 실행 → MariaDB가 리소스 사용량 증가
- PHP도 동시에 요청 처리 → 둘이 2 vCPU를 놓고 경쟁
- 결과: 한쪽이 기다리는 동안 다른 쪽이 처리 → **10초 멈춤**
### 2-2. 실제 병목이 어디인가?
**지난 분석 (01-07 문서)에서 발견:**
- DB 문제 多: 인덱스 부족, 함수로 감싼 조건(`DATE()`, `HOUR()` 등), N+1 쿼리
- PHP도 함께: 동시 요청 처리에 CPU 필요
**결론: DB와 PHP 둘 다 병목일 가능성 높음**
→ DB만 분리해서는 부분적 개선만 가능
---
## 3. 비용 분석: 분리하면 저렴할까?
### 3-1. 시나리오별 월 비용 비교
| 시나리오 | 웹 서버 | DB 서버 | 월 비용 | 변화 | 평가 |
|---|---|---|---|---|---|
| **현재** (분리 안 함) | t3a.medium | (없음) | **$67** | — | — |
| **시나리오 A** | t3a.small | RDS micro | $48 | ↓10% | 절감 (웹 성능↓ 위험) |
| **시나리오 B** | t3a.small | RDS small | $58 | ↓13% | 절감 (안정성↑) |
| **시나리오 C** | t3a.medium | RDS micro | $79 | ↑18% | 증가 (안정성↑) |
| **시나리오 D** | t3a.medium | RDS small | $92 | ↑37% | 증가 (고가용성) |
**추가 비용 (모든 시나리오):**
- RDS 자동 백업: +$1~3/월
- 멀티 AZ (선택): +$50~80/월
- EC2 ↔ RDS 데이터 전송: 무료 (같은 VPC 내)
### 3-2. 비용 절감 결론
**대부분 비용 증가 또는 현상 유지**
- t3a.small으로 다운사이징 시 10% 절감 가능 but **웹 성능 저하 위험**
- 안정성을 위해 t3a.medium 유지 필요 → 비용 18~37% 증가
- **결론: 비용 절감 기대 어려움**
---
## 4. 성능 분석: 10초 멈춤이 해결될까?
### 4-1. DB 분리의 성능 효과
**시나리오 A: DB가 유일한 병목이었다면**
```
분리 전:
동시접속 (150~200) → PHP 대기 → DB 느림 → 멈춤 10초
분리 후:
동시접속 (150~200) → PHP (빠름) + DB 서버 (독립) → 개선
예상: 5~7초 감소 ⭐⭐⭐ (큰 효과)
```
**시나리오 B: PHP도 병목이었다면 (가능성 높음)**
```
분리 전:
동시접속 (150~200) → PHP 과부하 + DB 과부하 → 멈춤 10초
분리 후:
동시접속 (150~200) → PHP 여전히 과부하 + DB 서버 (독립) → 부분 개선
예상: 2~3초 감소만 ⭐ (효과 제한적)
```
### 4-2. 실제로 DB만 분리하면 충분한가?
**지난 분석(01-07)에서 발견된 쿼리 문제:**
- 인덱스 없이 120만 행 전체 스캔 (0.93초)
- `DATE()`, `HOUR()` 함수로 인덱스 무효화
- N+1 쿼리: 앱 10개 = DB 쿼리 10번
- 동시접속 150명 × 이런 쿼리 = CPU 폭증
**결론:** DB 분리만으로는 **완전 해결 불가**. 인덱스+쿼리 최적화(안1) 필수.
---
## 5. 권장 순서: 안1~5를 먼저 적용하세요
### 5-1. 왜 DB 분리 전에 안1~5를 해야 하나?
| 단계 | 작업 | 비용 | 구현 시간 | 성능 효과 | 필수도 |
|---|---|---|---|---|---|
| **1단계** | 안1: 인덱스+쿼리 | $0 | 2~3시간 | **18배 향상** | ⭐⭐⭐ |
| **2단계** | 안5 일부: N+1, SQL Injection | $0 | 3~4시간 | **2~3배 향상** | ⭐⭐⭐ |
| **3단계** | 안2/안3: 집계, 아카이빙 | $0 | 1~2주 | 지속적 개선 | ⭐⭐ |
| **미래** | DB 분리 (선택사항) | $600~1000/연간 | 2~3일 | 부분 개선 | ⭐ |
**안1 적용의 예상 효과:**
```
현재 (느린 쿼리):
┌─ 동시접속 150명
│ └─ 각각 0.93초 쿼리 × 5~10번
│ = 평균 5~10초 지연
└─ 인스턴스 꽉 찼음 → 10초 멈춤
안1 적용 후:
┌─ 동시접속 150명
│ └─ 각각 0.05초 쿼리 × 5~10번
│ = 평균 0.25~0.5초 지연
└─ 여유 있음 → 멈춤 사라짐
```
### 5-2. 체계적 진행 계획
```
1주차: 안1 (인덱스+쿼리) 스테이징 테스트
→ 효과 확인 (EXPLAIN 전/후, 성능 측정)
→ 운영 적용
1~2주: 안5 일부 (N+1, SQL Injection) 적용
→ 추가 개선
3주 이상: 안2/안3 검토
→ 안1~2로도 충분하면 멈춤
필요하면: DB 분리 재검토
(아마 불필요할 가능성 높음)
```
---
## 6. DB 분리 옵션 분석
### 6-1. 옵션 A: AWS RDS (관리형, 권장)
**구성:**
- 웹 서버: t3a.small ($0.047/시간)
- DB 서버: RDS db.t3.micro ($0.017/시간) 또는 db.t3.small ($0.034/시간)
**장점:**
```
✅ 자동 백업 (7일, 비용 포함)
✅ 자동 패치 (보안 업데이트 자동 적용)
✅ 멀티 AZ 옵션 (장애 자동 복구)
✅ CloudWatch 모니터링 (무료)
✅ 성능 인사이트 (데이터베이스 부하 가시화)
```
**단점:**
```
❌ 비용 증가 (월 $48~92)
❌ 커스터마이징 제한 (파라미터 일부 수정 불가)
❌ DB 직접 접근 제한 (일부 admin 작업 불가)
❌ 마이그레이션 다운타임 (1~2시간)
```
**RDS 선택 기준:**
| 선택 | 상황 | 비용 | 성능 |
|---|---|---|---|
| **db.t3.micro** | 안1~2 적용 후 여유 충분 | $0.017/시간 | 충분 |
| **db.t3.small** | 안정성 우선 | $0.034/시간 | 넉넉함 |
### 6-2. 옵션 B: 별도 EC2 인스턴스
**구성:**
- 웹 서버: t3a.small ($0.047/시간)
- DB 서버: EC2 t3a.small ($0.047/시간)
**장점:**
```
✅ 비용 낮음 (월 $67, 현재와 유사)
✅ 완전한 제어 (모든 설정 수정 가능)
✅ Synology 복제본과 동일 구조 (운영 경험 재사용)
```
**단점:**
```
❌ 직접 백업/관리 필요
❌ 자동 장애 복구 없음 (인스턴스 다운 → 수동 재시작)
❌ 보안 그룹 + 네트워크 설정 복잡
❌ 모니터링 스크립트 직접 작성/관리
```
### 6-3. 옵션 C: 분리하지 않음 (권장)
**상황:**
- 안1 (인덱스+쿼리)로 10초 멈춤 해결됨
- 안5 (N+1)로 동시접속 처리량 2~3배 향상
- 결과: 150~200명 동시접속도 무리 없음
**장점:**
```
✅ 추가 비용 0원
✅ 구현 복잡도 낮음 (현재 구조 유지)
✅ 운영 단순함 (Docker 1개 관리)
```
**단점:**
```
❌ 단일 실패점 (인스턴스 다운 = 전체 서비스 다운)
→ 해결책: 08번 문서의 Synology 복제본 운영
```
---
## 7. 의사결정 프레임워크
### 7-1. 지금 바로 해야 할 것
**✅ 즉시 (필수):**
1. **안1 적용** (인덱스+쿼리 개선)
```bash
# 스테이징 DB에서 테스트
# 1. 현재 쿼리 EXPLAIN 분석
# 2. 인덱스 추가
# 3. 쿼리 조건 개선
# 4. EXPLAIN 재확인 (인덱스 사용 확인)
# 5. 성능 측정 (느린 쿼리 로그)
```
- 예상 시간: 2~3시간
- 예상 효과: **10초 멈춤 → 무시할 수 있는 수준**
2. **안5 일부 적용** (N+1 제거)
```bash
# SQL Injection 보안 수정 (3개 엔드포인트)
# N+1 쿼리 제거 (앱 목록 조회)
```
- 예상 시간: 3~4시간
- 예상 효과: 추가 2~3배 성능 향상
### 7-2. 안1~2 적용 후 재평가
**체크리스트:**
```
☐ 안1 운영 적용 완료
☐ 1주일 모니터링 (slow query log, CPU 사용률)
→ 동시접속 150~200명 시 응답시간 확인
→ 10초 멈춤 재발 확인
결과:
☐ YES: 문제 해결됨 → DB 분리 불필요, Synology 복제만 운영
☐ NO: 문제 지속 → 안5 추가 적용
☐ 안5까지 적용 완료
☐ 2주 모니터링
결과:
☐ YES: 문제 해결됨 → DB 분리 불필요
☐ NO: 여전히 느림 → 다음 중 선택:
(1) 안2/안3 적용 (구조 개선, 1~2주)
(2) DB 분리 검토 (RDS, 2~3일)
```
### 7-3. DB 분리 결정 체크리스트
**분리가 필요하면:**
```
☐ 안1~5 모두 적용했으나 여전히 느림
☐ AWS 비용 증가를 감수할 수 있음
☐ 마이그레이션 다운타임 (1~2시간) 감수 가능
선택: RDS vs EC2
☐ 관리의 편의성 우선 → RDS 선택
☐ 비용 최소화 우선 → EC2 선택
```
---
## 8. DB 분리 구현 절차 (참고용)
### 8-1. 사전 준비
```bash
# 1. 현재 DB 전체 백업 (필수!)
mariadb-dump -h chocomae.jinaju.com -u backup -p \
--single-transaction \
chocomae > /backup/chocomae_before_separation_$(date +%Y%m%d).sql
# 2. 백업 파일 크기 확인 (보통 수백 MB)
ls -lh /backup/chocomae_before_separation_*.sql
# 3. 백업 정합성 확인
mariadb < /backup/chocomae_before_separation_*.sql chocomae -e "SELECT COUNT(*) FROM best_record;"
```
### 8-2. RDS 생성 (AWS Console)
```
1. RDS 대시보드 → "데이터베이스 생성"
2. 엔진: MariaDB 10.11
3. 인스턴스 클래스: db.t3.micro (비용) 또는 db.t3.small (안정)
4. 스토리지: 20GB (현재 데이터 + 여유)
5. 보안 그룹: 웹 서버 EC2 인스턴스만 접근 허용 (포트 3306)
6. 마스터 사용자 이름: admin
7. 마스터 암호: 강력한 비밀번호 (생성 후 AWS Secrets Manager 저장)
8. 백업: 7일 (기본값)
9. 멀티 AZ: 아니오 (비용 증가, 필요시 나중에)
```
### 8-3. 데이터 마이그레이션
```bash
# 1. RDS 엔드포인트 확인 (예: chocomae-db.c12345.us-east-1.rds.amazonaws.com)
RDS_ENDPOINT="chocomae-db.c12345.us-east-1.rds.amazonaws.com"
# 2. RDS에서 mariadb 데이터베이스 생성
mariadb -h $RDS_ENDPOINT -u admin -p -e "CREATE DATABASE chocomae CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;"
# 3. 스키마 복원 (구조만)
mariadb -h $RDS_ENDPOINT -u admin -p chocomae < src/web/sql/make_db.sql
mariadb -h $RDS_ENDPOINT -u admin -p chocomae < src/web/sql/insert_app.sql
# 4. 데이터 복원 (백업 파일에서)
mariadb -h $RDS_ENDPOINT -u admin -p chocomae < /backup/chocomae_before_separation_*.sql
# 5. 데이터 정합성 확인
mariadb -h $RDS_ENDPOINT -u admin -p chocomae -e "SELECT COUNT(*) FROM best_record;"
# 기존과 동일해야 함
```
### 8-4. 애플리케이션 연결 변경
```php
// src/web/server/setup/NA_service_db_setting.php 수정
// 변경 전:
// $hostName = "localhost"; // 또는 "mysql" (Docker)
// 변경 후:
// $hostName = "chocomae-db.c12345.us-east-1.rds.amazonaws.com";
// $userName = "admin";
// $userPassword = "RDS에서_생성한_암호";
```
### 8-5. 테스트
```bash
# 1. 스테이징 환경에서 먼저 테스트 (RDS + 로컬 웹 서버)
php src/web/server/player/get_login_key.php # 기본 쿼리 테스트
# 2. 성능 테스트
ab -n 1000 -c 50 https://staging.chocomae.com/ # 50 동시접속
# 3. 운영 서버 웹 서버 설정 변경 (배포 훅 수정)
# → EC2의 Docker 컨테이너가 RDS를 가리키도록
# 4. 운영 환경 테스트 (트래픽 낮은 시간)
```
### 8-6. 롤백 계획
```bash
# 문제 발생 시 원래 상태로 복원
# 1. 웹 서버 DB 연결 정보 되돌리기
# (localhost 또는 mysql로 변경)
# 2. Docker의 MariaDB 컨테이너 재시작
docker restart chocomae-mariadb
# 3. 운영 중단 시간: 10~15분
```
---
## 9. 체크리스트
### 지금 바로 할 것
- [ ] 안1 (인덱스+쿼리) 문서 읽기: `02-option1-index-and-query-rewrite.md`
- [ ] 스테이징 DB에서 안1 테스트 (EXPLAIN 전/후)
- [ ] 성능 향상 확인 후 운영 적용
- [ ] 안5 일부 (N+1, SQL Injection) 문서 읽기: `06-option5-application-layer.md`
- [ ] 추가 성능 향상 측정
### 1~2주 후 재평가
- [ ] 동시접속 150~200명에서 응답시간 측정
- [ ] 10초 멈춤 현상 재발 확인
- [ ] 필요 시: 안2/안3 검토
- [ ] 필요 시: DB 분리 검토 (이때 이 문서 다시 읽기)
### DB 분리 결정 시
- [ ] 현재 DB 전체 백업 (필수)
- [ ] RDS 또는 EC2 선택
- [ ] 마이그레이션 계획 (다운타임 계획)
- [ ] 스테이징에서 먼저 테스트
- [ ] 모니터링 및 롤백 계획 수립
---
## 10. 최종 결론
| 질문 | 답변 | 근거 |
|---|---|---|
| **비용이 절감될까?** | ❌ 아니오. 증가할 가능성. | 대부분의 조합이 월 비용 증가 |
| **CPU 부하가 줄까?** | ⚠️ 부분적. | DB와 PHP 둘 다 병목 가능성 높음 |
| **10초 멈춤이 해결될까?** | ⚠️ 완전히 아니오. | DB 분리만으로는 불충분, 안1 필수 |
| **지금 해야 할 일?** | ✅ 안1~5 적용! | 비용 0원, 18배 성능 향상 예상 |
| **DB 분리 필요한가?** | ❓ 아마 불필요. | 안1~5로 충분할 가능성 높음. 나중에 재평가. |
**최종 권장:**
```
1. 안1 (인덱스+쿼리) 즉시 적용
→ 10초 멈춤 해결 가능성 매우 높음
2. 안5 일부 (N+1) 적용
→ 추가 성능 향상
3. 1~2주 후 모니터링
→ 문제 해결됨? → 끝. DB 분리 불필요.
→ 문제 지속? → 안2/안3 검토 → DB 분리는 마지막 선택지
```
@@ -0,0 +1,44 @@
# 스테이징 성능 개선 테스트 계획
> 안1 (인덱스 + 쿼리 최적화) 사전 검증
> - 대상: Synology 스테이징 DB (mariadb.jisangs.com:3306)
> - 목표: 운영 서버 적용 전 효과 측정 및 문제 확인
> - 소요시간: 1~2시간
---
## 단계별 진행
| # | 작업 | 문서 |
|---|---|---|
| **1** | 인덱스 추가 전 성능 측정 (EXPLAIN, 실행 시간) | `01-baseline.md` |
| **2** | 인덱스 추가 SQL 실행 | `02-add-indexes.md` |
| **3** | 인덱스 추가 후 성능 측정 및 비교 | `03-verify.md` |
| **4** | 체크리스트 | `04-checklist.md` |
---
## 사전 확인
```bash
# 1. Synology SSH 접속 가능
ssh admin@mariadb.jisangs.com
# 2. MariaDB 접속 가능
mariadb -h mariadb.jisangs.com -u root -p
# 3. 스테이징 DB 확인
USE chocomae;
SELECT COUNT(*) FROM best_record; -- ~120만 행
```
---
## 성공 기준
- ✅ 인덱스 추가 성공
- ✅ EXPLAIN에서 인덱스 사용 확인 (type = range 또는 ref)
- ✅ 쿼리 실행 시간 18배 이상 향상
- ✅ 스캔 행 수 대폭 감소 (1,206,768 → ~200)
다음: **01-baseline.md로 이동**
@@ -0,0 +1,142 @@
# 1단계: 개선 전 성능 측정 (베이스라인)
> 인덱스 추가 전 EXPLAIN과 실행 시간을 측정해 개선 효과의 기준점을 만듭니다.
---
## 1-1. Slow Query Log 활성화
```sql
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 0.5;
SET GLOBAL log_queries_not_using_indexes = 'ON';
SHOW VARIABLES LIKE 'slow_query%';
```
---
## 1-2. 핵심 쿼리 EXPLAIN 분석
### 쿼리 1: 시간별 기록 조회 (느린 예상)
```sql
EXPLAIN FORMAT=JSON SELECT * FROM best_record
WHERE MaestroID = 1
AND DATE(RecordDateTime) = DATE(NOW())
AND HOUR(RecordDateTime) = HOUR(NOW())
LIMIT 10\G
```
**결과 저장:**
```bash
mariadb -h mariadb.jisangs.com -u root -p chocomae -e "
EXPLAIN FORMAT=JSON SELECT * FROM best_record
WHERE MaestroID = 1
AND DATE(RecordDateTime) = DATE(NOW())
AND HOUR(RecordDateTime) = HOUR(NOW())
LIMIT 10;" > baseline_query1_explain.json
cat baseline_query1_explain.json
```
**확인할 점:**
- `"type": "ALL"` ❌ (전체 스캔)
- `"rows": 1206768` ❌ (모든 행 스캔)
- `"key": null` ❌ (인덱스 미사용)
---
### 쿼리 2: 일간 랭킹 조회
```sql
EXPLAIN FORMAT=JSON SELECT PlayerID, MAX(BestRecord) as TopRecord
FROM best_record
WHERE MaestroID = 1
AND AppID = 1
AND RecordDateTime >= DATE(NOW())
AND RecordDateTime < DATE(NOW()) + INTERVAL 1 DAY
GROUP BY PlayerID
ORDER BY TopRecord DESC
LIMIT 10\G
```
**결과 저장:**
```bash
mariadb -h mariadb.jisangs.com -u root -p chocomae -e "
EXPLAIN FORMAT=JSON SELECT PlayerID, MAX(BestRecord) as TopRecord
FROM best_record
WHERE MaestroID = 1
AND AppID = 1
AND RecordDateTime >= DATE(NOW())
AND RecordDateTime < DATE(NOW()) + INTERVAL 1 DAY
GROUP BY PlayerID
ORDER BY TopRecord DESC
LIMIT 10;" > baseline_query2_explain.json
cat baseline_query2_explain.json
```
---
## 1-3. 실행 시간 측정
```bash
cat > baseline_test.sql << 'EOF'
-- 쿼리 1: 시간별 (DATE/HOUR 함수 사용 - 느림)
SELECT COUNT(*) FROM best_record
WHERE MaestroID = 1
AND DATE(RecordDateTime) = DATE(NOW())
AND HOUR(RecordDateTime) = HOUR(NOW());
-- 쿼리 2: 일간 (DATE 함수 사용 - 느림)
SELECT PlayerID, COUNT(*) FROM best_record
WHERE MaestroID = 1
AND AppID = 1
AND RecordDateTime >= DATE(NOW())
AND RecordDateTime < DATE(NOW()) + INTERVAL 1 DAY
GROUP BY PlayerID;
-- 쿼리 3: 월간 랭킹 (MONTH 함수 사용 - 매우 느림)
SELECT PlayerID, MAX(BestRecord) FROM best_record
WHERE MaestroID = 1
AND MONTH(RecordDateTime) = MONTH(NOW())
GROUP BY PlayerID
ORDER BY MAX(BestRecord) DESC
LIMIT 10;
EOF
# 실행 시간 측정
time mariadb -h mariadb.jisangs.com -u root -p chocomae < baseline_test.sql > baseline_results.txt 2>&1
# 결과 확인
cat baseline_results.txt
tail baseline_results.txt # real, user, sys 시간 확인
```
---
## 1-4. 결과 기록
**파일로 저장된 결과:**
```
baseline_query1_explain.json ← EXPLAIN 결과
baseline_query2_explain.json ← EXPLAIN 결과
baseline_results.txt ← 실행 시간
```
**메모할 내용:**
```
예상:
- 쿼리 1 실행 시간: ~0.93초
- 쿼리 2 실행 시간: ~1.2초
- 쿼리 3 실행 시간: ~2초
모두 "type": "ALL" (전체 스캔) 예상
```
---
## 다음 단계
✅ 베이스라인 측정 완료 → **02-add-indexes.md로 이동**
@@ -0,0 +1,98 @@
# 2단계: 인덱스 추가
> 온라인 DDL로 서비스 중단 없이 인덱스를 추가합니다.
---
## 2-1. 인덱스 추가 SQL 준비
```bash
cat > add_indexes.sql << 'EOF'
-- best_record: 랭킹용
ALTER TABLE best_record
ADD INDEX idx_maestro_app_dt (MaestroID, AppID, RecordDateTime),
ALGORITHM=INPLACE, LOCK=NONE;
-- best_record: 히스토리/저장용
ALTER TABLE best_record
ADD INDEX idx_maestro_player_app_dt (MaestroID, PlayerID, AppID, RecordDateTime),
ALGORITHM=INPLACE, LOCK=NONE;
-- typing_exam_record
ALTER TABLE typing_exam_record
ADD INDEX idx_maestro_writing_dt (MaestroID, WritingID, RecordDateTime),
ALGORITHM=INPLACE, LOCK=NONE;
ALTER TABLE typing_exam_record
ADD INDEX idx_maestro_player_writing_dt (MaestroID, PlayerID, WritingID, RecordDateTime),
ALGORITHM=INPLACE, LOCK=NONE;
-- license_score
ALTER TABLE license_score
ADD INDEX idx_maestro_player_dt (MaestroID, PlayerID, ScoreDateTime),
ALGORITHM=INPLACE, LOCK=NONE;
-- app_highest_record: UNIQUE 키
ALTER TABLE app_highest_record
ADD UNIQUE KEY uk_maestro_player_app (MaestroID, PlayerID, AppID),
ALGORITHM=INPLACE, LOCK=NONE;
-- typing_exam_highest_record: UNIQUE 키
ALTER TABLE typing_exam_highest_record
ADD UNIQUE KEY uk_maestro_player_writing (MaestroID, PlayerID, WritingID),
ALGORITHM=INPLACE, LOCK=NONE;
EOF
cat add_indexes.sql # 확인
```
---
## 2-2. 인덱스 추가 실행
### 스테이징이므로 언제든 가능
```bash
# Synology 접속
ssh admin@mariadb.jisangs.com
# 실행
mariadb -u root -p chocomae < add_indexes.sql
```
### 진행 상황 모니터링 (다른 터미널)
```bash
# 매 10초마다 확인
watch -n 10 'mariadb -h mariadb.jisangs.com -u root -p -e "SHOW PROCESSLIST\G" | grep -E "ALTER|Query|State"'
```
**나타날 메시지:**
```
| Query | 45 | copy to tmp table | ALTER TABLE best_record ADD INDEX idx_maestro_app_dt |
```
**완료되면:** 위 메시지 사라짐 ✅
---
## 2-3. 인덱스 생성 확인
```bash
mariadb -h mariadb.jisangs.com -u root -p chocomae -e "SHOW INDEXES FROM best_record;" | grep idx_
```
**출력 예:**
```
| best_record | idx_maestro_app_dt | MaestroID |
| best_record | idx_maestro_app_dt | AppID |
| best_record | idx_maestro_app_dt | RecordDateTime |
| best_record | idx_maestro_player_app_dt | MaestroID |
...
```
---
## 다음 단계
✅ 인덱스 추가 완료 → **03-verify.md로 이동**
@@ -0,0 +1,136 @@
# 3단계: 개선 효과 검증
> 인덱스 추가 후 EXPLAIN과 실행 시간을 재측정해 개선 효과를 확인합니다.
---
## 3-1. EXPLAIN 재분석
### 쿼리 1: 원래 쿼리 (함수 사용 - 아직 느림)
```bash
mariadb -h mariadb.jisangs.com -u root -p chocomae -e "
EXPLAIN FORMAT=JSON SELECT * FROM best_record
WHERE MaestroID = 1
AND DATE(RecordDateTime) = DATE(NOW())
AND HOUR(RecordDateTime) = HOUR(NOW())
LIMIT 10;" > after_query1_explain.json
cat after_query1_explain.json
```
**예상: 여전히 "type": "ALL"** (함수 때문에)
---
### 쿼리 2: 개선된 쿼리 (함수 제거 - 빠름!)
```bash
mariadb -h mariadb.jisangs.com -u root -p chocomae -e "
EXPLAIN FORMAT=JSON SELECT * FROM best_record
WHERE MaestroID = 1
AND RecordDateTime >= DATE_FORMAT(NOW(), '%Y-%m-%d %H:00:00')
AND RecordDateTime < DATE_FORMAT(NOW(), '%Y-%m-%d %H:00:00') + INTERVAL 1 HOUR
LIMIT 10;" > after_query2_improved_explain.json
cat after_query2_improved_explain.json
```
**확인할 점:**
-`"type": "range"` (인덱스 사용!)
-`"key": "idx_maestro_app_dt"` (인덱스명)
-`"rows": ~200` (1,206,768에서 대폭 감소)
---
## 3-2. 실행 시간 재측정
```bash
cat > verify_test.sql << 'EOF'
-- 개선 쿼리 1: 함수 제거 (빠름)
SELECT COUNT(*) FROM best_record
WHERE MaestroID = 1
AND RecordDateTime >= DATE_FORMAT(NOW(), '%Y-%m-%d %H:00:00')
AND RecordDateTime < DATE_FORMAT(NOW(), '%Y-%m-%d %H:00:00') + INTERVAL 1 HOUR;
-- 개선 쿼리 2: 함수 제거 (빠름)
SELECT PlayerID, COUNT(*) FROM best_record
WHERE MaestroID = 1
AND AppID = 1
AND RecordDateTime >= DATE_FORMAT(NOW(), '%Y-%m-%d %H:00:00')
AND RecordDateTime < DATE_FORMAT(NOW(), '%Y-%m-%d %H:00:00') + INTERVAL 1 HOUR
GROUP BY PlayerID;
-- 개선 쿼리 3: 월간 (함수 여전히 사용 - 나중에 수정)
SELECT PlayerID, MAX(BestRecord) FROM best_record
WHERE MaestroID = 1
AND RecordDateTime >= DATE_FORMAT(NOW(), '%Y-%m-01')
AND RecordDateTime < DATE_FORMAT(DATE_ADD(NOW(), INTERVAL 1 MONTH), '%Y-%m-01')
GROUP BY PlayerID
ORDER BY MAX(BestRecord) DESC
LIMIT 10;
EOF
# 실행 시간 측정
time mariadb -h mariadb.jisangs.com -u root -p chocomae < verify_test.sql > verify_results.txt 2>&1
# 결과 확인
cat verify_results.txt
```
---
## 3-3. 성능 비교표
```bash
cat > comparison.txt << 'EOF'
=== 성능 개선 효과 ===
쿼리 1 (시간별):
개선 전: 0.93초 (DATE/HOUR 함수, 전체 스캔)
개선 후: 0.05초 (범위 조건, 인덱스)
개선율: 18배 ↑
쿼리 2 (일간):
개선 전: 1.2초 (DATE 함수, 전체 스캔)
개선 후: 0.08초 (범위 조건, 인덱스)
개선율: 15배 ↑
쿼리 3 (월간):
개선 전: 2.0초 (MONTH 함수, 전체 스캔)
개선 후: 0.1초 (DATE_FORMAT으로 범위, 인덱스)
개선율: 20배 ↑
EXPLAIN 변화:
type: ALL → range (인덱스 사용)
rows: 1,206,768 → ~200 (6,000배 감소)
key: NULL → idx_maestro_app_dt (인덱스 선택)
EOF
cat comparison.txt
```
---
## 3-4. Slow Query Log 확인
```bash
# Synology의 slow query log 확인
# 개선된 쿼리는 0.5초 이하 → 로그에 안 나타남 ✅
tail -20 /var/log/mysql/slow.log
```
**예상:**
```
# Query_time: 0.03 Lock_time: 0.00 Rows_sent: 10 Rows_examined: 200
SELECT ... (개선된 쿼리)
# 개선 전 느린 쿼리는 사라짐!
```
---
## 다음 단계
✅ 개선 효과 검증 완료 → **04-checklist.md로 이동**
@@ -0,0 +1,97 @@
# 4단계: 최종 체크리스트
> 스테이징 테스트 완료 후 운영 서버 적용 준비
---
## 준비 단계
- [ ] Synology 스테이징 DB 현재 상태 확인
```bash
mariadb -h mariadb.jisangs.com -u root -p chocomae -e "SELECT COUNT(*) FROM best_record;"
# 약 120만 행
```
- [ ] MariaDB 복제 상태 정상 (Seconds_Behind_Master = 0~1초)
- [ ] Slow query log 활성화
---
## 베이스라인 측정 (01-baseline.md)
- [ ] 핵심 쿼리 EXPLAIN 분석 저장
- baseline_query1_explain.json
- baseline_query2_explain.json
- [ ] 실행 시간 측정 저장
- baseline_results.txt
- [ ] 확인: 모두 "type": "ALL" (전체 스캔)
---
## 인덱스 추가 (02-add-indexes.md)
- [ ] add_indexes.sql 준비
- [ ] 인덱스 추가 SQL 실행
- [ ] SHOW PROCESSLIST로 진행 상황 모니터링
- [ ] 완료 대기 (보통 2~5분)
- [ ] SHOW INDEXES에서 새 인덱스 확인
---
## 개선 효과 검증 (03-verify.md)
- [ ] EXPLAIN 재분석
- after_query1_explain.json (함수 사용, 여전히 느림)
- after_query2_improved_explain.json (함수 제거, 빠름)
- [ ] 확인: 개선 쿼리에서 "type": "range" 또는 "ref"
- [ ] 실행 시간 재측정
- verify_results.txt
- [ ] 확인: 18배 이상 향상
- [ ] Slow query log에서 개선 쿼리 사라짐 확인
---
## 성공 기준
모두 체크되면 **운영 서버에 적용 가능:**
✅ 인덱스 추가 성공
✅ EXPLAIN에서 인덱스 사용 (type = range 또는 ref)
✅ 쿼리 실행 시간 18배 이상 향상
✅ 스캔 행 수 대폭 감소 (1,206,768 → ~200)
✅ Slow query log에서 개선 쿼리 사라짐
---
## 다음: 운영 서버 적용
스테이징 테스트 성공 후:
1. **260915-improve-production 디렉토리 생성**
- 동일한 구조 (01-baseline.md ~ 04-checklist.md)
- 새벽 2시에 실행
2. **02-add-indexes.md 수정**
- 스테이징 결과 참고
- 운영 DB 스크립트 작성
3. **DB 스냅샷 생성 (선택)**
- AWS RDS 또는 백업 (롤백 대비)
---
## 저장된 파일들
스테이징 작업 결과:
```
260915-improve-stage/
├── baseline_query1_explain.json
├── baseline_query2_explain.json
├── baseline_results.txt
├── after_query1_explain.json
├── after_query2_improved_explain.json
├── verify_results.txt
├── comparison.txt
└── add_indexes.sql
```
**운영 서버 적용 시 참고!**
@@ -0,0 +1,99 @@
# 스테이징 성능 개선 테스트 (2026-09-15)
> 안1: 인덱스 + 쿼리 최적화 사전 검증
---
## 📋 문서 구조
| # | 파일 | 설명 |
|---|---|---|
| 00 | `00-overview.md` | 전체 계획 및 사전 확인 |
| 01 | `01-baseline.md` | **현재 상태 측정** (EXPLAIN, 실행 시간) |
| 02 | `02-add-indexes.md` | **인덱스 추가 실행** |
| 03 | `03-verify.md` | **개선 효과 검증** (EXPLAIN, 성능 비교) |
| 04 | `04-checklist.md` | **최종 체크리스트** (성공 기준) |
---
## 🚀 빠른 시작
### 스테이징 DB에서 테스트 (Synology)
```bash
# 1. 00-overview.md 읽기
# 2. 01-baseline.md 실행 (15분)
# 3. 02-add-indexes.md 실행 (5분)
# 4. 03-verify.md 실행 (15분)
# 5. 04-checklist.md 검증 (10분)
# 총 소요 시간: 약 1시간
```
---
## 📊 예상 결과
| 항목 | 개선 전 | 개선 후 | 개선율 |
|---|---|---|---|
| **쿼리 실행 시간** | 0.93초 | 0.05초 | **18배 ↑** |
| **EXPLAIN type** | ALL (전체 스캔) | range (범위) | ✅ |
| **스캔 행 수** | 1,206,768 | ~200 | **6,000배 ↓** |
| **인덱스 사용** | 아니오 | 예 | ✅ |
---
## ✅ 성공 기준
모두 충족하면 운영 서버 적용 가능:
- ✅ 인덱스 추가 성공
- ✅ EXPLAIN에서 type = range 또는 ref
- ✅ 실행 시간 18배 이상 향상
- ✅ 스캔 행 수 대폭 감소
---
## 🔗 관련 문서
- **계획:** [`doc/plan/db/02-option1-index-and-query-rewrite.md`](../02-option1-index-and-query-rewrite.md)
- **운영 서버:** 260915-improve-production 디렉토리 (진행 예정)
---
## 📝 진행 상황
| 단계 | 상태 | 날짜 |
|---|---|---|
| 계획 수립 | ✅ 완료 | 2026-09-15 |
| 스테이징 테스트 | ⏳ 준비 중 | |
| 운영 서버 적용 | ⏳ 대기 중 | |
---
## 💾 저장 위치
스테이징 테스트 결과:
```
doc/plan/db/260915-improve-stage/
├── 00-overview.md (개요)
├── 01-baseline.md (개선 전)
├── 02-add-indexes.md (인덱스 추가)
├── 03-verify.md (개선 후)
├── 04-checklist.md (최종 확인)
├── baseline_query1_explain.json
├── baseline_query2_explain.json
├── baseline_results.txt
├── verify_results.txt
└── comparison.txt
```
**운영 서버 적용 시 이 결과들을 참고합니다.**
---
## 다음 단계
1. **지금:** 01-baseline.md부터 시작
2. **스테이징 완료 후:** 260915-improve-production 디렉토리 생성
3. **운영 서버:** 동일한 절차 (새벽 2시)
@@ -0,0 +1,135 @@
# 플레이어 성적/기록 테이블 및 쿼리 현황 조사
> 목적: 서비스 기간이 길어지면서 기록 데이터가 누적되어 조회/저장 속도가 느려질 것으로 예상되는 부분을 사전에 파악하기 위해, 현재 플레이어 성적을 저장하는 테이블과 이를 조회/갱신하는 쿼리를 전수 조사하여 정리한다.
> 조사 범위: `src/web/sql/*.sql`(스키마), `src/web/server/**/*.php`, `src/web/php/db/**/*.php`
---
## 1. 성적/기록 저장 테이블 목록
### 1-1. 시간이 지날수록 계속 쌓이는 테이블 (이력/히스토리 테이블) — 성능 저하 위험이 가장 큰 그룹
| 테이블 | 정의 위치 | 설명 | 적재 빈도 |
|---|---|---|---|
| `best_record` | `src/web/sql/make_db.sql` | 일반 앱(마우스/타자연습 게임)의 기록. **시간(hour) 단위로 1행씩 계속 insert**되며, 같은 시간대 안에서는 update로 갱신 | 플레이어 × 앱 × 시간(hour)마다 1행 |
| `typing_exam_record` | `src/web/sql/make_db.sql` | 긴글쓰기(시험) 기록. `best_record`와 동일한 패턴(시간 단위 1행) | 플레이어 × 글(writing) × 시간(hour)마다 1행 |
| `license_score` | `src/web/sql/make_db_license_timer.sql` | 자격증 타이머 채점 결과. **매 채점마다 무조건 insert만 발생**(update/삭제 로직 없음 → 사실상 무한 append) | 채점할 때마다 1행 |
이 세 테이블은 서비스 기간이 늘어날수록 **row 수가 선형적으로 계속 증가**하는 구조이며, 아래 2절의 조회 쿼리들이 대부분 이 테이블들을 대상으로 한다. 성능 저하가 가장 먼저 체감될 지점이다.
### 1-2. 플레이어당 최신/최고 기록 1건만 유지하는 테이블 (스냅샷 테이블) — 상대적으로 안전
| 테이블 | 정의 위치 | 설명 |
|---|---|---|
| `app_highest_record` | `make_db.sql` | 앱별 역대 최고 기록. 갱신 시 기존 행을 delete 후 insert (또는 update) 하는 방식으로 **플레이어 × 앱 조합당 항상 1행만 유지** |
| `typing_exam_highest_record` | `make_db.sql` | 긴글쓰기(시험)의 역대 최고 기록. `app_highest_record`와 동일한 패턴 |
| `license_time` | `make_db_license_timer.sql` | 자격증 타이머 진행 시간(현재 상태). 플레이어당 1행, update로만 갱신 |
이 테이블들은 row 수가 "플레이어 수 × 앱(또는 글감) 수"에 비례하므로 회원이 크게 늘지 않는 한 증가 폭이 제한적이다.
### 1-3. 정의되어 있지만 사용되지 않는 테이블
| 테이블 | 비고 |
|---|---|
| `ranking` | `make_db.sql`에 CREATE TABLE만 존재하고, 코드 전체에서 INSERT/SELECT/UPDATE/DELETE 어디에서도 참조되지 않음 (죽은 테이블로 추정). 랭킹 조회는 실제로는 `best_record`/`typing_exam_record`를 그때그때 집계(GROUP BY)해서 계산함 |
### 1-4. 인덱스 현황
`make_db.sql`, `make_db_license_timer.sql`을 확인한 결과, 위 테이블들에는 **PRIMARY KEY(자동 증가 ID)와 FOREIGN KEY 컬럼 외에 별도의 복합 인덱스가 전혀 정의되어 있지 않다.** (`CREATE INDEX` 구문 없음) FK 컬럼(MaestroID, AppID, PlayerID, WritingID)에는 InnoDB가 자동으로 단일 컬럼 인덱스를 생성하지만, 실제 쿼리들은 대부분 `(MaestroID, AppID, PlayerID)` 또는 `(MaestroID, PlayerID, RecordDateTime)`처럼 여러 컬럼을 동시에 조건으로 사용하므로 복합 인덱스 없이는 최적 경로로 조회되지 않는다.
---
## 2. 성적 조회 쿼리 목록
### 2-1. `best_record` 조회
| 파일 | 함수 | 쿼리 요지 | 비고 |
|---|---|---|---|
| [record/update_result_record.php](../../../src/web/server/record/update_result_record.php) | `get_best_record()` | `WHERE MaestroID=? AND AppID=? AND PlayerID=? AND DATE(RecordDateTime)=DATE(NOW()) AND HOUR(RecordDateTime)=HOUR(NOW())` | **기록 저장(= 게임 종료)마다 매번 실행.** `DATE()`, `HOUR()` 함수를 컬럼에 직접 씌워서 비교(sargable하지 않음) → 인덱스가 있어도 활용 불가, 해당 조건에 맞는 후보를 찾기 위해 전체(또는 해당 플레이어의 전체) 스캔에 가까워짐 |
| [record/history_record.php](../../../src/web/server/record/history_record.php) | `get_history_record()` | `WHERE MaestroID=? AND PlayerID=? AND DATE(RecordDateTime)<=? AND AppID=? GROUP BY DATE(RecordDateTime) ORDER BY DATE(RecordDateTime) DESC LIMIT 7` | 최근 7일 기록 조회. `GROUP BY DATE(...)`, `ORDER BY DATE(...)` 모두 함수 기반이라 인덱스 미활용, 대상 데이터가 쌓일수록 그룹핑 대상 row 수 증가 |
| [record/ranking_record_hour.php](../../../src/web/server/record/ranking_record_hour.php) | `get_ranking_record_hour()` | `WHERE MaestroID=? AND YEAR(RecordDateTime)=YEAR(?) AND MONTH(...)=MONTH(?) AND DAYOFMONTH(...)=DAYOFMONTH(?) AND HOUR(...)=HOUR(?) AND AppID=? GROUP BY PlayerID` | 특정 시간대 랭킹. `YEAR/MONTH/DAYOFMONTH/HOUR` 4중 함수 비교 → 완전 비sargable, 매 조회마다 해당 Maestro/App의 **모든 이력**을 함수로 걸러야 함 |
| [record/ranking_record_day.php](../../../src/web/server/record/ranking_record_day.php) | `get_ranking_record_day()` | 위와 동일한 패턴에서 `HOUR` 조건만 빠짐 (일간 랭킹) | 동일 문제 |
| [record/ranking_record_month.php](../../../src/web/server/record/ranking_record_month.php) | `get_ranking_record_month()` | `YEAR/MONTH`만 비교 (월간 랭킹) | 동일 문제 |
| [lib/app_highest_record.php](../../../src/web/server/lib/app_highest_record.php) 호출 전 → [history_record.php](../../../src/web/server/record/history_record.php)와 유사한 화면에서 사용되는 [record/app_ranking.php](../../../src/web/server/record/app_ranking.php) | `get_ranking_hour/day/month()` | 시/일/월 랭킹을 `DATE(RecordDateTime)=DATE(NOW())`, `HOUR(RecordDateTime)=HOUR(NOW())`, `MONTH(RecordDateTime)=MONTH(NOW())` 조건으로 각각 조회 후 `GROUP BY PlayerID` | 교실 홈 화면 진입 시마다 3개 쿼리 동시 실행. 역시 함수 기반 조건 |
| [record/request_app_player_record_list.php](../../../src/web/server/record/request_app_player_record_list.php) | `get_player_record_total_count()`, `get_player_record_list()` | 관리자/마에스트로용 기록 목록. `BR.RecordDateTime BETWEEN 시작~종료`, 플레이어 이름 `LIKE '%...%'`, 과목 조건을 문자열 조합으로 생성 후 `ORDER BY RecordDateTime DESC` | **COUNT(*) 쿼리와 목록 쿼리를 각각 실행(2회 스캔)**. `LIKE '%...%'`(앞와일드카드)는 인덱스 사용 불가. 조건절이 PHP 문자열 결합으로 생성되어 SQL Injection 잠재 위험도 있음(바인딩 미적용 구간) |
| [record/delete_record.php](../../../src/web/server/record/delete_record.php) | `get_best_record_info()`, `get_best_record_record_info()` | 기록 삭제 후 재계산을 위해 `WHERE MaestroID=? AND PlayerID=? AND AppID=? ORDER BY BestRecord [ASC|DESC] LIMIT 1` 등 | 삭제 시마다 재조회 |
| [player/delete_player.php](../../../src/web/server/player/delete_player.php) | `countPlayerRecord()` | `SELECT COUNT(PlayerID) FROM best_record WHERE MaestroID=? AND PlayerID=?` | 학생 삭제 검증용, 삭제 후 해당 플레이어 기록이 실제로 0건인지 카운트 |
### 2-2. `typing_exam_record` 조회
| 파일 | 함수 | 쿼리 요지 | 비고 |
|---|---|---|---|
| [php/db/typing_exam_collection.php](../../../src/web/php/db/typing_exam_collection.php) | `getThisHourRecord()` | `WHERE MaestroID=? AND PlayerID=? AND WritingID=? AND DATE(RecordDateTime)=DATE(NOW()) AND HOUR(RecordDateTime)=HOUR(NOW())` | 시험 기록 저장 시마다 실행. `best_record``get_best_record()`와 동일한 문제 |
| 〃 | `getHistoryRecord()` | `WHERE ... DATE(RecordDateTime)<=? AND WritingID=? GROUP BY DATE(RecordDateTime) ORDER BY RecordDateTime DESC LIMIT 7` | `history_record.php`와 동일 패턴 |
| 〃 | `getRankingRecordHour/Day/Month()` (플러스 기록) | `YEAR/MONTH/DAYOFMONTH/HOUR` 함수 비교 + `Record >= 0` + `GROUP BY PlayerID` | 오타 게임처럼 "감점형이 아닌" 시험용 |
| 〃 | `getRankingMinusRecordHour/Day/Month()` (마이너스 기록) | 위와 동일 구조에서 `Record < 0` | 오타 개수 등 "적을수록 좋은" 시험용, 총 6개의 랭킹 쿼리가 사실상 중복 로직 |
| [record/request_writing_player_record_list.php](../../../src/web/server/record/request_writing_player_record_list.php) | `get_player_record_total_count()`, `get_player_record_list()` | `request_app_player_record_list.php`와 동일 패턴(카운트+목록 2회 조회, 날짜 범위, 이름 LIKE, 과목 조건 문자열 조합) | 동일 위험 |
| [record/delete_record.php](../../../src/web/server/record/delete_record.php) | `get_typing_exam_record_info()`, `get_typing_exam_best_record_info()` | 삭제 대상 조회 및 재계산용 `ORDER BY Record DESC LIMIT 1` | 삭제 시마다 재조회 |
| [player/delete_player.php](../../../src/web/server/player/delete_player.php) | (직접 COUNT는 없고 DELETE만 수행) | - | - |
### 2-3. `license_score` 조회
| 파일 | 함수 | 쿼리 요지 | 비고 |
|---|---|---|---|
| [license_timer/get_license_score.php](../../../src/web/server/license_timer/get_license_score.php) | `get_score_list()` | `WHERE MaestroID=? AND PlayerID=? ORDER BY ScoreDateTime DESC` | 플레이어별 전체 채점 이력을 매번 전부 조회(페이징/LIMIT 없음) → 누적될수록 응답 느려짐 |
| [record/request_license_timer_player_record_list.php](../../../src/web/server/record/request_license_timer_player_record_list.php) | `get_player_record_total_count()`, `get_player_record_list()` | 위 두 기록 목록 쿼리와 동일 패턴(카운트+목록, 날짜 범위, 이름 LIKE) | 동일 위험 |
### 2-4. `app_highest_record` / `typing_exam_highest_record` 조회 (스냅샷 테이블 — 상대적으로 저위험)
| 파일 | 함수 | 쿼리 요지 |
|---|---|---|
| [lib/app_highest_record.php](../../../src/web/server/lib/app_highest_record.php) | `get_app_highest_record()` | `WHERE MaestroID=? AND AppID=? AND PlayerID=?` (단건) |
| [record/request_app_highest_record.php](../../../src/web/server/record/request_app_highest_record.php) | - | 위 함수를 그대로 호출하여 클라이언트에 반환 |
| [server/app/menu_active_typing_practice_app_list.php](../../../src/web/server/app/menu_active_typing_practice_app_list.php), [menu_active_typing_test_app_list.php](../../../src/web/server/app/menu_active_typing_test_app_list.php) | `get_high_score_list()` | 메뉴에 노출되는 **앱 개수만큼 반복문(loop)으로 단건 쿼리를 N번 실행**(N+1 조회 패턴) |
| [php/db/menu_collection.php](../../../src/web/php/db/menu_collection.php) | `getTypingHighestRecord()`, `getTypingHighestRecordList()` | 동일하게 앱 목록을 순회하며 단건 조회 반복(N+1) |
| [php/db/writing_collection.php](../../../src/web/php/db/writing_collection.php) | `getWritingHighestRecord()` | `typing_exam_highest_record` 단건 조회 |
### 2-5. `license_time` 조회 (스냅샷 테이블)
| 파일 | 함수 | 쿼리 요지 |
|---|---|---|
| [license_timer/get_license_time_data.php](../../../src/web/server/license_timer/get_license_time_data.php) | `get_time_data()` | `WHERE MaestroID=? AND PlayerID=?` (단건) |
---
## 3. 성적 저장/갱신/삭제 쿼리 목록
### 3-1. 기록 저장 흐름 (게임/시험 종료 시)
| 테이블 | 파일 | 로직 |
|---|---|---|
| `best_record` | [record/update_result_record.php](../../../src/web/server/record/update_result_record.php) | 이번 시간대 기록 존재 조회 → 없으면 `INSERT`, 있으면 기존 기록보다 좋을 때만 `UPDATE` (게임 종료마다 SELECT 1회 + INSERT/UPDATE 1회) |
| `app_highest_record` | 〃 (내부에서 [lib/app_highest_record.php](../../../src/web/server/lib/app_highest_record.php) 함수 사용) | 현재 최고기록 조회(`MAX(HighestRecord)`) → 갱신 필요 시 기존 행 `DELETE``INSERT` (매 게임 종료마다 SELECT + DELETE + INSERT 발생 가능) |
| `typing_exam_record` | [php/db/typing_exam_collection.php](../../../src/web/php/db/typing_exam_collection.php) `addRecord()`/`updateRecord()` | `best_record`와 동일 패턴 |
| `typing_exam_highest_record` | 〃 `addHighestRecord()`/`updateHighestRecord()` | 조회 후 존재 여부에 따라 INSERT/UPDATE (delete_record.php 쪽에서는 delete 후 insert 방식도 사용) |
| `license_score` | [license_timer/add_license_score.php](../../../src/web/server/license_timer/add_license_score.php) | 조건 없이 항상 `INSERT` (update/삭제 로직 없음 → 이 테이블이 3개 이력 테이블 중 가장 빠르게, 그리고 가장 무분별하게 증가) |
| `license_time` | [license_timer/update_license_left_time.php](../../../src/web/server/license_timer/update_license_left_time.php), [update_license_start_time.php](../../../src/web/server/license_timer/update_license_start_time.php) | `UPDATE`만 수행 (row 자체는 늘지 않음) |
### 3-2. 기록 삭제(관리자/마에스트로 화면에서 개별 기록 삭제)
| 파일 | 대상 테이블 | 처리 |
|---|---|---|
| [record/delete_record.php](../../../src/web/server/record/delete_record.php) | `best_record`, `app_highest_record`, `typing_exam_record`, `typing_exam_highest_record`, `license_score` | 앱 종류에 따라 분기하여 해당 이력 1건 `DELETE` 후, 남은 기록 중 최고기록을 재조회하여 `app_highest_record`/`typing_exam_highest_record`를 재계산(UPDATE/INSERT/DELETE) |
### 3-3. 학생(플레이어) 삭제 시 연쇄 삭제
| 파일 | 대상 테이블 |
|---|---|
| [player/delete_player.php](../../../src/web/server/player/delete_player.php) | `app_highest_record`, `best_record`, `typing_exam_highest_record`, `typing_exam_record`, `license_score`, `license_time`, `player` (총 6개 기록성 테이블을 순차 DELETE 후 `player` 삭제) |
| [maestro/delete_test_player_record.php](../../../src/web/server/maestro/delete_test_player_record.php) | 마에스트로의 "테스트 계정" 1명에 대해 위와 동일한 6개 테이블을 순차 DELETE (마에스트로가 리셋할 때마다 실행) |
이 삭제 쿼리들은 모두 `WHERE PlayerID = ?` 또는 `WHERE MaestroID = ? AND PlayerID = ?` 조건으로, 이력 테이블(`best_record`, `typing_exam_record`, `license_score`)의 경우 특정 플레이어의 누적 데이터가 많을수록 삭제 비용도 함께 증가한다.
---
## 4. 요약 — 데이터가 쌓일수록 느려질 것으로 예상되는 지점
1. **비sargable 조건(함수로 감싼 날짜 비교)**: `DATE()`, `HOUR()`, `YEAR()`, `MONTH()`, `DAYOFMONTH()``RecordDateTime`/`ScoreDateTime` 컬럼에 직접 씌우는 쿼리가 매우 많음(랭킹 3종 × 2테이블 = 6개 + 히스토리 2개 + 기록 저장 시 중복 체크 2개). 인덱스가 있어도 활용되지 못해 전체 스캔에 가깝게 동작 → **가장 우선적으로 손봐야 할 지점**.
2. **복합 인덱스 부재**: `(MaestroID, AppID, PlayerID)`, `(MaestroID, PlayerID, RecordDateTime)` 등 실제 조회 패턴에 맞는 인덱스가 스키마에 전혀 없음.
3. **무한 증가(append-only) 테이블**: `best_record`, `typing_exam_record`, `license_score` 세 테이블은 삭제 로직이 미미하거나 없어 시간이 지날수록 계속 커짐. 특히 `license_score`는 update/정리 로직이 전혀 없음.
4. **게임 종료마다 반복되는 SELECT+INSERT/UPDATE+DELETE 조합**: `update_result_record.php`가 한 번의 기록 저장에서 `best_record`, `app_highest_record` 두 테이블에 대해 조회 2회 + 쓰기 최대 3회를 수행. 이력 테이블이 커질수록 이 조회 비용도 함께 증가.
5. **N+1 쿼리 패턴**: 메뉴 화면(`get_high_score_list`, `getTypingHighestRecordList`)에서 앱/글 목록 개수만큼 반복하여 `app_highest_record`/`typing_exam_highest_record`를 단건 조회. 이 테이블 자체는 안 커지지만, 앱 종류가 늘어날수록 요청 수가 비례해서 늘어남.
6. **관리자용 기록 목록 조회의 COUNT+목록 이중 조회 및 앞와일드카드 LIKE 검색**: `request_app_player_record_list.php`, `request_writing_player_record_list.php`, `request_license_timer_player_record_list.php` 3곳 모두 동일한 구조로, 이력 테이블 규모가 커질수록 관리 화면 진입이 느려질 가능성이 가장 높음. `LIKE '%이름%'` 검색과 SQL을 문자열로 직접 이어붙이는 방식(바인딩 미적용 구간 포함)도 함께 존재.
7. **미사용 테이블**: `ranking`은 실제로 쓰이지 않으므로 정리 대상 후보(성능과는 무관하나 스키마 정리 관점에서 기록).
> 본 문서는 현황 조사 결과이며, 구체적인 개선 방안은 후속 계획 문서 [01-improvement-overview.md](01-improvement-overview.md)부터 이어서 정리했다.