Files
chocomae/doc/plan/db/01-improvement-overview.md
T
2026-09-15 10:38:05 +09:00

20 KiB
Raw Blame History

플레이어 기록 DB 성능 개선 방안 — 개요

  • 선행 문서: player-record-tables-and-queries.md (기록 테이블·쿼리 현황 조사)
  • 대상 DB: 운영 MariaDB 10.11.13
  • 작성일: 2026-09-14
  • 이 문서는 계획 문서입니다. 소스 코드와 DB는 아직 변경하지 않았습니다.

문서 구성

순서 문서 내용
1 이 문서 현재 상황, 쉬운 원리 설명, 용어 사전, 5개 안 비교, 추천 로드맵, 사전 측정 방법
2 02-option1-index-and-query-rewrite.md 안1. 인덱스 추가 + 쿼리 조건 개선
3 03-option2-daily-summary-tables.md 안2. 일별 최고기록 집계 테이블 도입
4 04-option3-archiving.md 안3. 오래된 원본 기록 아카이빙
5 05-option4-partitioning.md 안4. 테이블 파티셔닝 (보류 권장)
6 06-option5-application-layer.md 안5. 애플리케이션(PHP/화면) 개선
7 07-alternative-approaches.md 다른 방향의 해결책 연구: 학교별 테이블 분할, Redis·MongoDB·Kafka, DB 실행 환경 변경, 폴링·PHP 환경

처음이라면 이 문서의 2장(원리)4장(비교표)6장(로드맵) 순서로 읽고, 실제 작업할 안의 문서를 열어보는 것을 권장합니다.


1. 현재 상황 한눈에 보기

운영 DB에서 측정한 row 수 (2026-09 기준):

테이블 row 수 성격 비고
best_record 1,206,768 이력(계속 증가) 가장 큰 개선 대상. COUNT(*)만 0.93초
app_highest_record 228,930 스냅샷(플레이어×앱당 1행) UNIQUE 키가 없어 중복 행 존재 여부 점검 필요
typing_exam_record 127,955 이력(계속 증가) best_record와 같은 구조·같은 문제
typing_exam_highest_record 20,346 스냅샷
license_score 620 이력 현재 규모로는 문제 없음 (예방 차원만)
license_time 1 스냅샷 사실상 미사용 기능

SELECT COUNT(*) FROM best_record가 0.93초 걸렸다는 것은, DB가 120만 행을 처음부터 끝까지 훑었다는 뜻입니다. 현재 랭킹·히스토리·기록 저장 쿼리도 대부분 비슷한 방식으로 동작하므로, 게임이 끝날 때마다, 랭킹 화면을 열 때마다 이 비용이 반복됩니다. 기록은 매시간 쌓이므로 시간이 갈수록 더 느려집니다.


2. 왜 느려지는가 — 쉬운 설명

2-1. 인덱스 = 책 뒤의 "찾아보기(색인)"

  • 두꺼운 책에서 "파티셔닝"이라는 단어를 찾을 때, 첫 페이지부터 읽으면 오래 걸립니다(= 전체 스캔, Full Table Scan).
  • 책 뒤의 색인에서 "ㅍ" 항목을 찾아 페이지 번호로 바로 가면 빠릅니다(= 인덱스 탐색).
  • 현재 기록 테이블에는 "MaestroID 색인", "PlayerID 색인"처럼 한 가지 기준짜리 색인만 있습니다(외래 키를 만들 때 자동 생성된 것).

2-2. 복합 인덱스 = 전화번호부 (성 → 이름 순서)

  • 전화번호부는 "성"으로 먼저 정렬하고, 같은 성 안에서 "이름"으로 정렬합니다.
  • "김철수"는 빨리 찾지만, "성은 모르고 이름이 철수인 사람"은 전부 뒤져야 합니다.
  • 인덱스도 같습니다. (MaestroID, AppID, RecordDateTime) 인덱스는 "이 선생님의 → 이 앱의 → 이 시간대 기록"을 순서대로 좁혀서 바로 찾습니다. 컬럼 순서가 중요합니다. 보통 =로 비교하는 컬럼을 앞에, 범위(>=, <)로 비교하는 컬럼을 뒤에 둡니다.

2-3. 컬럼에 함수를 씌우면 색인을 쓸 수 없다

RecordDateTime 인덱스는 2026-09-14 13:25:10 같은 전체 시각 순서로 정렬되어 있습니다.

-- 현재 방식: "시(hour)가 13인 기록"
WHERE DATE(RecordDateTime) = DATE(NOW()) AND HOUR(RecordDateTime) = 13

