diff --git a/.claude/agents/perf-analyzer.md b/.claude/agents/perf-analyzer.md new file mode 100644 index 00000000..3dcbcf52 --- /dev/null +++ b/.claude/agents/perf-analyzer.md @@ -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개) +``` + +--- + +### 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 ` + `WHERE =?` 패턴이 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`) 명령은 절대 실행하지 않는다. diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..19d6d26f --- /dev/null +++ b/.claude/settings.local.json @@ -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)" + ] + } +} diff --git a/.gitignore b/.gitignore index 502eda0a..d310221b 100644 --- a/.gitignore +++ b/.gitignore @@ -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 # ==================== diff --git a/build.gradle b/build.gradle index 8a20b58d..0d6a8079 100644 --- a/build.gradle +++ b/build.gradle @@ -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' diff --git a/load-test/scenarios/bookmark-course.yml b/load-test/scenarios/bookmark-course.yml index fe0cfb9d..ec574cf1 100644 --- a/load-test/scenarios/bookmark-course.yml +++ b/load-test/scenarios/bookmark-course.yml @@ -1,5 +1,10 @@ config: target: "http://localhost:8082" + payload: + path: "../data/users.csv" + fields: + - token + order: random phases: - name: "Warm up" duration: 30 @@ -14,7 +19,7 @@ config: http: defaults: headers: - Authorization: "Bearer {{ $env.TEST_TOKEN }}" + Authorization: "Bearer {{ token }}" plugins: metrics-by-endpoint: useOnlyRequestNames: true diff --git a/load-test/scenarios/bookmark-place.yml b/load-test/scenarios/bookmark-place.yml index ecbac946..285b38a6 100644 --- a/load-test/scenarios/bookmark-place.yml +++ b/load-test/scenarios/bookmark-place.yml @@ -1,5 +1,10 @@ config: target: "http://localhost:8082" + payload: + path: "../data/users.csv" + fields: + - token + order: random phases: - name: "Warm up" duration: 30 @@ -14,7 +19,7 @@ config: http: defaults: headers: - Authorization: "Bearer {{ $env.TEST_TOKEN }}" + Authorization: "Bearer {{ token }}" plugins: metrics-by-endpoint: useOnlyRequestNames: true diff --git a/src/main/java/org/sopt/solply_server/domain/bookmark/entity/Bookmark.java b/src/main/java/org/sopt/solply_server/domain/bookmark/entity/Bookmark.java index 0d9131b7..fd0dd370 100644 --- a/src/main/java/org/sopt/solply_server/domain/bookmark/entity/Bookmark.java +++ b/src/main/java/org/sopt/solply_server/domain/bookmark/entity/Bookmark.java @@ -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" ) } ) diff --git a/src/main/java/org/sopt/solply_server/domain/place/entity/Place.java b/src/main/java/org/sopt/solply_server/domain/place/entity/Place.java index 56dbcbab..184ab866 100644 --- a/src/main/java/org/sopt/solply_server/domain/place/entity/Place.java +++ b/src/main/java/org/sopt/solply_server/domain/place/entity/Place.java @@ -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") } ) diff --git a/src/main/java/org/sopt/solply_server/domain/place/repository/querydsl/PlaceRepositoryImpl.java b/src/main/java/org/sopt/solply_server/domain/place/repository/querydsl/PlaceRepositoryImpl.java index 616fdd58..75fd8064 100644 --- a/src/main/java/org/sopt/solply_server/domain/place/repository/querydsl/PlaceRepositoryImpl.java +++ b/src/main/java/org/sopt/solply_server/domain/place/repository/querydsl/PlaceRepositoryImpl.java @@ -196,15 +196,17 @@ private String sanitizeForBooleanMode(final String token) { // 전체 조회 private List findPlacesWithoutTags(QPlace place, BooleanBuilder whereCondition) { - List 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; } // 메인 태그만 있는 경우 @@ -230,6 +232,9 @@ private List findPlacesWithMainTag(QPlace place, BooleanBuilder whereCond // 메인 태그와 서브 태그가 모두 있는 경우 private List findPlacesWithTags(QPlace place, BooleanBuilder whereCondition, PlaceSearchConditionDto condition) { + QPlaceTag placeTag = QPlaceTag.placeTag; + QTag tag = QTag.tag; + // 메인 태그 EXISTS 조건 whereCondition.and(createMainTagExistsCondition(place, condition.mainTagId())); @@ -246,30 +251,13 @@ private List 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 places) { - if (places.isEmpty()) return; - - List 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(); diff --git a/src/main/java/org/sopt/solply_server/domain/place/service/PlaceService.java b/src/main/java/org/sopt/solply_server/domain/place/service/PlaceService.java index 320ce678..37ba3758 100644 --- a/src/main/java/org/sopt/solply_server/domain/place/service/PlaceService.java +++ b/src/main/java/org/sopt/solply_server/domain/place/service/PlaceService.java @@ -110,12 +110,28 @@ public PlaceFilterGetResponse getPlacesByTownAndTag( townValidator.validateTownId(townId); - List places = getPlacesByCondition(userId, townId, isBookmarkSearch, mainTagId, subTagAIdList, subTagBIdList); - - // 북마크 여부: Set.contains() - Set bookmarkedIds = userId != null - ? new HashSet<>(placeBookmarkFacade.getBookmarkedPlaceIdsForTown(userId, townId)) - : Collections.emptySet(); + boolean isOnlyBookmarkSearch = Boolean.TRUE.equals(isBookmarkSearch); + + // isBookmarkSearch=true 시 orderedIds를 한 번만 조회해서 장소 목록 필터링과 북마크 Set에 재사용 + // (기존에는 getBookmarkedPlacesByLatest()와 북마크 Set 생성 시 각각 1회씩, 총 2회 호출했음) + List bookmarkedOrderedIds = (isOnlyBookmarkSearch && userId != null) + ? placeBookmarkFacade.getBookmarkedPlaceIdsForTown(userId, townId) + : null; + + // 북마크 검색 시 orderedIds를 함께 전달 → 내부에서 북마크 최신순으로 재정렬 + List places = getPlacesByCondition(townId, isOnlyBookmarkSearch, mainTagId, subTagAIdList, subTagBIdList, bookmarkedOrderedIds); + + Set bookmarkedIds; + if (bookmarkedOrderedIds != null) { + // 북마크 검색: 이미 조회한 orderedIds를 Set으로 변환해서 재사용 + bookmarkedIds = new HashSet<>(bookmarkedOrderedIds); + } else if (userId != null) { + // 일반 장소 목록 + 로그인 상태: 각 장소 카드의 북마크 여부(하트) 표시를 위해 조회 + bookmarkedIds = new HashSet<>(placeBookmarkFacade.getBookmarkedPlaceIdsForTown(userId, townId)); + } else { + // 비로그인: 북마크 여부 불필요 + bookmarkedIds = Collections.emptySet(); + } List placePreviewDtoList = places.stream() .map(place -> PlacePreviewDto.of( @@ -191,14 +207,15 @@ public PlaceSearchResponse searchPlaces(final String keyword) { //=== Private Methods ===// - private List getPlacesByCondition(final Long userId, final Long selectedTownId, final boolean isOnlyBookmarkSearch, - final Long mainTagId, final List subTagAIdList, final List subTagBIdList) { + private List getPlacesByCondition(final Long selectedTownId, final boolean isOnlyBookmarkSearch, + final Long mainTagId, final List subTagAIdList, final List subTagBIdList, + final List bookmarkedOrderedIds) { if (mainTagId != null) { tagValidator.validatePlaceTagConditions(mainTagId, subTagAIdList, subTagBIdList); } if (isOnlyBookmarkSearch) { - return getBookmarkedPlacesByLatest(userId, selectedTownId, mainTagId, subTagAIdList, subTagBIdList); + return getBookmarkedPlacesByLatest(selectedTownId, mainTagId, subTagAIdList, subTagBIdList, bookmarkedOrderedIds); } return placeRepository.findPlacesByConditions( @@ -208,17 +225,16 @@ private List getPlacesByCondition(final Long userId, final Long selectedT /** * 북마크 장소 최신순 조회. - * ZSET에서 최신순 정렬된 placeIds 추출 → DB에서 태그 조건 필터링 → ZSET 순서 복원. + * 상위에서 조회한 orderedIds(ZSET 최신순) → DB에서 태그 조건 필터링 → ZSET 순서 복원. */ private List getBookmarkedPlacesByLatest( - final Long userId, final Long selectedTownId, final Long mainTagId, final List subTagAIdList, - final List subTagBIdList + final List subTagBIdList, + final List orderedIds ) { - List orderedIds = placeBookmarkFacade.getBookmarkedPlaceIdsForTown(userId, selectedTownId); - if (orderedIds.isEmpty()) return List.of(); + if (orderedIds == null || orderedIds.isEmpty()) return List.of(); List filtered = placeRepository.findPlacesByConditions( PlaceSearchConditionDto.of( @@ -232,7 +248,7 @@ private List getBookmarkedPlacesByLatest( ); if (filtered.isEmpty()) return List.of(); - // DB 결과를 ZSET 순서(최신순)로 재정렬 + // DB 결과를 ZSET 순서(북마크 최신순)로 재정렬 Map placeMap = filtered.stream() .collect(Collectors.toMap(Place::getId, Function.identity())); return orderedIds.stream() diff --git a/src/main/resources/db/migration/V10__optimize_indexes_for_bookmark_query.sql b/src/main/resources/db/migration/V10__optimize_indexes_for_bookmark_query.sql new file mode 100644 index 00000000..5dd87085 --- /dev/null +++ b/src/main/resources/db/migration/V10__optimize_indexes_for_bookmark_query.sql @@ -0,0 +1,12 @@ +-- bookmarks: user_id + target_type + created_at 복합 인덱스로 교체 +-- 적용 쿼리 패턴: WHERE user_id = ? AND target_type = 'PLACE' [AND target_id = ?] [ORDER BY created_at DESC] +DROP INDEX idx_bookmark_user_created ON bookmarks; +CREATE INDEX idx_bookmark_user_type_created + ON bookmarks (user_id, target_type, created_at DESC); + +-- places: town_id + active + created_at 복합 인덱스로 교체 +-- 적용 쿼리 패턴: WHERE town_id = ? AND active = true ORDER BY created_at DESC +-- 주의: idx_places_town_id는 FK 제약 조건에서 사용 중이므로 새 인덱스를 먼저 생성 후 삭제 +CREATE INDEX idx_places_town_active_created + ON places (town_id, active, created_at DESC); +DROP INDEX idx_places_town_id ON places; diff --git a/src/main/resources/db/migration/V11__simplify_indexes.sql b/src/main/resources/db/migration/V11__simplify_indexes.sql new file mode 100644 index 00000000..517eae1f --- /dev/null +++ b/src/main/resources/db/migration/V11__simplify_indexes.sql @@ -0,0 +1,13 @@ +-- bookmarks: created_at은 backfill 쿼리에 ORDER BY가 없으므로 불필요 +-- target_id가 인덱스에 없어 커버링 인덱스도 아님 +-- (user_id, target_type) 복합 인덱스로 축소 +DROP INDEX idx_bookmark_user_type_created ON bookmarks; +CREATE INDEX idx_bookmark_user_type + ON bookmarks (user_id, target_type); + +-- places: created_at은 LIMIT 없는 전체 조회에서 filesort 비용 절감 효과가 없으므로 제거 +-- 주의: idx_places_town_active_created는 town_id FK 제약에서 사용 중이므로 +-- 새 인덱스를 먼저 생성 후 삭제 +CREATE INDEX idx_places_town_active + ON places (town_id, active); +DROP INDEX idx_places_town_active_created ON places; diff --git a/src/main/resources/spy.properties b/src/main/resources/spy.properties new file mode 100644 index 00000000..924a696d --- /dev/null +++ b/src/main/resources/spy.properties @@ -0,0 +1,9 @@ +appender=com.p6spy.engine.spy.appender.Slf4JLogger +logMessageFormat=com.p6spy.engine.spy.appender.CustomLineFormat +customLogMessageFormat=%(executionTime)ms | %(sqlSingleLine) + +filter.multiline=false +excludecategories=info,debug,result,resultset,batch,commit,rollback + +# HikariCP keepalive +excludestatements=SELECT 1 diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index f2652765..b1c3d881 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -16,6 +16,10 @@ spring: - org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration - org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration +decorator: + datasource: + enabled: false + app: env-prefix: test