diff --git a/.claude/agents/query-source-mapper.md b/.claude/agents/query-source-mapper.md deleted file mode 100644 index 73bb287..0000000 --- a/.claude/agents/query-source-mapper.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -name: query-source-mapper -description: optimize-performance 스킬 전용 가공 워커. mysql 1차 쿼리 통계 출력을 읽고 각 쿼리의 출처(리포지토리 메서드)를 Grep으로 매핑해, 템플릿 작성 규칙대로 같은 경로에 가공본을 덮어쓴다. 판정과 해석은 쓰지 않는다. 단독 호출용이 아니다. -tools: Read, Grep, Glob, Write -model: sonnet ---- - -optimize-performance 스킬이 위임한 쿼리 통계 가공본 작성을 수행한다. - -## 입력 - -호출 프롬프트로 받는다. 경로는 전부 전체 경로다. - -- 1차 출력 파일 경로 (`query-stats-summary-{n}.md`, `mysql -B`의 탭 구분 출력) -- 템플릿 경로 (`.claude/skills/optimize-performance/template/query-stats-template.md`) -- `record.md` 경로 - Phase 1의 예상 쿼리 목록이 출처 매핑의 1차 후보다 -- 상태 번호 `n` -- k6 요약 경로 (`k6-test-summary-{n}.json`) - 측정 조건 헤더(VU, duration, 요청 수)에 쓴다 -- `n >= 1`이면 직전 가공본(`query-stats-summary-{n-1}.md`) 경로 - -## 절차 - -1. 템플릿을 Read해 상단 **작성 규칙**을 그대로 따르라. 특히: - 반올림 금지, 행 재정렬 금지, `DIGEST_TEXT` 전문 유지, 트랜잭션 제어문 포함, - `읽은행/반환행` 유지, `n >= 1`이면 직전 가공본과 대조해 **직전 상태 대비** 작성. -2. 각 쿼리의 **출처**를 채워라. `record.md`의 예상 쿼리 목록이 1차 후보이고, - Grep으로 확인된 것만 `{클래스}.{메서드}`로 적는다. - 목록에 없는 쿼리는 인터셉터, Hibernate 내부 조회를 의심하되, - 확인하지 못하면 `미상`으로 둔다. 그럴듯한 이름을 지어내지 마라. -3. 완성한 가공본을 **1차 출력과 같은 경로에 덮어써라.** 별도 파일을 만들지 마라. - -## 반환 - -아래 사실만 반환하라. 병목 판정, 원인 해석, 개선 제안을 반환하지 마라. - -- 가공한 행 수와 요청당 쿼리 수 합 -- 출처 미상 목록 (없으면 "없음") -- `DIGEST_TEXT` 잘림 발생 여부 diff --git a/.claude/resources/plans/PLAN-108.md b/.claude/resources/plans/PLAN-108.md new file mode 100644 index 0000000..655a4b9 --- /dev/null +++ b/.claude/resources/plans/PLAN-108.md @@ -0,0 +1,372 @@ +# [PLAN-108] 성능 측정 스킬의 관측 수단 정비 + +> 이슈: #108 +> 브랜치: chore/108-perf-skill-observability + +## 목표 +optimize-performance 스킬에서 (1) 쿼리 출처 매핑을 서브에이전트 위임 없이 메인이 직접 하고, +(2) k6 측정 중 JVM, 풀, 캐시, Redis 상태를 주기 샘플링해 병목 판정 재료에 넣고, +(3) 실행계획 표에 노드별 실제 소요 ms와 쿼리 전체 소요를 계산해 보여준다. +겸해서 #104 이후 템플릿과 어긋난 `application-perf.yml`을 맞추고, 스킬 안의 "애플리케이션 캐시 없음" 전제를 걷어낸다. + +## 사전 확인 (2026-08-30 조사) +- `query-source-mapper` 참조는 세 곳뿐이다: `SKILL.md:8` allowed-tools, `phase-4-baseline.md:22`, `phase-8-verify.md:27`. + `query-stats-template.md`의 작성 규칙에는 서브에이전트를 전제한 문구가 없다 (규칙 1의 "Read로 읽고 같은 경로에 덮어쓴다"는 메인이 해도 그대로 맞는다). 이슈의 해당 항목은 확인 결과 변경 불필요. +- 위임이 토큰을 더 쓰는 이유: 워커가 템플릿, `record.md`, k6 요약, 직전 가공본을 전부 다시 읽는다. 메인은 Phase 1에서 예상 쿼리 목록을 이미 확인했으므로 목록에 있는 쿼리는 Grep 없이 매핑할 수 있다. + Grep은 **목록에 없는 쿼리**에만 필요하다. 여기에 `spring_data_repository_invocations_seconds_count{repository,method}`를 더하면 메서드별 호출 수가 지표로 나와 매핑을 검증할 수 있다. +- perf 프로파일 앱이 떠 있어 `/actuator/prometheus` 지표 이름을 실측했다 (Micrometer 1.16.1, 지표군 약 90개). 라벨 순서는 `application`이 맨 앞이다. + + | 지표군 | 노출 | 형태 | + |---|---|---| + | `jvm_memory_used_bytes{area="heap",id=...}`, `jvm_memory_max_bytes` | 있음 | id별 3행. max는 Eden, Survivor가 `-1`, Old Gen만 Xmx(4 GiB) | + | `jvm_gc_pause_seconds_count`, `_sum`, `_max` | 있음 | `cause`, `gc` 라벨별 여러 행. count, sum은 누적 카운터 | + | `jvm_gc_overhead`, `jvm_memory_usage_after_gc{pool="long-lived"}`, `jvm_gc_memory_allocated_bytes_total`, `_promoted_bytes_total` | 있음 | 단일 | + | `jvm_threads_live_threads`, `jvm_threads_states_threads{state}` | 있음 | states는 blocked, runnable, waiting 등 6행 | + | `hikaricp_connections_{active,pending,timeout_total,acquire_seconds_max,usage_seconds_sum,usage_seconds_count}{pool="HikariPool-1"}` | 있음 | 단일 | + | `http_server_requests_active_seconds_gcount`, `_max` | 있음 | `uri`, `method` 등 라벨별 | + | `process_cpu_usage`, `system_cpu_usage` | 있음 | 단일 | + | `cache_gets_total{cache,result=hit\|miss\|pending}`, `cache_puts_total`, `cache_removals_total` | 있음 | 캐시명별. 지금은 `major-courses` 하나 | + | `lettuce_seconds_count`, `_sum`, `_max{db_operation}` | 있음 | Redis 명령별(GET, SET, ...) | + | `spring_data_repository_invocations_seconds_count`, `_sum`, `_max{repository,method}` | 있음 | 리포지토리 메서드별 | + | `tomcat_threads_*` | **없음** | `server.tomcat.mbeanregistry.enabled`가 꺼져 있다. **포함하지 않는다** (결정 1). 서버 포화는 `http_server_requests_active`로 본다 | + +- Micrometer `_max` 계열(`gc_pause_seconds_max`, `acquire_seconds_max`, `requests_active_seconds_max`)은 최근 창(기본 2분)에서 감쇠하는 값이라 유휴 상태에서 0이다. 부하 중 주기 샘플링이어야 잡힌다. +- **`src/main/resources/application-perf.yml`이 템플릿과 다르다.** #104가 src 쪽에 `spring.data.redis`, `spring.cache`(type redis, `enable-statistics: true`, TTL 25h), `cache.major-courses.refresh-cron`을 넣었고 헤더 주석("원본은 템플릿이다", 선행 명령에 `redis`)도 바꿨다. 템플릿엔 없다. + src의 캐시 주석은 "Phase 8이 actuator의 `cache_gets_total`로 hit / miss를 읽는다"고 적었지만 스킬에는 그 절차가 없다. 이번에 샘플러가 그 역할을 맡는다. +- 캐시 사용처: `course/infra/CourseCacheLoader`(`@Cacheable`, `@CachePut` `major-courses`, 키는 학과명), `course/infra/CourseCacheWarmer`, `global/config/RedisCacheConfig`(`CacheErrorHandler`로 Redis 장애 시 DB 폴백). compose 서비스는 `uss-mysql`, `uss-redis`. +- 스킬 안에서 "애플리케이션 캐시 없음"을 전제한 곳: `SKILL.md:61`, `PERF-template.md:50`, `k6-script-template.js:50`(`app_cache: '없음'`), `phase-2-environment.md:27`(compose에 `redis` 없음). `fix-concurrency`의 `CONCURRENCY-template.md:74`에도 같은 문구가 있으나 다른 스킬이라 이번 범위 밖. +- `commands.md` A 블록은 이미 `jq`, `python3`를 쓴다. 샘플러는 `curl`, `awk`만 쓰면 새 의존이 없다. +- `EXPLAIN ANALYZE`의 `actual time=A..B`는 loops당 평균이다 (`.claude/resources/perf/104/major/query-plan-*.txt`). 노드 실제 소요는 `B × loops`, 쿼리 전체 소요는 루트 노드의 B다. + 이 값은 결과 전송 시간을 뺀 서버 실행 시간이라 digest의 `mean_ms`보다 작게 나오는 것이 정상이다. + +## 영향 범위 +### 신규 파일 +- `.claude/skills/_shared/jvm-sampler.sh` — `/actuator/prometheus` 주기 샘플링(`sample`), 가공본 생성(`summarize`), 지표 노출 점검(`check`) + +### 삭제 파일 +- `.claude/agents/query-source-mapper.md` + +### 수정 파일 +- `.claude/skills/optimize-performance/SKILL.md` — allowed-tools에서 `Agent(query-source-mapper)` 제거, 측정 스택 표와 산출물 규약에 JVM 가공본 추가, "애플리케이션 캐시도 없다" 문구 교체 +- `.claude/skills/optimize-performance/phases/phase-2-environment.md` — compose에 `redis`, 템플릿과 src yml의 diff 점검, Redis ping과 샘플러 `check` 게이트 추가 +- `.claude/skills/optimize-performance/phases/phase-4-baseline.md` — 위임 절차를 메인 직접 가공으로 교체, JVM 가공본 제시와 진단 표 행 추가 +- `.claude/skills/optimize-performance/phases/phase-6-snapshot.md` — 노드별 표에 `소요 ms` 칼럼과 `쿼리 전체` 한 줄 추가 +- `.claude/skills/optimize-performance/phases/phase-8-verify.md` — 위임 절차 교체, JVM 전후 비교 추가 +- `.claude/skills/optimize-performance/template/commands.md` — A 블록에 샘플러 기동, 정지, 가공 단계 추가, 워밍업 주석에 Redis +- `.claude/skills/optimize-performance/template/PERF-template.md` — 캐시 상태 문구 교체, 기준선, 개선 전후, 최종 요약에 JVM 행과 쿼리 전체 소요 행 추가 +- `.claude/skills/optimize-performance/template/application-perf.yml` — **삭제** (결정 4). src가 유일한 정의 +- `src/main/resources/application-perf.yml` — 헤더 주석과 캐시 주석만 수정. 설정값은 그대로 +- `.claude/skills/optimize-performance/template/perf-env.sh` — "Phase 2에서 파일을 만든 뒤" 경고 문구 정정 +- `.claude/spec/secret-convention.md` — 템플릿 경로 참조를 src로 +- `.claude/skills/optimize-performance/template/k6-script-template.js` — `app_cache` 자리표시자 + +> `query-stats-template.md`, `phase-1/3/5/7/9`, `optimize-performance/template/output.md`, `fix-concurrency/*`는 손대지 않는다. +> `fix-concurrency/template/application-conc.yml`도 같은 구조(템플릿 + src 둘 다 추적)이지만 다른 스킬이라 범위 밖. 별도 이슈 권장. +> open-issue 스킬이 `feat-issue-template.md`를 참조하지만 실제 파일은 `feature-issue-template.md`인 문제는 이 이슈 범위 밖이다. + +## 구현 계획 + +### 1. `jvm-sampler.sh` (신규, `.claude/skills/_shared/`) + +`mint-tokens.sh`와 같은 형태: 상단 주석에 목적, 사용법, 출력 형태. `set -euo pipefail`. 인자는 `--key value` 파싱. + +``` +bash jvm-sampler.sh check [--url URL] +bash jvm-sampler.sh sample --out FILE [--url URL] [--interval SEC] +bash jvm-sampler.sh summarize --in FILE --out FILE [--requests N] +``` +- `URL` 기본 `http://localhost:8081/actuator/prometheus`, `SEC` 기본 `5` (결정 2). + +**두 종류의 원본.** 게이지는 시계열이 필요하고, 라벨이 가변인 카운터(캐시명별, Redis 명령별, 메서드별)는 증분만 필요하다. 그래서 `sample`은 두 가지를 남긴다. +- `{out}` (CSV): 매 회 게이지와 대표 카운터 한 행 +- `{out}.first.prom`, `{out}.last.prom`: 첫 스크랩과 마지막 스크랩 원문. 매 회 `.last.prom`을 덮어쓴다 + +**공통 파서 `scrape_row()`** - `curl -s -m 3 $URL` 출력을 awk 한 번에 넘겨 CSV 한 행을 만든다. 칼럼 순서 고정: + +``` +offset_s,heap_used_mb,heap_max_mb,old_after_gc_pct,gc_count,gc_pause_ms,gc_pause_max_ms,gc_overhead,threads_live,threads_blocked,hikari_active,hikari_pending,hikari_timeout_total,hikari_acquire_max_ms,http_active,http_active_max_ms,process_cpu,system_cpu +``` + +| 칼럼 | 계산 | +|---|---| +| `heap_used_mb` | `jvm_memory_used_bytes` 중 `area="heap"` 행의 합 / 1048576 | +| `heap_max_mb` | `jvm_memory_max_bytes` 중 `area="heap"`이고 값 > 0인 행의 합 / 1048576 (G1은 Old Gen 한 행이 Xmx다) | +| `old_after_gc_pct` | `jvm_memory_usage_after_gc{pool="long-lived"}` × 100 | +| `gc_count`, `gc_pause_ms` | `jvm_gc_pause_seconds_count`, `_sum` 전 행의 합. `_sum`은 × 1000 | +| `gc_pause_max_ms` | `jvm_gc_pause_seconds_max` 전 행 중 최댓값 × 1000 | +| `gc_overhead` | `jvm_gc_overhead` 그대로 (0~1) | +| `threads_live`, `threads_blocked` | `jvm_threads_live_threads`, `jvm_threads_states_threads{state="blocked"}` | +| `hikari_*` | 해당 지표 그대로. `acquire_seconds_max` × 1000 | +| `http_active`, `http_active_max_ms` | `http_server_requests_active_seconds_gcount` 전 행의 합, `_max` 전 행 중 최댓값 × 1000 | +| `process_cpu`, `system_cpu` | 그대로 | + +- 라벨은 `application`이 맨 앞이므로 `{area="heap"` 같은 접두 매칭을 쓰지 않는다. awk에서 `index($0, "area=\"heap\"")`으로 잡는다. +- 지표군이 응답에 없으면 그 칼럼을 **빈 값**으로 둔다. 0으로 채우지 않는다. summarize가 빈 칼럼을 `미수집`으로 보고한다. +- 지수 표기(`1.09051904E8`)는 awk가 숫자로 읽는다. printf로 고정 소수점 출력. + +**`check`** - 한 번 긁어 지표군 7개(heap, gc, threads, hikari, http active, process cpu, system cpu)의 존재를 `있음 / 없음`으로 한 줄씩 출력하고, 하나라도 없으면 exit 1. 캐시, Redis, 리포지토리 지표는 대상에 따라 없을 수 있으므로 게이트가 아니고 `있음 / 없음`만 참고로 찍는다. +`없음`에는 조치를 붙인다: hikari, http active → "메인 포트로 요청 1회 뒤 재시도", gc → "GC가 아직 한 번도 안 돈 것. 워밍업 뒤 재시도", 그 외 → "perf 프로파일로 떴는지 1)을 확인". curl 자체가 실패하면 "앱이 8081에 떠 있지 않다"로 exit 1. + +**`sample`** - `trap 'exit 0' TERM INT`. 파일이 없으면 헤더를 쓴다. 첫 스크랩을 `.first.prom`에 저장. `START=$(date +%s)`, 매 회 `offset_s = now - START`로 한 행 append하고 원문을 `.last.prom`에 덮어쓴 뒤 `sleep $INTERVAL`. 종료 신호를 받을 때까지 돈다. + +**`summarize`** - CSV와 `.first.prom`, `.last.prom`을 읽어 아래 md를 `--out`에 쓴다. **증분이 전부 0인 구획은 표 대신 한 줄로 접는다** (결정 3). + +``` +# jvm-metrics-{n} + +샘플 {N}건 / 간격 {s}s / 구간 0~{last}s / 요청 {requests}건 + +## 게이지 (샘플 중 최대, 평균) + +| 지표 | 최대 | 평균 | 최대 시점(s) | 기준 | +|---|---|---|---|---| +| heap 사용 (MB) | | | | heap max {M} MB | +| GC 후 old gen 점유 (%) | | | | 바닥이 오르면 누수나 캐시 적재 | +| GC 최장 정지 (ms) | | - | | | +| GC overhead | | | | GC가 쓴 CPU 비율 | +| 스레드 수 / blocked | | | | blocked > 0이면 락 경합 | +| HikariCP active | | | | 풀 크기는 record.md 측정 환경 | +| HikariCP pending | | | | 0보다 크면 커넥션 대기 | +| HikariCP acquire max (ms) | | - | | | +| 처리 중 요청 수 / 최장 (ms) | | | | 서버 안 동시 요청 | +| process CPU | | | | 0~1 | +| system CPU | | | | 0~1 | + +## 누적 (측정 구간 증분) + +| 지표 | 시작 | 끝 | 증분 | 요청당 | +|---|---|---|---|---| +| GC 횟수 | | | | | +| GC 일시정지 합 (ms) | | | | | +| 할당량 (MB) | | | | | +| old gen 승격량 (MB) | | | | | +| HikariCP timeout | | | | | +| 커넥션 보유 평균 (ms) | - | - | usage_sum 증분 / usage_count 증분 | - | + +## 리포지토리 호출 + +| repository.method | 호출 증분 | 요청당 | mean ms | +|---|---|---|---| +(호출 증분 > 0인 메서드만, 증분 내림차순) + +## 캐시 + +| 캐시 | hit | miss | pending | put | removal | 적중률 | +|---|---|---|---|---|---|---| +(어느 캐시든 증분 > 0일 때만. 아니면 `캐시: 측정 구간 접근 없음`) + +## Redis + +| 명령 | 호출 증분 | 요청당 | mean ms | max ms | +|---|---|---|---|---| +(증분 > 0인 명령만. 아니면 `Redis: 측정 구간 호출 없음`) + +## 타임라인 + +| offset_s | heap_used_mb | gc_pause_ms(증분) | gc_pause_max_ms | threads_blocked | hikari_active | hikari_pending | http_active | process_cpu | +|---|---|---|---|---|---|---|---|---| +``` +- `{n}`은 `--out` 파일명(`jvm-metrics-{n}.md`)에서 딴다. 파일명이 그 형태가 아니면 파일명 그대로 쓴다. +- `--requests`가 없으면 "요청당" 칼럼은 `-`. +- 최대 시점은 그 칼럼이 최댓값을 처음 기록한 행의 `offset_s`. +- 타임라인의 `gc_pause_ms(증분)`는 직전 행과의 차. 첫 행은 0. +- 증분은 `.last.prom` - `.first.prom`. 라벨 집합이 다르면(끝에만 있는 메서드) 시작을 0으로 본다. +- 캐시 적중률 = hit / (hit + miss). 분모 0이면 `-`. +- 빈 칼럼은 요약 표에 `미수집`, 타임라인에 `-`. +- 소수 자릿수: MB와 ms는 1자리, CPU와 overhead는 3자리, 적중률은 % 1자리. 나머지는 정수. + +### 2. `commands.md` A 블록 + +워밍업 주석에 Redis를 넣고, 5) 리셋과 6) 측정 사이에 샘플러 기동, 측정 뒤에 요청 수 확인, 샘플러 정지와 가공을 넣는다. 번호를 다시 매긴다. + +```bash +# 2) 워밍업 (JIT, 커넥션 풀, InnoDB 버퍼 풀, Redis 캐시). 이 실행의 결과는 쓰지 않는다 +k6 run -e PHASE=warmup $TARGET_DIR/test-script.js + +# ... 3) 4) 기존 그대로 + +# 5) 쿼리 통계 리셋 +mysqlp -e "TRUNCATE TABLE performance_schema.events_statements_summary_by_digest;" + +# 6) JVM 샘플러. 측정과 함께 돌고 8)에서 멈춘다 +bash .claude/skills/_shared/jvm-sampler.sh sample --out $TARGET_DIR/jvm-samples-{n}.csv & +SAMPLER_PID=$! + +# 7) 측정 +k6 run -e PHASE=measure -e SUMMARY_OUT=$TARGET_DIR/k6-test-summary-{n}.json $TARGET_DIR/test-script.js + +# 8) 샘플러 정지, 요청 수 확인, 가공. 원본은 가공본에 전부 들어가므로 지운다 +kill $SAMPLER_PID; wait $SAMPLER_PID 2>/dev/null +REQS=$(jq -r '.requests // empty' $TARGET_DIR/k6-test-summary-{n}.json) +if ! [ "$REQS" -gt 0 ] 2>/dev/null; then + echo "요청 수가 '$REQS'다. 측정이 실패했으므로 가공과 통계 수집을 하지 않는다. 원인을 확인하고 재측정하라." +else +bash .claude/skills/_shared/jvm-sampler.sh summarize \ + --in $TARGET_DIR/jvm-samples-{n}.csv --out $TARGET_DIR/jvm-metrics-{n}.md --requests $REQS \ + && rm $TARGET_DIR/jvm-samples-{n}.csv $TARGET_DIR/jvm-samples-{n}.csv.first.prom $TARGET_DIR/jvm-samples-{n}.csv.last.prom + +# 9) 쿼리 통계 수집 (기존 mysqlp -B 블록 그대로) +mysqlp -B -e "..." | tee $TARGET_DIR/query-stats-summary-{n}.md +fi +``` + +이유 표에 추가: + +| 요소 | 이유 | +|---|---| +| 워밍업에 Redis | `major-courses` 같은 캐시는 첫 요청이 채운다. 워밍업 없이 재면 miss 구간이 측정에 섞인다 | +| 샘플러를 리셋 뒤, 측정 앞에 띄움 | 워밍업의 GC와 heap이 섞이지 않는다. 샘플의 `offset_s` 0이 측정 시작이고 `.first.prom`이 증분의 기준점이다 | +| `kill` 뒤 `wait` | 마지막 행과 `.last.prom`을 다 쓴 뒤에 가공한다 | +| `REQS`를 가공 앞으로 | 샘플러 가공본의 "요청당" 칼럼과 쿼리 통계의 `per_req`가 같은 분모를 쓴다 | +| 원본 CSV, prom 삭제 | 산출물 규약대로 1차 출력은 남기지 않는다. 타임라인과 증분이 가공본에 전부 있다 | + +### 3. `SKILL.md` + +- frontmatter `allowed-tools`: `Agent(query-source-mapper)` 제거. +- **측정 스택** 표에 행 추가: `| JVM, 커넥션 풀, 캐시, Redis | /actuator/prometheus 주기 샘플링 (_shared/jvm-sampler.sh) |` +- 61행 교체: "InnoDB 버퍼 풀은 재기동 없이 비울 수 없고, Redis 캐시(`major-courses`)는 워밍업이 채운다. 측정은 **warm으로 통일**하고 매번 같은 워밍업으로 상태를 맞춘다. 캐시를 비운 상태를 재려면 그 사실과 방법(`redis-cli FLUSHDB`)을 `record.md` 측정 환경에 적는다." +- **산출물 규약** 트리와 표에 `jvm-metrics-{n}.md` 추가 (만드는 Phase 4, 8 / 템플릿 `jvm-sampler.sh summarize` 출력). +- **측정 산출물의 형태** 목록에 추가: "`jvm-metrics-{n}.md`는 스크립트가 만든 완성본이다. 스킬은 읽기만 하고 다시 쓰지 않는다. 캐시, Redis, 리포지토리 구획은 측정 구간에 증분이 있을 때만 나타난다. 구획이 없다는 것은 그 대상이 거기 닿지 않았다는 관측이다." + +### 4. `application-perf.yml` 단일화 (결정 4) + +- `template/application-perf.yml`을 `git rm`한다. 템플릿은 src에 파일이 없던 시절(스킬 도입 `473508b`)의 "없으면 생성"용이었고, #100(`7ac566b`)에서 src가 커밋된 뒤로는 존재 이유가 없다. +- src 헤더 주석: "성능 측정 전용 프로파일. optimize-performance 스킬의 Phase 2가 이 파일을 확인한다. 이 파일이 유일한 정의다." +- src의 캐시 주석 "Phase 8이 actuator의 `cache_gets_total{result="hit"|"miss"}`로 hit / miss를 읽는다"를 "jvm-sampler.sh가 `cache_gets_total{result="hit"|"miss"}`로 적중률을 만든다"로 바꾼다. +- 그 외 값은 손대지 않는다. +- `perf-env.sh`의 secret-key 경고 문구에서 "Phase 2에서 파일을 만든 뒤"를 "레포 루트에서 source했는지 확인"으로 바꾼다. + +### 5. `phase-2-environment.md` + +- 참조 파일 목록의 템플릿 경로를 `src/main/resources/application-perf.yml`로 교체. +- 절차 1 교체: "`src/main/resources/application-perf.yml`을 Read해 `maximum-pool-size`, `show_sql`, 드라이버, Redis 접속을 확인한다. 이 파일이 perf 프로파일의 유일한 정의다." 복사와 diff 절차는 두지 않는다. +- 절차 2의 compose 명령: `up -d mysql redis`. +- 절차 3에 항목 추가: + ```bash + # 8) Redis (PONG). 없으면 CacheErrorHandler가 DB로 폴백해 캐시 없는 상태를 재게 된다 + docker exec uss-redis redis-cli ping + + # 9) JVM 샘플러가 긁을 지표가 다 있는가 (게이트 7개 모두 "있음") + bash .claude/skills/_shared/jvm-sampler.sh check + ``` +- 실패 조치 추가: 8) 실패 → `docker-compose ... up -d redis`. 9) `hikaricp`, `http` 없음 → 2)를 메인 포트로 보냈는지 확인. `jvm_gc_pause` 없음 → 2)를 몇 번 더 보내고 재시도. 그 외 → 1)로 돌아가 perf 프로파일로 떴는지 확인. +- 절차 5와 출력의 캐시 상태: "warm 고정, Redis 캐시 워밍업으로 적재". +- 다음 Phase 조건의 "여덟 항목"을 "열 항목"으로. + +### 6. `phase-4-baseline.md` + +절차 2~4를 교체한다. + +``` +2. 끝나면 `k6-test-summary-0.json`, `jvm-metrics-0.md`, 1차 출력 `query-stats-summary-0.md`를 Read한다. + - 파일이 없으면 원인을 확인하고 재실행을 요청한다. 추정으로 채우지 마라. + - `checks_rate`가 1이 아니면 `checks[]`에서 어떤 항목이 깨졌는지 먼저 본다. 데이터 검증 check가 깨진 측정은 진단에 쓰지 않는다. + - `jvm-metrics-0.md`의 게이지 표에 `미수집`이 있으면 Phase 2의 9)를 다시 통과시킨 뒤 재측정한다. + +3. `query-stats-template.md`의 작성 규칙대로 가공본을 같은 경로에 덮어쓴다. + 출처는 `record.md`의 예상 쿼리 목록과 `jvm-metrics-0.md`의 **리포지토리 호출** 표로 맞춘다. + 목록에 있는 쿼리는 Phase 1에서 이미 확인했으므로 Grep하지 않는다. 리포지토리 호출 표의 메서드별 호출 증분과 digest의 `calls`가 맞아떨어지면 그것이 출처 확인이다. + 목록에도 표에도 없는 쿼리만 테이블명과 컬럼 조합으로 Grep해 확인하고, 못 찾으면 `미상`으로 둔다. 미상이 남는 것은 정상이다. + +4. 가공본을 k6 요약, JVM 가공본과 함께 제시하고 **병목 판정을 묻는다** (`SKILL.md`의 **역할 경계**). + - 제시할 것: 응답시간 분포, 처리량, check 결과, 쿼리별 요청당 호출 수, 총 시간 비중, `examined_per_sent`, + heap 최대와 heap max, GC 일시정지 합과 최장 정지, GC overhead, blocked 스레드 최대, HikariCP pending 최대와 acquire max, 커넥션 보유 평균, + 처리 중 요청 최대, process CPU 최대. 캐시와 Redis 구획이 있으면 적중률과 Redis 명령별 시간도 함께. + - 물을 것: "요청당 쿼리 수와 시간이 쏠린 지점, 그리고 JVM과 풀, 캐시의 상태를 보고, 병목의 성격을 어떻게 판단하십니까?" +``` + +진단 표에 행 추가 (기존 "단건은 빠른데 VU를 올리면 급락" 행 아래): + +| 관측 | 진단 | 유력한 기법 | +|---|---|---| +| HikariCP pending 최대 > 0, acquire max가 p95에 근접 | 커넥션 대기 | 풀 크기, 트랜잭션 범위 축소, 쿼리 수 감소 | +| 커넥션 보유 평균이 쿼리 mean_ms 합보다 훨씬 큼 | 트랜잭션이 커넥션을 오래 쥠 | 트랜잭션 범위 축소, 직렬화를 트랜잭션 밖으로 | +| GC overhead가 크거나 최장 정지가 p99에 근접, heap 최대가 heap max에 근접 | 메모리 압박 | 응답 크기 축소, 불필요한 엔티티 로딩 제거, 힙 설정 | +| 할당량 요청당 값이 응답 크기보다 훨씬 큼 | 요청 중 버려지는 객체가 많음 | DTO projection, 컬렉션 가공 축소 | +| blocked 스레드 > 0 | 애플리케이션 락 경합 | 락 범위 축소, 락 없는 구조 | +| 캐시 적용 대상인데 hit 증분 0, miss만 증가 | 캐시 미적중 (키 불일치, TTL, 워밍업 누락) | 키 설계, 워밍업 | +| Redis 명령 시간 합이 쿼리 total_ms에 근접 | 캐시 왕복이 병목 | 직렬화 크기 축소, 로컬 캐시 계층 | +| process CPU가 높고 GC는 조용한데 DB 시간 비중이 낮음 | 애플리케이션 연산 | 컬렉션 가공, 직렬화 경로 | +| DB와 JVM 지표가 모두 여유인데 API가 느림 | 직렬화, 응답 크기 | DTO 축소, 페이징 | + +출력에 `jvm-metrics-0.md` 추가. + +### 7. `phase-6-snapshot.md` + +절차 4의 노드별 표와 칼럼 설명을 교체한다. + +``` +4. 계획을 노드별 표와 카운터 표로 정리해 대화에 제시한다. 파일에는 쓰지 않는다. + 표 위에 `쿼리 전체: {루트 노드 actual time의 뒤 값} ms` 한 줄을 적는다. + + | 노드 | 접근 방식 / 인덱스 | actual time | loops | 소요 ms | 추정 rows | 실측 rows | 비고 | + |---|---|---|---|---|---|---|---| +``` + +칼럼 설명 표에서 `actual time=A..B` 행을 아래로 바꾸고 `소요 ms` 행을 추가: + +| 칼럼 | 설명 | +|---|---| +| actual time=A..B | A는 첫 행까지, B는 마지막 행까지(ms). **loops당 평균**이고 자식 노드 시간을 포함한다 | +| 소요 ms | `B × loops`. 그 노드가 자식까지 포함해 실제로 쓴 시간. 부모의 소요를 넘지 않는다. 루트의 소요가 쿼리 전체다 | + +`쿼리 전체`에 대한 주의를 한 줄 붙인다: "결과 전송 시간을 뺀 서버 실행 시간이라 digest의 `mean_ms`보다 작은 것이 정상이다. 둘의 차가 크면 전송량(응답 크기)을 본다." + +### 8. `phase-8-verify.md` + +- 절차 2: "쿼리 통계는 3의 위임이 끝난 뒤 가공본을 읽는다" 삭제. Read 목록에 `jvm-metrics-{n}.md`와 `-{n-1}` 추가. +- 절차 3의 `query-stats-summary-{n}.md` 항목을 교체: + "Phase 4의 3과 같은 방법으로 메인이 가공본을 쓴다. `n >= 1`이므로 직전 가공본 `query-stats-summary-{n-1}.md`를 Read해 헤더의 **직전 상태 대비**를 채운다." +- 절차 4의 하드웨어 의존 증거에 추가: "쿼리 전체 소요(EXPLAIN ANALYZE 루트), heap 최대, GC 일시정지 합과 최장 정지, HikariCP pending 최대, 커넥션 보유 평균, process CPU 최대." + 하드웨어 독립 증거에 추가: "요청당 리포지토리 호출 수, 요청당 할당량, 캐시 적중률 (구획이 있을 때)." +- 출력에 `jvm-metrics-{n}.md` 추가. + +### 9. `PERF-template.md` + +- 50행 캐시 상태: "warm 고정. InnoDB 버퍼 풀은 재기동 없이 비울 수 없고, Redis 캐시는 워밍업이 채운다. 매 측정 전 같은 워밍업으로 맞춘다. {비운 상태를 쟀으면 그 방법}". +- **기준선** 표에 행 추가: `heap 최대 / heap max`, `GC 일시정지 합 / 최장`, `GC overhead`, `HikariCP pending 최대 / acquire max`, `커넥션 보유 평균`, `blocked 스레드 최대`, `process CPU 최대`, `캐시 적중률 (구획 없으면 -)`. +- **실행계획** 항목에 추가: `- 쿼리 전체 소요: {ms} (루트 노드)`, `- 비용 상위 노드: {노드} {소요 ms} / {노드} {소요 ms}`. +- **개선 전 지표** 표에 행 추가: `쿼리 전체 소요 (EXPLAIN ANALYZE)`, `GC 일시정지 합 / HikariCP pending 최대`, `요청당 리포지토리 호출 수 / 할당량`. +- **개선 후 지표** 표: 하드웨어 의존 구분에 `쿼리 전체 소요`, `heap 최대`, `GC 일시정지 합 / 최장`, `HikariCP pending 최대`, `커넥션 보유 평균`, `process CPU 최대` 추가. + 하드웨어 독립 구분에 `요청당 리포지토리 호출 수`, `요청당 할당량 (MB)` 추가. 기존 `캐시 hit / miss, 적중률` 행은 유지. +- **최종 요약** 표에 같은 행 추가. + +### 10. `k6-script-template.js` + +- 50행 `app_cache: '없음'` → `app_cache: '{Redis warm / 없음}'`. 작성 규칙 1의 "고치는 자리" 목록은 이미 CONDITION을 포함하므로 규칙 문장은 그대로. + +### 11. `.claude/agents/query-source-mapper.md` 삭제 + +`git rm`. 삭제 뒤 `grep -rn query-source-mapper .claude/`가 0건이어야 한다. + +## 결정 필요 (Decisions needed) +- [x] **1. Tomcat 스레드 지표 포함 여부** — A) 포함: `mbeanregistry.enabled: true`를 perf yml 두 곳에 넣고 앱을 재기동한다 / B) 제외 + → **B 확정 (사용자 선택, 2026-08-30).** 서버 포화는 `http_server_requests_active`(처리 중 요청 수)로 본다. 이 지표는 설정 없이 이미 노출된다. +- [x] **2. 샘플링 간격 기본값** — A) 5초 / B) 2초 + → **A 확정 (사용자 선택, 2026-08-30).** `--interval`로 바꿀 수 있다. +- [x] **3. 캐시, Redis 지표를 언제 넣는가** — A) 스킬이 Phase 1에서 실행 경로를 읽고 캐시 대상일 때만 구획을 켠다 / B) 샘플러는 전부 긁고 summarize가 측정 구간 증분이 0인 구획을 접는다 + → **B 확정 (사용자 요청 "사용하는 상황에 동적으로", 2026-08-30).** 설정이 아니라 관측값이 구획을 정한다. `@CacheEvict`처럼 간접적으로 닿는 경우를 코드 읽기로 놓치지 않고, 캐시 대상인데 hit 증분이 0인 것 자체가 진단 근거가 된다. 리포지토리 호출 표에도 같은 규칙. +- [x] **4. 템플릿과 src `application-perf.yml` 불일치** — A) 이번 이슈에서 함께 맞춘다 / B) 별도 이슈 + → **A 확정 (사용자 선택, 2026-08-30).** 처음엔 "src를 정본으로 템플릿을 동기화하고 Phase 2가 diff로 감시"로 구현했으나, 구현 뒤 사용자가 템플릿의 존재 이유를 물어 이력을 확인한 결과 + 템플릿은 src에 파일이 없던 시절의 "없으면 생성"용이었고 #100 이후 그 경우가 없다. 같은 정의를 두 곳에 두고 게이트로 드리프트를 막는 것은 덧대기라서 **템플릿 삭제, src 단일 정본**으로 재확정 (사용자 선택, 2026-08-30). + 이에 따라 스킬의 "애플리케이션 캐시 없음" 전제(SKILL.md, PERF-template, k6 템플릿, phase-2)도 함께 걷는다. + +## 검증 +- `jvm-sampler.sh check`를 지금 떠 있는 perf 앱에 대해 실행해 게이트 7개 모두 `있음`, exit 0인지 본다. 앱을 내린 상태(또는 잘못된 `--url`)에서는 exit 1과 안내 메시지가 나오는지 본다. +- `sample --out {scratch}/s.csv --interval 1 &` 로 10초 돌리고 `kill`, `wait` 뒤 CSV가 헤더 + 약 10행이고 빈 칼럼이 없는지, `.first.prom`, `.last.prom`이 있는지 확인. 그 사이 `curl localhost:8080/api/v1/courses/major`(토큰 필요, `mint-tokens.sh`)를 몇 번 쳐서 hikari_active, http_active가 0이 아닌 행과 리포지토리, 캐시, Redis 증분이 잡히는지 본다. +- 같은 방식으로 캐시에 닿지 않는 엔드포인트(`/api/v1/courses/terms`)만 친 뒤 summarize해 캐시와 Redis 구획이 "측정 구간 호출 없음" 한 줄로 접히는지 본다 (결정 3의 동작 확인). +- `summarize`의 요약 표 최대, 평균, 최대 시점, 누적 표 증분, 적중률을 CSV와 prom 원문과 손으로 대조한다. 빈 칼럼이 `미수집`으로 나오는지 본다. +- `commands.md` A 블록 6)~9)를 zsh에서 그대로 실행해 `$!`, `kill`, `wait`, `if` 블록이 zsh와 bash 모두에서 동작하는지 확인 (`perf-env.sh`가 둘 다 지원한다고 명시하므로). +- `grep -rn "template/application-perf" .claude/` 0건. `git ls-files | grep application-perf`가 src 한 건만. 그 뒤 Phase 2 게이트 0)~9)가 전부 통과하는지 본다. +- `grep -rn "query-source-mapper" .claude/` 0건. `grep -rn "애플리케이션 캐시" .claude/skills/optimize-performance/` 0건. `SKILL.md` frontmatter의 `allowed-tools`가 한 줄로 유효한지 확인. +- 스킬 텍스트 정합성: phase-2의 "열 항목", phase-4와 8의 출력 목록, SKILL.md 산출물 표와 트리가 서로 같은 파일명(`jvm-metrics-{n}.md`)을 쓰는지 grep으로 대조. +- 실행 가능한 템플릿은 커밋 전 실행한다는 규칙을 따른다. 위 항목 중 스크립트 실행은 전부 커밋 전에 한다. + +## Deviation Log +> implement 스킬이 구현 중 계획을 벗어난 지점을 여기에 기록한다. (작성 시점엔 비워둔다) + +- 검증 "yml 반영 후 재기동": 생략 — 이유: src `application-perf.yml`은 주석 두 줄만 바뀌어 동작 변화가 없다. Phase 2 게이트 8)(Redis PONG), 9)(`check` 7개 있음)는 떠 있는 앱에서 3회 연속 통과를 확인했다. +- `template/application-perf.yml`: 동기화가 아니라 삭제, `phase-2` 절차 1의 diff 게이트 제거, `perf-env.sh` 경고 문구 정정 — 이유: 결정 4 재확정 (사용자, 2026-08-30). 구현 중 사용자 질문으로 템플릿이 존재 이유를 잃은 사실이 드러났다. 계획서 4, 5절과 영향 범위를 그에 맞게 고쳤다. +- 검증 "`grep -rn query-source-mapper .claude/` 0건": 스킬, 에이전트 디렉토리 기준 0건 — 이유: `resources/plans/PLAN-96.md`(도입 이력)와 이 계획서 자체에는 이름이 남는다. 이력은 지우지 않는다. +- `jvm-sampler.sh check`: 지표 존재 판정을 `grep -q`가 아니라 `grep -c`로 — 이유: 92KB 스크랩을 `printf`로 흘리는데 `-q`가 첫 매치에서 끝나면 `printf`가 SIGPIPE를 받고 `pipefail`이 실패로 판정해 결과가 실행마다 달랐다(cache가 늘 "없음", threads가 간헐 "없음"). 끝까지 읽는 `-c`로 3회 연속 안정 확인. +- `jvm-sampler.sh summarize`: 증분 계산의 키를 `$1`이 아니라 "값 앞까지 전부"로 — 이유: `jvm_gc_pause_seconds_count{action="end of minor GC",...}`처럼 라벨에 공백이 있어 `$1`로 자르면 GC 두 행이 한 키로 뭉개져 472가 1로 나왔다. 원문 합계(472 → 473)와 일치 확인. diff --git a/.claude/skills/_shared/jvm-sampler.sh b/.claude/skills/_shared/jvm-sampler.sh new file mode 100755 index 0000000..e47b2a1 --- /dev/null +++ b/.claude/skills/_shared/jvm-sampler.sh @@ -0,0 +1,399 @@ +#!/usr/bin/env bash +# 부하 측정 중 애플리케이션의 JVM, 커넥션 풀, 캐시, Redis 상태를 /actuator/prometheus 에서 주기적으로 긁어 가공본을 만든다. +# +# 왜 주기 샘플링인가: +# Micrometer의 *_max 계열(gc_pause_seconds_max, acquire_seconds_max, requests_active_seconds_max)은 +# 최근 창(기본 2분)에서 감쇠하는 값이라 측정이 끝난 뒤 한 번 긁으면 0이다. 부하 중에 긁어야 잡힌다. +# heap 피크, 커넥션 대기 피크 같은 순간값도 전후 스냅샷으로는 보이지 않는다. +# +# 두 종류의 원본을 남긴다: +# {out} 게이지 시계열 CSV. 매 회 한 행 +# {out}.first.prom 첫 스크랩 원문. 누적 카운터 증분의 기준점 +# {out}.last.prom 마지막 스크랩 원문. 매 회 덮어쓴다 +# 라벨이 가변인 카운터(캐시명별, Redis 명령별, 리포지토리 메서드별)는 CSV 칼럼이 아니라 first/last 증분으로 본다. +# 새 지표군이 생겨도 칼럼을 늘리지 않아도 된다. +# +# 사용: +# bash jvm-sampler.sh check [--url URL] 지표 노출 점검. 게이트 7개 중 하나라도 없으면 exit 1 +# bash jvm-sampler.sh sample --out FILE [--url URL] [--interval SEC] 종료 신호(TERM, INT)를 받을 때까지 긁는다 +# bash jvm-sampler.sh summarize --in FILE --out FILE [--requests N] CSV와 first/last 원문을 읽어 md 가공본을 쓴다 +# +# URL 기본값 http://localhost:8081/actuator/prometheus, SEC 기본값 5 +# +# 예 (commands.md A 블록): +# bash .claude/skills/_shared/jvm-sampler.sh sample --out $TARGET_DIR/jvm-samples-0.csv & +# SAMPLER_PID=$! +# k6 run ... +# kill $SAMPLER_PID; wait $SAMPLER_PID 2>/dev/null +# bash .claude/skills/_shared/jvm-sampler.sh summarize --in $TARGET_DIR/jvm-samples-0.csv --out $TARGET_DIR/jvm-metrics-0.md --requests $REQS +# +# 가공본 규칙: +# - 캐시, Redis, 리포지토리 구획은 측정 구간 증분이 있을 때만 표로 나타난다. 증분이 전부 0이면 한 줄로 접는다. +# 구획이 없다는 것은 그 대상이 거기 닿지 않았다는 관측이다. +# - 응답에 없는 지표군은 CSV에서 빈 칼럼, 요약 표에서 `미수집`이다. 0으로 채우지 않는다. +# - 소수 자릿수: MB와 ms는 1자리, CPU와 overhead는 3자리, 요청당은 3자리, 적중률은 % 1자리. + +set -euo pipefail + +DEFAULT_URL=http://localhost:8081/actuator/prometheus +DEFAULT_INTERVAL=5 +CSV_HEADER=offset_s,heap_used_mb,heap_max_mb,old_after_gc_pct,gc_count,gc_pause_ms,gc_pause_max_ms,gc_overhead,threads_live,threads_blocked,hikari_active,hikari_pending,hikari_timeout_total,hikari_acquire_max_ms,http_active,http_active_max_ms,process_cpu,system_cpu + +usage() { + sed -n '2,32p' "$0" | sed 's/^# \{0,1\}//' >&2 + exit 1 +} + +MODE="${1:-}" +[ -n "$MODE" ] && shift || usage + +URL="$DEFAULT_URL" +INTERVAL="$DEFAULT_INTERVAL" +OUT="" +IN="" +REQUESTS="" + +while [ $# -gt 0 ]; do + case "$1" in + --url) URL="$2"; shift 2 ;; + --out) OUT="$2"; shift 2 ;; + --in) IN="$2"; shift 2 ;; + --interval) INTERVAL="$2"; shift 2 ;; + --requests) REQUESTS="$2"; shift 2 ;; + *) echo "알 수 없는 인자: $1" >&2; exit 1 ;; + esac +done + +scrape() { + curl -sf -m 3 "$URL" +} + +# 스크랩 원문(stdin)을 CSV 한 행(offset 제외)으로. 지표군이 없으면 그 칼럼은 빈 값. +# 라벨은 application 이 맨 앞이라 이름 뒤 접두 매칭 대신 index() 로 라벨을 찾는다. +row_from_scrape() { + awk ' + function f(v, has, d) { return has ? sprintf("%." d "f", v) : "" } + /^#/ { next } + /^jvm_memory_used_bytes\{/ && index($0, "area=\"heap\"") { heap_used += $NF; has_heap = 1 } + /^jvm_memory_max_bytes\{/ && index($0, "area=\"heap\"") { if ($NF > 0) heap_max += $NF } + /^jvm_memory_usage_after_gc\{/ && index($0, "pool=\"long-lived\"") { old_after = $NF * 100; has_old = 1 } + /^jvm_gc_pause_seconds_count\{/ { gc_count += $NF; has_gc = 1 } + /^jvm_gc_pause_seconds_sum\{/ { gc_sum += $NF * 1000 } + /^jvm_gc_pause_seconds_max\{/ { v = $NF * 1000; if (v > gc_max) gc_max = v } + /^jvm_gc_overhead[{ ]/ { gc_ovh = $NF; has_ovh = 1 } + /^jvm_threads_live_threads[{ ]/ { threads = $NF; has_threads = 1 } + /^jvm_threads_states_threads\{/ && index($0, "state=\"blocked\"") { blocked = $NF; has_blocked = 1 } + /^hikaricp_connections_active\{/ { h_active += $NF; has_hikari = 1 } + /^hikaricp_connections_pending\{/ { h_pending += $NF } + /^hikaricp_connections_timeout_total\{/ { h_timeout += $NF } + /^hikaricp_connections_acquire_seconds_max\{/ { v = $NF * 1000; if (v > h_acq) h_acq = v } + /^http_server_requests_active_seconds_gcount\{/ { http_active += $NF; has_http = 1 } + /^http_server_requests_active_seconds_max\{/ { v = $NF * 1000; if (v > http_max) http_max = v } + /^process_cpu_usage[{ ]/ { pcpu = $NF; has_pcpu = 1 } + /^system_cpu_usage[{ ]/ { scpu = $NF; has_scpu = 1 } + END { + printf "%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s\n", + f(heap_used / 1048576, has_heap, 1), f(heap_max / 1048576, has_heap, 1), f(old_after, has_old, 1), + f(gc_count, has_gc, 0), f(gc_sum, has_gc, 1), f(gc_max, has_gc, 1), f(gc_ovh, has_ovh, 3), + f(threads, has_threads, 0), f(blocked, has_blocked, 0), + f(h_active, has_hikari, 0), f(h_pending, has_hikari, 0), f(h_timeout, has_hikari, 0), f(h_acq, has_hikari, 1), + f(http_active, has_http, 0), f(http_max, has_http, 1), + f(pcpu, has_pcpu, 3), f(scpu, has_scpu, 3) + }' +} + +# ── check ────────────────────────────────────────────────────────────────────── + +do_check() { + local body + if ! body=$(scrape); then + echo "앱이 $URL 에 떠 있지 않다. perf 프로파일로 기동했는지, 관리 포트가 8081인지 확인하라." >&2 + exit 1 + fi + + local missing=0 + check_family() { # $1 표시명, $2 패턴, $3 게이트 여부(1/0), $4 없을 때 조치 + # grep -q 는 첫 매치에서 끝나 printf 가 SIGPIPE 를 받고 pipefail 이 실패로 판정한다. -c 로 끝까지 읽는다 + if [ "$(printf '%s\n' "$body" | grep -cE "$2")" -gt 0 ]; then + printf '%-14s 있음\n' "$1" + else + printf '%-14s 없음 → %s\n' "$1" "$4" + if [ "$3" = 1 ]; then + missing=$((missing + 1)) + fi + fi + return 0 + } + + echo "게이트 (7개 모두 있어야 한다)" + check_family heap '^jvm_memory_used_bytes\{.*area="heap"' 1 "perf 프로파일로 떴는지 Phase 2의 1)을 확인" + check_family gc '^jvm_gc_pause_seconds_count\{' 1 "GC가 아직 한 번도 안 돈 것. 메인 포트로 요청을 몇 번 보내고 재시도" + check_family threads '^jvm_threads_live_threads' 1 "perf 프로파일로 떴는지 Phase 2의 1)을 확인" + check_family hikari '^hikaricp_connections_active\{' 1 "메인 포트로 요청 1회 뒤 재시도" + check_family http_active '^http_server_requests_active_seconds_gcount\{' 1 "메인 포트로 요청 1회 뒤 재시도" + check_family process_cpu '^process_cpu_usage' 1 "perf 프로파일로 떴는지 Phase 2의 1)을 확인" + check_family system_cpu '^system_cpu_usage' 1 "perf 프로파일로 떴는지 Phase 2의 1)을 확인" + echo + echo "참고 (대상에 따라 없을 수 있다. 게이트 아님)" + check_family cache '^cache_gets_total\{' 0 "캐시가 등록되지 않았다. 캐싱 대상이 아니면 무관" + check_family redis '^lettuce_seconds_count\{' 0 "Redis 클라이언트가 아직 명령을 보내지 않았다" + check_family repository '^spring_data_repository_invocations_seconds_count\{' 0 "리포지토리 호출이 아직 없다. 요청 1회 뒤 생긴다" + + if [ "$missing" -gt 0 ]; then + echo + echo "게이트 ${missing}개 없음. 조치 뒤 다시 실행하라." >&2 + exit 1 + fi +} + +# ── sample ───────────────────────────────────────────────────────────────────── + +do_sample() { + [ -n "$OUT" ] || { echo "--out 이 필요하다" >&2; exit 1; } + [ -d "$(dirname "$OUT")" ] || { echo "디렉토리가 없다: $(dirname "$OUT")" >&2; exit 1; } + + if [ ! -f "$OUT" ]; then + echo "$CSV_HEADER" > "$OUT" + fi + + local start now body row sleep_pid="" + start=$(date +%s) + # 종료 신호가 sleep 을 기다리지 않게 sleep 을 백그라운드로 두고 wait 한다 + trap '[ -n "$sleep_pid" ] && kill "$sleep_pid" 2>/dev/null; exit 0' TERM INT + + while :; do + now=$(date +%s) + if body=$(scrape); then + [ -f "$OUT.first.prom" ] || printf '%s\n' "$body" > "$OUT.first.prom" + printf '%s\n' "$body" > "$OUT.last.prom" + row=$(printf '%s\n' "$body" | row_from_scrape) + echo "$((now - start)),$row" >> "$OUT" + else + echo "offset $((now - start))s: 스크랩 실패 ($URL). 이 회차는 기록하지 않는다." >&2 + fi + sleep "$INTERVAL" & + sleep_pid=$! + wait "$sleep_pid" || true + sleep_pid="" + done +} + +# ── summarize ────────────────────────────────────────────────────────────────── + +do_summarize() { + [ -n "$IN" ] || { echo "--in 이 필요하다" >&2; exit 1; } + [ -n "$OUT" ] || { echo "--out 이 필요하다" >&2; exit 1; } + [ -f "$IN" ] || { echo "CSV가 없다: $IN" >&2; exit 1; } + [ -f "$IN.first.prom" ] && [ -f "$IN.last.prom" ] || { echo "원문이 없다: $IN.first.prom / $IN.last.prom" >&2; exit 1; } + if [ -n "$REQUESTS" ] && ! [ "$REQUESTS" -gt 0 ] 2>/dev/null; then + echo "--requests 는 양의 정수여야 한다: '$REQUESTS'" >&2; exit 1 + fi + + local base name + base=$(basename "$OUT") + case "$base" in + jvm-metrics-*.md) name="jvm-metrics-${base#jvm-metrics-}"; name="${name%.md}" ;; + *) name="$base" ;; + esac + + { + echo "# $name" + echo + + # 헤더와 게이지 표. 칼럼: 1 offset 2 heap_used 3 heap_max 4 old_after 5 gc_count 6 gc_pause 7 gc_max 8 gc_ovh + # 9 threads 10 blocked 11 h_active 12 h_pending 13 h_timeout 14 h_acq 15 http_active 16 http_max 17 pcpu 18 scpu + awk -F, -v requests="$REQUESTS" ' + function mx(c, d) { return n[c] ? sprintf("%." d "f", m[c]) : "미수집" } + function av(c, d) { return n[c] ? sprintf("%." d "f", s[c] / n[c]) : "미수집" } + function at(c) { return n[c] ? t[c] : "-" } + NR == 1 { next } + { + rows++; last = $1 + for (c = 2; c <= 18; c++) { + if ($c == "") continue + n[c]++; s[c] += $c + if (!(c in m) || $c > m[c]) { m[c] = $c; t[c] = $1 } + } + heap_max = ($3 != "") ? $3 : heap_max + } + END { + interval = rows > 1 ? sprintf("%.0f", last / (rows - 1)) : "-" + req = requests != "" ? requests "건" : "미지정" + printf "샘플 %d건 / 간격 %ss / 구간 0~%ss / 요청 %s\n\n", rows, interval, last, req + print "## 게이지 (샘플 중 최대, 평균)" + print "" + print "| 지표 | 최대 | 평균 | 최대 시점(s) | 기준 |" + print "|---|---|---|---|---|" + printf "| heap 사용 (MB) | %s | %s | %s | heap max %s MB |\n", mx(2,1), av(2,1), at(2), (heap_max != "" ? heap_max : "미수집") + printf "| GC 후 old gen 점유 (%%) | %s | %s | %s | 바닥이 오르면 누수나 캐시 적재 |\n", mx(4,1), av(4,1), at(4) + printf "| GC 최장 정지 (ms) | %s | - | %s | |\n", mx(7,1), at(7) + printf "| GC overhead | %s | %s | %s | GC가 쓴 CPU 비율 (0~1) |\n", mx(8,3), av(8,3), at(8) + printf "| 스레드 수 / blocked | %s / %s | %s / %s | %s | blocked > 0이면 락 경합 |\n", mx(9,0), mx(10,0), av(9,1), av(10,1), at(10) + printf "| HikariCP active | %s | %s | %s | 풀 크기는 record.md 측정 환경 |\n", mx(11,0), av(11,1), at(11) + printf "| HikariCP pending | %s | %s | %s | 0보다 크면 커넥션 대기 |\n", mx(12,0), av(12,1), at(12) + printf "| HikariCP acquire max (ms) | %s | - | %s | |\n", mx(14,1), at(14) + printf "| 처리 중 요청 수 / 최장 (ms) | %s / %s | %s / - | %s | 서버 안 동시 요청 |\n", mx(15,0), mx(16,1), av(15,1), at(15) + printf "| process CPU | %s | %s | %s | 0~1 |\n", mx(17,3), av(17,3), at(17) + printf "| system CPU | %s | %s | %s | 0~1 |\n", mx(18,3), av(18,3), at(18) + print "" + }' "$IN" + + # 누적 증분과 라벨 가변 구획. first.prom → last.prom + awk -v requests="$REQUESTS" ' + function lab(s, name, r) { + if (match(s, name "=\"[^\"]*\"")) { + r = substr(s, RSTART + length(name) + 2, RLENGTH - length(name) - 3) + return r + } + return "" + } + function delta(k) { return (k in last) ? last[k] - ((k in first) ? first[k] : 0) : 0 } + function per(v) { return requests != "" ? sprintf("%.3f", v / requests) : "-" } + # 키는 값 앞까지 전부다. 라벨 안에 공백이 있어(action="end of minor GC") $1 로 자르면 행이 뭉개진다 + function key(line, k) { k = line; sub(/ [^ ]*$/, "", k); return k } + /^#/ { next } + FNR == NR { first[key($0)] = $NF; next } + { last[key($0)] = $NF } + END { + # 누적 표. 라벨이 있는 지표는 라벨 무시하고 합산한다 + for (k in last) { + if (k ~ /^jvm_gc_pause_seconds_count\{/) { gc_c_l += last[k]; gc_c_f += (k in first ? first[k] : 0); has_gc = 1 } + if (k ~ /^jvm_gc_pause_seconds_sum\{/) { gc_s_l += last[k]; gc_s_f += (k in first ? first[k] : 0) } + if (k ~ /^jvm_gc_memory_allocated_bytes_total/) { al_l += last[k]; al_f += (k in first ? first[k] : 0); has_al = 1 } + if (k ~ /^jvm_gc_memory_promoted_bytes_total/) { pr_l += last[k]; pr_f += (k in first ? first[k] : 0); has_pr = 1 } + if (k ~ /^hikaricp_connections_timeout_total\{/) { to_l += last[k]; to_f += (k in first ? first[k] : 0); has_to = 1 } + if (k ~ /^hikaricp_connections_usage_seconds_sum\{/) { us_s += delta(k); has_us = 1 } + if (k ~ /^hikaricp_connections_usage_seconds_count\{/) { us_c += delta(k) } + } + print "## 누적 (측정 구간 증분)" + print "" + print "| 지표 | 시작 | 끝 | 증분 | 요청당 |" + print "|---|---|---|---|---|" + if (has_gc) { + printf "| GC 횟수 | %d | %d | %d | %s |\n", gc_c_f, gc_c_l, gc_c_l - gc_c_f, per(gc_c_l - gc_c_f) + printf "| GC 일시정지 합 (ms) | %.1f | %.1f | %.1f | %s |\n", gc_s_f * 1000, gc_s_l * 1000, (gc_s_l - gc_s_f) * 1000, per((gc_s_l - gc_s_f) * 1000) + } else { + print "| GC 횟수 | 미수집 | 미수집 | 미수집 | - |" + print "| GC 일시정지 합 (ms) | 미수집 | 미수집 | 미수집 | - |" + } + if (has_al) printf "| 할당량 (MB) | %.1f | %.1f | %.1f | %s |\n", al_f / 1048576, al_l / 1048576, (al_l - al_f) / 1048576, per((al_l - al_f) / 1048576) + else print "| 할당량 (MB) | 미수집 | 미수집 | 미수집 | - |" + if (has_pr) printf "| old gen 승격량 (MB) | %.1f | %.1f | %.1f | %s |\n", pr_f / 1048576, pr_l / 1048576, (pr_l - pr_f) / 1048576, per((pr_l - pr_f) / 1048576) + else print "| old gen 승격량 (MB) | 미수집 | 미수집 | 미수집 | - |" + if (has_to) printf "| HikariCP timeout | %d | %d | %d | %s |\n", to_f, to_l, to_l - to_f, per(to_l - to_f) + else print "| HikariCP timeout | 미수집 | 미수집 | 미수집 | - |" + if (has_us) printf "| 커넥션 보유 평균 (ms) | - | - | %s | - |\n", (us_c > 0 ? sprintf("%.1f", us_s / us_c * 1000) : "호출 없음") + else print "| 커넥션 보유 평균 (ms) | - | - | 미수집 | - |" + print "" + + # 리포지토리 호출. 증분 > 0인 메서드만, 증분 내림차순. + # 정렬은 sort 파이프로 한다. 파이프 출력이 awk 의 print 버퍼보다 먼저 나가지 않게 fflush 한다. + # 행은 ROW 접두로 흘려보내고 뒤 awk 가 표로 감싼다. + print "## 리포지토리 호출" + fflush() + sortcmd = "sort -t\"\t\" -k2,2nr" + for (k in last) { + if (k !~ /^spring_data_repository_invocations_seconds_count\{/) continue + d = delta(k); if (d <= 0) continue + ks = k; sub(/_count\{/, "_sum{", ks) + mean = (ks in last) ? delta(ks) / d * 1000 : 0 + printf "ROW\t%d\t%s.%s\t%s\t%.1f\n", d, lab(k, "repository"), lab(k, "method"), per(d), mean | sortcmd + } + close(sortcmd) + print "END_REPO" + }' "$IN.first.prom" "$IN.last.prom" | awk ' + /^## 리포지토리 호출$/ { print; print ""; next } + /^ROW\t/ { + if (!hdr) { print "| repository.method | 호출 증분 | 요청당 | mean ms |"; print "|---|---|---|---|"; hdr = 1 } + split($0, f, "\t") + printf "| %s | %s | %s | %s |\n", f[3], f[2], f[4], f[5] + next + } + /^END_REPO$/ { if (!hdr) print "리포지토리 호출: 측정 구간 호출 없음"; print ""; next } + { print }' + + # 캐시와 Redis 구획. 증분이 있을 때만 표 + awk -v requests="$REQUESTS" ' + function lab(s, name, r) { + if (match(s, name "=\"[^\"]*\"")) return substr(s, RSTART + length(name) + 2, RLENGTH - length(name) - 3) + return "" + } + function delta(k) { return (k in last) ? last[k] - ((k in first) ? first[k] : 0) : 0 } + function per(v) { return requests != "" ? sprintf("%.3f", v / requests) : "-" } + function key(line, k) { k = line; sub(/ [^ ]*$/, "", k); return k } + /^#/ { next } + FNR == NR { first[key($0)] = $NF; next } + { last[key($0)] = $NF } + END { + for (k in last) { + if (k ~ /^cache_gets_total\{/) { c = lab(k, "cache"); r = lab(k, "result"); gets[c, r] += delta(k); caches[c] = 1; cache_any += delta(k) } + if (k ~ /^cache_puts_total\{/) { c = lab(k, "cache"); puts[c] += delta(k); caches[c] = 1; cache_any += delta(k) } + if (k ~ /^cache_removals_total\{/) { c = lab(k, "cache"); rems[c] += delta(k); caches[c] = 1; cache_any += delta(k) } + if (k ~ /^lettuce_seconds_count\{/) { + op = lab(k, "db_operation"); d = delta(k) + if (d > 0) { + ks = k; sub(/_count\{/, "_sum{", ks) + km = k; sub(/_count\{/, "_max{", km) + rc[op] += d; rs[op] += (ks in last ? delta(ks) : 0) + mv = (km in last) ? last[km] * 1000 : 0; if (mv > rm[op]) rm[op] = mv + redis_any += d + } + } + } + print "## 캐시" + print "" + if (cache_any > 0) { + print "| 캐시 | hit | miss | pending | put | removal | 적중률 |" + print "|---|---|---|---|---|---|---|" + for (c in caches) { + h = gets[c, "hit"] + 0; mi = gets[c, "miss"] + 0 + rate = (h + mi > 0) ? sprintf("%.1f%%", 100 * h / (h + mi)) : "-" + printf "| %s | %d | %d | %d | %d | %d | %s |\n", c, h, mi, gets[c, "pending"] + 0, puts[c] + 0, rems[c] + 0, rate + } + } else { + print "캐시: 측정 구간 접근 없음" + } + print "" + print "## Redis" + print "" + if (redis_any > 0) { + print "| 명령 | 호출 증분 | 요청당 | mean ms | max ms |" + print "|---|---|---|---|---|" + fflush() + sortcmd = "sort -t\"\t\" -k2,2nr" + for (op in rc) printf "ROW\t%d\t%s\t%s\t%.2f\t%.1f\n", rc[op], op, per(rc[op]), rs[op] / rc[op] * 1000, rm[op] | sortcmd + close(sortcmd) + } else { + print "Redis: 측정 구간 호출 없음" + } + print "" + }' "$IN.first.prom" "$IN.last.prom" | awk ' + /^ROW\t/ { split($0, f, "\t"); printf "| %s | %s | %s | %s | %s |\n", f[3], f[2], f[4], f[5], f[6]; next } + { print }' + + # 타임라인 + awk -F, ' + function v(c) { return $c == "" ? "-" : $c } + NR == 1 { + print "## 타임라인" + print "" + print "| offset_s | heap_used_mb | gc_pause_ms(증분) | gc_pause_max_ms | threads_blocked | hikari_active | hikari_pending | http_active | process_cpu |" + print "|---|---|---|---|---|---|---|---|---|" + next + } + { + inc = ($6 == "" || prev == "") ? ($6 == "" ? "-" : "0.0") : sprintf("%.1f", $6 - prev) + if ($6 != "") prev = $6 + printf "| %s | %s | %s | %s | %s | %s | %s | %s | %s |\n", $1, v(2), inc, v(7), v(10), v(11), v(12), v(15), v(17) + }' "$IN" + } > "$OUT" + + echo "가공본 작성: $OUT" +} + +case "$MODE" in + check) do_check ;; + sample) do_sample ;; + summarize) do_summarize ;; + *) usage ;; +esac diff --git a/.claude/skills/open-issue/SKILL.md b/.claude/skills/open-issue/SKILL.md index 95e6b40..d585e5b 100644 --- a/.claude/skills/open-issue/SKILL.md +++ b/.claude/skills/open-issue/SKILL.md @@ -28,16 +28,15 @@ effort: xhigh - 정확히 무엇을(동작, 범위, 경계) - 왜(배경, 문제 상황) - 제약사항(있는 경우에) -3. 아래 다섯 요소가 확정되면 다음 Phase로 이동하라 +3. 아래 네 요소가 확정되면 다음 Phase로 이동하라 - 종류: feat / fix / refactor / hotfix / docs / test / cicd / chore / analysis 중 하나 - 제목: 작업을 한 줄로 표현하는 명사형. `.claude/spec/git-convention.md`의 제목 규칙을 따른다 - 40자 이내, 클래스명 나열과 괄호 중첩 금지, 대상을 나열하지 말고 무엇을 해결하는지를 남긴다. - 설명: 이 작업이 왜 필요한지 1~3문장 - 작업 항목: 체크리스트로 쪼갠 하위 작업 목록 - - 연관 도메인 -> 다음 Phase 조건: 종류·제목·설명·작업 항목·연관 도메인이 사용자와 함께 확정되었을 때 +> 다음 Phase 조건: 종류, 제목, 설명, 작업 항목이 사용자와 함께 확정되었을 때 > Skip 조건: 없음 (필수 Phase) @@ -46,11 +45,11 @@ effort: xhigh 1. 종류별 title 접두사·이슈 라벨·브랜치 접두사는 `.claude/spec/git-convention.md`의 커밋 타입 표를 참조하라 (접두사 `{종류}:`, 브랜치 `{종류}/`, 라벨은 표의 이슈 라벨). 이슈 본문 템플릿은 다음을 쓴다: - `.github/ISSUE_TEMPLATE/{종류}-issue-template.md`가 있으면 Read해서 본문 구조를 그대로 따른다. - - 전용 템플릿이 없는 종류(test, cicd, chore, analysis)는 `feat-issue-template.md`의 본문 구조를 재사용한다. -2. Phase 1 결과를 template 규격에 맞춰 본문으로 구성하라: - - 1. Issue Description: 설명 - - 2. Issue Task: 작업 항목을 `- [ ] {작업명}` 체크리스트로 - - 3. Related Domain: 해당 도메인만 `- [x]`, 나머지는 `- [ ]` 유지 + - 전용 템플릿이 없는 종류(cicd, chore, analysis)는 `feat-issue-template.md`의 본문 구조를 재사용한다. +2. Phase 1 결과를 template 규격에 맞춰 본문으로 구성하라. 템플릿의 구획은 세 개다: + - 이슈 내용: 설명. 문제가 여러 개면 번호를 붙여 나눈다 + - 작업 내용: 작업 항목을 `- [ ] {작업명}` 체크리스트로. 갈래가 여러 개면 굵은 소제목으로 묶는다 + - 첨부 파일: 근거가 되는 파일 경로, 로그, 링크. 없으면 구획 제목만 두고 비운다 3. 제목과 본문 모두 `.claude/spec/git-convention.md`의 표기 규칙을 따른다 (가운데점 대신 콤마, 긴 대시 대신 짧은 대시). 4. 본문을 스크래치 파일에 저장해두면 `gh` 전달이 안전하다 (`--body-file`로 넘김). @@ -66,7 +65,7 @@ effort: xhigh ``` - 담당자는 항상 호출자 본인(`@me`)이다. 이슈 템플릿 frontmatter에는 담당자를 고정하지 않는다 (웹 UI로 이슈를 여는 다른 사람에게 잘못 배정된다). - - 라벨 이름은 이모지 뒤 공백까지 정확해야 한다. 실패하면 `gh label list`로 실제 이름을 확인하라. + - 라벨 이름은 표의 표기 그대로다. 실패하면 `gh label list`로 실제 이름을 확인하라. 2. 출력된 이슈 URL에서 이슈 번호를 파싱하라 (다음 Phase에서 브랜치명에 사용). > 다음 Phase 조건: 이슈가 생성되고 번호를 확보했을 때 diff --git a/.claude/skills/optimize-performance/SKILL.md b/.claude/skills/optimize-performance/SKILL.md index 6a2d5f8..8c3c4bd 100644 --- a/.claude/skills/optimize-performance/SKILL.md +++ b/.claude/skills/optimize-performance/SKILL.md @@ -5,7 +5,7 @@ description: | Trigger: "/optimize-performance {엔드포인트}", "이 API 성능 개선하자", "느린 API 최적화하자" Do NOT use for: 구현 계획 수립(→ write-plan), 계획 기반 구현(→ implement) Boundary: 측정 설계, 관측 결과 정리, 기법 제시, 호출자와의 설계 협의, 확정된 설계의 적용, 기록까지 수행한다. 부하 테스트와 DB 조회 실행은 호출자가 직접 한다. 무엇이 병목인지, 어떤 기법을 쓸지, 어떻게 설계할지는 스킬이 단독으로 정하지 않는다. -allowed-tools: Read, Grep, Glob, Edit, Write, Skill, Bash(git *), Bash(gh *), Agent(query-source-mapper) +allowed-tools: Read, Grep, Glob, Edit, Write, Skill, Bash(git *), Bash(gh *) model: opus effort: xhigh --- @@ -52,13 +52,15 @@ MySQL 8.0 / InnoDB, 컨테이너 `uss-mysql`, 호스트 포트 3307. PostgreSQL | 접근 방식별 실제 작업량 | `FLUSH STATUS` → 쿼리 → `SHOW SESSION STATUS` (`Handler_%`, `Sort_%`) | | 옵티마이저 통계 갱신 | `ANALYZE TABLE` | | 인덱스 카디널리티 | `SHOW INDEX FROM` | +| JVM, 커넥션 풀, 캐시, Redis | `/actuator/prometheus` 주기 샘플링 (`_shared/jvm-sampler.sh`) | PostgreSQL과 달라서 측정에 영향을 주는 것: - `TIMER_WAIT` 계열은 **피코초**다. ms는 `/1e9`. - `EXPLAIN ANALYZE`에 `BUFFERS`가 없다. 읽은 페이지 대신 `Handler_%` 카운터로 **읽은 행 수**를 본다. - `VACUUM`이 없다. 갱신할 것은 옵티마이저 통계(`ANALYZE TABLE`)뿐이다. -- InnoDB 버퍼 풀은 재기동 없이 비울 수 없고 애플리케이션 캐시도 없다. 측정은 **warm으로 통일**하고 매번 같은 워밍업으로 상태를 맞춘다. +- InnoDB 버퍼 풀은 재기동 없이 비울 수 없고, Redis 캐시(`major-courses`)는 워밍업이 채운다. 측정은 **warm으로 통일**하고 매번 같은 워밍업으로 상태를 맞춘다. + 캐시를 비운 상태를 재려면 그 사실과 방법(`redis-cli FLUSHDB`)을 `record.md` 측정 환경에 적는다. **셸 환경.** 호출자가 새 터미널마다 한 번 source한다. 경로 변수, 서명키, `mysqlp` 접속 함수가 여기서 정의된다. 접속 옵션의 이유는 스크립트 주석에 있다. 명령 블록에 이 정의를 다시 적지 마라. @@ -110,6 +112,7 @@ Phase 4, 6, 8이 제시하는 명령은 `template/commands.md`에 있다. phase ├── record.md ├── test-script.js ├── k6-test-summary-{n}.json + ├── jvm-metrics-{n}.md ├── query-stats-summary-{n}.md └── query-plan-{n}.txt ``` @@ -121,6 +124,7 @@ Phase 4, 6, 8이 제시하는 명령은 `template/commands.md`에 있다. phase | `record.md` | 1 | `template/PERF-template.md` | | `test-script.js` | 3-B | `template/k6-script-template.js` | | `k6-test-summary-{n}.json` | 4, 8 | k6 `handleSummary` 출력 | +| `jvm-metrics-{n}.md` | 4, 8 | `_shared/jvm-sampler.sh summarize` 출력 | | `query-stats-summary-{n}.md` | 4, 8 | `template/query-stats-template.md` | | `query-plan-{n}.txt` | 6, 8 | 원본 그대로 | @@ -136,6 +140,8 @@ Read와 Write의 대상 경로에는 셸 변수가 통하지 않는다. 전체 - `k6-test-summary-{n}.json`과 `query-stats-summary-{n}.md`는 **가공본**이다. 1차 출력을 읽고 같은 경로에 소비 가능한 형태로 다시 쓴다. 1차 출력은 따로 보존하지 않되, 원문 없이는 재현할 수 없는 것(쿼리 원문)은 가공본에 포함한다. - `query-plan-{n}.txt`는 **원본 그대로** 둔다. 노드 트리 전체가 근거다. 가공은 대화의 표로만 한다. +- `jvm-metrics-{n}.md`는 스크립트가 만든 완성본이다. 스킬은 읽기만 하고 다시 쓰지 않는다. + 캐시, Redis, 리포지토리 구획은 측정 구간에 증분이 있을 때만 나타난다. 구획이 없다는 것은 그 대상이 거기 닿지 않았다는 관측이다. - 수치를 임의로 반올림하지 마라. 앞선 상태의 파일을 덮어쓰지 마라. - `record.md`에는 원본을 옮기지 않고 해석과 판정만 적는다. - 일회성 조회(행 수 확인, `SHOW CREATE TABLE`, `SHOW INDEX`)는 파일로 남기지 않는다. diff --git a/.claude/skills/optimize-performance/phases/phase-2-environment.md b/.claude/skills/optimize-performance/phases/phase-2-environment.md index 9d695cf..cdb598d 100644 --- a/.claude/skills/optimize-performance/phases/phase-2-environment.md +++ b/.claude/skills/optimize-performance/phases/phase-2-environment.md @@ -9,22 +9,21 @@ - Phase 1 완료 ### 참조 파일 -- `.claude/skills/optimize-performance/template/application-perf.yml` +- `src/main/resources/application-perf.yml` - `.claude/skills/optimize-performance/template/perf-env.sh` ### 절차 -1. `src/main/resources/application-perf.yml`을 Glob으로 확인한다. - - 없으면 `template/application-perf.yml` 내용 그대로 생성하고, 만들었다는 사실을 알린다. - - 있으면 Read해 `maximum-pool-size`, `show_sql`, 드라이버를 확인한다. SQL 로깅이나 p6spy가 켜져 있으면 끄도록 요청한다. - 요청당 수십 줄을 찍는 로깅은 측정값을 통째로 바꾼다. +1. `src/main/resources/application-perf.yml`을 Read해 `maximum-pool-size`, `show_sql`, 드라이버, Redis 접속을 확인한다. + 이 파일이 perf 프로파일의 유일한 정의다. SQL 로깅이나 p6spy가 켜져 있으면 끄도록 요청한다. + 요청당 수십 줄을 찍는 로깅은 측정값을 통째로 바꾼다. 2. 셸 환경과 서버 기동을 제시한다. 애플리케이션은 별도 터미널에서 띄우게 하고, 기동 완료를 확인받은 뒤 3으로 간다. ```bash # 측정 터미널 (레포 루트) source .claude/skills/optimize-performance/template/perf-env.sh {이슈번호} {슬러그} - docker-compose -f docker/docker-compose-local.yml up -d mysql + docker-compose -f docker/docker-compose-local.yml up -d mysql redis # 애플리케이션 터미널 ./gradlew bootRun --args='--spring.profiles.active=perf' @@ -65,10 +64,19 @@ # 7) 버퍼 풀 크기. 데이터가 이보다 크면 디스크 I/O가 측정에 섞인다 mysqlp -e "SELECT @@innodb_buffer_pool_size / 1024 / 1024 AS buffer_pool_mib;" + + # 8) Redis (PONG). 없으면 CacheErrorHandler가 DB로 폴백해 캐시 없는 상태를 재게 된다 + docker exec uss-redis redis-cli ping + + # 9) JVM 샘플러가 긁을 지표가 다 있는가 (게이트 7개 모두 "있음") + bash .claude/skills/_shared/jvm-sampler.sh check ``` 4. 실패 항목의 조치: - 3)이 0 → 설정을 의심하기 전에 2)를 메인 포트로 보냈는지 확인한다. + - 8) 실패 → `docker-compose -f docker/docker-compose-local.yml up -d redis`. + - 9)에서 `hikari`, `http_active` 없음 → 2)를 메인 포트로 보냈는지 확인한다. `gc` 없음 → 아직 GC가 없던 것이니 2)를 몇 번 더 보내고 재시도한다. + 그 외가 없음 → 1)로 돌아가 perf 프로파일로 떴는지 확인한다. - 4)의 `ps`가 0 → `docker/docker-compose-local.yml`의 mysql `command`에 `--performance-schema=ON`을 추가하고 컨테이너를 재기동한다. - 소비자가 `NO` → 재기동 없이 켠다. @@ -83,14 +91,13 @@ MySQL 8.0.28+는 digest에서 `IN` 목록을 `IN (...)`로 접으므로 `@BatchSize`의 자리표시자 1000개도 100자 미만이다. 실측 길이가 닿지 않으면 올리지 마라. -5. 결과를 `record.md`의 **측정 환경**에 적는다. 프로파일, 커넥션 풀 크기(`maximum-pool-size`), 버퍼 풀 크기, 캐시 상태(warm 고정). - cold 측정을 설계하지 마라. Phase 4와 8이 같은 워밍업으로 버퍼 풀 상태를 맞춘다. +5. 결과를 `record.md`의 **측정 환경**에 적는다. 프로파일, 커넥션 풀 크기(`maximum-pool-size`), 버퍼 풀 크기, 캐시 상태(warm 고정, Redis 캐시는 워밍업으로 적재). + cold 측정을 설계하지 마라. Phase 4와 8이 같은 워밍업으로 버퍼 풀과 Redis 상태를 맞춘다. ### 출력 -- `src/main/resources/application-perf.yml` 존재 - `record.md`의 **측정 환경**에 프로파일, 풀 크기, 버퍼 풀 크기, 캐시 상태, 진행 상태 Phase 2 ✅ -> 다음 Phase 조건: 3의 여덟 항목이 모두 통과했을 때 → Phase 3 +> 다음 Phase 조건: 3의 열 항목이 모두 통과했을 때 → Phase 3 > > Skip 조건: 같은 이슈의 다른 대상에서 통과했고 그 사이에 애플리케이션과 컨테이너를 재기동하지 않았으면, > 앞선 대상의 **측정 환경**을 옮겨 적고 ⏭️로 표기한다. diff --git a/.claude/skills/optimize-performance/phases/phase-4-baseline.md b/.claude/skills/optimize-performance/phases/phase-4-baseline.md index 1bb4ac6..3390638 100644 --- a/.claude/skills/optimize-performance/phases/phase-4-baseline.md +++ b/.claude/skills/optimize-performance/phases/phase-4-baseline.md @@ -15,17 +15,21 @@ 1. `commands.md`의 **A. 부하 측정** 블록을 `{n}` = 0으로 채워 제시한다. 실행은 호출자가 한다. 이 블록은 대상 하나만 잰다. 다른 대상의 스크립트를 이어서 돌리게 하지 마라 (`SKILL.md`의 **대상 진행 규칙**). -2. 끝나면 `k6-test-summary-0.json`을 Read한다. 쿼리 통계 1차 출력은 메인에서 Read하지 않는다. +2. 끝나면 `k6-test-summary-0.json`, `jvm-metrics-0.md`, 1차 출력 `query-stats-summary-0.md`를 Read한다. - 파일이 없으면 원인을 확인하고 재실행을 요청한다. 추정으로 채우지 마라. - `checks_rate`가 1이 아니면 `checks[]`에서 어떤 항목이 깨졌는지 먼저 본다. 데이터 검증 check가 깨진 측정은 진단에 쓰지 않는다. + - `jvm-metrics-0.md`의 게이지 표에 `미수집`이 있으면 Phase 2의 9)를 다시 통과시킨 뒤 재측정한다. -3. 가공본 작성을 `query-source-mapper`에 위임한다. Grep 흔적이 메인 컨텍스트에 남지 않게 하는 위임이다. - 프롬프트에 넘길 것(전체 경로): 1차 출력 `query-stats-summary-0.md`, 템플릿 `query-stats-template.md`, `record.md`, 상태 번호 `n=0`, `k6-test-summary-0.json`. - 반환된 출처 미상 목록과 잘림 여부만 확인한다. 미상이 남는 것은 정상이다. 채우라고 재호출하지 마라. +3. `query-stats-template.md`의 작성 규칙대로 가공본을 같은 경로에 덮어쓴다. + 출처는 `record.md`의 예상 쿼리 목록과 `jvm-metrics-0.md`의 **리포지토리 호출** 표로 맞춘다. + 목록에 있는 쿼리는 Phase 1에서 이미 확인했으므로 Grep하지 않는다. 리포지토리 호출 표의 메서드별 호출 증분과 digest의 `calls`가 맞아떨어지면 그것이 출처 확인이다. + 목록에도 표에도 없는 쿼리만 테이블명과 컬럼 조합으로 Grep해 확인하고, 못 찾으면 `미상`으로 둔다. 미상이 남는 것은 정상이다. -4. 가공본을 Read해 k6 요약과 함께 제시하고 **병목 판정을 묻는다** (`SKILL.md`의 **역할 경계**). - - 제시할 것: 응답시간 분포, 처리량, check 결과, 쿼리별 요청당 호출 수, 총 시간 비중, `examined_per_sent`. - - 물을 것: "요청당 쿼리 수와 시간이 쏠린 지점을 보고, 병목의 성격을 어떻게 판단하십니까?" +4. 가공본을 k6 요약, JVM 가공본과 함께 제시하고 **병목 판정을 묻는다** (`SKILL.md`의 **역할 경계**). + - 제시할 것: 응답시간 분포, 처리량, check 결과, 쿼리별 요청당 호출 수, 총 시간 비중, `examined_per_sent`, + heap 최대와 heap max, GC 일시정지 합과 최장 정지, GC overhead, blocked 스레드 최대, HikariCP pending 최대와 acquire max, 커넥션 보유 평균, + 처리 중 요청 최대, process CPU 최대. 캐시와 Redis 구획이 있으면 적중률과 Redis 명령별 시간도 함께. + - 물을 것: "요청당 쿼리 수와 시간이 쏠린 지점, 그리고 JVM과 풀, 캐시의 상태를 보고, 병목의 성격을 어떻게 판단하십니까?" - 아래 표는 호출자가 막혔을 때 꺼내는 재료다. 먼저 보여주지 마라. | 관측 | 진단 | 유력한 기법 | @@ -36,6 +40,15 @@ | 쿼리 효율적이고 호출도 적은데 API가 느림 | DB 밖 문제 | 직렬화, 응답 크기, 컬렉션 가공 | | 매 요청이 같은 결과를 다시 계산 | 불필요한 재조회 | 캐싱 | | 단건은 빠른데 VU를 올리면 급락 | 자원 경합 | 커넥션 풀, 트랜잭션 범위 축소, 락 경합 | + | HikariCP pending 최대 > 0, acquire max가 p95에 근접 | 커넥션 대기 | 풀 크기, 트랜잭션 범위 축소, 쿼리 수 감소 | + | 커넥션 보유 평균이 쿼리 mean_ms 합보다 훨씬 큼 | 트랜잭션이 커넥션을 오래 쥠 | 트랜잭션 범위 축소, 직렬화를 트랜잭션 밖으로 | + | GC overhead가 크거나 최장 정지가 p99에 근접, heap 최대가 heap max에 근접 | 메모리 압박 | 응답 크기 축소, 불필요한 엔티티 로딩 제거, 힙 설정 | + | 할당량 요청당 값이 응답 크기보다 훨씬 큼 | 요청 중 버려지는 객체가 많음 | DTO projection, 컬렉션 가공 축소 | + | blocked 스레드 > 0 | 애플리케이션 락 경합 | 락 범위 축소, 락 없는 구조 | + | 캐시 적용 대상인데 hit 증분 0, miss만 증가 | 캐시 미적중 (키 불일치, TTL, 워밍업 누락) | 키 설계, 워밍업 | + | Redis 명령 시간 합이 쿼리 total_ms에 근접 | 캐시 왕복이 병목 | 직렬화 크기 축소, 로컬 캐시 계층 | + | process CPU가 높고 GC는 조용한데 DB 시간 비중이 낮음 | 애플리케이션 연산 | 컬렉션 가공, 직렬화 경로 | + | DB와 JVM 지표가 모두 여유인데 API가 느림 | 직렬화, 응답 크기 | DTO 축소, 페이징 | | 쓰기에서 VU에 비례해 대기가 늘어남 | 같은 행에 쓰기가 몰림 | 락 범위 축소, 원자적 UPDATE | 5. 판정의 타당성을 확인한다. @@ -45,7 +58,7 @@ 6. 확정된 판정과 근거 수치를 `record.md`의 **기준선**에 적는다. 판정의 주체가 호출자였다는 사실은 적지 않는다. ### 출력 -- `tokens.json`, `k6-test-summary-0.json`, `query-stats-summary-0.md` (가공본) +- `tokens.json`, `k6-test-summary-0.json`, `jvm-metrics-0.md`, `query-stats-summary-0.md` (가공본) - `record.md`의 **기준선**과 진단, 진행 상태 Phase 4 ✅ ### 실패 처리 diff --git a/.claude/skills/optimize-performance/phases/phase-6-snapshot.md b/.claude/skills/optimize-performance/phases/phase-6-snapshot.md index 055a5d3..a701a1f 100644 --- a/.claude/skills/optimize-performance/phases/phase-6-snapshot.md +++ b/.claude/skills/optimize-performance/phases/phase-6-snapshot.md @@ -25,10 +25,11 @@ 이 파일은 원본 그대로 둔다. 4. 계획을 노드별 표와 카운터 표로 정리해 대화에 제시한다. 파일에는 쓰지 않는다. + 표 위에 `쿼리 전체: {루트 노드 actual time의 뒤 값} ms` 한 줄을 적는다. **표 아래에 칼럼 설명을 그 계획의 실제 수치로 예를 들어 붙인다.** 표만 던지지 마라. - | 노드 | 접근 방식 / 인덱스 | actual time | 추정 rows | 실측 rows | loops | 비고 | - |---|---|---|---|---|---|---| + | 노드 | 접근 방식 / 인덱스 | actual time | loops | 소요 ms | 추정 rows | 실측 rows | 비고 | + |---|---|---|---|---|---|---|---| | 카운터 | 값 | 뜻 | |---|---|---| @@ -36,6 +37,7 @@ | 칼럼 | 설명 | |---|---| | actual time=A..B | A는 첫 행까지, B는 마지막 행까지(ms). **loops당 평균**이고 자식 노드 시간을 포함한다 | + | 소요 ms | `B × loops`. 그 노드가 자식까지 포함해 실제로 쓴 시간. 부모의 소요를 넘지 않는다. 루트의 소요가 쿼리 전체다 | | 추정 rows | 옵티마이저가 실행 전에 인덱스 통계로 계산한 예상 행 수. 접근 방식과 조인 순서는 이 값으로 정해진다 | | 실측 rows | 그 노드가 실제로 내보낸 행 수. loops당 평균이므로 총량은 loops를 곱한다. 추정과 10배 이상 벌어지면 통계가 낡았거나 계획이 잘못 골라진 것이다 | | loops | 노드가 실행된 횟수. Nested Loop 안쪽이면 바깥 행 수만큼 커진다 | @@ -46,6 +48,7 @@ | `Sort_scan`, `Sort_rows` | filesort 발생 여부와 정렬한 행 수. 인덱스 순서로 정렬이 풀리면 0 | PostgreSQL의 `BUFFERS`에 해당하는 항목은 없다. 읽은 페이지 대신 읽은 행으로 본다. 없는 지표를 있는 것처럼 적지 마라. + `쿼리 전체`는 결과 전송 시간을 뺀 서버 실행 시간이라 digest의 `mean_ms`보다 작은 것이 정상이다. 둘의 차가 크면 전송량(응답 크기)을 본다. 그다음 **어느 노드가 비용을 먹고 있는지 묻는다** (`SKILL.md`의 **역할 경계**). 특정 행을 강조하거나 순서를 바꿔 답을 유도하지 마라. 호출자가 막히면 6의 위험 신호 표를 함께 본다. 확정된 해석을 **실행계획**에 적는다. diff --git a/.claude/skills/optimize-performance/phases/phase-8-verify.md b/.claude/skills/optimize-performance/phases/phase-8-verify.md index 11b5a5a..ab5a3d9 100644 --- a/.claude/skills/optimize-performance/phases/phase-8-verify.md +++ b/.claude/skills/optimize-performance/phases/phase-8-verify.md @@ -19,13 +19,13 @@ - 앞선 상태의 파일을 덮어쓰지 마라. - 조건이 달라졌으면 기록에 명시하고 비교 가능한 범위를 좁혀 해석한다. -2. 끝나면 `k6-test-summary-{n}.json`과 `query-plan-{n}.txt`를 개선 전 파일(`-{n-1}`)과 함께 Read한다. - 최초 상태와의 누적 변화가 필요하면 `-0`도 읽는다. 쿼리 통계는 3의 위임이 끝난 뒤 가공본을 읽는다. +2. 끝나면 `k6-test-summary-{n}.json`, `jvm-metrics-{n}.md`, `query-plan-{n}.txt`, 1차 출력 `query-stats-summary-{n}.md`를 개선 전 파일(`-{n-1}`)과 함께 Read한다. + 최초 상태와의 누적 변화가 필요하면 `-0`도 읽는다. - `checks_rate`가 떨어졌으면 응답 내용이 달라진 것이다. 수치 비교보다 이 사실을 먼저 보고한다. 3. 가공본을 만든다. - - `query-stats-summary-{n}.md`: `query-source-mapper`에 위임한다. 프롬프트에 넘길 것(전체 경로): 1차 출력, 템플릿, `record.md`, - 상태 번호 `n`, `k6-test-summary-{n}.json`, 직전 가공본 `query-stats-summary-{n-1}.md`. 헤더의 **직전 상태 대비**는 에이전트가 적는다. + - `query-stats-summary-{n}.md`: Phase 4의 3과 같은 방법으로 메인이 쓴다. `n >= 1`이므로 직전 가공본 `query-stats-summary-{n-1}.md`를 Read해 + 헤더의 **직전 상태 대비**를 채운다. - `k6-test-summary-{n}.json`: 최상위에 `delta_vs_prev`를 덧붙인다. 다른 필드는 손대지 마라. 값은 각 파일의 것을 자릿수 그대로 옮긴다. ```json @@ -38,8 +38,10 @@ ``` 4. 전후를 두 축으로 제시하고 **개선 여부 판정을 묻는다** (`SKILL.md`의 **역할 경계**). - - 하드웨어 의존 증거: p95, p99, RPS. 로컬 절대값은 믿지 말고 상대 변화만 쓴다. - - 하드웨어 독립 증거: 요청당 쿼리 수, `examined_per_sent`, Handler / Sort 카운터, 접근 방식과 사용 인덱스. + - 하드웨어 의존 증거: p95, p99, RPS, 쿼리 전체 소요(EXPLAIN ANALYZE 루트), heap 최대, GC 일시정지 합과 최장 정지, HikariCP pending 최대, 커넥션 보유 평균, process CPU 최대. + 로컬 절대값은 믿지 말고 상대 변화만 쓴다. + - 하드웨어 독립 증거: 요청당 쿼리 수, `examined_per_sent`, Handler / Sort 카운터, 접근 방식과 사용 인덱스, 요청당 리포지토리 호출 수, 요청당 할당량, + 캐시 적중률 (구획이 있을 때). - 실행계획은 Phase 6과 같은 노드별 표로, 칼럼 설명을 함께 붙인다. - 물을 것: "이 변화가 기법의 효과라고 보십니까, 측정 편차라고 보십니까?" - 개선이 없거나 나빠졌으면 그대로 제시한다. 유리하게 해석하지 마라. @@ -59,7 +61,7 @@ 판정만 하고, 계속할지는 호출자에게 확인한다. ### 출력 -- `k6-test-summary-{n}.json` (`delta_vs_prev` 포함), `query-stats-summary-{n}.md` (가공본), `query-plan-{n}.txt` (원본) +- `k6-test-summary-{n}.json` (`delta_vs_prev` 포함), `jvm-metrics-{n}.md`, `query-stats-summary-{n}.md` (가공본), `query-plan-{n}.txt` (원본) - `record.md`의 사이클 {n} **개선 후 지표**와 **판정**, 진행 상태 Phase 8 ✅ ### 실패 처리 diff --git a/.claude/skills/optimize-performance/template/PERF-template.md b/.claude/skills/optimize-performance/template/PERF-template.md index 16c63a4..dd6bd53 100644 --- a/.claude/skills/optimize-performance/template/PERF-template.md +++ b/.claude/skills/optimize-performance/template/PERF-template.md @@ -47,7 +47,7 @@ | 규모 근거 | 운영(앱 시드) 대비 {배수}. {왜 이 배수인지, 무엇을 관측하려는지} | | 카디널리티 | {컬럼별 서로 다른 값의 개수와 분포 쏠림} | | 부하 조건 | VU {n}, 유지 {t} (ramp-up {t1} + 유지 {t} + ramp-down {t2} = 총 {T}), USER_COUNT {n} | -| 캐시 상태 | warm 고정. InnoDB 버퍼 풀은 재기동 없이 비울 수 없고 애플리케이션 캐시는 없다. 매 측정 전 같은 워밍업으로 맞춘다 | +| 캐시 상태 | warm 고정. InnoDB 버퍼 풀은 재기동 없이 비울 수 없고, Redis 캐시는 워밍업이 채운다. 매 측정 전 같은 워밍업으로 맞춘다. {비운 상태를 쟀으면 그 방법} | | 되돌리기 절차 | {쓰기 엔드포인트면 Phase 3-B에서 확정한 SQL / 읽기면 `불필요`} | | 시드 | `../seeds.sql` + {이어 붙인 모듈} / 미사용. 변수: {확정한 변수값} | | 토큰 | `../tokens.json` (`mint-tokens.sh`, 회원 id {시작}~{끝}) / 불필요 | @@ -62,6 +62,14 @@ | 에러율 | | | check 통과율 | | | 요청당 쿼리 수 | | +| heap 최대 / heap max | | +| GC 일시정지 합 / 최장 | | +| GC overhead | | +| HikariCP pending 최대 / acquire max | | +| 커넥션 보유 평균 | | +| blocked 스레드 최대 | | +| process CPU 최대 | | +| 캐시 적중률 (구획 없으면 `-`) | | ### 쿼리 통계 (total_ms 상위) @@ -99,6 +107,9 @@ | 요청당 쿼리 수 | | | 대상 쿼리 calls / mean_ms / total_ms | | | 읽은 행 / 반환 행 (`examined_per_sent`) | | +| 쿼리 전체 소요 (EXPLAIN ANALYZE) | | +| GC 일시정지 합 / HikariCP pending 최대 | | +| 요청당 리포지토리 호출 수 / 할당량 | | **실행계획** @@ -106,6 +117,8 @@ - EXPLAIN 파라미터: {? = 값, ? = 값} (이후 모든 사이클에서 동일) - 값 선정 근거: {흔한 값 / 드문 값 중 무엇을 골랐고 왜} +- 쿼리 전체 소요: {ms} (루트 노드) +- 비용 상위 노드: {노드} {소요 ms} / {노드} {소요 ms} - 접근 방식: {풀스캔 / ref / range / ...}, 사용 인덱스: {인덱스명 / `없음`} - 실측 rows 대 반환 행 수: {n} / {m} - 옵티마이저 추정 대 실측: {rows=n} / {actual rows=m} @@ -126,7 +139,15 @@ | 하드웨어 의존 | p95 | | | | | | p99 | | | | | | RPS | | | | +| | 쿼리 전체 소요 | | | | +| | heap 최대 | | | | +| | GC 일시정지 합 / 최장 | | | | +| | HikariCP pending 최대 | | | | +| | 커넥션 보유 평균 | | | | +| | process CPU 최대 | | | | | 하드웨어 독립 | 요청당 쿼리 수 | | | | +| | 요청당 리포지토리 호출 수 | | | | +| | 요청당 할당량 (MB) | | | | | | 대상 쿼리 total_ms | | | | | | 읽은 행 / 반환 행 | | | | | | 접근 방식과 인덱스 | | | | @@ -149,7 +170,15 @@ | 하드웨어 의존 | p95 | | | | | | p99 | | | | | | RPS | | | | +| | 쿼리 전체 소요 | | | | +| | heap 최대 | | | | +| | GC 일시정지 합 / 최장 | | | | +| | HikariCP pending 최대 | | | | +| | 커넥션 보유 평균 | | | | +| | process CPU 최대 | | | | | 하드웨어 독립 | 요청당 쿼리 수 | | | | +| | 요청당 리포지토리 호출 수 | | | | +| | 요청당 할당량 (MB) | | | | | | 읽은 행 / 반환 행 | | | | | | 접근 방식과 인덱스 | | | | | | `Handler_read_rnd_next` | | | | diff --git a/.claude/skills/optimize-performance/template/application-perf.yml b/.claude/skills/optimize-performance/template/application-perf.yml deleted file mode 100644 index 45b3e52..0000000 --- a/.claude/skills/optimize-performance/template/application-perf.yml +++ /dev/null @@ -1,89 +0,0 @@ -# 성능 측정 전용 프로파일. Phase 2에서 src/main/resources/application-perf.yml 이 없으면 이 내용으로 생성한다. -# -# 목적은 하나다. 측정값을 왜곡하는 요소를 전부 끄는 것. 값을 바꿀 때는 주석의 이유를 먼저 읽어라. -# -# 기동: ./gradlew bootRun --args='--spring.profiles.active=perf' -# 선행: docker-compose -f docker/docker-compose-local.yml up -d mysql - -spring: - application: - name: uss-server-perf - - datasource: - # docker-compose-local.yml의 mysql. 호스트 포트는 3307이다. - url: jdbc:mysql://127.0.0.1:3307/uss_db?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Seoul&rewriteBatchedStatements=true - username: root - password: root - # p6spy 드라이버를 쓰지 마라. 쿼리마다 프록시 한 겹의 오버헤드가 측정값에 들어간다. - driver-class-name: com.mysql.cj.jdbc.Driver - - hikari: - # Phase 3-B에서 VU를 정할 때 이 값을 함께 본다. 풀보다 훨씬 큰 VU는 커넥션 대기를 잰다. - # 바꾸면 record.md의 측정 환경에 시점과 함께 남긴다. - maximum-pool-size: 10 - minimum-idle: 10 - connection-timeout: 10000 - # 측정 중 커넥션이 재생성되면 그 지연이 응답시간에 섞인다. - max-lifetime: 1800000 - - jpa: - hibernate: - ddl-auto: none - properties: - hibernate: - # SQL 로깅은 요청당 수십 줄을 찍는다. 측정값을 통째로 바꾼다. - format_sql: false - show_sql: false - # 통계 수집도 오버헤드다. 쿼리 관측은 performance_schema로 한다. - generate_statistics: false - show-sql: false - # 뷰 렌더링이 없다. 열려 있으면 커넥션 점유 구간이 실제보다 길어진다. - open-in-view: false - - flyway: - enabled: true - baseline-on-migrate: true - locations: - - classpath:database/migration - - classpath:database/seed - -inu: - course-api: - # InuCourseApiProperties가 바인딩한다. 연계 API는 IP 화이트리스트라 로컬에서 호출되지 않는다. 기동에 필요한 자리만 채운다. - base-url: http://localhost/unused-in-perf-profile - auth-key: unused - mod-date: "20260101" - -security: - jwt: - # 로컬 측정 전용 키. perf-env.sh가 이 값을 읽어 토큰을 만든다. 바꾸면 토큰도 다시 만든다. 32바이트 이상. - secret-key: perf-only-local-secret-key-not-for-any-real-environment - # JwtProvider 생성자가 요구하는 두 키. 측정 중 만료되면 401이 섞이므로 넉넉히 잡는다. - access-token-expiration-time: 86400000 - admin-access-token-expiration-time: 86400000 - -management: - server: - # Phase 2의 actuator 확인 명령이 8081을 본다. - port: 8081 - endpoints: - web: - exposure: - include: health,prometheus - metrics: - tags: - application: uss-server-perf - distribution: - # 없으면 Phase 2의 히스토그램 버킷 확인이 0으로 나온다. - percentiles-histogram: - http.server.requests: true - slo: - http.server.requests: 100ms,200ms,500ms,1s,2s,5s - -logging: - level: - root: WARN - uss.code: WARN - org.hibernate.SQL: OFF - org.hibernate.orm.jdbc.bind: OFF - p6spy: OFF diff --git a/.claude/skills/optimize-performance/template/commands.md b/.claude/skills/optimize-performance/template/commands.md index f01ccd2..b30be83 100644 --- a/.claude/skills/optimize-performance/template/commands.md +++ b/.claude/skills/optimize-performance/template/commands.md @@ -14,7 +14,7 @@ bash .claude/skills/_shared/mint-tokens.sh \ --out $PERF_DIR/tokens.json python3 -c "import json;print(len(json.load(open('$PERF_DIR/tokens.json'))))" # USER_COUNT와 같아야 한다 -# 2) 워밍업 (JIT, 커넥션 풀, InnoDB 버퍼 풀). 이 실행의 결과는 쓰지 않는다 +# 2) 워밍업 (JIT, 커넥션 풀, InnoDB 버퍼 풀, Redis 캐시). 이 실행의 결과는 쓰지 않는다 k6 run -e PHASE=warmup $TARGET_DIR/test-script.js # 3) 되돌리기. 쓰기 엔드포인트면 record.md의 되돌리기 SQL을 여기서 실행한다. 읽기면 없음 @@ -29,14 +29,24 @@ UNION ALL SELECT 'enrolled', COALESCE(sum(current_enrollment), 0) FROM courses;" # 5) 쿼리 통계 리셋 mysqlp -e "TRUNCATE TABLE performance_schema.events_statements_summary_by_digest;" -# 6) 측정 +# 6) JVM 샘플러. 측정과 함께 돌고 8)에서 멈춘다 +bash .claude/skills/_shared/jvm-sampler.sh sample --out $TARGET_DIR/jvm-samples-{n}.csv & +SAMPLER_PID=$! + +# 7) 측정 k6 run -e PHASE=measure -e SUMMARY_OUT=$TARGET_DIR/k6-test-summary-{n}.json $TARGET_DIR/test-script.js -# 7) 쿼리 통계 수집. 요청 수를 분모로 넘겨 요청당 호출 수까지 뽑는다 +# 8) 샘플러 정지, 요청 수 확인, 가공. 원본은 가공본에 전부 들어가므로 지운다 +kill $SAMPLER_PID; wait $SAMPLER_PID 2>/dev/null REQS=$(jq -r '.requests // empty' $TARGET_DIR/k6-test-summary-{n}.json) if ! [ "$REQS" -gt 0 ] 2>/dev/null; then - echo "요청 수가 '$REQS'다. 측정이 실패했으므로 통계를 수집하지 않는다. 원인을 확인하고 재측정하라." + echo "요청 수가 '$REQS'다. 측정이 실패했으므로 가공과 통계 수집을 하지 않는다. 원인을 확인하고 재측정하라." else +bash .claude/skills/_shared/jvm-sampler.sh summarize \ + --in $TARGET_DIR/jvm-samples-{n}.csv --out $TARGET_DIR/jvm-metrics-{n}.md --requests $REQS \ + && rm $TARGET_DIR/jvm-samples-{n}.csv $TARGET_DIR/jvm-samples-{n}.csv.first.prom $TARGET_DIR/jvm-samples-{n}.csv.last.prom + +# 9) 쿼리 통계 수집. 요청 수를 분모로 넘겨 요청당 호출 수까지 뽑는다 mysqlp -B -e " SELECT COUNT_STAR AS calls, COUNT_STAR / $REQS AS per_req, @@ -59,8 +69,13 @@ fi | 요소 | 이유 | |---|---| +| 워밍업에 Redis | `major-courses` 같은 캐시는 첫 요청이 채운다. 워밍업 없이 재면 miss 구간이 측정에 섞인다 | | 워밍업 → 되돌리기 → `ANALYZE` 순서 | 되돌리기 `DELETE` 뒤에 통계를 갱신해야 옵티마이저가 같은 계획을 고른다. Phase 4와 8의 계획이 달라지면 전후 비교가 아니다 | | 리셋 뒤에 곧바로 측정 | digest는 인스턴스 전역이다. 사이에 다른 부하가 끼면 통계가 섞이고 `per_req`가 틀린다 | +| 샘플러를 리셋 뒤, 측정 앞에 띄움 | 워밍업의 GC와 heap이 섞이지 않는다. 샘플의 `offset_s` 0이 측정 시작이고 `.first.prom`이 증분의 기준점이다 | +| `kill` 뒤 `wait` | 마지막 행과 `.last.prom`을 다 쓴 뒤에 가공한다 | +| `REQS`를 가공 앞으로 | 샘플러 가공본의 "요청당" 칼럼과 쿼리 통계의 `per_req`가 같은 분모를 쓴다 | +| 원본 CSV, prom 삭제 | 산출물 규약대로 1차 출력은 남기지 않는다. 타임라인과 증분이 가공본에 전부 있다 | | `-B` | 기본 박스 출력은 `DIGEST_TEXT`를 잘라 출처를 매핑할 수 없게 한다 | | `/1e9` | `TIMER_WAIT`는 피코초다 | | 반올림 없음 | `per_req`를 둘째 자리에서 자르면 0.005 미만 쿼리가 `0.00`으로 사라진다. 반올림은 대화의 표에서만 한다 | diff --git a/.claude/skills/optimize-performance/template/k6-script-template.js b/.claude/skills/optimize-performance/template/k6-script-template.js index 5adeb7a..d3871f8 100644 --- a/.claude/skills/optimize-performance/template/k6-script-template.js +++ b/.claude/skills/optimize-performance/template/k6-script-template.js @@ -47,7 +47,7 @@ const CONDITION = { ramp_down: '30s', total_duration: '{ramp_up + duration + ramp_down}', db_cache: 'warm', - app_cache: '없음', + app_cache: '{Redis warm / 없음}', user_count: USER_COUNT, }; diff --git a/.claude/skills/optimize-performance/template/perf-env.sh b/.claude/skills/optimize-performance/template/perf-env.sh index b70d448..58a4f84 100644 --- a/.claude/skills/optimize-performance/template/perf-env.sh +++ b/.claude/skills/optimize-performance/template/perf-env.sh @@ -20,11 +20,11 @@ export PERF_DIR=.claude/resources/perf/$1 export TARGET_DIR=$PERF_DIR/$2 export SEEDS=.claude/skills/optimize-performance/template/seeds -# 주석 줄을 건너뛰고 키 값만 집는다. 파일은 Phase 2가 만든다. 그 전에는 비어 있어도 된다. +# 주석 줄을 건너뛰고 키 값만 집는다. PERF_JWT_SECRET=$(grep -E '^[[:space:]]*secret-key:' src/main/resources/application-perf.yml 2>/dev/null | awk '{print $2}') export PERF_JWT_SECRET if [ -z "$PERF_JWT_SECRET" ]; then - echo "경고: application-perf.yml의 secret-key를 읽지 못했다. Phase 2에서 파일을 만든 뒤 다시 source하라." >&2 + echo "경고: src/main/resources/application-perf.yml의 secret-key를 읽지 못했다. 레포 루트에서 source했는지 확인하라." >&2 fi # uss_db 접속. 네 요소 모두 필요하다. diff --git a/.claude/spec/git-convention.md b/.claude/spec/git-convention.md index 680ce9d..e934b56 100644 --- a/.claude/spec/git-convention.md +++ b/.claude/spec/git-convention.md @@ -21,7 +21,8 @@ description: 커밋, 브랜치, PR 등 Git 작업 시 적용되는 규칙 | analysis | 코드 동작 분석, 조사 | `Analysis` | > `type`은 커밋 접두사(`{type}:`), 브랜치 접두사(`{type}/`), GitHub 이슈 라벨에 공통으로 쓰인다. -> 라벨 이름은 위 표기 그대로 써야 한다. 이모지 뒤 공백까지 일치해야 `gh issue create --label`이 통과한다. +> 라벨 이름은 위 표기 그대로 써야 한다. 한 글자라도 다르면 `gh issue create --label`이 실패한다. +> `.github/ISSUE_TEMPLATE/{type}-issue-template.md`의 frontmatter 라벨도 같은 표기를 쓴다. > 현재 레포에 실제로 등록된 목록은 `gh label list`로 확인한다. 예시: `feat: 강의 검색 기능 구현(#123)` diff --git a/.claude/spec/secret-convention.md b/.claude/spec/secret-convention.md index 93b743b..a732b58 100644 --- a/.claude/spec/secret-convention.md +++ b/.claude/spec/secret-convention.md @@ -63,5 +63,5 @@ GitHub 저장소 Settings > Secrets and variables > Actions 에서 삭제한다. - 시크릿 값을 코드, 설정 파일, 계획서, PR 본문, 커밋 메시지에 적지 마라. **이름만 적는다** - `application-prod.yml`은 워크플로가 값을 덮어쓰는 자리다. 실제 값을 커밋해 두지 마라 - 워크플로 로그에 시크릿을 `echo` 하지 마라. GitHub가 마스킹하지만 가공된 형태는 새어 나간다 -- 로컬 측정용 `application-perf.yml`에는 실제 시크릿을 넣지 마라. - 측정 전용 더미 값을 쓴다(`.claude/skills/optimize-performance/template/application-perf.yml` 참조) +- 로컬 측정용 `src/main/resources/application-perf.yml`에는 실제 시크릿을 넣지 마라. + 측정 전용 더미 값을 쓴다(파일 안 주석 참조) diff --git a/.github/ISSUE_TEMPLATE/docs-issue-template.md b/.github/ISSUE_TEMPLATE/docs-issue-template.md index 05ba98b..e481890 100644 --- a/.github/ISSUE_TEMPLATE/docs-issue-template.md +++ b/.github/ISSUE_TEMPLATE/docs-issue-template.md @@ -2,8 +2,7 @@ name: Docs issue template about: For docs issue title: '' -labels: "\U0001F4DA Docs" -assignees: xunxxoie +labels: "Docs" --- diff --git a/.github/ISSUE_TEMPLATE/feature-issue-template.md b/.github/ISSUE_TEMPLATE/feat-issue-template.md similarity index 77% rename from .github/ISSUE_TEMPLATE/feature-issue-template.md rename to .github/ISSUE_TEMPLATE/feat-issue-template.md index 641a2a1..09ac0a8 100644 --- a/.github/ISSUE_TEMPLATE/feature-issue-template.md +++ b/.github/ISSUE_TEMPLATE/feat-issue-template.md @@ -2,8 +2,7 @@ name: Feature issue template about: For feature issue title: '' -labels: "\U0001F33C Feat" -assignees: xunxxoie +labels: "Feat" --- diff --git a/.github/ISSUE_TEMPLATE/fix-issue-template.md b/.github/ISSUE_TEMPLATE/fix-issue-template.md index 75846aa..6bcc951 100644 --- a/.github/ISSUE_TEMPLATE/fix-issue-template.md +++ b/.github/ISSUE_TEMPLATE/fix-issue-template.md @@ -2,8 +2,7 @@ name: Fix issue template about: For fix issue title: '' -labels: "\U0001F528 Fix" -assignees: xunxxoie +labels: "Fix" --- diff --git a/.github/ISSUE_TEMPLATE/hotfix-issue-template.md b/.github/ISSUE_TEMPLATE/hotfix-issue-template.md index d4d7518..0a466a0 100644 --- a/.github/ISSUE_TEMPLATE/hotfix-issue-template.md +++ b/.github/ISSUE_TEMPLATE/hotfix-issue-template.md @@ -2,8 +2,7 @@ name: Hotfix issue template about: For hotfix issue title: '' -labels: "\U0001F525 HotFix" -assignees: xunxxoie +labels: "HotFix" --- diff --git a/.github/ISSUE_TEMPLATE/refactor-issue-template.md b/.github/ISSUE_TEMPLATE/refactor-issue-template.md index 317bcc2..924b354 100644 --- a/.github/ISSUE_TEMPLATE/refactor-issue-template.md +++ b/.github/ISSUE_TEMPLATE/refactor-issue-template.md @@ -2,8 +2,7 @@ name: Refactor issue template about: For refactor issue title: '' -labels: "\U0001F9D1\U0001F3FB‍\U0001F4BB Refactor" -assignees: xunxxoie +labels: "Refactor" --- diff --git a/.github/ISSUE_TEMPLATE/test-issue-template.md b/.github/ISSUE_TEMPLATE/test-issue-template.md index 0478e01..2cd8dcf 100644 --- a/.github/ISSUE_TEMPLATE/test-issue-template.md +++ b/.github/ISSUE_TEMPLATE/test-issue-template.md @@ -2,8 +2,7 @@ name: Test issue template about: For test issue title: '' -labels: "\U0001F646\U0001F3FB‍♂️ Test" -assignees: xunxxoie +labels: "Test" --- diff --git a/src/main/resources/application-perf.yml b/src/main/resources/application-perf.yml index 6d1d33d..cca28d1 100644 --- a/src/main/resources/application-perf.yml +++ b/src/main/resources/application-perf.yml @@ -1,4 +1,4 @@ -# 성능 측정 전용 프로파일. 원본은 .claude/skills/optimize-performance/template/application-perf.yml 이다. +# 성능 측정 전용 프로파일. optimize-performance 스킬의 Phase 2가 이 파일을 확인한다. 이 파일이 유일한 정의다. # # 목적은 하나다. 측정값을 왜곡하는 요소를 전부 끄는 것. 값을 바꿀 때는 주석의 이유를 먼저 읽어라. # @@ -38,7 +38,7 @@ spring: cache: type: redis redis: - # Phase 8이 actuator의 cache_gets_total{result="hit"|"miss"}로 hit / miss를 읽는다. 없으면 캐시 지표가 안 뜬다. + # jvm-sampler.sh가 cache_gets_total{result="hit"|"miss"}로 적중률을 만든다. 없으면 캐시 지표가 안 뜬다. enable-statistics: true # 정적 강의 목록의 안전망. 매일 새벽 재적재가 정상이면 만료 전에 덮어써져 발동하지 않는다. time-to-live: 25h