운영 서버에서 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:
@@ -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로 이동**
|
||||
Reference in New Issue
Block a user