운영 서버에서 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>
This commit is contained in:
2026-09-15 23:30:42 +09:00
parent ded12332d5
commit 03eea2098e
56 changed files with 6993 additions and 0 deletions
@@ -0,0 +1,377 @@
# 02단계: 개선 전 성능 측정 (베이스라인)
> 인덱스 추가 전 EXPLAIN과 실행 시간을 측정해 개선 효과의 기준점을 만듭니다.
> 04단계에서 **같은 스크립트, 같은 쿼리, 같은 시각**으로 다시 측정해 비교합니다.
모든 명령은 `result/` 디렉터리에서 실행합니다.
```bash
cd doc/plan/db/260915-improve-production/result
```
---
## 측정 방식
| 원칙 | 이유 |
|---|---|
| 02와 04가 같은 쿼리 파일(`queries/*.sql`)을 실행 | 쿼리가 다르면 실행 시간을 비교할 수 없음 |
| `NOW()` 대신 **지난달의 시각을 고정** (`params.sql`) | 새벽 작업 시간에는 현재 시각의 기록이 거의 없음. 지난달 데이터는 02와 04 사이에 바뀌지 않아 결과 행 수까지 비교 가능 |
| 같은 조건을 **함수형**과 **범위형** 두 가지로 측정 | 인덱스만의 효과(함수형)와 인덱스 + 쿼리 수정 효과(범위형)를 나눠 확인 |
| 쿼리 묶음을 2회 실행해 **2회차** 값 사용 | 1회차에는 디스크에서 페이지를 읽는 시간이 섞임 |
| `SQL_NO_CACHE` | 쿼리 캐시 결과로 시간이 왜곡되지 않게 함 |
| 쿼리 파일 | 조건 | 형태 |
|---|---|---|
| `q1_hour_func.sql` | 특정 1시간 기록 | `DATE()`, `HOUR()` 함수 (수정 전 코드 형태) |
| `q2_hour_range.sql` | 특정 1시간 기록 | 범위 조건 (Stage에서 수정한 코드 형태) |
| `q3_day_func.sql` | 일간 랭킹 | `YEAR()`, `MONTH()`, `DAYOFMONTH()` 함수 |
| `q4_day_range.sql` | 일간 랭킹 | 범위 조건 |
| `q5_month_func.sql` | 월간 랭킹 | `YEAR()`, `MONTH()` 함수 |
| `q6_month_range.sql` | 월간 랭킹 | 범위 조건 |
---
## 2-0. 측정 기준 시각 정하기
MaestroID 181 · AppID 21의 **지난달** 기록 중 가장 많은 시간대를 찾습니다.
**아래 명령어를 터미널에 복사-붙여넣기해서 실행하세요:**
```bash
mariadb -h chocomae.jinaju.com -u jisangs -p chocomae -e "
SELECT DATE(RecordDateTime) AS day, HOUR(RecordDateTime) AS hour, COUNT(*) AS cnt
FROM best_record
WHERE MaestroID = 181 AND AppID = 21
AND RecordDateTime >= CAST(DATE_FORMAT(NOW() - INTERVAL 1 MONTH, '%Y-%m-01') AS DATETIME)
AND RecordDateTime < CAST(DATE_FORMAT(NOW(), '%Y-%m-01') AS DATETIME)
GROUP BY day, hour
ORDER BY cnt DESC
LIMIT 5;" > baseline_pick_time.txt
cat baseline_pick_time.txt
```
첫 번째 행의 `day``hour``params.sql`을 만듭니다. **아래 값은 예시이니 반드시 바꾸세요.**
```bash
cat > params.sql << 'EOF'
SET @maestro = 181, @app = 21, @day = '2026-08-20', @hour = 14;
EOF
cat params.sql
```
> 04단계가 끝날 때까지 `params.sql`을 바꾸지 마세요.
---
## 2-1. Slow Query Log 켜기 (SUPER 권한)
> `SET GLOBAL`은 SUPER 권한이 필요합니다. 2026-09-15에 `jisangs` 계정에 임시 부여했으므로 jisangs로 실행하면 됩니다 (`mariadb -h chocomae.jinaju.com -u jisangs -p chocomae`로 접속 후 아래 SQL 실행). 운영의 원래 값은 `slow_query_log=ON`, `long_query_time=3`, `log_output=FILE`이며(01 1-4), 05단계에서 이 값으로 복원합니다.
```sql
SET GLOBAL log_output = 'FILE,TABLE';
SET GLOBAL long_query_time = 0.5;
SET GLOBAL slow_query_log = 'ON';
SHOW GLOBAL VARIABLES WHERE Variable_name IN
('slow_query_log', 'long_query_time', 'log_output', 'log_queries_not_using_indexes');
```
- `log_output = 'FILE,TABLE'`: 기존 파일 로그(`/var/log/mysql/mariadb-slow.log`)는 유지하면서 04단계에서 `mysql.slow_log` 테이블로 조회할 수 있게 합니다.
- `long_query_time`**새 연결부터** 적용됩니다. PHP는 요청마다 새로 연결하므로 바로 반영됩니다.
- ⚠️ `log_queries_not_using_indexes`는 켜지 마세요. 인덱스를 쓰지 않는 모든 쿼리가 실행 시간과 상관없이 기록되어 `mysql.slow_log`가 빠르게 커집니다.
---
## 2-2. 측정 파일 만들기
### 측정 쿼리 6개
> 쿼리 파일은 반드시 `SELECT`로 시작해야 합니다. 스크립트가 앞에 `EXPLAIN FORMAT=JSON `을 붙여 실행하므로 주석을 넣지 마세요.
**아래 명령어를 터미널에 복사-붙여넣기해서 실행하세요:**
```bash
mkdir -p queries
cat > queries/q1_hour_func.sql << 'EOF'
SELECT SQL_NO_CACHE PlayerID, BestRecord, RecordDateTime
FROM best_record
WHERE MaestroID = @maestro AND AppID = @app
AND DATE(RecordDateTime) = @day
AND HOUR(RecordDateTime) = @hour
ORDER BY RecordDateTime, PlayerID;
EOF
cat > queries/q2_hour_range.sql << 'EOF'
SELECT SQL_NO_CACHE PlayerID, BestRecord, RecordDateTime
FROM best_record
WHERE MaestroID = @maestro AND AppID = @app
AND RecordDateTime >= CAST(@day AS DATETIME) + INTERVAL @hour HOUR
AND RecordDateTime < CAST(@day AS DATETIME) + INTERVAL (@hour + 1) HOUR
ORDER BY RecordDateTime, PlayerID;
EOF
cat > queries/q3_day_func.sql << 'EOF'
SELECT SQL_NO_CACHE PlayerID, MAX(BestRecord) AS HighScore
FROM best_record
WHERE MaestroID = @maestro AND AppID = @app
AND YEAR(RecordDateTime) = YEAR(@day)
AND MONTH(RecordDateTime) = MONTH(@day)
AND DAYOFMONTH(RecordDateTime) = DAYOFMONTH(@day)
GROUP BY PlayerID
ORDER BY HighScore DESC, PlayerID;
EOF
cat > queries/q4_day_range.sql << 'EOF'
SELECT SQL_NO_CACHE PlayerID, MAX(BestRecord) AS HighScore
FROM best_record
WHERE MaestroID = @maestro AND AppID = @app
AND RecordDateTime >= CAST(@day AS DATETIME)
AND RecordDateTime < CAST(@day AS DATETIME) + INTERVAL 1 DAY
GROUP BY PlayerID
ORDER BY HighScore DESC, PlayerID;
EOF
cat > queries/q5_month_func.sql << 'EOF'
SELECT SQL_NO_CACHE PlayerID, MAX(BestRecord) AS HighScore
FROM best_record
WHERE MaestroID = @maestro AND AppID = @app
AND YEAR(RecordDateTime) = YEAR(@day)
AND MONTH(RecordDateTime) = MONTH(@day)
GROUP BY PlayerID
ORDER BY HighScore DESC, PlayerID;
EOF
cat > queries/q6_month_range.sql << 'EOF'
SELECT SQL_NO_CACHE PlayerID, MAX(BestRecord) AS HighScore
FROM best_record
WHERE MaestroID = @maestro AND AppID = @app
AND RecordDateTime >= CAST(DATE_FORMAT(@day, '%Y-%m-01') AS DATETIME)
AND RecordDateTime < CAST(DATE_FORMAT(@day, '%Y-%m-01') AS DATETIME) + INTERVAL 1 MONTH
GROUP BY PlayerID
ORDER BY HighScore DESC, PlayerID;
EOF
ls queries/
```
### 측정 스크립트
비밀번호를 한 번만 입력받아 EXPLAIN 6개와 실행 시간을 모두 측정합니다. 비밀번호는 파일이나 명령 인자로 남지 않습니다.
**아래 명령어를 터미널에 복사-붙여넣기해서 실행하세요:**
```bash
cat > run_measure.sh << 'EOF'
#!/bin/zsh
# 02·04단계 공통 측정 스크립트
# 사용법: zsh run_measure.sh baseline (02단계, 인덱스 추가 전)
# zsh run_measure.sh after (04단계, 인덱스 추가 후)
PREFIX=$1
if [[ $PREFIX != baseline && $PREFIX != after ]]; then
echo "사용법: zsh run_measure.sh baseline|after" >&2; exit 1
fi
cd "${0:A:h}" || exit 1
printf "Enter password: "; read -rs DBPW; echo
db() {
mariadb --defaults-extra-file=<(printf '[client]\npassword="%s"\n' "$DBPW") \
-h chocomae.jinaju.com -u jisangs chocomae "$@"
}
# 1) EXPLAIN: 쿼리마다 JSON 파일 1개
for q in queries/*.sql; do
out="${PREFIX}_explain_${q:t:r}.json"
{ cat params.sql; printf 'EXPLAIN FORMAT=JSON '; cat "$q"; } | db -N -r > "$out" \
|| { echo "EXPLAIN 실패: $q" >&2; exit 1; }
echo "저장: $out"
done
# 2) 실행 시간: 같은 쿼리 묶음을 2회 실행 (2회차 값 사용)
for round in 1 2; do
{ cat params.sql
for q in queries/*.sql; do
echo "SELECT '=== round ${round}: ${q:t:r} ===' AS msg;"
cat "$q"
done
} | db -vvv || { echo "실행 시간 측정 실패" >&2; exit 1; }
done > "${PREFIX}_perf.txt"
echo "저장: ${PREFIX}_perf.txt"
# 3) 요약: 쿼리 이름 / 결과 행 수 / 실행 시간 (2회차)
echo
grep -E '^\| === |rows? in set|Empty set' "${PREFIX}_perf.txt" | paste - - - | grep 'round 2' \
| sed -E 's/\| === round 2: (.*) === \|/\1/; s/\t1 row in set \([0-9.]+ sec\)//'
EOF
```
### EXPLAIN 요약 스크립트
EXPLAIN JSON에서 `access_type`, 사용 인덱스, `rows`만 뽑아 한 줄씩 보여줍니다.
**아래 명령어를 터미널에 복사-붙여넣기해서 실행하세요:**
```bash
cat > summarize_explain.py << 'EOF'
# 사용법: python3 summarize_explain.py baseline [after]
import glob
import json
import sys
def find_table(node):
if isinstance(node, dict):
if isinstance(node.get("table"), dict):
return node["table"]
children = node.values()
elif isinstance(node, list):
children = node
else:
return None
for child in children:
found = find_table(child)
if found:
return found
return None
def keys_of(node):
if isinstance(node, dict):
for k, v in node.items():
if k == "key":
yield v
else:
yield from keys_of(v)
elif isinstance(node, list):
for v in node:
yield from keys_of(v)
for prefix in sys.argv[1:]:
for path in sorted(glob.glob(f"{prefix}_explain_*.json")):
with open(path, encoding="utf-8") as f:
table = find_table(json.load(f))
key = table.get("key") or " ∩ ".join(keys_of(table.get("index_merge", {}))) or "-"
print(f"{path:40} access_type={str(table.get('access_type')):12} key={key:28} rows={table.get('rows')}")
EOF
```
---
## 2-3. 베이스라인 측정 실행
**아래 명령어를 터미널에 복사-붙여넣기해서 실행하세요:**
```bash
zsh run_measure.sh baseline
```
**출력 예 (숫자는 예시):**
```
Enter password:
저장: baseline_explain_q1_hour_func.json
...
저장: baseline_explain_q6_month_range.json
저장: baseline_perf.txt
q1_hour_func 38 rows in set (0.052 sec)
q2_hour_range 38 rows in set (0.049 sec)
q3_day_func 21 rows in set (0.061 sec)
q4_day_range 21 rows in set (0.058 sec)
q5_month_func 96 rows in set (0.410 sec)
q6_month_range 96 rows in set (0.395 sec)
```
**확인할 점:**
- 함수형과 범위형 짝(q1↔q2, q3↔q4, q5↔q6)의 **결과 행 수가 같아야** 합니다. 다르면 범위 조건이 잘못된 것이니 03단계로 가지 말고 원인을 확인하세요.
- `Empty set`이면 그 시각에 기록이 없는 것이니 2-0에서 다른 시각을 고르세요.
- 비밀번호를 틀리면 `Access denied`와 함께 멈춥니다. 다시 실행하세요.
---
## 2-4. EXPLAIN 요약
**아래 명령어를 터미널에 복사-붙여넣기해서 실행하세요:**
```bash
python3 summarize_explain.py baseline | tee baseline_explain_summary.txt
```
**운영 예상:** 01단계 사전 측정처럼 `index_merge`(MaestroID ∩ AppID)나 단일 인덱스 접근이 나오고, `rows`는 수천~1만 행 수준입니다. 전체 스캔(`ALL`)이 아닐 수 있으니 나온 값을 그대로 기준값으로 기록하세요.
---
## 2-4-1. 서버 측 실행 시간 (ANALYZE)
2-3의 실행 시간은 Mac ↔ AWS 네트워크 왕복이 포함된 클라이언트 시간입니다. 서버가 쿼리를 처리한 시간(`r_total_time_ms`)을 따로 기록해 두면 네트워크 영향 없이 비교할 수 있습니다.
> 운영 측정 (2026-09-15): 클라이언트 시간 약 90ms 중 서버 처리 시간이 약 75ms였습니다. 6개 쿼리 모두 `index_merge`로 1만여 행을 읽기 때문입니다. `ANALYZE`는 쿼리를 실제로 실행하지만 조회(SELECT)만 하므로 데이터는 바뀌지 않습니다.
**요약 스크립트 만들기:**
```bash
cat > summarize_analyze.py << 'EOF'
# 사용법: python3 summarize_analyze.py baseline [after]
import json
import sys
decoder = json.JSONDecoder()
for prefix in sys.argv[1:]:
text = open(f"{prefix}_analyze.txt", encoding="utf-8").read()
names = [line.split("=== ")[1].split(" ===")[0] for line in text.splitlines() if line.startswith("=== ")]
docs, pos = [], 0
while True:
start = text.find("{", pos)
if start < 0:
break
doc, pos = decoder.raw_decode(text, start)
docs.append(doc)
for name, doc in zip(names, docs):
block = doc["query_block"]
print(f"{prefix:8} {name:16} server_time_ms={block.get('r_total_time_ms')}")
EOF
```
**측정 (비밀번호 한 번 입력):**
```bash
{ cat params.sql
for q in queries/*.sql; do
echo "SELECT '=== ${q:t:r} ===';"
printf 'ANALYZE FORMAT=JSON '
cat "$q"
done
} | mariadb -h chocomae.jinaju.com -u jisangs -p chocomae -N -r > baseline_analyze.txt
python3 summarize_analyze.py baseline | tee baseline_analyze_summary.txt
```
---
## 2-5. 결과 기록
**파일로 저장된 결과:**
```
params.sql, queries/*.sql ← 04단계에서 그대로 재사용 (수정 금지)
run_measure.sh, summarize_explain.py ← 04단계에서 그대로 재사용
baseline_pick_time.txt ← 측정 기준 시각 선정 근거
baseline_explain_q*.json (6개) ← EXPLAIN 결과
baseline_explain_summary.txt ← EXPLAIN 요약
baseline_perf.txt ← 실행 시간 (-vvv 전체 출력, 네트워크 포함)
summarize_analyze.py ← 04단계에서 그대로 재사용
baseline_analyze.txt ← ANALYZE 결과 (서버 측 실행 통계)
baseline_analyze_summary.txt ← 서버 측 실행 시간 요약
```
2-3 요약과 2-4 결과는 04단계 4-4의 `comparison.txt`에 옮겨 적습니다.
---
## 다음 단계
✅ 베이스라인 측정 완료 → **03-add-indexes.md로 이동**