DB 개선 계획 작성
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
# 플레이어 기록 DB 성능 개선 방안 — 개요
|
||||
|
||||
> - 선행 문서: [player-record-tables-and-queries.md](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](02-option1-index-and-query-rewrite.md) | 안1. 인덱스 추가 + 쿼리 조건 개선 |
|
||||
| 3 | [03-option2-daily-summary-tables.md](03-option2-daily-summary-tables.md) | 안2. 일별 최고기록 집계 테이블 도입 |
|
||||
| 4 | [04-option3-archiving.md](04-option3-archiving.md) | 안3. 오래된 원본 기록 아카이빙 |
|
||||
| 5 | [05-option4-partitioning.md](05-option4-partitioning.md) | 안4. 테이블 파티셔닝 (보류 권장) |
|
||||
| 6 | [06-option5-application-layer.md](06-option5-application-layer.md) | 안5. 애플리케이션(PHP/화면) 개선 |
|
||||
| 7 | [07-alternative-approaches.md](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` 같은 **전체 시각 순서**로 정렬되어 있습니다.
|
||||
|
||||
```sql
|
||||
-- 현재 방식: "시(hour)가 13인 기록"
|
||||
WHERE DATE(RecordDateTime) = DATE(NOW()) AND HOUR(RecordDateTime) = 13
|
||||
```
|
||||
|
||||
DB 입장에서는 각 행의 `RecordDateTime`에 `DATE()`, `HOUR()`를 **계산해 봐야** 조건에 맞는지 알 수 있으므로, 색인이 있어도 모든 행을 확인합니다.
|
||||
|
||||
```sql
|
||||
-- 개선 방식: "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_record`의 `PlayerID` 색인 |
|
||||
| 복합 인덱스 | 여러 컬럼을 순서대로 묶은 인덱스 | `(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장](03-option2-daily-summary-tables.md), 두 경로 비교: [안3 3-2](04-option3-archiving.md)
|
||||
|
||||
---
|
||||
|
||||
## 7. 사전 측정 절차 (0단계)
|
||||
|
||||
> 운영 DB에서 무거운 쿼리를 반복 실행하면 서비스에 영향이 갈 수 있으므로, 가능하면 **최신 백업을 스테이징 DB(`mariadb.jisangs.com`)에 복원해서** 측정하세요. 운영에서 실행할 때는 사용량이 적은 시간대에 실행하세요.
|
||||
|
||||
### 7-1. 테이블·인덱스 크기
|
||||
|
||||
```sql
|
||||
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. 월별 적재량 (증가 속도 파악)
|
||||
|
||||
```sql
|
||||
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) 기록이 많은 선생님·앱 조합을 찾습니다.
|
||||
|
||||
```sql
|
||||
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;
|
||||
```
|
||||
|
||||
2) 그 값으로 현재 형태의 일간 랭킹 쿼리 계획을 확인합니다. (`123`, `5`는 위 결과로 교체)
|
||||
|
||||
```sql
|
||||
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;
|
||||
```
|
||||
|
||||
3) EXPLAIN 결과 읽는 법
|
||||
|
||||
| 컬럼 | 볼 것 | 좋은 값 / 나쁜 값 |
|
||||
|---|---|---|
|
||||
| `type` | 어떻게 찾는가 | `ref`, `range`, `eq_ref` 좋음 / **`ALL`(전체 스캔) 나쁨** |
|
||||
| `key` | 사용한 인덱스 | 기대한 인덱스 이름이 보이면 좋음 / `NULL`이면 인덱스 미사용 |
|
||||
| `rows` | 읽을 것으로 예상하는 행 수 | 작을수록 좋음 (120만 근처면 전체 스캔) |
|
||||
| `Extra` | 추가 작업 | `Using index`(커버링) 좋음 / `Using temporary; Using filesort`는 대상 행이 많을 때 부담 |
|
||||
|
||||
4) 실제 소요 시간까지 보려면 `EXPLAIN` 대신 `ANALYZE FORMAT=JSON`을 사용합니다. **이 명령은 쿼리를 실제로 실행**하므로 SELECT에만 사용하세요. 결과의 `r_total_time_ms`가 실제 소요 시간(ms)입니다.
|
||||
|
||||
### 7-4. 슬로우 쿼리 로그 켜기 (일시적)
|
||||
|
||||
```sql
|
||||
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. 최고기록 테이블 중복 점검
|
||||
|
||||
```sql
|
||||
-- 같은 (선생님, 플레이어, 앱) 조합이 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](../../../src/web/server/record/request_app_player_record_list.php), [request_writing_player_record_list.php](../../../src/web/server/record/request_writing_player_record_list.php), [request_license_timer_player_record_list.php](../../../src/web/server/record/request_license_timer_player_record_list.php) | 시작일·종료일·학생 이름·AppID를 SQL 문자열에 그대로 이어붙임 | 요청 값을 조작하면 다른 선생님의 기록 조회 등 **SQL Injection 가능** | 안5: 전부 `?` 바인딩으로 변경 |
|
||||
| 높음 (보안) | [history_record.php](../../../src/web/server/record/history_record.php) | `AppID`를 쿼리 문자열에 직접 연결 | 위와 동일 | 안1 쿼리 수정 시 바인딩으로 변경 |
|
||||
| 중간 (정확성) | [app_ranking.php](../../../src/web/server/record/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](../../../src/web/php/db/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](../../db/260907-daily-db-backup/backup-db.sh)를 수동 실행하고 백업 파일 크기를 확인합니다.
|
||||
2. **스테이징 먼저**: 최신 백업을 스테이징 DB에 복원해 같은 작업을 먼저 해보고, 소요 시간과 결과를 기록합니다.
|
||||
3. **사용량이 적은 시간대**: 운영 DB 구조 변경은 새벽에 진행합니다.
|
||||
4. **한 번에 하나씩**: 인덱스 추가 → 확인 → 코드 배포 → 확인 순서로, 여러 변경을 한꺼번에 하지 않습니다.
|
||||
5. **롤백 SQL을 미리 준비**: 각 안 문서의 "롤백" 절을 작업 전에 복사해 둡니다.
|
||||
6. **스키마 변경 이력 남기기**: 운영 DB에 적용한 SQL은 `src/web/sql/migration/YYMMDD_설명.sql` 같은 파일로 저장소에 남기고, 신규 설치용 [make_db.sql](../../../src/web/sql/make_db.sql)에도 반영해 운영 DB와 스키마 파일이 달라지지 않게 합니다.
|
||||
Reference in New Issue
Block a user