# 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로 이동**