- 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>
13 KiB
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의
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. 중복 규모 확인
아래 명령어를 터미널에 복사-붙여넣기해서 실행하세요:
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 권한만 필요)
아래 명령어를 터미널에 복사-붙여넣기해서 실행하세요:
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 키가 생기기 전까지 새 중복이 생길 수 있습니다.
아래 명령어를 터미널에 복사-붙여넣기해서 실행하세요:
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로 정리합니다 (높은 점수, 같으면 최근 행을 남김).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 준비
아래 명령어를 터미널에 복사-붙여넣기해서 실행하세요:
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. 인덱스 추가 실행
아래 명령어를 터미널에 복사-붙여넣기해서 실행하세요:
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을 다시 실행한 뒤 이 명령을 다시 실행하세요. 이미 만든 인덱스는 건너뜁니다.
진행 상황 모니터링 (다른 터미널)
다른 터미널을 열어서 아래 명령어를 반복 실행하세요:
mariadb -h chocomae.jinaju.com -u jisangs -p chocomae -e "SHOW PROCESSLIST;" | grep -E "ALTER|Query|State"
자동 반복 실행 (선택사항):
비밀번호는 처음에 한 번만 입력합니다. 10초마다 다시 묻지 않습니다. 비밀번호는 파일이나 명령 인자로 남기지 않고 메모리에만 두며, Ctrl+C로 멈추면 함께 사라집니다.
(
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. 인덱스 생성 확인
아래 명령어를 터미널에 복사-붙여넣기해서 실행하세요:
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+ 함수)
꼭 필요한 파일:
src/web/server/record/app_ranking.php(3개 함수)src/web/server/record/ranking_record_*.php(3개 파일)src/web/server/record/history_record.phpsrc/web/php/db/typing_exam_collection.php(8개 함수)
변경 예시
-- 변경 전 (인덱스 일부만 사용)
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
수정 진행 상황 (Stage에서 완료됨)
- ✅ Stage 서버에서 7개 파일 모두 수정 완료
- ✅ 결과 동일성 검증 완료 (EXCEPT로 확인)
- ✅ EXPLAIN 성능 개선 확인 (rows: 5,804 → 1, cost: 8.634 → 0.00488)
- ⚠️ 후속 수정 (2026-09-15): 히스토리 쿼리 2곳(
history_record.php,typing_exam_collection.phpgetHistoryRecord())의 하한 조건>= DATE(?) - INTERVAL 7 DAY삭제 → "기록이 있는 최근 7일" 동작 복원. 운영 배포 코드에 반드시 포함 (상세) - Stage에서 히스토리 재검증 (8일보다 전 기록만 있는 학생으로 일반 앱·긴글의 시작·결과 화면 확인)
Production 적용 방법
실제 적용 (2026-09-15): 인덱스 추가(3-2) 직후, 04단계 측정 전에 애플리케이션(PHP, 화면) 코드도 운영에 배포했습니다. 04단계 측정 쿼리는
queries/*.sql을 직접 실행하므로 코드 배포의 영향을 받지 않습니다.
Option A: 단계적 적용 (권장)
- 먼저 인덱스만 적용 (이 파일)
- 1~2일 모니터링
- 이후 코드 배포
Option B: 한 번에 적용
- 인덱스 추가 + 코드 배포 동시 진행
- 더 빠른 성능 향상
다음 단계
✅ 인덱스 추가 완료 → 04-verify.md로 이동