Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 0 additions & 38 deletions .claude/agents/query-source-mapper.md

This file was deleted.

372 changes: 372 additions & 0 deletions .claude/resources/plans/PLAN-108.md

Large diffs are not rendered by default.

399 changes: 399 additions & 0 deletions .claude/skills/_shared/jvm-sampler.sh

Large diffs are not rendered by default.

17 changes: 8 additions & 9 deletions .claude/skills/open-issue/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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`로 넘김).

Expand All @@ -66,7 +65,7 @@ effort: xhigh
```
- 담당자는 항상 호출자 본인(`@me`)이다. 이슈 템플릿 frontmatter에는 담당자를 고정하지 않는다
(웹 UI로 이슈를 여는 다른 사람에게 잘못 배정된다).
- 라벨 이름은 이모지 뒤 공백까지 정확해야 한다. 실패하면 `gh label list`로 실제 이름을 확인하라.
- 라벨 이름은 표의 표기 그대로다. 실패하면 `gh label list`로 실제 이름을 확인하라.
2. 출력된 이슈 URL에서 이슈 번호를 파싱하라 (다음 Phase에서 브랜치명에 사용).

> 다음 Phase 조건: 이슈가 생성되고 번호를 확보했을 때
Expand Down
10 changes: 8 additions & 2 deletions .claude/skills/optimize-performance/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
---
Expand Down Expand Up @@ -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` 접속 함수가 여기서 정의된다.
접속 옵션의 이유는 스크립트 주석에 있다. 명령 블록에 이 정의를 다시 적지 마라.
Expand Down Expand Up @@ -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
```
Expand All @@ -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 | 원본 그대로 |

Expand All @@ -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`)는 파일로 남기지 않는다.
Expand Down
27 changes: 17 additions & 10 deletions .claude/skills/optimize-performance/phases/phase-2-environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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` → 재기동 없이 켠다.

Expand All @@ -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 조건: 같은 이슈의 다른 대상에서 통과했고 그 사이에 애플리케이션과 컨테이너를 재기동하지 않았으면,
> 앞선 대상의 **측정 환경**을 옮겨 적고 ⏭️로 표기한다.
29 changes: 21 additions & 8 deletions .claude/skills/optimize-performance/phases/phase-4-baseline.md
Original file line number Diff line number Diff line change
Expand Up @@ -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과 풀, 캐시의 상태를 보고, 병목의 성격을 어떻게 판단하십니까?"
- 아래 표는 호출자가 막혔을 때 꺼내는 재료다. 먼저 보여주지 마라.

| 관측 | 진단 | 유력한 기법 |
Expand All @@ -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. 판정의 타당성을 확인한다.
Expand All @@ -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 ✅

### 실패 처리
Expand Down
Loading
Loading