DB 입장에서는 각 행의 RecordDateTimeDATE(), HOUR()계산해 봐야 조건에 맞는지 알 수 있으므로, 색인이 있어도 모든 행을 확인합니다.

-- 개선 방식: "13:00 이상 14:00 미만"
WHERE RecordDateTime >= '2026-09-14 13:00:00' AND RecordDateTime < '2026-09-14 14:00:00'

이 조건은 색인 안에서 연속된 한 구간이므로 그 구간만 읽습니다. 이렇게 인덱스를 활용할 수 있는 조건을 sargable하다고 부릅니다. 결과는 완전히 같고 방식만 다릅니다.

2-4. 매시간 1행씩 영원히 쌓이는 구조

best_record는 "플레이어 × 앱 × 1시간"마다 1행이 생깁니다. 삭제하는 로직이 사실상 없으므로, 인덱스를 잘 만들어도 히스토리·월간 랭킹처럼 넓은 범위를 묶어 계산(GROUP BY)하는 쿼리는 데이터가 늘수록 결국 느려집니다. 이 문제는 "미리 날짜별로 계산해 둔 작은 테이블(집계 테이블)"과 "오래된 원본 분리(아카이빙)"로 해결합니다.


3. 용어 사전

용어 이 프로젝트에서의 예
인덱스 (Index) 원하는 행을 빨리 찾기 위한 정렬된 색인 best_recordPlayerID 색인
복합 인덱스 여러 컬럼을 순서대로 묶은 인덱스 (MaestroID, AppID, RecordDateTime)
커버링 인덱스 쿼리에 필요한 컬럼이 전부 인덱스에 들어 있어 원본 행을 읽지 않아도 되는 인덱스 랭킹용 인덱스에 PlayerID, BestRecord까지 포함
전체 스캔 (Full Scan) 테이블의 모든 행을 처음부터 끝까지 읽음 현재 랭킹/히스토리 쿼리
sargable 인덱스를 활용할 수 있는 형태의 조건 RecordDateTime >= ? AND RecordDateTime < ?
EXPLAIN 쿼리를 실행하지 않고 "어떻게 실행할지" 계획을 보여주는 명령 EXPLAIN SELECT ...
ANALYZE 쿼리를 실제로 실행하고 계획과 실제 소요를 함께 보여줌 ANALYZE FORMAT=JSON SELECT ...
집계(요약) 테이블 원본을 미리 묶어 계산해 둔 작은 테이블 안2의 daily_best_record (플레이어×앱×날짜당 1행)
UPSERT "없으면 INSERT, 있으면 UPDATE"를 쿼리 한 번으로 처리 INSERT ... ON DUPLICATE KEY UPDATE
UNIQUE 키 같은 값 조합이 두 번 들어가지 못하게 막는 인덱스 app_highest_record (MaestroID, PlayerID, AppID)
아카이빙 오래된 데이터를 별도 보관 테이블로 옮겨 운영 테이블을 작게 유지 best_record_archive
파티셔닝 한 테이블을 내부적으로 연/월 단위 "서랍"으로 나누어 저장 RecordDateTime 연도별 파티션
온라인 DDL 서비스 중에도 테이블 구조(인덱스 등)를 변경하는 방식 ALTER TABLE ... ALGORITHM=INPLACE, LOCK=NONE
N+1 쿼리 목록 1번 조회 후 항목마다 쿼리를 1번씩 더 실행하는 비효율 패턴 메뉴 화면의 앱별 최고기록 조회
슬로우 쿼리 로그 기준 시간보다 오래 걸린 쿼리를 기록하는 DB 기능 long_query_time = 1

4. 5개 개선안 요약 비교

안1. 인덱스 + 쿼리 조건 안2. 일별 집계 테이블 안3. 원본 아카이빙 안4. 파티셔닝 안5. 애플리케이션 개선
핵심 아이디어 알맞은 색인을 만들고, 색인을 쓸 수 있게 조건을 바꾼다 날짜별 최고기록을 미리 계산해 두고 거기서 조회한다 오래된 원본을 보관 테이블로 옮긴다 테이블을 연/월 서랍으로 나눈다 PHP 코드의 비효율(N+1, 이중 조회, 문자열 SQL)을 고친다
효과 즉시 큼 장기적으로 매우 큼 장기 용량 관리 장기 용량 관리 중간 (특정 화면)
난이도 하~중
위험도 낮음 중간 중간 높음 낮음
예상 작업량 1~2일 3~5일 2~3일 3~5일 + 서비스 점검 시간 2~4일
DB 구조 변경 인덱스 추가, UNIQUE 키 새 테이블 2개 보관 테이블 2개 + 배치 PK 변경, FK 제거, 테이블 재구성 없음
코드 수정 범위 쿼리 조건 (약 7개 파일) 기록 저장·삭제·조회 경로 관리자 기록 목록, 삭제 경로 적음 (쿼리 조건은 안1 필요) 메뉴·기록 목록 API·화면
기존 데이터 변경 최고기록 중복 행 정리만 없음 (새 테이블에 복사) 원본 행 이동 테이블 재구성 없음
추천 1단계 (필수) 2단계 3단계 보류 병행

