From 2b56748bb1ea8ce693ca2be51f9896907af9441e Mon Sep 17 00:00:00 2001 From: dongan Date: Mon, 1 Jun 2026 20:29:00 +0900 Subject: [PATCH 1/6] =?UTF-8?q?Perf:=20=EB=B6=80=ED=95=98=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EA=B8=B0=EB=B0=98=20=EC=84=B1=EB=8A=A5=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - findNearby() 바운딩박스 선필터 + (latitude, longitude) 복합 B-tree 인덱스 추가 - 전수 정렬(풀 스캔) → 반경 5km BETWEEN 후보 축소 후 거리 정렬 - PostGIS geometry 컬럼 + GIST 인덱스 마이그레이션 스크립트 추가 - findNearbyWithGist() (ST_DWithin + KNN) 쿼리 추가 - 인기 매장 Redis 캐시 적용 (CacheConfig 신규, TTL 5분) - 캐시 미적용으로 매 요청마다 DB 집계 쿼리 실행되던 문제 해결 - 리뷰 조회 페이징 적용 (전체 로드 → 최근 20건) - 엔티티 인덱스 추가: Store / Reservation / Review / Menu / Bookmark FK 컬럼 - Bookmark 폴더 삭제 배치 UPDATE (forEach N번 → 벌크 1번) - page size 상한 100 제한, findPopular countQuery 최적화 - k6 07 에러율 오탐 수정 (SLA 체크 분리) - k6 08 http_req_failed 오탐 제거, 주석 비관적락→Redis Lua 정정 - Tomcat 메트릭 수집 활성화 - k6 run.ps1 prometheus push interval 2s 설정 Co-Authored-By: Claude Sonnet 4.6 --- data/migration_v2_postgis_geometry.sql | 30 +++++++++++++++ k6/01-store-browse.js | 1 + k6/02-reservation-concurrency.js | 1 + k6/03-full-flow.js | 1 + k6/05-store-list.js | 1 + k6/06-store-nearby.js | 1 + k6/07-remains.js | 9 +++-- k6/08-coupon-issue.js | 11 ++++-- k6/10-soak.js | 1 + k6/run.ps1 | 3 +- .../catchtable/bookmark/entity/Bookmark.java | 8 +++- .../repository/BookmarkRepository.java | 6 +++ .../bookmark/service/BookmarkService.java | 3 +- .../catchtable/global/config/CacheConfig.java | 36 ++++++++++++++++++ .../java/com/catchtable/menu/entity/Menu.java | 7 +++- .../reservation/entity/Reservation.java | 11 ++++++ .../com/catchtable/review/entity/Review.java | 8 +++- .../review/repository/ReviewRepository.java | 9 +++-- .../review/service/ReviewService.java | 7 +++- .../com/catchtable/store/entity/Store.java | 15 +++++++- .../store/repository/StoreRepository.java | 38 ++++++++++++++++++- .../store/service/StoreService.java | 29 +++++++++++--- 22 files changed, 207 insertions(+), 29 deletions(-) create mode 100644 data/migration_v2_postgis_geometry.sql create mode 100644 src/main/java/com/catchtable/global/config/CacheConfig.java diff --git a/data/migration_v2_postgis_geometry.sql b/data/migration_v2_postgis_geometry.sql new file mode 100644 index 0000000..05c4d91 --- /dev/null +++ b/data/migration_v2_postgis_geometry.sql @@ -0,0 +1,30 @@ +-- PostGIS geometry 컬럼 추가 + GIST 인덱스 마이그레이션 +-- 실행 방법: docker exec -i catchtable-db psql -U $DB_USER -d $DB_NAME < data/migration_v2_postgis_geometry.sql +-- 주의: ddl-auto를 validate로 변경하기 전에 반드시 이 스크립트를 먼저 실행해야 함 + +-- 1. PostGIS 확장 활성화 (이미 활성화된 경우 무시) +CREATE EXTENSION IF NOT EXISTS postgis; + +-- 2. stores 테이블에 geometry 컬럼 추가 +ALTER TABLE stores + ADD COLUMN IF NOT EXISTS location geometry(Point, 4326); + +-- 3. 기존 latitude/longitude 데이터를 geometry 컬럼으로 복사 +UPDATE stores +SET location = ST_SetSRID(ST_MakePoint(longitude, latitude), 4326) +WHERE location IS NULL + AND latitude IS NOT NULL + AND longitude IS NOT NULL; + +-- 4. GIST 공간 인덱스 생성 (B-tree @Index로는 생성 불가 — 반드시 SQL로 직접 생성) +CREATE INDEX IF NOT EXISTS idx_stores_location_gist + ON stores USING GIST (location); + +-- 5. 기존 nearby 성능 개선용 복합 B-tree 인덱스 (바운딩박스 선필터용) +CREATE INDEX IF NOT EXISTS idx_stores_lat_lng + ON stores (latitude, longitude) + WHERE is_deleted = false; + +-- 검증 쿼리 +-- SELECT COUNT(*) FROM stores WHERE location IS NULL; -- 0이어야 정상 +-- SELECT COUNT(*) FROM pg_indexes WHERE tablename = 'stores' AND indexname LIKE 'idx_stores_%'; diff --git a/k6/01-store-browse.js b/k6/01-store-browse.js index 15cf225..77b72d1 100644 --- a/k6/01-store-browse.js +++ b/k6/01-store-browse.js @@ -35,6 +35,7 @@ const rampDuration = new Trend('browse_ramp_duration', true); const errorRate = new Rate('browse_error_rate'); export const options = { + summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(90)', 'p(95)', 'p(99)'], scenarios: { // ── SCENARIO A: 이벤트 스파이크 ──────────────────────────────────────── diff --git a/k6/02-reservation-concurrency.js b/k6/02-reservation-concurrency.js index 3fc22af..0922c15 100644 --- a/k6/02-reservation-concurrency.js +++ b/k6/02-reservation-concurrency.js @@ -30,6 +30,7 @@ const errorRate = new Rate('reservation_error_rate'); const reserveDuration = new Trend('reservation_duration', true); export const options = { + summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(90)', 'p(95)', 'p(99)'], scenarios: { // 100명이 동시에 한꺼번에 예약 요청 — 스파이크 시나리오 spike: { diff --git a/k6/03-full-flow.js b/k6/03-full-flow.js index 0f19b96..f0ea053 100644 --- a/k6/03-full-flow.js +++ b/k6/03-full-flow.js @@ -33,6 +33,7 @@ const stepDurations = { const errorRate = new Rate('flow_error_rate'); export const options = { + summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(90)', 'p(95)', 'p(99)'], scenarios: { // SCENARIO A: 이벤트 스파이크 — 50명 즉시 점프로 이벤트 오픈 순간 재현 (패턴 근거는 01-store-browse.js 헤더) diff --git a/k6/05-store-list.js b/k6/05-store-list.js index 30d6385..1316b91 100644 --- a/k6/05-store-list.js +++ b/k6/05-store-list.js @@ -19,6 +19,7 @@ const rampDuration = new Trend('list_ramp_duration', true); const errorRate = new Rate('store_list_error_rate'); export const options = { + summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(90)', 'p(95)', 'p(99)'], scenarios: { // SCENARIO A: 이벤트 스파이크 — 50명 즉시 점프로 이벤트 오픈 순간 재현 (패턴 근거는 01-store-browse.js 헤더) diff --git a/k6/06-store-nearby.js b/k6/06-store-nearby.js index 0b2c8fe..b45fa3f 100644 --- a/k6/06-store-nearby.js +++ b/k6/06-store-nearby.js @@ -32,6 +32,7 @@ const LOCATIONS = [ ]; export const options = { + summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(90)', 'p(95)', 'p(99)'], scenarios: { // SCENARIO A: 이벤트 스파이크 — 50명 즉시 점프로 이벤트 오픈 순간 재현 (패턴 근거는 01-store-browse.js 헤더) diff --git a/k6/07-remains.js b/k6/07-remains.js index 1b6f023..520190f 100644 --- a/k6/07-remains.js +++ b/k6/07-remains.js @@ -22,6 +22,7 @@ const STORE_IDS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; const REMAINS_SLA_MS = Number(__ENV.REMAINS_SLA_MS || 400); export const options = { + summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(90)', 'p(95)', 'p(99)'], scenarios: { // SCENARIO A: 이벤트 스파이크 — 50명 즉시 점프로 이벤트 오픈 순간 재현 (패턴 근거는 01-store-browse.js 헤더) @@ -77,11 +78,11 @@ export default function () { remainsDuration.add(res.timings.duration); isSpike ? spikeDuration.add(res.timings.duration) : rampDuration.add(res.timings.duration); - const ok = check(res, { - '잔여석 200': (r) => r.status === 200, - [`응답 ${REMAINS_SLA_MS}ms 이내`]: (r) => r.timings.duration < REMAINS_SLA_MS, - }); + // HTTP 성공 여부만 errorRate에 반영 — SLA 초과는 에러가 아닌 별도 지표로 관찰 + const ok = check(res, { '잔여석 200': (r) => r.status === 200 }); errorRate.add(!ok); + // SLA 체크: threshold 위반 여부는 remains_duration p95/p99로 판단 + check(res, { [`응답 ${REMAINS_SLA_MS}ms 이내`]: (r) => r.timings.duration < REMAINS_SLA_MS }); }); sleep(0.5); diff --git a/k6/08-coupon-issue.js b/k6/08-coupon-issue.js index 98b30fc..f517884 100644 --- a/k6/08-coupon-issue.js +++ b/k6/08-coupon-issue.js @@ -10,7 +10,7 @@ * * 목적: * - POST /coupons/{templateId}/issue 에 100명 동시 요청 - * - 비관적 락(SELECT FOR UPDATE)으로 초과 발급 방지 검증 + * - Redis Lua 원자 발급(Redisson)으로 초과 발급 방지 검증 * - 재고 소진 후 올바른 에러 응답 확인 * * 기대 결과 (다중 토큰 기준): @@ -60,6 +60,7 @@ const issueDuration = new Trend('coupon_issue_duration', true); const errorRate = new Rate('coupon_error_rate'); export const options = { + summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(90)', 'p(95)', 'p(99)'], scenarios: { // 선착순 스파이크: 5초 만에 50명 동시 요청 spike: { @@ -76,9 +77,11 @@ export const options = { }, }, thresholds: { - coupon_issue_duration: ['p(95)<2000'], // 비관적 락 대기 포함 2초 기준 - coupon_error_rate: ['rate<0.01'], // 5xx 에러만 실패 (재고 소진은 정상) - http_req_failed: ['rate<0.01'], + coupon_issue_duration: ['p(95)<2000'], // Redis Lua 발급 대기 포함 2초 기준 + coupon_error_rate: ['rate<0.01'], // 5xx 에러만 실패 (재고 소진/중복은 정상 4xx) + // http_req_failed 제거: 재고 소진(400)/중복 방지(409)가 정상 응답이므로 + // k6 빌트인 http_req_failed가 이를 전부 실패로 집계해 오탐 발생 + // 실제 장애 판단은 coupon_error_rate(5xx 전용)로만 평가 }, }; diff --git a/k6/10-soak.js b/k6/10-soak.js index fc73de3..d7425b7 100644 --- a/k6/10-soak.js +++ b/k6/10-soak.js @@ -39,6 +39,7 @@ const START_TIME = Date.now(); const LATE_PHASE_MS = 20 * 60 * 1000; export const options = { + summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(90)', 'p(95)', 'p(99)'], scenarios: { // 3분 워밍업 후 10VU 유지 → 30분 장시간 운영 soak: { diff --git a/k6/run.ps1 b/k6/run.ps1 index 484bc0f..ae5527b 100644 --- a/k6/run.ps1 +++ b/k6/run.ps1 @@ -66,7 +66,8 @@ Write-Host "" # k6 v2.0+ 호환 — 기본은 p99만 노출하므로 p50/p90/p95도 명시 push # 대시보드 Row A/B의 백분위 패널이 작동하려면 필수 -$env:K6_PROMETHEUS_RW_TREND_STATS = 'p(50),p(90),p(95),p(99),min,max,avg' +$env:K6_PROMETHEUS_RW_TREND_STATS = 'p(50),p(90),p(95),p(99),min,max,avg' +$env:K6_PROMETHEUS_RW_PUSH_INTERVAL = '2s' # PrometheusUrl 지정 시에만 remote_write 사용. 미지정 시 콘솔 출력만. if ($PrometheusUrl) { diff --git a/src/main/java/com/catchtable/bookmark/entity/Bookmark.java b/src/main/java/com/catchtable/bookmark/entity/Bookmark.java index a395eb9..3ad2b3b 100644 --- a/src/main/java/com/catchtable/bookmark/entity/Bookmark.java +++ b/src/main/java/com/catchtable/bookmark/entity/Bookmark.java @@ -9,7 +9,13 @@ import java.time.LocalDateTime; @Entity -@Table(name = "bookmarks") +@Table( + name = "bookmarks", + indexes = { + @Index(name = "idx_bookmark_folder_deleted", columnList = "folder_id, is_deleted"), + @Index(name = "idx_bookmark_store_folder", columnList = "store_id, folder_id") + } +) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PRIVATE) diff --git a/src/main/java/com/catchtable/bookmark/repository/BookmarkRepository.java b/src/main/java/com/catchtable/bookmark/repository/BookmarkRepository.java index c7a317c..5838377 100644 --- a/src/main/java/com/catchtable/bookmark/repository/BookmarkRepository.java +++ b/src/main/java/com/catchtable/bookmark/repository/BookmarkRepository.java @@ -2,6 +2,7 @@ import com.catchtable.bookmark.entity.Bookmark; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -13,6 +14,11 @@ public interface BookmarkRepository extends JpaRepository { @Query("SELECT b FROM Bookmark b JOIN FETCH b.store WHERE b.folder.id = :folderId AND b.isDeleted = false") List findByFolderIdAndIsDeletedFalse(@Param("folderId") Long folderId); + // 폴더 삭제 시 벌크 소프트 삭제 — forEach UPDATE N번 → UPDATE 1번 + @Modifying + @Query("UPDATE Bookmark b SET b.isDeleted = true WHERE b.folder.id = :folderId AND b.isDeleted = false") + void softDeleteAllByFolderId(@Param("folderId") Long folderId); + Optional findByIdAndIsDeletedFalse(Long id); boolean existsByFolder_IdAndStore_IdAndIsDeletedFalse(Long folderId, Long storeId); diff --git a/src/main/java/com/catchtable/bookmark/service/BookmarkService.java b/src/main/java/com/catchtable/bookmark/service/BookmarkService.java index a66f87f..eb3f684 100644 --- a/src/main/java/com/catchtable/bookmark/service/BookmarkService.java +++ b/src/main/java/com/catchtable/bookmark/service/BookmarkService.java @@ -95,8 +95,7 @@ public BookmarkFolderUpdateResponse updateFolder(Long userId, Long folderId, Boo public BookmarkFolderDeleteResponse deleteFolder(Long userId, Long folderId) { BookmarkFolder folder = getEditableFolder(userId, folderId); - bookmarkRepository.findByFolderIdAndIsDeletedFalse(folderId) - .forEach(Bookmark::softDelete); + bookmarkRepository.softDeleteAllByFolderId(folderId); folder.softDelete(); diff --git a/src/main/java/com/catchtable/global/config/CacheConfig.java b/src/main/java/com/catchtable/global/config/CacheConfig.java new file mode 100644 index 0000000..b310408 --- /dev/null +++ b/src/main/java/com/catchtable/global/config/CacheConfig.java @@ -0,0 +1,36 @@ +package com.catchtable.global.config; + +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import java.time.Duration; +import java.util.Map; + +@EnableCaching +@Configuration +public class CacheConfig { + + @Bean + public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) { + RedisCacheConfiguration defaults = RedisCacheConfiguration.defaultCacheConfig() + .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) + .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new Jackson2JsonRedisSerializer<>(Object.class))) + .disableCachingNullValues(); + + Map cacheConfigs = Map.of( + "popularStores", defaults.entryTtl(Duration.ofMinutes(5)) + ); + + return RedisCacheManager.builder(connectionFactory) + .cacheDefaults(defaults.entryTtl(Duration.ofMinutes(10))) + .withInitialCacheConfigurations(cacheConfigs) + .build(); + } +} diff --git a/src/main/java/com/catchtable/menu/entity/Menu.java b/src/main/java/com/catchtable/menu/entity/Menu.java index dae2680..ac0101f 100644 --- a/src/main/java/com/catchtable/menu/entity/Menu.java +++ b/src/main/java/com/catchtable/menu/entity/Menu.java @@ -9,7 +9,12 @@ import java.time.LocalDateTime; @Entity -@Table(name = "menu") +@Table( + name = "menu", + indexes = { + @Index(name = "idx_menu_store_deleted", columnList = "store_id, is_deleted") + } +) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Menu { diff --git a/src/main/java/com/catchtable/reservation/entity/Reservation.java b/src/main/java/com/catchtable/reservation/entity/Reservation.java index 5368ce8..66a4d27 100644 --- a/src/main/java/com/catchtable/reservation/entity/Reservation.java +++ b/src/main/java/com/catchtable/reservation/entity/Reservation.java @@ -14,10 +14,12 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.Index; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.PrePersist; import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -25,6 +27,15 @@ @Getter @Entity +@Table( + name = "reservations", + indexes = { + @Index(name = "idx_reservation_user_id", columnList = "user_id"), + @Index(name = "idx_reservation_user_status", columnList = "user_id, status"), + @Index(name = "idx_reservation_remain_id", columnList = "remain_id"), + @Index(name = "idx_reservation_status", columnList = "status") + } +) @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Reservation { diff --git a/src/main/java/com/catchtable/review/entity/Review.java b/src/main/java/com/catchtable/review/entity/Review.java index 88bba21..658fdbe 100644 --- a/src/main/java/com/catchtable/review/entity/Review.java +++ b/src/main/java/com/catchtable/review/entity/Review.java @@ -18,7 +18,13 @@ @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity -@Table(name = "reviews") +@Table( + name = "reviews", + indexes = { + @Index(name = "idx_review_store_deleted", columnList = "store_id, is_deleted"), + @Index(name = "idx_review_user_deleted", columnList = "user_id, is_deleted") + } +) public class Review { @Id diff --git a/src/main/java/com/catchtable/review/repository/ReviewRepository.java b/src/main/java/com/catchtable/review/repository/ReviewRepository.java index bc26697..7a120c2 100644 --- a/src/main/java/com/catchtable/review/repository/ReviewRepository.java +++ b/src/main/java/com/catchtable/review/repository/ReviewRepository.java @@ -1,6 +1,7 @@ package com.catchtable.review.repository; import com.catchtable.review.entity.Review; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -9,13 +10,13 @@ public interface ReviewRepository extends JpaRepository { - // 특정 매장의 삭제되지 않은 모든 리뷰 조회 (가장 최근 작성된 순) + // 특정 매장의 삭제되지 않은 리뷰 조회 — 페이징 적용 (최근 N건) @Query("SELECT r FROM Review r JOIN FETCH r.user WHERE r.store.id = :storeId AND r.isDeleted = false ORDER BY r.createdAt DESC") - List findAllByStoreIdAndIsDeletedFalseOrderByCreatedAtDesc(@Param("storeId") Long storeId); + List findAllByStoreIdAndIsDeletedFalseOrderByCreatedAtDesc(@Param("storeId") Long storeId, Pageable pageable); - // 특정 사용자가 작성한 삭제되지 않은 모든 리뷰 조회 + // 특정 사용자가 작성한 삭제되지 않은 리뷰 조회 — 페이징 적용 @Query("SELECT r FROM Review r JOIN FETCH r.store WHERE r.user.id = :userId AND r.isDeleted = false ORDER BY r.createdAt DESC") - List findAllByUserIdAndIsDeletedFalseOrderByCreatedAtDesc(@Param("userId") Long userId); + List findAllByUserIdAndIsDeletedFalseOrderByCreatedAtDesc(@Param("userId") Long userId, Pageable pageable); // 해당 예약을 통해 이미 리뷰를 작성했는지 확인 (중복 리뷰 방지) boolean existsByReservationIdAndIsDeletedFalse(Long reservationId); diff --git a/src/main/java/com/catchtable/review/service/ReviewService.java b/src/main/java/com/catchtable/review/service/ReviewService.java index fc06918..2338922 100644 --- a/src/main/java/com/catchtable/review/service/ReviewService.java +++ b/src/main/java/com/catchtable/review/service/ReviewService.java @@ -20,6 +20,7 @@ import com.catchtable.user.repository.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -81,7 +82,8 @@ public List getStoreReviews(Long storeId) { throw new CustomException(ErrorCode.STORE_NOT_FOUND); } - List reviews = reviewRepository.findAllByStoreIdAndIsDeletedFalseOrderByCreatedAtDesc(storeId); + List reviews = reviewRepository.findAllByStoreIdAndIsDeletedFalseOrderByCreatedAtDesc( + storeId, PageRequest.of(0, 20)); return reviews.stream() .map(ReviewResponseDto::from) .toList(); @@ -93,7 +95,8 @@ public List getMyReviews(Long userId) { throw new CustomException(ErrorCode.USER_NOT_FOUND); } - List reviews = reviewRepository.findAllByUserIdAndIsDeletedFalseOrderByCreatedAtDesc(userId); + List reviews = reviewRepository.findAllByUserIdAndIsDeletedFalseOrderByCreatedAtDesc( + userId, PageRequest.of(0, 20)); return reviews.stream() .map(ReviewResponseDto::from) .toList(); diff --git a/src/main/java/com/catchtable/store/entity/Store.java b/src/main/java/com/catchtable/store/entity/Store.java index 5e4a02e..7596ebc 100644 --- a/src/main/java/com/catchtable/store/entity/Store.java +++ b/src/main/java/com/catchtable/store/entity/Store.java @@ -4,13 +4,21 @@ import com.catchtable.global.exception.ErrorCode; import jakarta.persistence.*; import lombok.*; +import org.locationtech.jts.geom.Point; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.UpdateTimestamp; import java.time.LocalDateTime; @Entity -@Table(name = "stores") +@Table( + name = "stores", + indexes = { + @Index(name = "idx_store_lat_lng", columnList = "latitude, longitude"), + @Index(name = "idx_store_deleted_category", columnList = "is_deleted, category"), + @Index(name = "idx_store_deleted_district", columnList = "is_deleted, district") + } +) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor @@ -37,6 +45,11 @@ public class Store { @Column(nullable = false) private Double longitude; + // PostGIS geometry 컬럼 — ST_DWithin/KNN 공간 인덱스 활용 + // migration_v2_postgis_geometry.sql 실행 후 GIST 인덱스 적용됨 + @Column(columnDefinition = "geometry(Point, 4326)", insertable = false, updatable = false) + private Point location; + @Column(nullable = false) private String address; diff --git a/src/main/java/com/catchtable/store/repository/StoreRepository.java b/src/main/java/com/catchtable/store/repository/StoreRepository.java index 6c36a67..661b4a4 100644 --- a/src/main/java/com/catchtable/store/repository/StoreRepository.java +++ b/src/main/java/com/catchtable/store/repository/StoreRepository.java @@ -21,14 +21,15 @@ public interface StoreRepository extends JpaRepository, JpaSpecific * - averageStar는 NULL일 수 있어 COALESCE로 0 처리 * - 모든 통계 값이 동률일 때 ID 오름차순(=먼저 등록된 순)으로 결정성 보장 */ - @Query(""" + @Query(value = """ SELECT s FROM Store s WHERE s.isDeleted = false ORDER BY COALESCE(s.averageStar, 0) DESC, s.reviewCount DESC, s.bookmarkCount DESC, s.id ASC - """) + """, + countQuery = "SELECT COUNT(s) FROM Store s WHERE s.isDeleted = false") List findPopular(Pageable pageable); /** @@ -39,9 +40,16 @@ ORDER BY COALESCE(s.averageStar, 0) DESC, * * NOTE: Native query — H2 등 PostGIS 미지원 DB에서는 동작하지 않으므로 통합 테스트 시 주의. */ + /** + * 바운딩박스 선필터(BETWEEN) → 후보 좁힌 뒤 거리 정렬. + * (latitude, longitude) B-tree 인덱스가 BETWEEN 필터를 가속한다. + * deltaLat/deltaLng는 호출부에서 반경(m)을 각도로 변환해 전달. + */ @Query(value = """ SELECT * FROM stores s WHERE s.is_deleted = false + AND s.latitude BETWEEN :minLat AND :maxLat + AND s.longitude BETWEEN :minLng AND :maxLng ORDER BY ST_DistanceSphere( ST_MakePoint(s.longitude, s.latitude), ST_MakePoint(:lon, :lat) @@ -51,6 +59,10 @@ ORDER BY ST_DistanceSphere( nativeQuery = true) List findNearby(@Param("lat") double latitude, @Param("lon") double longitude, + @Param("minLat") double minLat, + @Param("maxLat") double maxLat, + @Param("minLng") double minLng, + @Param("maxLng") double maxLng, Pageable pageable); /** @@ -80,6 +92,28 @@ List findNearbyByPopularity(@Param("lat") double latitude, @Param("radiusMeters") double radiusMeters, Pageable pageable); + /** + * ST_DWithin + GIST 인덱스 — 반경 내 매장을 공간 인덱스로 빠르게 필터링 후 거리순 정렬. + * migration_v2_postgis_geometry.sql 실행 후 location 컬럼과 GIST 인덱스가 존재해야 동작. + * 기존 findNearby(바운딩박스)의 장기 대체용. + */ + @Query(value = """ + SELECT * FROM stores s + WHERE s.is_deleted = false + AND ST_DWithin( + s.location::geography, + ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography, + :radiusMeters + ) + ORDER BY s.location <-> ST_SetSRID(ST_MakePoint(:lon, :lat), 4326) ASC, + s.id ASC + """, + nativeQuery = true) + List findNearbyWithGist(@Param("lat") double latitude, + @Param("lon") double longitude, + @Param("radiusMeters") double radiusMeters, + Pageable pageable); + @Query("SELECT s.storeName FROM Store s WHERE s.isDeleted = false AND LOWER(s.storeName) LIKE LOWER(CONCAT('%', :name, '%')) ORDER BY s.storeName ASC") List findNamesByNameContaining(@Param("name") String name, Pageable pageable); diff --git a/src/main/java/com/catchtable/store/service/StoreService.java b/src/main/java/com/catchtable/store/service/StoreService.java index f0b592e..89b3698 100644 --- a/src/main/java/com/catchtable/store/service/StoreService.java +++ b/src/main/java/com/catchtable/store/service/StoreService.java @@ -19,6 +19,8 @@ import com.catchtable.global.exception.ErrorCode; import jakarta.persistence.criteria.Predicate; import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.ToolParam; import org.springframework.ai.chat.model.ToolContext; @@ -57,15 +59,17 @@ public StoreCreateResponse createStore(Long userId, StoreCreateRequest request) */ @Transactional(readOnly = true) public List getStores(String name, Category category, District district, int page, int size) { + int limitedSize = Math.min(size, 100); String trimmedName = (name == null || name.isBlank()) ? null : name.trim(); Specification spec = buildStoreSpec(trimmedName, category, district); - Pageable pageable = PageRequest.of(page, size, popularitySort()); + Pageable pageable = PageRequest.of(page, limitedSize, popularitySort()); return storeRepository.findAll(spec, pageable).getContent().stream() .map(StoreListResponse::from) .toList(); } - // 인기 매장 (averageStar DESC → reviewCount DESC → bookmarkCount DESC) — DB 정렬 + LIMIT + // 인기 매장 — Redis 캐시(TTL 5분) 적용, Cache Stampede 방지 + @Cacheable(value = "popularStores", key = "#limit") @Transactional(readOnly = true) public List getPopularStores(int limit) { return storeRepository.findPopular(PageRequest.of(0, limit)).stream() @@ -73,6 +77,12 @@ public List getPopularStores(int limit) { .toList(); } + @CacheEvict(value = "popularStores", allEntries = true) + @Transactional + public void evictPopularStoresCache() { + // 매장 등록/수정/리뷰 생성 등 인기도 변동 시 호출 + } + @Tool(description = "사용자 주변의 인기 매장을 조회합니다. '내 주변 맛집', '근처 인기 매장', '주변 맛집 추천' 등의 요청에 사용하세요. 위치 정보가 없으면 사용할 수 없습니다.") @Transactional(readOnly = true) public String getNearbyPopularStoresForAi(ToolContext toolContext) { @@ -101,12 +111,19 @@ public String getNearbyPopularStoresForAi(ToolContext toolContext) { .collect(Collectors.joining("\n")); } - // 내 주변 매장 (좌표 거리 정렬 + DB 페이지네이션) + // 내 주변 매장 (바운딩박스 선필터 + 거리 정렬) @Transactional(readOnly = true) public List getNearbyStores(double latitude, double longitude, int page, int size) { - return storeRepository.findNearby(latitude, longitude, PageRequest.of(page, size)).stream() - .map(StoreListResponse::from) - .toList(); + int limitedSize = Math.min(size, 100); + // 반경 5km를 위경도 델타로 변환 (1도 ≈ 111km, 한국 위도 기준 경도 1도 ≈ 88km) + double deltaLat = 5000.0 / 111_000.0; + double deltaLng = 5000.0 / 88_000.0; + return storeRepository.findNearby( + latitude, longitude, + latitude - deltaLat, latitude + deltaLat, + longitude - deltaLng, longitude + deltaLng, + PageRequest.of(page, limitedSize) + ).stream().map(StoreListResponse::from).toList(); } /** From 07de34600f0aa060ee78500c3475533c6b461ea8 Mon Sep 17 00:00:00 2001 From: dongan Date: Mon, 1 Jun 2026 20:38:11 +0900 Subject: [PATCH 2/6] =?UTF-8?q?Fix:=20Gemini=20=EC=BD=94=EB=93=9C=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - location 컬럼 geometry → geography 타입 변경 (GIST 인덱스 직접 활용) - findNearbyWithGist 쿼리 불필요한 ::geography 캐스팅 제거 - Store.java columnDefinition geography(Point, 4326)로 수정 - CacheConfig GenericJackson2JsonRedisSerializer 복원 (타입 정보 보존) - @Cacheable sync=true 추가 (Cache Stampede 방지) - @Modifying clearAutomatically=true 추가 (영속성 컨텍스트 불일치 방지) - findPopular 무의미한 countQuery 제거 (List 반환 시 미실행) - migration_v3_geometry_to_geography.sql 추가 Co-Authored-By: Claude Sonnet 4.6 --- data/migration_v3_geometry_to_geography.sql | 20 +++++++++++++++++++ .../repository/BookmarkRepository.java | 2 +- .../catchtable/global/config/CacheConfig.java | 4 ++-- .../com/catchtable/store/entity/Store.java | 2 +- .../store/repository/StoreRepository.java | 5 ++--- .../store/service/StoreService.java | 2 +- 6 files changed, 27 insertions(+), 8 deletions(-) create mode 100644 data/migration_v3_geometry_to_geography.sql diff --git a/data/migration_v3_geometry_to_geography.sql b/data/migration_v3_geometry_to_geography.sql new file mode 100644 index 0000000..c30e0e4 --- /dev/null +++ b/data/migration_v3_geometry_to_geography.sql @@ -0,0 +1,20 @@ +-- geometry → geography 타입 변환 마이그레이션 +-- 근거: geometry 컬럼에 ::geography 캐스팅 시 GIST 인덱스를 타지 못해 Full Table Scan 발생 +-- geography 타입으로 변경하면 캐스팅 없이 인덱스 직접 활용 가능 +-- 실행: docker exec -i catchtable-db psql -U $DB_USER -d $DB_NAME < data/migration_v3_geometry_to_geography.sql + +-- 1. 기존 GIST 인덱스 삭제 (컬럼 타입 변경 전에 제거 필요) +DROP INDEX IF EXISTS idx_stores_location_gist; + +-- 2. geometry 컬럼을 geography 타입으로 변환 +ALTER TABLE stores + ALTER COLUMN location TYPE geography(Point, 4326) + USING location::geography; + +-- 3. geography 컬럼에 GIST 인덱스 재생성 +CREATE INDEX IF NOT EXISTS idx_stores_location_gist + ON stores USING GIST (location); + +-- 검증 +-- SELECT pg_typeof(location) FROM stores LIMIT 1; -- geography(Point,4326) 이어야 정상 +-- SELECT indexname FROM pg_indexes WHERE tablename = 'stores' AND indexname = 'idx_stores_location_gist'; diff --git a/src/main/java/com/catchtable/bookmark/repository/BookmarkRepository.java b/src/main/java/com/catchtable/bookmark/repository/BookmarkRepository.java index 5838377..259476f 100644 --- a/src/main/java/com/catchtable/bookmark/repository/BookmarkRepository.java +++ b/src/main/java/com/catchtable/bookmark/repository/BookmarkRepository.java @@ -15,7 +15,7 @@ public interface BookmarkRepository extends JpaRepository { List findByFolderIdAndIsDeletedFalse(@Param("folderId") Long folderId); // 폴더 삭제 시 벌크 소프트 삭제 — forEach UPDATE N번 → UPDATE 1번 - @Modifying + @Modifying(clearAutomatically = true) @Query("UPDATE Bookmark b SET b.isDeleted = true WHERE b.folder.id = :folderId AND b.isDeleted = false") void softDeleteAllByFolderId(@Param("folderId") Long folderId); diff --git a/src/main/java/com/catchtable/global/config/CacheConfig.java b/src/main/java/com/catchtable/global/config/CacheConfig.java index b310408..d18b95a 100644 --- a/src/main/java/com/catchtable/global/config/CacheConfig.java +++ b/src/main/java/com/catchtable/global/config/CacheConfig.java @@ -6,7 +6,7 @@ import org.springframework.data.redis.cache.RedisCacheConfiguration; import org.springframework.data.redis.cache.RedisCacheManager; import org.springframework.data.redis.connection.RedisConnectionFactory; -import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.RedisSerializationContext; import org.springframework.data.redis.serializer.StringRedisSerializer; @@ -21,7 +21,7 @@ public class CacheConfig { public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) { RedisCacheConfiguration defaults = RedisCacheConfiguration.defaultCacheConfig() .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) - .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new Jackson2JsonRedisSerializer<>(Object.class))) + .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())) .disableCachingNullValues(); Map cacheConfigs = Map.of( diff --git a/src/main/java/com/catchtable/store/entity/Store.java b/src/main/java/com/catchtable/store/entity/Store.java index 7596ebc..a8230b8 100644 --- a/src/main/java/com/catchtable/store/entity/Store.java +++ b/src/main/java/com/catchtable/store/entity/Store.java @@ -47,7 +47,7 @@ public class Store { // PostGIS geometry 컬럼 — ST_DWithin/KNN 공간 인덱스 활용 // migration_v2_postgis_geometry.sql 실행 후 GIST 인덱스 적용됨 - @Column(columnDefinition = "geometry(Point, 4326)", insertable = false, updatable = false) + @Column(columnDefinition = "geography(Point, 4326)", insertable = false, updatable = false) private Point location; @Column(nullable = false) diff --git a/src/main/java/com/catchtable/store/repository/StoreRepository.java b/src/main/java/com/catchtable/store/repository/StoreRepository.java index 661b4a4..62673ba 100644 --- a/src/main/java/com/catchtable/store/repository/StoreRepository.java +++ b/src/main/java/com/catchtable/store/repository/StoreRepository.java @@ -29,7 +29,6 @@ ORDER BY COALESCE(s.averageStar, 0) DESC, s.bookmarkCount DESC, s.id ASC """, - countQuery = "SELECT COUNT(s) FROM Store s WHERE s.isDeleted = false") List findPopular(Pageable pageable); /** @@ -101,11 +100,11 @@ List findNearbyByPopularity(@Param("lat") double latitude, SELECT * FROM stores s WHERE s.is_deleted = false AND ST_DWithin( - s.location::geography, + s.location, ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography, :radiusMeters ) - ORDER BY s.location <-> ST_SetSRID(ST_MakePoint(:lon, :lat), 4326) ASC, + ORDER BY s.location <-> ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography ASC, s.id ASC """, nativeQuery = true) diff --git a/src/main/java/com/catchtable/store/service/StoreService.java b/src/main/java/com/catchtable/store/service/StoreService.java index 89b3698..e761b2b 100644 --- a/src/main/java/com/catchtable/store/service/StoreService.java +++ b/src/main/java/com/catchtable/store/service/StoreService.java @@ -69,7 +69,7 @@ public List getStores(String name, Category category, Distric } // 인기 매장 — Redis 캐시(TTL 5분) 적용, Cache Stampede 방지 - @Cacheable(value = "popularStores", key = "#limit") + @Cacheable(value = "popularStores", key = "#limit", sync = true) @Transactional(readOnly = true) public List getPopularStores(int limit) { return storeRepository.findPopular(PageRequest.of(0, limit)).stream() From 8abbe95935ce6740cd89865cce19a80a1c8fc3cc Mon Sep 17 00:00:00 2001 From: dongan Date: Mon, 1 Jun 2026 20:43:10 +0900 Subject: [PATCH 3/6] =?UTF-8?q?Refactor:=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=20=ED=8C=8C=EC=9D=BC=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20=EB=B0=8F=20after-changes=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - migration_v2/v3 분리 파일 제거, migration_v2_postgis_geography.sql 하나로 통합 (운영 서버는 geography 타입으로 단일 마이그레이션 실행) - after-changes.md 갱신: Gemini 코드리뷰 반영 사항 추가 (geography 타입, sync=true, clearAutomatically=true, countQuery 제거) Co-Authored-By: Claude Sonnet 4.6 --- data/migration_v2_postgis_geometry.sql | 30 --------------------- data/migration_v3_geometry_to_geography.sql | 20 -------------- 2 files changed, 50 deletions(-) delete mode 100644 data/migration_v2_postgis_geometry.sql delete mode 100644 data/migration_v3_geometry_to_geography.sql diff --git a/data/migration_v2_postgis_geometry.sql b/data/migration_v2_postgis_geometry.sql deleted file mode 100644 index 05c4d91..0000000 --- a/data/migration_v2_postgis_geometry.sql +++ /dev/null @@ -1,30 +0,0 @@ --- PostGIS geometry 컬럼 추가 + GIST 인덱스 마이그레이션 --- 실행 방법: docker exec -i catchtable-db psql -U $DB_USER -d $DB_NAME < data/migration_v2_postgis_geometry.sql --- 주의: ddl-auto를 validate로 변경하기 전에 반드시 이 스크립트를 먼저 실행해야 함 - --- 1. PostGIS 확장 활성화 (이미 활성화된 경우 무시) -CREATE EXTENSION IF NOT EXISTS postgis; - --- 2. stores 테이블에 geometry 컬럼 추가 -ALTER TABLE stores - ADD COLUMN IF NOT EXISTS location geometry(Point, 4326); - --- 3. 기존 latitude/longitude 데이터를 geometry 컬럼으로 복사 -UPDATE stores -SET location = ST_SetSRID(ST_MakePoint(longitude, latitude), 4326) -WHERE location IS NULL - AND latitude IS NOT NULL - AND longitude IS NOT NULL; - --- 4. GIST 공간 인덱스 생성 (B-tree @Index로는 생성 불가 — 반드시 SQL로 직접 생성) -CREATE INDEX IF NOT EXISTS idx_stores_location_gist - ON stores USING GIST (location); - --- 5. 기존 nearby 성능 개선용 복합 B-tree 인덱스 (바운딩박스 선필터용) -CREATE INDEX IF NOT EXISTS idx_stores_lat_lng - ON stores (latitude, longitude) - WHERE is_deleted = false; - --- 검증 쿼리 --- SELECT COUNT(*) FROM stores WHERE location IS NULL; -- 0이어야 정상 --- SELECT COUNT(*) FROM pg_indexes WHERE tablename = 'stores' AND indexname LIKE 'idx_stores_%'; diff --git a/data/migration_v3_geometry_to_geography.sql b/data/migration_v3_geometry_to_geography.sql deleted file mode 100644 index c30e0e4..0000000 --- a/data/migration_v3_geometry_to_geography.sql +++ /dev/null @@ -1,20 +0,0 @@ --- geometry → geography 타입 변환 마이그레이션 --- 근거: geometry 컬럼에 ::geography 캐스팅 시 GIST 인덱스를 타지 못해 Full Table Scan 발생 --- geography 타입으로 변경하면 캐스팅 없이 인덱스 직접 활용 가능 --- 실행: docker exec -i catchtable-db psql -U $DB_USER -d $DB_NAME < data/migration_v3_geometry_to_geography.sql - --- 1. 기존 GIST 인덱스 삭제 (컬럼 타입 변경 전에 제거 필요) -DROP INDEX IF EXISTS idx_stores_location_gist; - --- 2. geometry 컬럼을 geography 타입으로 변환 -ALTER TABLE stores - ALTER COLUMN location TYPE geography(Point, 4326) - USING location::geography; - --- 3. geography 컬럼에 GIST 인덱스 재생성 -CREATE INDEX IF NOT EXISTS idx_stores_location_gist - ON stores USING GIST (location); - --- 검증 --- SELECT pg_typeof(location) FROM stores LIMIT 1; -- geography(Point,4326) 이어야 정상 --- SELECT indexname FROM pg_indexes WHERE tablename = 'stores' AND indexname = 'idx_stores_location_gist'; From e2eaebc75b1de4613e08e982367a022eb3d5e1f4 Mon Sep 17 00:00:00 2001 From: dongan Date: Mon, 1 Jun 2026 20:44:52 +0900 Subject: [PATCH 4/6] =?UTF-8?q?Add:=20PostGIS=20geography=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=20=EC=9A=B4=EC=98=81=20=EC=84=9C=EB=B2=84=EC=9A=A9=20?= =?UTF-8?q?=EB=8B=A8=EC=9D=BC=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - migration_v2_postgis_geography.sql: geography(Point, 4326) 직접 추가 (로컬의 geometry→geography 2단계 과정을 운영용으로 통합) Co-Authored-By: Claude Sonnet 4.6 --- data/migration_v2_postgis_geography.sql | 32 +++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 data/migration_v2_postgis_geography.sql diff --git a/data/migration_v2_postgis_geography.sql b/data/migration_v2_postgis_geography.sql new file mode 100644 index 0000000..806090c --- /dev/null +++ b/data/migration_v2_postgis_geography.sql @@ -0,0 +1,32 @@ +-- PostGIS geography 컬럼 추가 + GIST 인덱스 마이그레이션 +-- 실행: docker exec -i catchtable-db psql -U $DB_USER -d $DB_NAME < data/migration_v2_postgis_geography.sql +-- 로컬에서 이미 실행했다면 운영 서버에서만 실행할 것 + +-- 1. PostGIS 확장 활성화 +CREATE EXTENSION IF NOT EXISTS postgis; + +-- 2. stores 테이블에 geography 컬럼 추가 +-- geometry 대신 geography 사용: ::geography 캐스팅 없이 GIST 인덱스 직접 활용 가능 +ALTER TABLE stores + ADD COLUMN IF NOT EXISTS location geography(Point, 4326); + +-- 3. 기존 latitude/longitude 데이터를 geography 컬럼으로 복사 +UPDATE stores +SET location = ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)::geography +WHERE location IS NULL + AND latitude IS NOT NULL + AND longitude IS NOT NULL; + +-- 4. GIST 공간 인덱스 생성 +CREATE INDEX IF NOT EXISTS idx_stores_location_gist + ON stores USING GIST (location); + +-- 5. nearby 바운딩박스 선필터용 복합 B-tree 인덱스 +CREATE INDEX IF NOT EXISTS idx_stores_lat_lng + ON stores (latitude, longitude) + WHERE is_deleted = false; + +-- 검증 +-- SELECT pg_typeof(location) FROM stores LIMIT 1; -- geography 이어야 정상 +-- SELECT COUNT(*) FROM stores WHERE location IS NULL; -- 0 이어야 정상 +-- SELECT indexname FROM pg_indexes WHERE tablename = 'stores' AND indexname LIKE 'idx_stores%'; From d9bca994cc47902b16f59bba967e60e38da60b52 Mon Sep 17 00:00:00 2001 From: kimjb Date: Mon, 1 Jun 2026 20:57:54 +0900 Subject: [PATCH 5/6] =?UTF-8?q?Fix:=20B.3=20=ED=8C=A8=EB=84=90=20legend=20?= =?UTF-8?q?+=20E.3=20CB=20=ED=8C=A8=EB=84=90=20=EB=A9=94=ED=8A=B8=EB=A6=AD?= =?UTF-8?q?=20=EC=9D=B4=EB=A6=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashboards/catcheat-loadtest-dashboard.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/grafana/provisioning/dashboards/catcheat-loadtest-dashboard.json b/grafana/provisioning/dashboards/catcheat-loadtest-dashboard.json index 0c54d2f..b6a4926 100644 --- a/grafana/provisioning/dashboards/catcheat-loadtest-dashboard.json +++ b/grafana/provisioning/dashboards/catcheat-loadtest-dashboard.json @@ -898,18 +898,18 @@ "type": "prometheus", "uid": "DS_PROMETHEUS" }, - "expr": "{__name__=~\"k6_.*_spike_duration_p95\"} * 1000", + "expr": "label_replace({__name__=~\"k6_.*_spike_duration_p95\"} * 1000, \"scenario\", \"$1\", \"__name__\", \"k6_(.*)_spike_duration_p95\")", "refId": "A", - "legendFormat": "spike p95 ({{__name__}})" + "legendFormat": "{{scenario}} spike p95" }, { "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, - "expr": "{__name__=~\"k6_.*_ramp_duration_p95\"} * 1000", + "expr": "label_replace({__name__=~\"k6_.*_ramp_duration_p95\"} * 1000, \"scenario\", \"$1\", \"__name__\", \"k6_(.*)_ramp_duration_p95\")", "refId": "B", - "legendFormat": "ramp p95 ({{__name__}})" + "legendFormat": "{{scenario}} ramp p95" } ] }, @@ -2070,7 +2070,7 @@ "type": "timeseries", "targets": [ { - "expr": "sum by (kind) (rate(resilience4j_circuitbreaker_calls_total{name=~\"ai-api|chatbot\"}[1m]))", + "expr": "sum by (kind) (rate(resilience4j_circuitbreaker_calls_seconds_count{name=~\"ai-api|chatbot\"}[1m])) or label_replace(sum(rate(resilience4j_circuitbreaker_not_permitted_calls_total{name=~\"ai-api|chatbot\"}[1m])), \"kind\", \"not_permitted\", \"\", \"\")", "legendFormat": "{{kind}}", "refId": "A", "datasource": { From 1fb83f119d13e3b3b70ee5aae9f2ce4d6cebeb62 Mon Sep 17 00:00:00 2001 From: kimjb Date: Mon, 1 Jun 2026 21:22:58 +0900 Subject: [PATCH 6/6] =?UTF-8?q?Fix:=20findPopular=20@Query=20=EC=96=B4?= =?UTF-8?q?=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20=EB=8B=AB=ED=9E=98=20?= =?UTF-8?q?=EB=88=84=EB=9D=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/catchtable/store/repository/StoreRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/catchtable/store/repository/StoreRepository.java b/src/main/java/com/catchtable/store/repository/StoreRepository.java index 62673ba..2f0fa6c 100644 --- a/src/main/java/com/catchtable/store/repository/StoreRepository.java +++ b/src/main/java/com/catchtable/store/repository/StoreRepository.java @@ -28,7 +28,7 @@ ORDER BY COALESCE(s.averageStar, 0) DESC, s.reviewCount DESC, s.bookmarkCount DESC, s.id ASC - """, + """) List findPopular(Pageable pageable); /**