Files
jisangs 03eea2098e 운영 서버에서 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>
2026-09-15 23:30:42 +09:00

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.phpis_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_rowsunique_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+ 함수)

꼭 필요한 파일:

  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개 함수)

변경 예시

-- 변경 전 (인덱스 일부만 사용)
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.php getHistoryRecord())의 하한 조건 >= DATE(?) - INTERVAL 7 DAY 삭제 → "기록이 있는 최근 7일" 동작 복원. 운영 배포 코드에 반드시 포함 (상세)
  • 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로 이동