각 안은 서로 배타적이지 않습니다. 안1 → 안2 → 안3은 순서대로 쌓아 올리는 구조이고, 안5는 언제든 병행할 수 있으며, 안4는 안3과 목적이 겹쳐 현재는 권장하지 않습니다.


5. 문제점 × 개선안 매트릭스

선행 문서 4장의 문제 1~7이 각 안으로 얼마나 해결되는지 정리했습니다. (◎ 근본 해결 / ○ 상당 부분 개선 / △ 일부 도움 / - 무관)

# 문제점 안1 안2 안3 안4 안5
1 날짜 컬럼에 함수를 씌운 조건 (인덱스 무력화) - △ (안1이 선행되어야 효과) -
2 복합 인덱스 부재 ○ (새 테이블은 처음부터 인덱스 설계) - -
3 이력 테이블 무한 증가 - ○ (조회가 원본에 의존하지 않게 됨) -
4 기록 저장 시 반복되는 조회+쓰기 비용, 최고기록 delete→insert ◎ (UPSERT) - -
5 메뉴 화면 N+1 쿼리 - - - -
6 관리자 기록 목록: COUNT+목록 이중 조회, LIKE '%…%', 문자열 SQL ○ (날짜 인덱스) - ○ (조회 대상 축소)
7 미사용 ranking 테이블 - - - - ○ (재활용 또는 삭제 결정)

6. 추천 조합과 단계별 로드맵

단계 내용 목표 / 완료 조건 참고 문서
0단계. 측정 (반나절) 테이블 크기, 대표 쿼리 EXPLAIN 기준값, 슬로우 쿼리 로그, 최고기록 중복 점검 개선 전 수치를 표로 남김 (7장) 이 문서 7장
1단계. 안1 인덱스 추가 → 쿼리 조건 sargable 변환 → 월간 랭킹 버그 수정 랭킹·기록 저장 쿼리 EXPLAIN에서 type=ALL(전체 스캔)이 사라짐 안1
1단계 병행. 안5 일부 기록 목록 API의 SQL Injection 제거 (보안 이슈라 우선) 모든 조건이 ? 바인딩 안5
2단계. 안2 일별 집계 테이블 생성 → 저장 로직에 반영 → 과거 데이터 채우기 → 조회 전환 히스토리/일간·월간 랭킹이 집계 테이블에서 조회됨, 기존 결과와 동일 안2
3단계. 안3 보관 기간 결정 → 아카이브 테이블·배치 도입 운영 best_record가 보관 기간 이내 데이터만 유지, "최근 7일 히스토리"는 계속 정상 안3
수시. 안5 나머지 N+1 제거, 기록 목록 페이징, 랭킹 캐시, 중복 코드 정리 메뉴 화면 쿼리 수가 앱 개수와 무관해짐 안5
보류. 안4 재검토 조건 충족 시에만 검토 원본 1,000만 행 초과 등 안4

왜 이 순서인가

  • 안1은 기존 데이터를 거의 건드리지 않고, 인덱스는 추가해도 기존 코드가 그대로 동작하므로 가장 안전하게 큰 효과를 얻습니다.
  • 안2는 "몇 년 전 기록이라도 플레이어가 마지막으로 플레이한 7일은 보여준다"는 요구사항을 원본 테이블 없이도 만족시키는 장치입니다. 따라서 안3(원본 분리)보다 반드시 먼저 해야 합니다.
  • 안3은 안2가 끝나야 안전하게 오래된 원본을 옮길 수 있습니다.

