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%'; 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": { 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 baa2db8..0922c15 100644 --- a/k6/02-reservation-concurrency.js +++ b/k6/02-reservation-concurrency.js @@ -30,8 +30,7 @@ const errorRate = new Rate('reservation_error_rate'); const reserveDuration = new Trend('reservation_duration', true); export const options = { - // stdout 메트릭에 p99 노출 (기본은 p95까지만) - summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(95)', 'p(99)'], + 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 77b9e87..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,8 +60,7 @@ const issueDuration = new Trend('coupon_issue_duration', true); const errorRate = new Rate('coupon_error_rate'); export const options = { - // stdout 메트릭에 p99 노출 (기본은 p95까지만 → 결과 출력에서 p99=0 표시 버그 해소) - summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(95)', 'p(99)'], + summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(90)', 'p(95)', 'p(99)'], scenarios: { // 선착순 스파이크: 5초 만에 50명 동시 요청 spike: { @@ -78,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..259476f 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(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); + 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..d18b95a --- /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.GenericJackson2JsonRedisSerializer; +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 GenericJackson2JsonRedisSerializer())) + .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..a8230b8 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 = "geography(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..2f0fa6c 100644 --- a/src/main/java/com/catchtable/store/repository/StoreRepository.java +++ b/src/main/java/com/catchtable/store/repository/StoreRepository.java @@ -21,7 +21,7 @@ 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, @@ -39,9 +39,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 +58,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 +91,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, + ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography, + :radiusMeters + ) + ORDER BY s.location <-> ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography 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..e761b2b 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", sync = true) @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(); } /**