Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;

Expand All @@ -21,6 +23,7 @@
public class CacheController {

private final CacheService cacheService;
private final StoreCacheService storeCacheService;

@Operation(summary = "가맹점 외부 링크 캐시 초기화", description = "카카오 api를 통해 받은 모든 가맹점 외부 링크 캐시를 삭제합니다")
@DeleteMapping("/store-id")
Expand All @@ -35,4 +38,14 @@ public ResponseEntity<ApiResponse<Object>> clearStoredOpeningHoursCache(
@AuthenticationPrincipal CustomUserDetails userDetails) {
return ResponseEntity.ok(ApiResponse.from(cacheService.clearStoredOpeningHoursCache()));
}

@Operation(
summary = "가맹점 위치 데이터 강제 동기화",
description = "MySQL의 모든 가맹점 위치 정보를 Redis Geo Index로 적재합니다.")
@PostMapping("/store-locations")
public ResponseEntity<ApiResponse<Object>> syncStoreLocations(
@AuthenticationPrincipal CustomUserDetails userDetails) {
;
return ResponseEntity.ok(ApiResponse.from(storeCacheService.syncStoreLocations()));
}
Comment on lines +41 to +50
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 때 중복저장 등의 문제는 없는지 궁금해용~

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorted Set은 동일한 id를 중복해서 가질 수 없대 ! 동일한 id로 데이터를 넣으려고 하면, 덮어쓰기 작업이 일어날 뿐 데이터가 중복되어 늘어나지 않는다고 합니당 !

}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -64,4 +70,58 @@ private static String generateRecentSearchKey(long userId) {
String key = RedisKey.RECENT_SEARCHES_KEY.getKey() + ":" + userId;
return key;
}

// 주변 가맹점 ID 리스트 조회
public List<Long> findNearbyStoreIds(Double latitude, Double longitude, Double radiusMeters) {
String key = RedisKey.STORE_LOCATIONS_KEY.getKey();

GeoResults<RedisGeoCommands.GeoLocation<Object>> 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<Long> 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<Store> stores) {
String key = RedisKey.STORE_LOCATIONS_KEY.getKey();

redisTemplate.executePipelined(
(org.springframework.data.redis.core.RedisCallback<Object>)
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;
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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분에 실행
Expand Down Expand Up @@ -59,4 +60,9 @@ public void syncCommunityPostViewCount() {

redisTemplateCacheService.clearViewCounts(RedisKey.COMMUNITY_POST_VIEWS_KEY);
}

@Scheduled(cron = "0 0 3 * * *")
public void dailyLocationSync() {
storeCacheService.syncStoreLocations();
}
}
Original file line number Diff line number Diff line change
@@ -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<Store> 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 + " 건의 위치 데이터를 동기화했습니다.";
}
}
4 changes: 3 additions & 1 deletion src/main/java/com/kkinikong/be/cache/type/RedisKey.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ public interface StoreRepository extends JpaRepository<Store, Long>, StoreReposi

List<Store> findByRegion(String region);

@Query("SELECT s.id FROM Store s WHERE s.region = :region")
List<Long> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,7 @@ Page<Store> findStoresUnified(
Long userId);

List<Store> findTopViewedStores(Double latitude, Double longitude);

Page<Store> findStoresByIdsForMap(
List<Long> ids, String keyword, Category category, Pageable pageable, Long usedId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,36 @@ public List<Store> findTopViewedStores(Double latitude, Double longitude) {
return queryFactory.selectFrom(store).where(whereBuilder).orderBy(orderBy).limit(8).fetch();
}

@Override
public Page<Store> findStoresByIdsForMap(
List<Long> 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<Tuple> tuples = fetchStores(whereBuilder, sortOrder, pageable, userId);
List<Store> storeList = convertTuplesToStores(tuples, userId);
long total = fetchTotalCount(whereBuilder);

return new PageImpl<>(storeList, pageable, total);
}

// 키워드 검색 조건 생성
private BooleanBuilder buildKeywordCondition(String keyword) {
String normalizedKeyword = keyword.replaceAll("\\s+", "");
Expand Down
16 changes: 11 additions & 5 deletions src/main/java/com/kkinikong/be/store/service/StoreService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<StoreListItemResponse> getStoreList(
Double latitude,
Double longitude,
Expand All @@ -78,24 +79,29 @@ public PageResponse<StoreListItemResponse> getStoreList(
storePage, store -> StoreListItemResponse.from(store, tagMap.get(store.getId())));
}

/// 가맹점 지도 조회
// 가맹점 지도 조회
public PageResponse<StoreMapListItemResponse> 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<Long> nearbyIds =
redisTemplateCacheService.findNearbyStoreIds(latitude, longitude, radius);

Page<Store> 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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,22 @@
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
@Service
@RequiredArgsConstructor
public class StoreUploadService {
private final StoreJdbcRepository storeJdbcRepository;
private final StoreRepository storeRepository;
private final RedisTemplateCacheService redisTemplateCacheService;

@Transactional
public StoreUploadResponse upload(MultipartFile file) {
Expand All @@ -40,12 +44,24 @@ public StoreUploadResponse upload(MultipartFile file) {
// 파일의 첫 번째 데이터에서 지역 정보 추출
String targetRegion = newStores.get(0).getRegion();

// 삭제될 기존 데이터의 ID 확보
List<Long> oldStoreIds = storeRepository.findIdsByRegion(targetRegion);

// 기존 데이터는 유지하며 정보 갱신, 신규 데이터는 추가
storeJdbcRepository.upsertStores(newStores);

// 새로운 파일에 없는 가맹점 삭제
storeJdbcRepository.deleteMissingStores(targetRegion);

// redis에 해당 지역의 기존 데이터 삭제
redisTemplateCacheService.removeStoreLocationsBulk(oldStoreIds);

// 최신화된 해당 지역 데이터 조회
List<Store> updatedStores = storeRepository.findByRegion(targetRegion);

// redis에 최신 데이터 저장
redisTemplateCacheService.saveStoreLocationsBulk(updatedStores);

Comment on lines +47 to +64
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

새로운 csv파일을 업로드 할 때 해당 지역의 기존 redis에 있던 데이터는 전부 삭제하고 새로운 csv 파일의 데이터를 넣는거라구 보면 될까??! 어차피 csv 파일 올리는건 횟수가 적으니까 상관없다고 생각하는뎅 그냥 궁금한점이야!!!ㅎㅎ

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

맞아 ! 우리 csv파일이 '지역'을 기준으로 되어있어서, 그 '지역'에 해당하는 데이터를 다 지우고 다시 새로 업로드 하는 방식이야 !

return new StoreUploadResponse(newStores.size(), newStores.size());
}

Expand Down