안2를 건너뛰는 경로도 있습니다

  • 학생들이 하루 한 수업(한 시간)만 플레이하는 경우가 많으면, 집계 테이블 행 수가 원본과 크게 다르지 않을 수 있습니다. 이때 안2의 가치는 속도보다 "원본 없이도 히스토리가 동작하는 구조"입니다.
  • 안1 후 히스토리·월간 랭킹이 충분히 빠르다면, 안2 없이 안3의 "대안 B(히스토리를 아카이브 테이블에서 보충 조회)" 로 요구사항을 만족시키는 더 단순한 경로를 택할 수 있습니다.
  • 판단 기준과 측정 방법: 안2 3장, 두 경로 비교: 안3 3-2

7. 사전 측정 절차 (0단계)

운영 DB에서 무거운 쿼리를 반복 실행하면 서비스에 영향이 갈 수 있으므로, 가능하면 최신 백업을 스테이징 DB(mariadb.jisangs.com)에 복원해서 측정하세요. 운영에서 실행할 때는 사용량이 적은 시간대에 실행하세요.

7-1. 테이블·인덱스 크기

SELECT TABLE_NAME,
       TABLE_ROWS                               AS approx_rows,
       ROUND(DATA_LENGTH  / 1024 / 1024, 1)     AS data_mb,
       ROUND(INDEX_LENGTH / 1024 / 1024, 1)     AS index_mb
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'chocomae'
ORDER BY DATA_LENGTH DESC;
  • TABLE_ROWS는 추정치입니다(정확한 값은 COUNT(*)).
  • 함께 확인: SELECT @@innodb_buffer_pool_size / 1024 / 1024 AS buffer_pool_mb; — 데이터+인덱스 크기가 이 값보다 훨씬 크면 디스크 읽기가 많아져 느려집니다.

7-2. 월별 적재량 (증가 속도 파악)

SELECT DATE_FORMAT(RecordDateTime, '%Y-%m') AS ym, COUNT(*) AS cnt
FROM best_record
GROUP BY ym
ORDER BY ym;

이 결과로 "보관 기간을 13개월로 하면 운영 테이블에 몇 행이 남는지"(안3), "1,000만 행에 언제 도달하는지"(안4 재검토 시점)를 계산할 수 있습니다.

7-3. 대표 쿼리 EXPLAIN 기준값

  1. 기록이 많은 선생님·앱 조합을 찾습니다.
SELECT MaestroID, AppID, COUNT(*) AS cnt
FROM best_record
WHERE RecordDateTime >= NOW() - INTERVAL 30 DAY
GROUP BY MaestroID, AppID
ORDER BY cnt DESC
LIMIT 5;
  1. 그 값으로 현재 형태의 일간 랭킹 쿼리 계획을 확인합니다. (123, 5는 위 결과로 교체)
EXPLAIN
SELECT BR.PlayerID, U.Name, MAX(BR.BestRecord) AS HighScore
FROM best_record BR, player U
WHERE BR.MaestroID = 123 AND BR.PlayerID = U.PlayerID
  AND YEAR(BR.RecordDateTime) = YEAR('2026-09-10')
  AND MONTH(BR.RecordDateTime) = MONTH('2026-09-10')
  AND DAYOFMONTH(BR.RecordDateTime) = DAYOFMONTH('2026-09-10')
  AND BR.AppID = 5
GROUP BY BR.PlayerID
ORDER BY MAX(BR.BestRecord) DESC;
  1. EXPLAIN 결과 읽는 법
컬럼 볼 것 좋은 값 / 나쁜 값
type 어떻게 찾는가 ref, range, eq_ref 좋음 / ALL(전체 스캔) 나쁨
key 사용한 인덱스 기대한 인덱스 이름이 보이면 좋음 / NULL이면 인덱스 미사용
rows 읽을 것으로 예상하는 행 수 작을수록 좋음 (120만 근처면 전체 스캔)
Extra 추가 작업 Using index(커버링) 좋음 / Using temporary; Using filesort는 대상 행이 많을 때 부담
  1. 실제 소요 시간까지 보려면 EXPLAIN 대신 ANALYZE FORMAT=JSON을 사용합니다. 이 명령은 쿼리를 실제로 실행하므로 SELECT에만 사용하세요. 결과의 r_total_time_ms가 실제 소요 시간(ms)입니다.

7-4. 슬로우 쿼리 로그 켜기 (일시적)

SET GLOBAL slow_query_log = 1;
SET GLOBAL long_query_time = 1;       -- 1초 이상 걸린 쿼리 기록
SET GLOBAL log_output = 'TABLE';      -- mysql.slow_log 테이블에 기록

-- 하루 정도 서비스 후 확인
SELECT start_time, query_time, rows_examined, LEFT(sql_text, 200) AS sql_head
FROM mysql.slow_log
ORDER BY query_time DESC
LIMIT 30;

