From 807ca07a4724efa3b149f0336ce24fea6330ab69 Mon Sep 17 00:00:00 2001 From: erika0915 Date: Thu, 25 Dec 2025 01:26:22 +0900 Subject: [PATCH 1/4] =?UTF-8?q?[SCRUM-428]=20Feat:=20Redis=20Geospatial?= =?UTF-8?q?=EC=9D=84=20=ED=99=9C=EC=9A=A9=ED=95=9C=20=EA=B0=80=EB=A7=B9?= =?UTF-8?q?=EC=A0=90=20=EC=9C=84=EC=B9=98=20=EA=B8=B0=EB=B0=98=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=84=B1=EB=8A=A5=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/RedisTemplateCacheService.java | 37 +++++++++++++++++++ .../com/kkinikong/be/cache/type/RedisKey.java | 4 +- .../store/StoreRepositoryCustom.java | 3 ++ .../store/StoreRepositoryCustomImpl.java | 23 ++++++++++++ .../be/store/service/StoreService.java | 16 +++++--- 5 files changed, 77 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/kkinikong/be/cache/service/RedisTemplateCacheService.java b/src/main/java/com/kkinikong/be/cache/service/RedisTemplateCacheService.java index 9fc9c17..1303002 100644 --- a/src/main/java/com/kkinikong/be/cache/service/RedisTemplateCacheService.java +++ b/src/main/java/com/kkinikong/be/cache/service/RedisTemplateCacheService.java @@ -4,7 +4,13 @@ import java.util.Map; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.geo.Distance; +import org.springframework.data.geo.GeoResults; +import org.springframework.data.geo.Point; +import org.springframework.data.redis.connection.RedisGeoCommands; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.domain.geo.GeoReference; +import org.springframework.data.redis.domain.geo.Metrics; import org.springframework.stereotype.Service; import lombok.extern.slf4j.Slf4j; @@ -64,4 +70,35 @@ private static String generateRecentSearchKey(long userId) { String key = RedisKey.RECENT_SEARCHES_KEY.getKey() + ":" + userId; return key; } + + // 가맹점 위치 정보 단건 저장 + public void saveStoreLocation(Long storeId, Double latitude, Double longitude) { + String key = RedisKey.STORE_LOCATIONS_KEY.getKey(); + redisTemplate.opsForGeo().add(key, new Point(longitude, latitude), storeId.toString()); + } + + // 주변 가맹점 ID 리스트 조회 + public List findNearbyStoreIds(Double latitude, Double longitude, Double radiusMeters) { + String key = RedisKey.STORE_LOCATIONS_KEY.getKey(); + + GeoResults> results = + redisTemplate + .opsForGeo() + .search( + key, + GeoReference.fromCoordinate(longitude, latitude), + new Distance(radiusMeters, Metrics.METERS), + RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs().sortAscending()); + + if (results == null) return List.of(); + + return results.getContent().stream() + .map(res -> Long.parseLong(res.getContent().getName().toString())) + .toList(); + } + + // 가맹점 삭제 시 Geo 데이터도 삭제 + public void removeStoreLocation(Long storeId) { + redisTemplate.opsForZSet().remove(RedisKey.STORE_LOCATIONS_KEY.getKey(), storeId.toString()); + } } diff --git a/src/main/java/com/kkinikong/be/cache/type/RedisKey.java b/src/main/java/com/kkinikong/be/cache/type/RedisKey.java index 4be1f25..5a0a9b3 100644 --- a/src/main/java/com/kkinikong/be/cache/type/RedisKey.java +++ b/src/main/java/com/kkinikong/be/cache/type/RedisKey.java @@ -6,7 +6,9 @@ public enum RedisKey { STORE_VIEWS_KEY("store-views"), COMMUNITY_POST_VIEWS_KEY("community-post-views"), - RECENT_SEARCHES_KEY("recent-searches"); + RECENT_SEARCHES_KEY("recent-searches"), + STORE_LOCATIONS_KEY("store-locations"), + ; private final String key; diff --git a/src/main/java/com/kkinikong/be/store/repository/store/StoreRepositoryCustom.java b/src/main/java/com/kkinikong/be/store/repository/store/StoreRepositoryCustom.java index 57fc714..90b08bf 100644 --- a/src/main/java/com/kkinikong/be/store/repository/store/StoreRepositoryCustom.java +++ b/src/main/java/com/kkinikong/be/store/repository/store/StoreRepositoryCustom.java @@ -22,4 +22,7 @@ Page findStoresUnified( Long userId); List findTopViewedStores(Double latitude, Double longitude); + + Page findStoresByIdsForMap( + List ids, String keyword, Category category, Pageable pageable, Long usedId); } diff --git a/src/main/java/com/kkinikong/be/store/repository/store/StoreRepositoryCustomImpl.java b/src/main/java/com/kkinikong/be/store/repository/store/StoreRepositoryCustomImpl.java index 038cd0f..11335a4 100644 --- a/src/main/java/com/kkinikong/be/store/repository/store/StoreRepositoryCustomImpl.java +++ b/src/main/java/com/kkinikong/be/store/repository/store/StoreRepositoryCustomImpl.java @@ -69,6 +69,29 @@ public List findTopViewedStores(Double latitude, Double longitude) { return queryFactory.selectFrom(store).where(whereBuilder).orderBy(orderBy).limit(8).fetch(); } + @Override + public Page findStoresByIdsForMap( + List ids, String keyword, Category category, Pageable pageable, Long userId) { + + BooleanBuilder whereBuilder = new BooleanBuilder(); + whereBuilder.and(store.id.in(ids)); // redis에서 필터링해준 id 리스트 안에 포함된 데이터만 조회 + + if (category != null) { + whereBuilder.and(store.category.eq(category)); + } + + if (keyword != null && !keyword.isBlank()) { + whereBuilder.and(buildKeywordCondition(keyword)); + } + + OrderSpecifier[] sortOrder = new OrderSpecifier[] {store.id.asc()}; + List tuples = fetchStores(whereBuilder, sortOrder, pageable, userId); + List storeList = convertTuplesToStores(tuples, userId); + long total = fetchTotalCount(whereBuilder); + + return new PageImpl<>(storeList, pageable, total); + } + // 키워드 검색 조건 생성 private BooleanBuilder buildKeywordCondition(String keyword) { String normalizedKeyword = keyword.replaceAll("\\s+", ""); diff --git a/src/main/java/com/kkinikong/be/store/service/StoreService.java b/src/main/java/com/kkinikong/be/store/service/StoreService.java index 68d5e42..e461f43 100644 --- a/src/main/java/com/kkinikong/be/store/service/StoreService.java +++ b/src/main/java/com/kkinikong/be/store/service/StoreService.java @@ -48,11 +48,12 @@ public class StoreService { private final UserRepository userRepository; private final StoreTagCountRepository storeTagCountRepository; + private static final double DEFAULT_RADIUS_METERS = 5000.0; private static final String NO_INFO = "NO_INFO"; private static final double DEFAULT_LATITUDE = 37.545472; private static final double DEFAULT_LONGITUDE = 126.676902; - /// 카테고리와 정렬 조건 기반 가맹점 리스트 조회 + // 카테고리와 정렬 조건 기반 가맹점 리스트 조회 public PageResponse getStoreList( Double latitude, Double longitude, @@ -78,24 +79,29 @@ public PageResponse getStoreList( storePage, store -> StoreListItemResponse.from(store, tagMap.get(store.getId()))); } - /// 가맹점 지도 조회 + // 가맹점 지도 조회 public PageResponse getStoreMapList( Double latitude, Double longitude, - Double radius, + Double radiusMeters, String keyword, Category category, int page, int size, Long userId) { + double radius = (radiusMeters != null) ? radiusMeters : DEFAULT_RADIUS_METERS; + latitude = getOrDefault(latitude, StoreService.DEFAULT_LATITUDE); longitude = getOrDefault(longitude, StoreService.DEFAULT_LONGITUDE); Pageable pageable = PageRequest.of(page, size); + // redis에서 주변 가맹점 id만 가져오기 + List nearbyIds = + redisTemplateCacheService.findNearbyStoreIds(latitude, longitude, radius); + Page storePage = - storeRepository.findStoresUnified( - latitude, longitude, radius, keyword, category, StoreSort.DISTANCE, pageable, userId); + storeRepository.findStoresByIdsForMap(nearbyIds, keyword, category, pageable, userId); return PageResponse.from(storePage, StoreMapListItemResponse::from); } From a8898ca84757b02ccb88152dc985aa25be0874f6 Mon Sep 17 00:00:00 2001 From: erika0915 Date: Thu, 25 Dec 2025 11:46:26 +0900 Subject: [PATCH 2/4] =?UTF-8?q?[SCRUM-428]=20Feat:=20csv=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=8B=9C=20redis?= =?UTF-8?q?=EC=97=90=20=ED=95=A8=EA=BB=98=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EB=B0=8F=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/RedisTemplateCacheService.java | 43 ++++++++++++++----- .../repository/store/StoreRepository.java | 3 ++ .../be/store/service/StoreUploadService.java | 16 +++++++ 3 files changed, 52 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/kkinikong/be/cache/service/RedisTemplateCacheService.java b/src/main/java/com/kkinikong/be/cache/service/RedisTemplateCacheService.java index 1303002..a8e4319 100644 --- a/src/main/java/com/kkinikong/be/cache/service/RedisTemplateCacheService.java +++ b/src/main/java/com/kkinikong/be/cache/service/RedisTemplateCacheService.java @@ -6,7 +6,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.geo.Distance; import org.springframework.data.geo.GeoResults; -import org.springframework.data.geo.Point; import org.springframework.data.redis.connection.RedisGeoCommands; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.domain.geo.GeoReference; @@ -16,6 +15,7 @@ import lombok.extern.slf4j.Slf4j; import com.kkinikong.be.cache.type.RedisKey; +import com.kkinikong.be.store.domain.Store; @Service @Slf4j @@ -71,12 +71,6 @@ private static String generateRecentSearchKey(long userId) { return key; } - // 가맹점 위치 정보 단건 저장 - public void saveStoreLocation(Long storeId, Double latitude, Double longitude) { - String key = RedisKey.STORE_LOCATIONS_KEY.getKey(); - redisTemplate.opsForGeo().add(key, new Point(longitude, latitude), storeId.toString()); - } - // 주변 가맹점 ID 리스트 조회 public List findNearbyStoreIds(Double latitude, Double longitude, Double radiusMeters) { String key = RedisKey.STORE_LOCATIONS_KEY.getKey(); @@ -97,8 +91,37 @@ public List findNearbyStoreIds(Double latitude, Double longitude, Double r .toList(); } - // 가맹점 삭제 시 Geo 데이터도 삭제 - public void removeStoreLocation(Long storeId) { - redisTemplate.opsForZSet().remove(RedisKey.STORE_LOCATIONS_KEY.getKey(), storeId.toString()); + // 여러 ID를 한 번에 삭제 + public void removeStoreLocationsBulk(List storeIds) { + if (storeIds == null || storeIds.isEmpty()) return; + + String key = RedisKey.STORE_LOCATIONS_KEY.getKey(); + Object[] members = storeIds.stream().map(Object::toString).toArray(); + + redisTemplate.opsForZSet().remove(key, members); + } + + // 여러 데이터를 한 번에 저장 + public void saveStoreLocationsBulk(List stores) { + String key = RedisKey.STORE_LOCATIONS_KEY.getKey(); + + redisTemplate.executePipelined( + (org.springframework.data.redis.core.RedisCallback) + connection -> { + for (Store store : stores) { + if (store.getId() != null) { + connection + .geoCommands() + .geoAdd( + key.getBytes(), + new org.springframework.data.redis.connection.RedisGeoCommands + .GeoLocation<>( + store.getId().toString().getBytes(), + new org.springframework.data.geo.Point( + store.getLongitude(), store.getLatitude()))); + } + } + return null; + }); } } diff --git a/src/main/java/com/kkinikong/be/store/repository/store/StoreRepository.java b/src/main/java/com/kkinikong/be/store/repository/store/StoreRepository.java index 1b7280a..d568185 100644 --- a/src/main/java/com/kkinikong/be/store/repository/store/StoreRepository.java +++ b/src/main/java/com/kkinikong/be/store/repository/store/StoreRepository.java @@ -18,6 +18,9 @@ public interface StoreRepository extends JpaRepository, StoreReposi List findByRegion(String region); + @Query("SELECT s.id FROM Store s WHERE s.region = :region") + List findIdsByRegion(@Param("region") String region); + @Modifying(clearAutomatically = true) @Query("UPDATE Store s SET s.viewCount = s.viewCount + :count WHERE s.id = :storeId") void incrementViews(@Param("storeId") Long storeId, @Param("count") Long count); diff --git a/src/main/java/com/kkinikong/be/store/service/StoreUploadService.java b/src/main/java/com/kkinikong/be/store/service/StoreUploadService.java index c318de5..f99e951 100644 --- a/src/main/java/com/kkinikong/be/store/service/StoreUploadService.java +++ b/src/main/java/com/kkinikong/be/store/service/StoreUploadService.java @@ -18,11 +18,13 @@ import org.apache.commons.csv.CSVParser; import org.apache.commons.csv.CSVRecord; +import com.kkinikong.be.cache.service.RedisTemplateCacheService; import com.kkinikong.be.store.domain.Store; import com.kkinikong.be.store.domain.type.Category; import com.kkinikong.be.store.dto.response.StoreUploadResponse; import com.kkinikong.be.store.exception.StoreException; import com.kkinikong.be.store.exception.errorcode.StoreErrorCode; +import com.kkinikong.be.store.repository.store.StoreRepository; import com.kkinikong.be.store.repository.storeupload.StoreJdbcRepository; @Slf4j @@ -30,6 +32,8 @@ @RequiredArgsConstructor public class StoreUploadService { private final StoreJdbcRepository storeJdbcRepository; + private final StoreRepository storeRepository; + private final RedisTemplateCacheService redisTemplateCacheService; @Transactional public StoreUploadResponse upload(MultipartFile file) { @@ -40,12 +44,24 @@ public StoreUploadResponse upload(MultipartFile file) { // 파일의 첫 번째 데이터에서 지역 정보 추출 String targetRegion = newStores.get(0).getRegion(); + // 삭제될 기존 데이터의 ID 확보 + List oldStoreIds = storeRepository.findIdsByRegion(targetRegion); + // 기존 데이터는 유지하며 정보 갱신, 신규 데이터는 추가 storeJdbcRepository.upsertStores(newStores); // 새로운 파일에 없는 가맹점 삭제 storeJdbcRepository.deleteMissingStores(targetRegion); + // redis에 해당 지역의 기존 데이터 삭제 + redisTemplateCacheService.removeStoreLocationsBulk(oldStoreIds); + + // 최신화된 해당 지역 데이터 조회 + List updatedStores = storeRepository.findByRegion(targetRegion); + + // redis에 최신 데이터 저장 + redisTemplateCacheService.saveStoreLocationsBulk(updatedStores); + return new StoreUploadResponse(newStores.size(), newStores.size()); } From d4d26ca7d39b34c50101a281ff747091dd0bd21b Mon Sep 17 00:00:00 2001 From: erika0915 Date: Thu, 25 Dec 2025 13:10:43 +0900 Subject: [PATCH 3/4] =?UTF-8?q?[SCRUM-428]=20Feat:=20=EA=B0=80=EB=A7=B9?= =?UTF-8?q?=EC=A0=90=20=EA=B1=B0=EB=A6=AC=EC=88=9C=20=EC=A0=95=EB=A0=AC=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/store/StoreRepositoryCustomImpl.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/kkinikong/be/store/repository/store/StoreRepositoryCustomImpl.java b/src/main/java/com/kkinikong/be/store/repository/store/StoreRepositoryCustomImpl.java index 11335a4..8d69aa5 100644 --- a/src/main/java/com/kkinikong/be/store/repository/store/StoreRepositoryCustomImpl.java +++ b/src/main/java/com/kkinikong/be/store/repository/store/StoreRepositoryCustomImpl.java @@ -84,7 +84,14 @@ public Page findStoresByIdsForMap( whereBuilder.and(buildKeywordCondition(keyword)); } - OrderSpecifier[] sortOrder = new OrderSpecifier[] {store.id.asc()}; + String format = + "FIELD({0}, " + String.join(", ", ids.stream().map(String::valueOf).toList()) + ")"; + OrderSpecifier fieldOrder = + new OrderSpecifier<>( + com.querydsl.core.types.Order.ASC, + Expressions.numberTemplate(Integer.class, format, store.id)); + OrderSpecifier[] sortOrder = new OrderSpecifier[] {fieldOrder, store.id.asc()}; + List tuples = fetchStores(whereBuilder, sortOrder, pageable, userId); List storeList = convertTuplesToStores(tuples, userId); long total = fetchTotalCount(whereBuilder); From 7c77e2295b1ab115e76fb3796e30f7a272e453f8 Mon Sep 17 00:00:00 2001 From: erika0915 Date: Thu, 25 Dec 2025 16:03:28 +0900 Subject: [PATCH 4/4] =?UTF-8?q?[SCRUM-428]=20Feat:=20redis=EC=99=80=20mysq?= =?UTF-8?q?l=20db=EC=9D=98=20=EC=A0=95=ED=95=A9=EC=84=B1=EC=9D=84=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=94=EB=8A=94=20api=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EB=B0=8F=20=EC=8A=A4=EC=BC=80=EC=A5=B4=EB=9F=AC=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../be/cache/controller/CacheController.java | 13 ++++++ .../be/cache/service/ScheduledService.java | 6 +++ .../be/cache/service/StoreCacheService.java | 44 +++++++++++++++++++ 3 files changed, 63 insertions(+) create mode 100644 src/main/java/com/kkinikong/be/cache/service/StoreCacheService.java diff --git a/src/main/java/com/kkinikong/be/cache/controller/CacheController.java b/src/main/java/com/kkinikong/be/cache/controller/CacheController.java index 40ce15d..65a1dc6 100644 --- a/src/main/java/com/kkinikong/be/cache/controller/CacheController.java +++ b/src/main/java/com/kkinikong/be/cache/controller/CacheController.java @@ -3,6 +3,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -11,6 +12,7 @@ import lombok.RequiredArgsConstructor; import com.kkinikong.be.cache.service.CacheService; +import com.kkinikong.be.cache.service.StoreCacheService; import com.kkinikong.be.global.response.ApiResponse; import com.kkinikong.be.user.utils.CustomUserDetails; @@ -21,6 +23,7 @@ public class CacheController { private final CacheService cacheService; + private final StoreCacheService storeCacheService; @Operation(summary = "가맹점 외부 링크 캐시 초기화", description = "카카오 api를 통해 받은 모든 가맹점 외부 링크 캐시를 삭제합니다") @DeleteMapping("/store-id") @@ -35,4 +38,14 @@ public ResponseEntity> clearStoredOpeningHoursCache( @AuthenticationPrincipal CustomUserDetails userDetails) { return ResponseEntity.ok(ApiResponse.from(cacheService.clearStoredOpeningHoursCache())); } + + @Operation( + summary = "가맹점 위치 데이터 강제 동기화", + description = "MySQL의 모든 가맹점 위치 정보를 Redis Geo Index로 적재합니다.") + @PostMapping("/store-locations") + public ResponseEntity> syncStoreLocations( + @AuthenticationPrincipal CustomUserDetails userDetails) { + ; + return ResponseEntity.ok(ApiResponse.from(storeCacheService.syncStoreLocations())); + } } diff --git a/src/main/java/com/kkinikong/be/cache/service/ScheduledService.java b/src/main/java/com/kkinikong/be/cache/service/ScheduledService.java index f1da559..8e39a1d 100644 --- a/src/main/java/com/kkinikong/be/cache/service/ScheduledService.java +++ b/src/main/java/com/kkinikong/be/cache/service/ScheduledService.java @@ -20,6 +20,7 @@ public class ScheduledService { private final RedisTemplateCacheService redisTemplateCacheService; private final StoreRepository storeRepository; + private final StoreCacheService storeCacheService; private final CommunityPostRepository communityPostRepository; @Scheduled(cron = "0 0 * * * *") // 매시간 0분에 실행 @@ -59,4 +60,9 @@ public void syncCommunityPostViewCount() { redisTemplateCacheService.clearViewCounts(RedisKey.COMMUNITY_POST_VIEWS_KEY); } + + @Scheduled(cron = "0 0 3 * * *") + public void dailyLocationSync() { + storeCacheService.syncStoreLocations(); + } } diff --git a/src/main/java/com/kkinikong/be/cache/service/StoreCacheService.java b/src/main/java/com/kkinikong/be/cache/service/StoreCacheService.java new file mode 100644 index 0000000..f85cbb8 --- /dev/null +++ b/src/main/java/com/kkinikong/be/cache/service/StoreCacheService.java @@ -0,0 +1,44 @@ +package com.kkinikong.be.cache.service; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import com.kkinikong.be.store.domain.Store; +import com.kkinikong.be.store.repository.store.StoreRepository; + +@Slf4j +@Service +@RequiredArgsConstructor +public class StoreCacheService { + + private final StoreRepository storeRepository; + private final RedisTemplateCacheService redisTemplateCacheService; + + @Transactional(readOnly = true) + public String syncStoreLocations() { + log.info("가맹점 위치 정보 전수 동기화 시작..."); + + int pageSize = 1000; + int pageNumber = 0; + long totalCount = 0; + + while (true) { + Page storePage = storeRepository.findAll(PageRequest.of(pageNumber, pageSize)); + + if (storePage.isEmpty()) break; + + redisTemplateCacheService.saveStoreLocationsBulk(storePage.getContent()); + totalCount += storePage.getNumberOfElements(); + log.info("{}건 적재 완료...", totalCount); + + if (!storePage.hasNext()) break; + pageNumber++; + } + return "성공적으로 " + totalCount + " 건의 위치 데이터를 동기화했습니다."; + } +}