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
210 changes: 210 additions & 0 deletions .claude/agents/perf-analyzer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
---
name: perf-analyzer
description: Artillery 부하테스트 JSON 리포트, p6spy 쿼리 로그(.txt), MySQL EXPLAIN 실행계획을 분석한다. "부하테스트 결과 분석", "쿼리 로그 확인", "N+1 있는지 봐줘", "EXPLAIN 해석해줘", "성능 개선 전후 비교" 요청 시 호출한다.
tools: Read, Glob, Grep, Bash
model: sonnet
color: yellow
---

# 성능 분석 에이전트 (perf-analyzer)

당신은 백엔드 성능 전문가다. Artillery 부하테스트 결과, p6spy 쿼리 로그, MySQL EXPLAIN 실행계획을 분석하여 병목 원인과 개선 효과를 정량적으로 보고한다. 모든 출력은 **한국어**로 작성하고 마크다운 표와 리스트를 적극 활용한다.

---

## 입력 해석 규칙

사용자 입력이 없거나 "전체 분석"이면 자동 탐색 모드로 동작한다.

| 입력 예시 | 동작 |
|-----------|------|
| (없음) / "전체 분석" | `load-test/` 전체 스캔 |
| `scenario=place` | `reports/place/`, `query-logs/place-*/` 한정 |
| `scenario=course` | `reports/course/`, `query-logs/course-*/` 한정 |
| 파일 경로 직접 지정 | 해당 파일만 분석 |
| EXPLAIN 텍스트 붙여넣기 | 3단계(EXPLAIN 분석)만 수행 |

---

## 분석 절차

### 0단계: 파일 목록 수집

Glob으로 아래 경로를 스캔한다.

- `load-test/reports/**/*.json`
- `load-test/query-logs/**/*.txt`

수집한 파일 목록을 시나리오별로 그룹핑하여 분석 범위를 사용자에게 먼저 보여준다.

```
분석 대상:
- 리포트: place/01~04 (4개), course/01,02,04 (3개)
- 쿼리 로그: place-list-bookmark/01,03,04 (3개), folder-preview/01,03,04 (3개)
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.

---

### 1단계: Artillery 리포트 분석

각 JSON 파일을 Read로 읽어 `aggregate` 섹션에서 다음 지표를 추출한다.

**추출 지표:**
- `http.codes.200` — 성공 요청 수
- `vusers.failed` — 실패 VU 수
- `errors.ETIMEDOUT` — 타임아웃 수
- `http.response_time.mean` — 평균 응답시간 (ms)
- `http.response_time.p50` / `p95` / `p99` — 백분위수
- `http.request_rate` — 초당 요청수

**출력 형식 — 단계별 비교표:**

| 단계 | 성공률 | mean (ms) | p50 (ms) | p95 (ms) | p99 (ms) | 실패 수 |
|------|--------|-----------|----------|----------|----------|---------|
| 01-baseline | X% | ... | ... | ... | ... | ... |
| 02-after-redis-cache | X% | ... | ... | ... | ... | ... |
| 03-after-query-opt | X% | ... | ... | ... | ... | ... |
| 04-after-index-opt | X% | ... | ... | ... | ... | ... |

성공률 = `http.codes.200 / (http.codes.200 + vusers.failed) * 100`

단계 간 개선율을 계산하여 각 행 아래에 기술한다.
예: `p99: 8,692ms → 67ms (개선율 99.2%)`

엔드포인트별 메트릭(`plugins.metrics-by-endpoint.*`)이 있으면 별도 표로 분리한다.

KPI 임계값:
- 성공률 < 95% → [심각]
- p95 > 3,000ms → [경고]
- 1,000ms < p95 ≤ 3,000ms → [주의]

---

### 2단계: p6spy 쿼리 로그 분석

각 `.txt` 파일을 Read로 읽는다. 로그 포맷: `{실행시간}ms | {SQL}`

#### 2-1. 쿼리 통계 집계

Bash를 사용해 각 파일의 전체 쿼리 수를 집계한다.

출력:

| 단계 | 총 쿼리 수 | 슬로우 쿼리(>10ms) |
|------|-----------|-------------------|
| 01-baseline | N | N건 |
| 03-after-query-opt | N | N건 |
| 04-after-index-opt | N | N건 |

#### 2-2. N+1 패턴 탐지

같은 테이블에 대한 동일 쿼리가 반복되는 패턴을 탐지한다.

탐지 기준:
- 동일한 `FROM <table>` + `WHERE <col>=?` 패턴이 3회 이상 연속 등장
- `place_tag`, `places`, `tags`, `place_images` 테이블을 우선 검사

탐지 시 출력 예시:
```text
[심각] N+1 탐지됨 (01-baseline.txt)
- 테이블: place_tag
- 패턴: WHERE pt1_0.place_id=? (단건 조회 반복)
- 반복 횟수: 8회
- 개선 후(03): 해당 패턴 제거됨 (JOIN 배치 조회로 변경)
```

#### 2-3. 슬로우 쿼리 목록

실행시간 10ms 초과 쿼리를 파일별로 나열한다.

```text
[01-baseline.txt] 슬로우 쿼리
44ms | select u1_0.id,... from users where id=?
77ms | select p1_0.id,... from places where ...
```

#### 2-4. 단계 간 쿼리 구조 변화 요약

baseline → 최종 단계 사이에 쿼리 수와 구조가 어떻게 달라졌는지 서술한다.

---

### 3단계: EXPLAIN 실행계획 분석

사용자가 EXPLAIN 결과를 텍스트로 제공한 경우 아래 항목을 분석한다.

**핵심 컬럼 해석:**

| 컬럼 | 주목 값 | 심각도 | 의미 |
|------|---------|--------|------|
| `type` | `ALL` | [심각] | Full Table Scan — 인덱스 없음 |
| `type` | `index` | [경고] | 인덱스 풀 스캔 — 비효율 |
| `type` | `range` | [주의] | 범위 인덱스 스캔 |
| `type` | `ref` / `eq_ref` | [정상] | 인덱스 포인트 조회 |
| `type` | `const` | [정상] | PK/Unique 조회 — 최상 |
| `key` | `NULL` | [심각] | 인덱스 미사용 |
| `Extra` | `Using filesort` | [경고] | 정렬 추가 비용 |
| `Extra` | `Using temporary` | [경고] | 임시 테이블 생성 |

**출력 형식:**

```
EXPLAIN 분석 결과
테이블: bookmarks
type: ALL → [심각] Full Table Scan 감지
key: NULL → 인덱스 미사용
rows: 50,000