-- 측정이 끝나면 끄기
SET GLOBAL slow_query_log = 0;

SET GLOBAL은 DB 컨테이너가 재시작되면 초기화됩니다. 계속 켜 두려면 Docker의 MariaDB 설정 파일(my.cnf)에 추가해야 합니다.

7-5. 최고기록 테이블 중복 점검

-- 같은 (선생님, 플레이어, 앱) 조합이 2행 이상인 경우
SELECT MaestroID, PlayerID, AppID, COUNT(*) AS cnt
FROM app_highest_record
GROUP BY MaestroID, PlayerID, AppID
HAVING cnt > 1
ORDER BY cnt DESC
LIMIT 50;

SELECT MaestroID, PlayerID, WritingID, COUNT(*) AS cnt
FROM typing_exam_highest_record
GROUP BY MaestroID, PlayerID, WritingID
HAVING cnt > 1
ORDER BY cnt DESC
LIMIT 50;

결과가 있으면 안1의 UNIQUE 키 추가 전에 정리가 필요합니다(안1 문서 3-2 참고).

7-6. 측정 결과 기록 양식

측정 항목 개선 전 안1 후 안2 후 안3 후
best_record data_mb / index_mb
일간 랭킹 쿼리 rows / 실제 ms
시간 랭킹 쿼리 rows / 실제 ms
히스토리(최근 7일) 쿼리 rows / 실제 ms
기록 저장 시 중복 확인 쿼리 rows / 실제 ms
관리자 기록 목록(50건) 실제 ms
1초 이상 슬로우 쿼리 수 (1일)
일일 백업 파일 크기

8. 조사 중 발견한 버그·위험 (개선 작업 때 함께 수정 권장)

우선순위 파일 내용 영향 조치
높음 (보안) request_app_player_record_list.php, request_writing_player_record_list.php, request_license_timer_player_record_list.php 시작일·종료일·학생 이름·AppID를 SQL 문자열에 그대로 이어붙임 요청 값을 조작하면 다른 선생님의 기록 조회 등 SQL Injection 가능 안5: 전부 ? 바인딩으로 변경
높음 (보안) history_record.php AppID를 쿼리 문자열에 직접 연결 위와 동일 안1 쿼리 수정 시 바인딩으로 변경
중간 (정확성) app_ranking.php get_ranking_month() MONTH()만 비교하고 연도(YEAR)를 비교하지 않음 작년·재작년 같은 달 기록까지 이달의 랭킹에 섞임 안1: 월 범위 조건으로 변경하며 자연히 해결
중간 (정합성) app_highest_record, typing_exam_highest_record UNIQUE 키 없이 "삭제 후 삽입"으로 갱신 동시에 기록이 저장되면 같은 조합이 2행 이상 생길 수 있음 안1: 중복 정리 + UNIQUE 키 / 안2: UPSERT
낮음 (버그) typing_exam_collection.php getHighestRecordArrayForAllWriting() bind_param("iii", …)에 값은 2개만 전달 이 함수 호출 시 오류 호출처 확인 후 "ii"로 수정 또는 미사용이면 삭제
낮음 (코드) record/*.php 여러 파일 if($replyJSON.length === 0)는 PHP에서 "문자열 연결"로 해석되어 항상 거짓 빈 결과 시 에러 응답 분기가 동작하지 않음 (현재는 빈 배열로 응답되어 큰 문제는 없음) 필요 시 count($replyJSON) === 0으로 정리

9. 적용 시 공통 안전 수칙

  1. 백업 먼저: 작업 직전에 backup-db.sh를 수동 실행하고 백업 파일 크기를 확인합니다.
  2. 스테이징 먼저: 최신 백업을 스테이징 DB에 복원해 같은 작업을 먼저 해보고, 소요 시간과 결과를 기록합니다.
  3. 사용량이 적은 시간대: 운영 DB 구조 변경은 새벽에 진행합니다.
  4. 한 번에 하나씩: 인덱스 추가 → 확인 → 코드 배포 → 확인 순서로, 여러 변경을 한꺼번에 하지 않습니다.
  5. 롤백 SQL을 미리 준비: 각 안 문서의 "롤백" 절을 작업 전에 복사해 둡니다.
  6. 스키마 변경 이력 남기기: 운영 DB에 적용한 SQL은 src/web/sql/migration/YYMMDD_설명.sql 같은 파일로 저장소에 남기고, 신규 설치용 make_db.sql에도 반영해 운영 DB와 스키마 파일이 달라지지 않게 합니다.