From 861f5a69c7677e0df89dabd8dbb529ea74c48118 Mon Sep 17 00:00:00 2001 From: Minkyu Shin Date: Tue, 24 Mar 2026 02:07:28 +0900 Subject: [PATCH 01/10] =?UTF-8?q?#352=20chore:=20p6spy=20=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC=20=EB=A1=9C=EA=B9=85=20=EC=84=A4=EC=A0=95=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + build.gradle | 3 +++ src/main/resources/spy.properties | 10 ++++++++++ 3 files changed, 14 insertions(+) create mode 100644 src/main/resources/spy.properties diff --git a/.gitignore b/.gitignore index 502eda0a..d0899ac2 100644 --- a/.gitignore +++ b/.gitignore @@ -78,6 +78,7 @@ Thumbs.db test-output/ reports/ load-test/reports/ +load-test/query-logs/ load-test/node_modules/ jacoco.exec diff --git a/build.gradle b/build.gradle index 8a20b58d..e8971fd3 100644 --- a/build.gradle +++ b/build.gradle @@ -130,6 +130,9 @@ dependencies { // ✅ Spring AI implementation 'org.springframework.ai:spring-ai-starter-model-openai' + // ✅ p6spy (쿼리 로깅) + implementation '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/src/main/resources/spy.properties b/src/main/resources/spy.properties new file mode 100644 index 00000000..79ffe71c --- /dev/null +++ b/src/main/resources/spy.properties @@ -0,0 +1,10 @@ +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 From e431cef74c6ff52f1a7cc8e4c9457b6be7787921 Mon Sep 17 00:00:00 2001 From: Minkyu Shin Date: Tue, 24 Mar 2026 02:07:38 +0900 Subject: [PATCH 02/10] =?UTF-8?q?#352=20refactor:=20=EB=B6=81=EB=A7=88?= =?UTF-8?q?=ED=81=AC=20=EC=9E=A5=EC=86=8C=20=EC=A1=B0=ED=9A=8C=20N+1=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=EC=A4=91=EB=B3=B5=20Redis=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - findPlacesWithoutTags: loadPlaceTagsAndTags 제거 → LEFT JOIN FETCH 통합 (2쿼리 → 1쿼리) - findPlacesWithTags: LEFT JOIN FETCH 추가로 PlaceTag N+1 방지 - PlaceService: getBookmarkedPlaceIdsForTown 중복 호출 제거, orderedIds 재사용으로 북마크 최신순 정렬 유지 --- .../querydsl/PlaceRepositoryImpl.java | 38 ++++++--------- .../domain/place/service/PlaceService.java | 46 +++++++++++++------ 2 files changed, 44 insertions(+), 40 deletions(-) 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() From e78654a763c55df38eb1f01d2755ea72b8636218 Mon Sep 17 00:00:00 2001 From: Minkyu Shin Date: Tue, 24 Mar 2026 03:36:55 +0900 Subject: [PATCH 03/10] =?UTF-8?q?#352=20refactor:=20bookmarks=C2=B7places?= =?UTF-8?q?=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=EC=B5=9C=EC=A0=81=ED=99=94=20?= =?UTF-8?q?(Flyway=20V10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bookmarks: (user_id, created_at) → (user_id, target_type, created_at DESC) - target_type 필터링 시 인덱스 풀 스캔 제거 - places: (town_id) → (town_id, active, created_at DESC) - active 필터 + created_at 정렬을 인덱스 내에서 처리 - 엔티티 @Index 어노테이션 동기화 --- .../domain/bookmark/entity/Bookmark.java | 4 ++-- .../solply_server/domain/place/entity/Place.java | 2 +- .../V10__optimize_indexes_for_bookmark_query.sql | 12 ++++++++++++ 3 files changed, 15 insertions(+), 3 deletions(-) create mode 100644 src/main/resources/db/migration/V10__optimize_indexes_for_bookmark_query.sql 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..a1b7ff58 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_created", + columnList = "user_id, target_type, created_at DESC" ) } ) 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..2d1b01e3 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_created", columnList = "town_id, active, created_at DESC"), @Index(name = "idx_places_created_by_created_at", columnList = "created_by, created_at") } ) 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; From e142fe8cd8a3b1ac37264a4cc15183470919d8a6 Mon Sep 17 00:00:00 2001 From: Minkyu Shin Date: Tue, 24 Mar 2026 04:01:14 +0900 Subject: [PATCH 04/10] =?UTF-8?q?#352=20chore:=20=EB=B6=80=ED=95=98?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=8B=9C=EB=82=98=EB=A6=AC?= =?UTF-8?q?=EC=98=A4=20=EB=8B=A4=EC=A4=91=20=EC=9C=A0=EC=A0=80=20CSV=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - $env.TEST_TOKEN 환경변수 → users.csv 토큰 풀로 교체 - order: random으로 요청마다 다른 유저 선택 - load-test/data/ gitignore 추가 (토큰 포함 파일 보호) --- .gitignore | 1 + load-test/scenarios/bookmark-course.yml | 7 ++++++- load-test/scenarios/bookmark-place.yml | 7 ++++++- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index d0899ac2..d310221b 100644 --- a/.gitignore +++ b/.gitignore @@ -80,6 +80,7 @@ reports/ load-test/reports/ load-test/query-logs/ load-test/node_modules/ +load-test/data/ jacoco.exec # ==================== 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 From 9904b732ff28bb9d78853f13c4037422bd421f91 Mon Sep 17 00:00:00 2001 From: Minkyu Shin Date: Tue, 24 Mar 2026 04:36:05 +0900 Subject: [PATCH 05/10] =?UTF-8?q?#352=20chore:=20=ED=95=9C=EA=B8=80=20?= =?UTF-8?q?=EA=B9=A8=EC=A7=84=20=EC=A3=BC=EC=84=9D=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 7 +++++++ build.gradle | 2 +- src/main/resources/spy.properties | 3 +-- src/test/resources/application-test.yml | 4 ++++ 4 files changed, 13 insertions(+), 3 deletions(-) create mode 100644 .claude/settings.local.json 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/build.gradle b/build.gradle index e8971fd3..0d6a8079 100644 --- a/build.gradle +++ b/build.gradle @@ -131,7 +131,7 @@ dependencies { implementation 'org.springframework.ai:spring-ai-starter-model-openai' // ✅ p6spy (쿼리 로깅) - implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.1' + runtimeOnly 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.1' // ✅ 테스트 testImplementation 'org.springframework.boot:spring-boot-starter-test' diff --git a/src/main/resources/spy.properties b/src/main/resources/spy.properties index 79ffe71c..924a696d 100644 --- a/src/main/resources/spy.properties +++ b/src/main/resources/spy.properties @@ -2,9 +2,8 @@ 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 쿼리 제외 +# 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 From 03d272de2bc4f33edb84dd7a3765543210e7992a Mon Sep 17 00:00:00 2001 From: Minkyu Shin Date: Tue, 24 Mar 2026 12:23:11 +0900 Subject: [PATCH 06/10] =?UTF-8?q?#352=20chore:=20perf-analyzer=20=EC=84=9C?= =?UTF-8?q?=EB=B8=8C=20=EC=97=90=EC=9D=B4=EC=A0=84=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Artillery 리포트·p6spy 쿼리 로그·EXPLAIN 실행계획 분석용 Claude Code 로컬 에이전트 - .claude/agents/perf-analyzer.md 생성 --- .claude/agents/perf-analyzer.md | 210 ++++++++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 .claude/agents/perf-analyzer.md diff --git a/.claude/agents/perf-analyzer.md b/.claude/agents/perf-analyzer.md new file mode 100644 index 00000000..09ebccbe --- /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 → [경고] +- p95 > 1,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` 테이블을 우선 검사 + +탐지 시 출력 예시: +``` +[심각] N+1 탐지됨 (01-baseline.txt) + - 테이블: place_tag + - 패턴: WHERE pt1_0.place_id=? (단건 조회 반복) + - 반복 횟수: 8회 + - 개선 후(03): 해당 패턴 제거됨 (JOIN 배치 조회로 변경) +``` + +#### 2-3. 슬로우 쿼리 목록 + +실행시간 10ms 초과 쿼리를 파일별로 나열한다. + +``` +[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`) 명령은 절대 실행하지 않는다. From 7e6c81b5c8e6d2a3d81afbb8d0626f7cdba00c98 Mon Sep 17 00:00:00 2001 From: Minkyu Shin Date: Tue, 24 Mar 2026 12:29:27 +0900 Subject: [PATCH 07/10] =?UTF-8?q?#352=20chore:=20perf-anlyzer.md=20?= =?UTF-8?q?=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/agents/perf-analyzer.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.claude/agents/perf-analyzer.md b/.claude/agents/perf-analyzer.md index 09ebccbe..3dcbcf52 100644 --- a/.claude/agents/perf-analyzer.md +++ b/.claude/agents/perf-analyzer.md @@ -73,10 +73,10 @@ Glob으로 아래 경로를 스캔한다. 엔드포인트별 메트릭(`plugins.metrics-by-endpoint.*`)이 있으면 별도 표로 분리한다. -**KPI 임계값:** +KPI 임계값: - 성공률 < 95% → [심각] - p95 > 3,000ms → [경고] -- p95 > 1,000ms → [주의] +- 1,000ms < p95 ≤ 3,000ms → [주의] --- @@ -105,7 +105,7 @@ Bash를 사용해 각 파일의 전체 쿼리 수를 집계한다. - `place_tag`, `places`, `tags`, `place_images` 테이블을 우선 검사 탐지 시 출력 예시: -``` +```text [심각] N+1 탐지됨 (01-baseline.txt) - 테이블: place_tag - 패턴: WHERE pt1_0.place_id=? (단건 조회 반복) @@ -117,7 +117,7 @@ Bash를 사용해 각 파일의 전체 쿼리 수를 집계한다. 실행시간 10ms 초과 쿼리를 파일별로 나열한다. -``` +```text [01-baseline.txt] 슬로우 쿼리 44ms | select u1_0.id,... from users where id=? 77ms | select p1_0.id,... from places where ... From 8064f420b2e0c8f1b8255cff23e9b07614770942 Mon Sep 17 00:00:00 2001 From: Minkyu Shin Date: Thu, 26 Mar 2026 16:14:03 +0900 Subject: [PATCH 08/10] =?UTF-8?q?#352=20refactor:=20places=20=EC=9D=B8?= =?UTF-8?q?=EB=8D=B1=EC=8A=A4=EC=97=90=EC=84=9C=20=EB=B6=88=ED=95=84?= =?UTF-8?q?=EC=9A=94=ED=95=9C=20created=5Fat=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/sopt/solply_server/domain/place/entity/Place.java | 2 +- .../db/migration/V11__simplify_places_town_active_index.sql | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 src/main/resources/db/migration/V11__simplify_places_town_active_index.sql 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 2d1b01e3..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_active_created", columnList = "town_id, active, created_at DESC"), + @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/resources/db/migration/V11__simplify_places_town_active_index.sql b/src/main/resources/db/migration/V11__simplify_places_town_active_index.sql new file mode 100644 index 00000000..011d037d --- /dev/null +++ b/src/main/resources/db/migration/V11__simplify_places_town_active_index.sql @@ -0,0 +1,5 @@ +-- places: created_at은 LIMIT 없는 전체 조회에서 filesort 비용 절감 효과가 없으므로 제거 +-- (town_id, active) 복합 인덱스로 축소 +DROP INDEX idx_places_town_active_created ON places; +CREATE INDEX idx_places_town_active + ON places (town_id, active); From 33864b003b616a82b7205726f4990346baa4e4aa Mon Sep 17 00:00:00 2001 From: Minkyu Shin Date: Thu, 26 Mar 2026 18:26:51 +0900 Subject: [PATCH 09/10] =?UTF-8?q?#352=20refactor:=20bookmarks=C2=B7places?= =?UTF-8?q?=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=EC=97=90=EC=84=9C=20=EB=B6=88?= =?UTF-8?q?=ED=95=84=EC=9A=94=ED=95=9C=20created=5Fat=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/bookmark/entity/Bookmark.java | 4 ++-- .../resources/db/migration/V11__simplify_indexes.sql | 12 ++++++++++++ .../V11__simplify_places_town_active_index.sql | 5 ----- 3 files changed, 14 insertions(+), 7 deletions(-) create mode 100644 src/main/resources/db/migration/V11__simplify_indexes.sql delete mode 100644 src/main/resources/db/migration/V11__simplify_places_town_active_index.sql 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 a1b7ff58..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_type_created", - columnList = "user_id, target_type, created_at DESC" + name = "idx_bookmark_user_type", + columnList = "user_id, target_type" ) } ) 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..d7ad2742 --- /dev/null +++ b/src/main/resources/db/migration/V11__simplify_indexes.sql @@ -0,0 +1,12 @@ +-- places: created_at은 LIMIT 없는 전체 조회에서 filesort 비용 절감 효과가 없으므로 제거 +-- (town_id, active) 복합 인덱스로 축소 +DROP INDEX idx_places_town_active_created ON places; +CREATE INDEX idx_places_town_active + ON places (town_id, active); + +-- 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); diff --git a/src/main/resources/db/migration/V11__simplify_places_town_active_index.sql b/src/main/resources/db/migration/V11__simplify_places_town_active_index.sql deleted file mode 100644 index 011d037d..00000000 --- a/src/main/resources/db/migration/V11__simplify_places_town_active_index.sql +++ /dev/null @@ -1,5 +0,0 @@ --- places: created_at은 LIMIT 없는 전체 조회에서 filesort 비용 절감 효과가 없으므로 제거 --- (town_id, active) 복합 인덱스로 축소 -DROP INDEX idx_places_town_active_created ON places; -CREATE INDEX idx_places_town_active - ON places (town_id, active); From 89adc4fff0adf076d13c8211f2bb299547ad6647 Mon Sep 17 00:00:00 2001 From: Minkyu Shin Date: Thu, 26 Mar 2026 18:30:28 +0900 Subject: [PATCH 10/10] =?UTF-8?q?#352=20fix:=20V11=20places=20=EC=9D=B8?= =?UTF-8?q?=EB=8D=B1=EC=8A=A4=20DROP=20=EC=88=9C=EC=84=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(FK=20=EC=A0=9C=EC=95=BD=20=EB=8C=80=EC=9D=91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../db/migration/V11__simplify_indexes.sql | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/main/resources/db/migration/V11__simplify_indexes.sql b/src/main/resources/db/migration/V11__simplify_indexes.sql index d7ad2742..517eae1f 100644 --- a/src/main/resources/db/migration/V11__simplify_indexes.sql +++ b/src/main/resources/db/migration/V11__simplify_indexes.sql @@ -1,12 +1,13 @@ --- places: created_at은 LIMIT 없는 전체 조회에서 filesort 비용 절감 효과가 없으므로 제거 --- (town_id, active) 복합 인덱스로 축소 -DROP INDEX idx_places_town_active_created ON places; -CREATE INDEX idx_places_town_active - ON places (town_id, active); - -- 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;