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/RedisTemplateCacheService.java b/src/main/java/com/kkinikong/be/cache/service/RedisTemplateCacheService.java index 9fc9c17..a8e4319 100644 --- a/src/main/java/com/kkinikong/be/cache/service/RedisTemplateCacheService.java +++ b/src/main/java/com/kkinikong/be/cache/service/RedisTemplateCacheService.java @@ -4,12 +4,18 @@ 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.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; import com.kkinikong.be.cache.type.RedisKey; +import com.kkinikong.be.store.domain.Store; @Service @Slf4j @@ -64,4 +70,58 @@ private static String generateRecentSearchKey(long userId) { String key = RedisKey.RECENT_SEARCHES_KEY.getKey() + ":" + userId; return key; } + + // 주변 가맹점 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(); + } + + // 여러 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/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 + " 건의 위치 데이터를 동기화했습니다."; + } +} 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/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/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..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 @@ -69,6 +69,36 @@ 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)); + } + + 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); + + 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); } 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()); }