03eea2098e
- 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>
303 lines
13 KiB
Markdown
303 lines
13 KiB
Markdown
# 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로 이동**
|