운영 서버에서 DB index 적용 작업 진행

- 01~07 작업 문서를 운영 측정값 기준으로 정리 (문서의 DB 비밀번호 삭제)
- app_highest_record 중복 8,795행 점수 기준 정리, 인덱스 5개 + UNIQUE 키 2개 추가
- 02·04 공통 측정 스크립트와 측정 결과, comparison.txt 추가
- 서버 처리 시간 18~292배 단축, 결과 행 수 동일
- 운영 데이터 백업과 권한(비밀번호 해시) 파일은 커밋에서 제외

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 23:30:42 +09:00
parent ded12332d5
commit 03eea2098e
56 changed files with 6993 additions and 0 deletions
@@ -0,0 +1,302 @@
# 03단계: 중복 정리 + 인덱스 추가
> 온라인 DDL로 서비스 중단 없이 인덱스를 추가합니다. UNIQUE 키를 넣기 전에 최고기록 테이블의 중복을 정리합니다.
모든 명령은 `result/` 디렉터리에서 실행합니다.
---
## 3-0. 중복 데이터 정리 (필수)
> UNIQUE 키 추가 전에 `app_highest_record`의 중복을 정리합니다. `typing_exam_highest_record`는 01단계에서 중복 0건이라 확인만 합니다.
### 왜 점수 기준으로 남기나
- 중복은 같은 학생의 결과 저장 요청이 동시에 들어올 때 생깁니다. `src/web/server/record/update_result_record.php`는 "조회 → 없으면 INSERT"와 "DELETE → INSERT"를 트랜잭션 없이 실행하므로 두 요청이 모두 INSERT할 수 있습니다.
- 그래서 **가장 최근 행이 최고 점수라는 보장이 없습니다.** 이전 버전 문서처럼 `MAX(ID)`를 남기면 최고기록이 낮아질 수 있습니다.
- 좋은 기록의 기준은 [util_app.php](../../../../src/web/server/lib/util_app.php)의 `is_highest_record_prefer_app()`을 따릅니다. **AppID 105는 낮은 점수**, 나머지 앱은 **높은 점수**가 좋은 기록입니다. 점수가 같으면 최근 행(ID가 큰 행)을 남깁니다.
- `typing_exam_highest_record`는 높은 점수가 좋은 기록입니다 (`update_typing_exam_record.php`).
### UNIQUE 키 추가 후 앱 동작
- 같은 조합의 INSERT가 또 들어오면 `ERROR 1062 Duplicate entry`로 거부되어 중복이 더 생기지 않습니다.
- PHP 7.3 mysqli는 기본 설정에서 예외를 던지지 않고 `execute()`가 false만 반환하므로 **화면이 멈추지 않습니다.** 먼저 저장된 행이 남습니다.
- 드물게 동시에 저장한 두 기록 중 덜 좋은 기록이 남을 수 있습니다 (지금은 같은 상황에서 중복 행이 생깁니다).
### 3-0-1. 중복 규모 확인
**아래 명령어를 터미널에 복사-붙여넣기해서 실행하세요:**
```bash
mariadb -h chocomae.jinaju.com -u jisangs -p chocomae -e "
SELECT COUNT(*) AS dup_groups, SUM(cnt - 1) AS rows_to_delete
FROM (SELECT COUNT(*) AS cnt FROM app_highest_record
GROUP BY MaestroID, PlayerID, AppID HAVING cnt > 1) t;" > dedupe_before_count.txt
cat dedupe_before_count.txt
```
`rows_to_delete` 값을 메모합니다. 3-0-2 백업 행 수, 3-0-3 삭제 행 수와 같아야 합니다. (01단계 측정: 4,823조합 / 8,795행. 작업 당일에는 조금 늘어날 수 있습니다)
### 3-0-2. 삭제 대상 행 백업
삭제할 행만 INSERT 문으로 저장합니다. 되돌릴 때 06단계에서 이 파일을 실행합니다. (SELECT 권한만 필요)
**아래 명령어를 터미널에 복사-붙여넣기해서 실행하세요:**
```bash
mariadb-dump -h chocomae.jinaju.com -u jisangs -p \
--single-transaction --no-create-info --skip-triggers --no-tablespaces --skip-add-locks \
--where="AppHighestRecordID IN (SELECT AppHighestRecordID FROM (SELECT AppHighestRecordID, ROW_NUMBER() OVER (PARTITION BY MaestroID, PlayerID, AppID ORDER BY CASE WHEN AppID = 105 THEN HighestRecord ELSE -HighestRecord END, AppHighestRecordID DESC) AS rn FROM app_highest_record) ranked WHERE rn > 1)" \
chocomae app_highest_record > backup_app_highest_dup_rows.sql
echo "백업한 행 수: $(grep -c '^(' backup_app_highest_dup_rows.sql)"
```
**확인할 점:** 백업한 행 수 = 3-0-1의 `rows_to_delete`
### 3-0-3. 중복 삭제 (DELETE 권한 — jisangs에 임시 부여됨)
> 3-2 인덱스 추가 **직전**에 실행하세요. 삭제 후 UNIQUE 키가 생기기 전까지 새 중복이 생길 수 있습니다.
**아래 명령어를 터미널에 복사-붙여넣기해서 실행하세요:**
```bash
mariadb -h chocomae.jinaju.com -u jisangs -p chocomae -vvv << 'EOF' | tee dedupe_result.txt
DELETE AHR FROM app_highest_record AHR
JOIN (
SELECT AppHighestRecordID FROM (
SELECT AppHighestRecordID,
ROW_NUMBER() OVER (
PARTITION BY MaestroID, PlayerID, AppID
ORDER BY CASE WHEN AppID = 105 THEN HighestRecord ELSE -HighestRecord END,
AppHighestRecordID DESC
) AS rn
FROM app_highest_record
) ranked
WHERE rn > 1
) dup ON dup.AppHighestRecordID = AHR.AppHighestRecordID;
SELECT 'app_highest_record' AS table_name,
COUNT(*) AS total_rows,
COUNT(DISTINCT MaestroID, PlayerID, AppID) AS unique_combos
FROM app_highest_record;
SELECT 'typing_exam_highest_record' AS table_name,
COUNT(*) AS total_rows,
COUNT(DISTINCT MaestroID, PlayerID, WritingID) AS unique_combos
FROM typing_exam_highest_record;
EOF
```
**확인할 점:**
- DELETE 결과 `Query OK, N rows affected`의 N = 3-0-1의 `rows_to_delete`
- 두 테이블 모두 `total_rows` = `unique_combos` (중복 없음)
> `typing_exam_highest_record`에서 `total_rows` ≠ `unique_combos`이면, 3-0-2와 같은 방식으로 백업한 뒤 아래 SQL로 정리합니다 (높은 점수, 같으면 최근 행을 남김).
>
> ```sql
> DELETE TEHR FROM typing_exam_highest_record TEHR
> JOIN (
> SELECT TypingExamHighestRecordID FROM (
> SELECT TypingExamHighestRecordID,
> ROW_NUMBER() OVER (
> PARTITION BY MaestroID, PlayerID, WritingID
> ORDER BY HighestRecord DESC, TypingExamHighestRecordID DESC
> ) AS rn
> FROM typing_exam_highest_record
> ) ranked
> WHERE rn > 1
> ) dup ON dup.TypingExamHighestRecordID = TEHR.TypingExamHighestRecordID;
> ```
---
## 3-1. 인덱스 추가 SQL 준비
**아래 명령어를 터미널에 복사-붙여넣기해서 실행하세요:**
```bash
cat > add_indexes.sql << 'EOF'
-- best_record: 랭킹용
ALTER TABLE best_record
ADD INDEX IF NOT EXISTS idx_maestro_app_dt (MaestroID, AppID, RecordDateTime),
ALGORITHM=INPLACE, LOCK=NONE;
-- best_record: 히스토리/저장용
ALTER TABLE best_record
ADD INDEX IF NOT EXISTS idx_maestro_player_app_dt (MaestroID, PlayerID, AppID, RecordDateTime),
ALGORITHM=INPLACE, LOCK=NONE;
-- typing_exam_record
ALTER TABLE typing_exam_record
ADD INDEX IF NOT EXISTS idx_maestro_writing_dt (MaestroID, WritingID, RecordDateTime),
ALGORITHM=INPLACE, LOCK=NONE;
ALTER TABLE typing_exam_record
ADD INDEX IF NOT EXISTS idx_maestro_player_writing_dt (MaestroID, PlayerID, WritingID, RecordDateTime),
ALGORITHM=INPLACE, LOCK=NONE;
-- license_score
ALTER TABLE license_score
ADD INDEX IF NOT EXISTS idx_maestro_player_dt (MaestroID, PlayerID, ScoreDateTime),
ALGORITHM=INPLACE, LOCK=NONE;
-- app_highest_record: UNIQUE 키
ALTER TABLE app_highest_record
ADD UNIQUE KEY IF NOT EXISTS 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 IF NOT EXISTS uk_maestro_player_writing (MaestroID, PlayerID, WritingID),
ALGORITHM=INPLACE, LOCK=NONE;
EOF
cat add_indexes.sql # 확인
```
> `IF NOT EXISTS`: 중간에 실패해 다시 실행해도 이미 만든 인덱스는 건너뜁니다 (`Note 1061 Duplicate key name`만 표시되고 에러는 아님).
---
## 3-2. 인덱스 추가 실행
**아래 명령어를 터미널에 복사-붙여넣기해서 실행하세요:**
```bash
mariadb -h chocomae.jinaju.com -u jisangs -p chocomae -vvv < add_indexes.sql | tee add_indexes_result.txt
```
**확인할 점:** ALTER 7개가 모두 `Query OK`로 끝남
**`Duplicate entry ... for key 'uk_...'` 에러가 나면:**
- 3-0 이후 새 중복이 생긴 것입니다. 에러가 난 지점에서 실행이 멈추고, 뒤의 ALTER는 실행되지 않습니다.
- 3-0-2(백업 파일 이름을 바꿔서)와 3-0-3을 다시 실행한 뒤 이 명령을 다시 실행하세요. 이미 만든 인덱스는 건너뜁니다.
### 진행 상황 모니터링 (다른 터미널)
**다른 터미널을 열어서 아래 명령어를 반복 실행하세요:**
```bash
mariadb -h chocomae.jinaju.com -u jisangs -p chocomae -e "SHOW PROCESSLIST;" | grep -E "ALTER|Query|State"
```
**자동 반복 실행 (선택사항):**
비밀번호는 처음에 한 번만 입력합니다. 10초마다 다시 묻지 않습니다. 비밀번호는 파일이나 명령 인자로 남기지 않고 메모리에만 두며, `Ctrl+C`로 멈추면 함께 사라집니다.
```bash
(
printf "Enter password: "; read -rs DBPW; echo
while true; do
clear
echo "=== $(date) === (종료: Ctrl+C)"
mariadb --defaults-extra-file=<(printf '[client]\npassword="%s"\n' "$DBPW") \
-h chocomae.jinaju.com -u jisangs chocomae -e "SHOW PROCESSLIST;" \
| grep -E "ALTER|Query|State"
sleep 10
done
)
```
> `--defaults-extra-file`은 반드시 첫 번째 옵션이어야 합니다. 비밀번호를 틀리면 매번 `Access denied`가 출력되니 `Ctrl+C`로 멈추고 다시 실행하세요.
>
> PROCESS 권한이 없으면 `SHOW PROCESSLIST`에는 내 계정의 연결만 보입니다. ALTER는 같은 `jisangs` 계정으로 실행하므로 보입니다.
**나타날 메시지 (State 문구는 버전에 따라 다름):**
```
| 123 | jisangs | ... | chocomae | Query | 45 | ... | ALTER TABLE best_record ADD INDEX IF NOT EXISTS idx_maestro_app_dt ... |
```
**완료되면:** ALTER TABLE 줄이 사라짐 ✅
---
## 3-3. 인덱스 생성 확인
**아래 명령어를 터미널에 복사-붙여넣기해서 실행하세요:**
```bash
mariadb -h chocomae.jinaju.com -u jisangs -p chocomae -e "
SELECT TABLE_NAME, INDEX_NAME, NON_UNIQUE,
GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS cols
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = 'chocomae'
AND INDEX_NAME IN ('idx_maestro_app_dt', 'idx_maestro_player_app_dt',
'idx_maestro_writing_dt', 'idx_maestro_player_writing_dt',
'idx_maestro_player_dt', 'uk_maestro_player_app', 'uk_maestro_player_writing')
GROUP BY TABLE_NAME, INDEX_NAME, NON_UNIQUE
ORDER BY TABLE_NAME, INDEX_NAME;" > add_indexes_check.txt
cat add_indexes_check.txt
```
**출력 예 (7행이어야 함):**
```
TABLE_NAME INDEX_NAME NON_UNIQUE cols
app_highest_record uk_maestro_player_app 0 MaestroID,PlayerID,AppID
best_record idx_maestro_app_dt 1 MaestroID,AppID,RecordDateTime
best_record idx_maestro_player_app_dt 1 MaestroID,PlayerID,AppID,RecordDateTime
license_score idx_maestro_player_dt 1 MaestroID,PlayerID,ScoreDateTime
typing_exam_highest_record uk_maestro_player_writing 0 MaestroID,PlayerID,WritingID
typing_exam_record idx_maestro_player_writing_dt 1 MaestroID,PlayerID,WritingID,RecordDateTime
typing_exam_record idx_maestro_writing_dt 1 MaestroID,WritingID,RecordDateTime
```
---
## 3-4. 쿼리 조건 수정 (선택: 인덱스 최대 활용)
> **중요:** 인덱스만으로도 성능이 개선되지만, 함수 기반 조건을 범위 조건으로 변경하면 **1,770배** 추가 성능 향상 가능 (스테이징 기준)
>
> 04단계는 함수형(q1·q3·q5)과 범위형(q2·q4·q6)을 모두 측정하므로, 운영에서도 인덱스만 적용했을 때와 쿼리까지 수정했을 때의 차이를 확인할 수 있습니다.
### 수정 대상 (7개 파일, 8+ 함수)
**꼭 필요한 파일:**
1. `src/web/server/record/app_ranking.php` (3개 함수)
2. `src/web/server/record/ranking_record_*.php` (3개 파일)
3. `src/web/server/record/history_record.php`
4. `src/web/php/db/typing_exam_collection.php` (8개 함수)
### 변경 예시
```sql
-- 변경 전 (인덱스 일부만 사용)
WHERE DATE(RecordDateTime) = DATE(NOW())
AND HOUR(RecordDateTime) = HOUR(NOW())
-- 변경 후 (인덱스 완전 활용)
WHERE 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
```
**자세한 변경 규칙:** [02-option1-index-and-query-rewrite.md section 3-3](../02-option1-index-and-query-rewrite.md)
### 수정 진행 상황 (Stage에서 완료됨)
- [x] ✅ Stage 서버에서 7개 파일 모두 수정 완료
- [x] ✅ 결과 동일성 검증 완료 (EXCEPT로 확인)
- [x] ✅ EXPLAIN 성능 개선 확인 (rows: 5,804 → 1, cost: 8.634 → 0.00488)
- [x] ⚠️ 후속 수정 (2026-09-15): 히스토리 쿼리 2곳(`history_record.php`, `typing_exam_collection.php` `getHistoryRecord()`)의 하한 조건 `>= DATE(?) - INTERVAL 7 DAY` 삭제 → "기록이 있는 최근 7일" 동작 복원. **운영 배포 코드에 반드시 포함** ([상세](../260915-improve-stage/result/FINAL-REPORT.md))
- [ ] Stage에서 히스토리 재검증 (8일보다 전 기록만 있는 학생으로 일반 앱·긴글의 시작·결과 화면 확인)
### Production 적용 방법
> **실제 적용 (2026-09-15):** 인덱스 추가(3-2) 직후, 04단계 측정 전에 애플리케이션(PHP, 화면) 코드도 운영에 배포했습니다. 04단계 측정 쿼리는 `queries/*.sql`을 직접 실행하므로 코드 배포의 영향을 받지 않습니다.
**Option A: 단계적 적용 (권장)**
1. 먼저 인덱스만 적용 (이 파일)
2. 1~2일 모니터링
3. 이후 코드 배포
**Option B: 한 번에 적용**
1. 인덱스 추가 + 코드 배포 동시 진행
2. 더 빠른 성능 향상
---
## 다음 단계
✅ 인덱스 추가 완료 → **04-verify.md로 이동**