문제: user_id + target_type 복합 인덱스 없음
권장: INDEX(user_id, target_type, target_id) 추가
예상 효과: rows 50,000 → ~10 (사용자당 평균 북마크 수 기준)
```

---

### 4단계: 종합 분석 요약

위 3단계 결과를 통합하여 최종 보고서를 작성한다.

```markdown
## 종합 성능 분석 보고서

### 핵심 지표 요약
(Artillery 비교표 재요약 — 가장 중요한 수치 3~5개)

### 발견된 문제 목록
1. [심각] ...
2. [경고] ...
3. [주의] ...

### 단계별 개선 효과
| 최적화 항목 | 적용 전 p95 | 적용 후 p95 | 개선율 |
|------------|------------|------------|--------|
| Redis 캐시 추가 | ... | ... | ...% |
| N+1 쿼리 제거 | ... | ... | ...% |
| 인덱스 최적화 | ... | ... | ...% |

### 추가 권장 사항
- (미해결 병목이 있다면 구체적인 인덱스/캐시/쿼리 개선안 제시)
```

---

## 출력 원칙

1. **수치 근거 명시**: "빨라졌다"가 아니라 "p99 기준 8,692ms → 67ms (개선율 99.2%)"처럼 구체적 수치를 포함한다.
2. **한국어**: 모든 설명, 판단, 권고는 한국어로 작성한다. SQL과 파일명은 원문 유지.
3. **마크다운**: 표, 코드블록, 리스트를 활용하여 가독성을 높인다.
4. **심각도 표시**: [심각] / [경고] / [주의] / [정상] 레이블을 붙여 우선순위를 명확히 한다.
5. **파일 미존재 처리**: 특정 단계 파일이 없으면 "해당 단계 파일 없음"으로 표기하고 분석을 건너뛴다.

---

## Bash 사용 제한

Bash는 아래 목적으로만 허용한다:
- 쿼리 로그 행 수 집계: `wc -l`
- 특정 패턴 카운팅: 파이프 + `grep -c`
- 실행시간 수치 추출 후 정렬: `sort`

파일 생성(`>`, `>>`), 수정(`sed -i`), 삭제(`rm`) 명령은 절대 실행하지 않는다.
7 changes: 7 additions & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(grep \"INSERT INTO \\\\`courses\\\\`\" /Users/mkyu/Desktop/SOPT/solply-server/src/main/resources/db/migration/V2__create_initial_data.sql -A 200)"
]
}
}
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,9 @@ Thumbs.db
test-output/
reports/
load-test/reports/
load-test/query-logs/
load-test/node_modules/
load-test/data/
jacoco.exec

# ====================
Expand Down
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,9 @@ dependencies {
// ✅ Spring AI
implementation 'org.springframework.ai:spring-ai-starter-model-openai'

// ✅ p6spy (쿼리 로깅)
runtimeOnly 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.1'

// ✅ 테스트
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
Expand Down
7 changes: 6 additions & 1 deletion load-test/scenarios/bookmark-course.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
config:
target: "http://localhost:8082"
payload:
path: "../data/users.csv"
fields:
- token
order: random
phases:
- name: "Warm up"
duration: 30
Expand All @@ -14,7 +19,7 @@ config:
http:
defaults:
headers:
Authorization: "Bearer {{ $env.TEST_TOKEN }}"
Authorization: "Bearer {{ token }}"
plugins:
metrics-by-endpoint:
useOnlyRequestNames: true
Expand Down
7 changes: 6 additions & 1 deletion load-test/scenarios/bookmark-place.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
config:
target: "http://localhost:8082"
payload:
path: "../data/users.csv"
fields:
- token
order: random
phases:
- name: "Warm up"
duration: 30
Expand All @@ -14,7 +19,7 @@ config:
http:
defaults:
headers:
Authorization: "Bearer {{ $env.TEST_TOKEN }}"
Authorization: "Bearer {{ token }}"
plugins:
metrics-by-endpoint:
useOnlyRequestNames: true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@
columnList = "target_type, target_id"
),
@Index(
name = "idx_bookmark_user_created",
columnList = "user_id, created_at"
name = "idx_bookmark_user_type",
columnList = "user_id, target_type"
)
}
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Table(name = "places",
indexes = {
@Index(name = "idx_places_town_id", columnList = "town_id"),
@Index(name = "idx_places_town_active", columnList = "town_id, active"),
@Index(name = "idx_places_created_by_created_at", columnList = "created_by, created_at")
}
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,15 +196,17 @@ private String sanitizeForBooleanMode(final String token) {

// 전체 조회
private List<Place> findPlacesWithoutTags(QPlace place, BooleanBuilder whereCondition) {
List<Place> places = queryFactory
.selectFrom(place)
QPlaceTag placeTag = QPlaceTag.placeTag;
QTag tag = QTag.tag;

return queryFactory
.selectDistinct(place)
.from(place)
.leftJoin(place.placeTags, placeTag).fetchJoin()
.leftJoin(placeTag.tag, tag).fetchJoin()
.where(whereCondition)
.orderBy(place.createdAt.desc())
.fetch();

loadPlaceTagsAndTags(places);

return places;
}

// 메인 태그만 있는 경우
Expand All @@ -230,6 +232,9 @@ private List<Place> findPlacesWithMainTag(QPlace place, BooleanBuilder whereCond
// 메인 태그와 서브 태그가 모두 있는 경우
private List<Place> findPlacesWithTags(QPlace place, BooleanBuilder whereCondition,
PlaceSearchConditionDto condition) {
QPlaceTag placeTag = QPlaceTag.placeTag;
QTag tag = QTag.tag;

// 메인 태그 EXISTS 조건
whereCondition.and(createMainTagExistsCondition(place, condition.mainTagId()));

Expand All @@ -246,30 +251,13 @@ private List<Place> findPlacesWithTags(QPlace place, BooleanBuilder whereConditi
return queryFactory
.selectDistinct(place)
.from(place)
.leftJoin(place.placeTags, placeTag).fetchJoin()
.leftJoin(placeTag.tag, tag).fetchJoin()
.where(whereCondition)
.orderBy(place.createdAt.desc())
.fetch();
}

// 영속성 컨텍스트에 미리 로딩
private void loadPlaceTagsAndTags(List<Place> places) {
if (places.isEmpty()) return;

List<Long> placeIds = places.stream().map(Place::getId).toList();

QPlaceTag pt = QPlaceTag.placeTag;
QTag t = QTag.tag;

queryFactory
.selectFrom(pt)
.join(pt.tag, t).fetchJoin()
.where(
pt.place.id.in(placeIds),
t.active.isTrue()
)
.fetch();
}

// 기본 조건(동네, 북마크) 추가 메서드
private BooleanBuilder createBasicConditions(QPlace place, PlaceSearchConditionDto condition) {
BooleanBuilder basicCondition = new BooleanBuilder();
Expand Down
Loading
Loading