From d7a05343fc5c3dc53554b642cec9520de70d01bd Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Mon, 12 May 2025 04:50:39 +0900 Subject: [PATCH 1/5] =?UTF-8?q?refactor:=20=EA=B3=B5=EA=B3=B5=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EB=8F=99=EA=B8=B0=ED=99=94=20=EC=8A=A4?= =?UTF-8?q?=EC=BC=80=EC=A4=84=EB=9F=AC=20=EB=A7=A4=EC=9D=BC=20->=20?= =?UTF-8?q?=EB=A7=A4=EC=A3=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../publicdata/schedule/PublicServiceDataScheduler.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/schedule/PublicServiceDataScheduler.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/schedule/PublicServiceDataScheduler.java index e6e7990..5b02854 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/schedule/PublicServiceDataScheduler.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/schedule/PublicServiceDataScheduler.java @@ -15,13 +15,13 @@ public class PublicServiceDataScheduler { private final PublicServiceDataService publicServiceDataService; /** - * 매일 새벽 2시에 공공서비스 데이터 전체 동기화 실행 + * 매주 월요일 새벽 2시에 공공서비스 데이터 전체 동기화 실행 * 1. 서비스 목록 동기화 * 2. 서비스 상세정보 동기화 * 3. 서비스 지원조건 동기화 * 4. 더 이상 사용되지 않는 데이터 정리 */ - @Scheduled(cron = "0 0 2 * * ?") + @Scheduled(cron = "0 0 2 ? * MON") public void syncAllPublicServiceData() { log.info("공공서비스 데이터 전체 동기화 스케줄러 시작"); From 8eefbb7eddc9d20f5dadca6861ebfa60f5663673 Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Mon, 12 May 2025 23:00:31 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=EC=B5=9C=EA=B7=BC=20=EB=B0=A9?= =?UTF-8?q?=EB=AC=B8=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hyetaekon/common/config/CacheConfig.java | 2 +- .../schedule/PublicServiceDataScheduler.java | 5 + .../controller/RecentVisitController.java | 57 +++++++ .../entity/{mongodb => }/CacheType.java | 5 +- .../service/PublicServiceHandler.java | 3 + .../service/RecentVisitService.java | 154 ++++++++++++++++++ .../util/ServiceCacheManager.java | 31 ++++ 7 files changed, 254 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/RecentVisitController.java rename src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/{mongodb => }/CacheType.java (70%) create mode 100644 src/main/java/com/hyetaekon/hyetaekon/publicservice/service/RecentVisitService.java create mode 100644 src/main/java/com/hyetaekon/hyetaekon/publicservice/util/ServiceCacheManager.java diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/config/CacheConfig.java b/src/main/java/com/hyetaekon/hyetaekon/common/config/CacheConfig.java index be25a93..a82f679 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/config/CacheConfig.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/config/CacheConfig.java @@ -6,7 +6,7 @@ import org.springframework.cache.caffeine.CaffeineCacheManager; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import com.hyetaekon.hyetaekon.publicservice.entity.mongodb.CacheType; +import com.hyetaekon.hyetaekon.publicservice.entity.CacheType; import java.time.Duration; import java.util.Arrays; diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/schedule/PublicServiceDataScheduler.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/schedule/PublicServiceDataScheduler.java index 5b02854..2ae7aa7 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/schedule/PublicServiceDataScheduler.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/schedule/PublicServiceDataScheduler.java @@ -2,6 +2,7 @@ import com.hyetaekon.hyetaekon.common.publicdata.service.PublicServiceDataService; import com.hyetaekon.hyetaekon.common.publicdata.util.PublicDataPath; +import com.hyetaekon.hyetaekon.publicservice.util.ServiceCacheManager; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; @@ -13,6 +14,7 @@ public class PublicServiceDataScheduler { private final PublicServiceDataService publicServiceDataService; + private final ServiceCacheManager serviceCacheManager; /** * 매주 월요일 새벽 2시에 공공서비스 데이터 전체 동기화 실행 @@ -43,6 +45,9 @@ public void syncAllPublicServiceData() { int deletedCount = publicServiceDataService.cleanupObsoleteServices(); log.info("미사용 공공서비스 데이터 {}건 삭제 완료", deletedCount); + // 5. 동기화 완료 후 캐시 초기화 + serviceCacheManager.clearAllServiceCaches(); + log.info("공공서비스 데이터 전체 동기화 완료"); } catch (Exception e) { log.error("공공서비스 데이터 동기화 중 오류 발생", e); diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/RecentVisitController.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/RecentVisitController.java new file mode 100644 index 0000000..4c87d07 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/RecentVisitController.java @@ -0,0 +1,57 @@ +package com.hyetaekon.hyetaekon.publicservice.controller; + +import com.hyetaekon.hyetaekon.common.jwt.CustomUserDetails; +import com.hyetaekon.hyetaekon.publicservice.dto.PublicServiceListResponseDto; +import com.hyetaekon.hyetaekon.publicservice.service.RecentVisitService; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Positive; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@Validated +@RestController +@RequestMapping("/api/services/recent") +@RequiredArgsConstructor +public class RecentVisitController { + + private final RecentVisitService recentVisitService; + + /** + * 최근 방문한 서비스 목록 조회 + */ + @GetMapping + public ResponseEntity> getRecentVisits( + @RequestParam(name = "page", defaultValue = "0") @Min(0) int page, + @RequestParam(name = "size", defaultValue = "3") @Positive @Max(10) int size, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + Long userId = userDetails != null ? userDetails.getId() : 0L; + + // 비로그인 사용자는 빈 목록 반환 + if (userId == 0L) { + return ResponseEntity.ok(Page.empty(PageRequest.of(page, size))); + } + + Page recentServices = recentVisitService.getRecentVisits(userId, page, size); + return ResponseEntity.ok(recentServices); + } + + /** + * 방문 기록 전체 삭제 + */ + @DeleteMapping + public ResponseEntity clearRecentVisits( + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + if (userDetails != null) { + recentVisitService.clearUserVisits(userDetails.getId()); + } + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/mongodb/CacheType.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/CacheType.java similarity index 70% rename from src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/mongodb/CacheType.java rename to src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/CacheType.java index bfb6a2a..e09d57d 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/mongodb/CacheType.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/CacheType.java @@ -1,4 +1,4 @@ -package com.hyetaekon.hyetaekon.publicservice.entity.mongodb; +package com.hyetaekon.hyetaekon.publicservice.entity; import lombok.Getter; import lombok.RequiredArgsConstructor; @@ -8,7 +8,8 @@ public enum CacheType { SERVICE_AUTOCOMPLETE("serviceAutocomplete", 2, 1200), // 자동완성 캐시 FILTER_OPTIONS("filterOptions", 24, 50), // 필터 옵션 캐시 (하루 유지) - MATCHED_SERVICES("matchedServices", 2, 100); // 맞춤 서비스 캐시 + MATCHED_SERVICES("matchedServices", 2, 100), // 맞춤 서비스 캐시 + SERVICE_BASIC_INFO("serviceBasicInfo", 12, 1000); // 서비스 기본 정보 캐시 private final String cacheName; private final int expiredAfterWrite; // 시간(hour) 단위 diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/PublicServiceHandler.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/PublicServiceHandler.java index 3f4eaf2..f306a02 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/PublicServiceHandler.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/PublicServiceHandler.java @@ -34,6 +34,7 @@ public class PublicServiceHandler { private final PublicServiceMapper publicServiceMapper; private final PublicServiceValidate publicServiceValidate; private final BookmarkRepository bookmarkRepository; + private final RecentVisitService recentVisitService; // 서비스분야별 서비스목록 조회(페이지) /*public Page getServicesByCategory(ServiceCategory category, Pageable pageable, Long userId) { @@ -67,6 +68,8 @@ public PublicServiceDetailResponseDto getServiceDetail(String serviceId, Long us service.updateViewsUp(); publicServiceRepository.save(service); + recentVisitService.addVisit(userId, serviceId); + PublicServiceDetailResponseDto dto = publicServiceMapper.toDetailDto(service); if (userId != 0L) { diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/RecentVisitService.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/RecentVisitService.java new file mode 100644 index 0000000..79b3635 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/RecentVisitService.java @@ -0,0 +1,154 @@ +package com.hyetaekon.hyetaekon.publicservice.service; + +import com.hyetaekon.hyetaekon.bookmark.repository.BookmarkRepository; +import com.hyetaekon.hyetaekon.bookmark.service.BookmarkService; +import com.hyetaekon.hyetaekon.publicservice.dto.PublicServiceListResponseDto; +import com.hyetaekon.hyetaekon.publicservice.entity.PublicService; +import com.hyetaekon.hyetaekon.publicservice.mapper.PublicServiceMapper; +import com.hyetaekon.hyetaekon.publicservice.repository.PublicServiceRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class RecentVisitService { + + private final RedisTemplate redisTemplate; + private final PublicServiceRepository publicServiceRepository; + private final PublicServiceMapper publicServiceMapper; + private final BookmarkRepository bookmarkRepository; + + // Redis 키 형식: "user:{userId}:recentServices" + private static final String KEY_PREFIX = "user:"; + private static final String KEY_SUFFIX = ":recentServices"; + + // 설정값 + private static final int MAX_ITEMS = 10; // 최대 저장 개수 + private static final int TTL_DAYS = 30; // 데이터 유지 기간(일) + + /** + * 사용자의 서비스 방문 기록 추가 + */ + public void addVisit(Long userId, String serviceId) { + if (userId == null || userId == 0 || serviceId == null) { + return; // 비로그인 사용자나 유효하지 않은 ID는 처리하지 않음 + } + + String key = generateKey(userId); + + // 이미 존재하는 경우 삭제 (중복 제거) + redisTemplate.opsForList().remove(key, 0, serviceId); + + // 맨 앞에 새로 추가 (최신 방문) + redisTemplate.opsForList().leftPush(key, serviceId); + + // 최대 10개로 제한 + redisTemplate.opsForList().trim(key, 0, MAX_ITEMS - 1); + + // TTL 설정 (30일) + redisTemplate.expire(key, TTL_DAYS, TimeUnit.DAYS); + + log.debug("사용자 {} - 최근 방문 서비스 추가: {}", userId, serviceId); + } + + /** + * 최근 방문 서비스 목록 조회 (페이징) + * - 서비스 ID 목록: Redis에서 조회 + * - 서비스 기본 정보: Caffeine 캐시 활용 (변경이 적은 정보) + * - 북마크 상태: 직접 DB 조회 (자주 변경되는 정보) + */ + public Page getRecentVisits(Long userId, int page, int size) { + String key = generateKey(userId); + + // 전체 개수 확인 + Long total = redisTemplate.opsForList().size(key); + if (total == null || total == 0) { + return Page.empty(PageRequest.of(page, size)); + } + + // 현재 페이지에 해당하는 범위 계산 + int start = page * size; + int end = start + size - 1; + + // Redis에서 ID 목록 조회 + List serviceIds = redisTemplate.opsForList().range(key, start, end); + if (serviceIds == null || serviceIds.isEmpty()) { + return Page.empty(PageRequest.of(page, size)); + } + + // 서비스 정보 조회 (ID별로 분리하여 처리) + List services = new ArrayList<>(); + for (String id : serviceIds) { + // 기본 정보는 캐시 활용 (캐시 실패 시 직접 조회) + PublicService service = getServiceBasicInfo(id); + if (service != null) { + services.add(service); + } + } + + // DTO 변환 및 북마크 정보 설정 + List dtoList = services.stream() + .map(service -> { + // 기본 정보 변환 + PublicServiceListResponseDto dto = publicServiceMapper.toListDto(service); + + // 북마크 상태는 항상 직접 조회 (자주 변경되는 정보) + if (userId != 0L) { + dto.setBookmarked(isBookmarked(userId, service.getId())); + } + + return dto; + }) + .collect(Collectors.toList()); + + // 페이지 객체 생성 및 반환 + return new PageImpl<>(dtoList, PageRequest.of(page, size), total); + } + + /** + * 서비스 기본 정보 조회 (캐시 적용) + * - 서비스 이름, 설명 등 자주 변경되지 않는 정보에 캐시 적용 + */ + @Cacheable(value = "serviceBasicInfo", key = "#serviceId", unless = "#result == null") + public PublicService getServiceBasicInfo(String serviceId) { + log.debug("Cache miss: 서비스 기본 정보 DB 조회 - ID: {}", serviceId); + return publicServiceRepository.findById(serviceId).orElse(null); + } + + /** + * 북마크 상태 조회 (항상 직접 DB 조회) + * - 자주 변경될 수 있어 캐시 미적용 + */ + public boolean isBookmarked(Long userId, String serviceId) { + return bookmarkRepository.existsByUserIdAndPublicServiceId(userId, serviceId); + } + + /** + * 특정 사용자의 전체 방문 기록 삭제 + */ + public void clearUserVisits(Long userId) { + String key = generateKey(userId); + redisTemplate.delete(key); + log.debug("사용자 {} - 방문 기록 전체 삭제", userId); + } + + /** + * Redis 키 생성 + */ + private String generateKey(Long userId) { + return KEY_PREFIX + userId + KEY_SUFFIX; + } +} \ No newline at end of file diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/util/ServiceCacheManager.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/util/ServiceCacheManager.java new file mode 100644 index 0000000..4e00b88 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/util/ServiceCacheManager.java @@ -0,0 +1,31 @@ +package com.hyetaekon.hyetaekon.publicservice.util; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ServiceCacheManager { + + private final CacheManager cacheManager; + + /** + * 특정 서비스의 기본 정보 캐시 삭제 + */ + @CacheEvict(value = "serviceBasicInfo", key = "#serviceId") + public void evictServiceBasicInfoCache(String serviceId) { + log.debug("서비스 기본 정보 캐시 삭제: {}", serviceId); + } + + /** + * 서비스 데이터 동기화 시 캐시 일괄 삭제 + */ + public void clearAllServiceCaches() { + cacheManager.getCache("serviceBasicInfo").clear(); + log.info("모든 서비스 기본 정보 캐시 삭제 완료"); + } +} From 89162c2bc49c68e71bffc2122765337f86c90b49 Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Mon, 12 May 2025 23:00:57 +0900 Subject: [PATCH 3/5] =?UTF-8?q?refactor:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EA=B8=B0=EB=A1=9D=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?redis=20=EC=A7=81=EC=A0=91=20=EC=82=AD=EC=A0=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Service/SearchHistoryService.java | 54 +++++++++++++------ 1 file changed, 38 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/hyetaekon/hyetaekon/searchHistory/Service/SearchHistoryService.java b/src/main/java/com/hyetaekon/hyetaekon/searchHistory/Service/SearchHistoryService.java index 130b628..d4951d7 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/searchHistory/Service/SearchHistoryService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/searchHistory/Service/SearchHistoryService.java @@ -5,12 +5,14 @@ import com.hyetaekon.hyetaekon.searchHistory.entity.SearchHistory; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import java.util.List; import java.util.Comparator; +import java.util.Set; import java.util.stream.Collectors; @Slf4j @@ -18,8 +20,11 @@ @RequiredArgsConstructor public class SearchHistoryService { private final SearchHistoryRepository searchHistoryRepository; + private final RedisTemplate redisTemplate; private static final int MAX_DISPLAY_COUNT = 6; + private static final long TTL_DAYS = 60; // 데이터 유지 기간(일) + /** * 검색 기록 저장 @@ -31,15 +36,7 @@ public void saveSearchHistory(Long userId, String searchTerm) { return; } - // 검색어 중복 제거를 위한 기존 검색 기록 확인 - List existingHistories = searchHistoryRepository.findByUserId(userId); - - // 이미 동일한 검색어가 있으면 삭제 - existingHistories.stream() - .filter(history -> history.getSearchTerm().equals(searchTerm)) - .forEach(history -> searchHistoryRepository.deleteById(history.getId())); - - // 새로운 검색 기록 저장 + // 새로운 검색 기록 생성 및 저장 SearchHistory newHistory = SearchHistory.of(userId, searchTerm); searchHistoryRepository.save(newHistory); log.debug("사용자 {} 검색 기록 저장: {}", userId, searchTerm); @@ -61,16 +58,28 @@ public List getUserSearchHistories(Long userId) { } /** - * 개별 검색 기록 삭제 + * 개별 검색 기록 삭제 - Redis 직접 접근 */ @Transactional public void deleteSearchHistory(Long userId, String historyId) { if (!StringUtils.hasText(historyId)) { return; } - // 사용자의 검색 기록만 삭제하기 위해 사용자 ID도 함께 확인 - searchHistoryRepository.deleteByUserIdAndId(userId, historyId); - log.debug("사용자 {} 검색 기록 삭제: {}", userId, historyId); + + // historyId가 올바른 형식인지 검증 (userId:timestamp 형식) + if (!historyId.startsWith(userId + ":")) { + log.warn("잘못된 historyId 형식 또는 권한 없음: {}", historyId); + return; + } + + // Redis에서 직접 키 삭제 + boolean deleted = Boolean.TRUE.equals(redisTemplate.delete(historyId)); + + if (deleted) { + log.debug("사용자 {} 검색 기록 삭제 성공: {}", userId, historyId); + } else { + log.warn("사용자 {} 검색 기록 삭제 실패: {}", userId, historyId); + } } /** @@ -78,8 +87,21 @@ public void deleteSearchHistory(Long userId, String historyId) { */ @Transactional public void deleteAllSearchHistories(Long userId) { - List histories = searchHistoryRepository.findByUserId(userId); - searchHistoryRepository.deleteAll(histories); - log.debug("사용자 {} 검색 기록 전체 삭제", userId); + // userId로 시작하는 모든 키 패턴 조회 + String keyPattern = userId + ":*"; + try { + // 패턴에 일치하는 모든 키 조회 + Set keys = redisTemplate.keys(keyPattern); + + if (keys != null && !keys.isEmpty()) { + // 모든 키 삭제 + redisTemplate.delete(keys); + log.debug("사용자 {} 검색 기록 전체 삭제 완료: {}개", userId, keys.size()); + } else { + log.debug("사용자 {} 삭제할 검색 기록 없음", userId); + } + } catch (Exception e) { + log.error("사용자 {} 검색 기록 전체 삭제 중 오류 발생", userId, e); + } } } From ba3ae54224805f520fe4654e25b52383739c0371 Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Mon, 12 May 2025 23:03:56 +0900 Subject: [PATCH 4/5] =?UTF-8?q?style:=20PathVariable=EB=AA=85=EC=B9=AD=20?= =?UTF-8?q?=EC=88=98=EC=A0=95,=20=EC=95=88=EC=93=B0=EB=8A=94=20import=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hyetaekon/publicservice/service/RecentVisitService.java | 2 -- .../searchHistory/controller/SearchHistoryController.java | 5 +++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/RecentVisitService.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/RecentVisitService.java index 79b3635..fe8d47b 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/RecentVisitService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/RecentVisitService.java @@ -1,7 +1,6 @@ package com.hyetaekon.hyetaekon.publicservice.service; import com.hyetaekon.hyetaekon.bookmark.repository.BookmarkRepository; -import com.hyetaekon.hyetaekon.bookmark.service.BookmarkService; import com.hyetaekon.hyetaekon.publicservice.dto.PublicServiceListResponseDto; import com.hyetaekon.hyetaekon.publicservice.entity.PublicService; import com.hyetaekon.hyetaekon.publicservice.mapper.PublicServiceMapper; @@ -16,7 +15,6 @@ import org.springframework.stereotype.Service; import java.util.ArrayList; -import java.util.Collections; import java.util.List; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; diff --git a/src/main/java/com/hyetaekon/hyetaekon/searchHistory/controller/SearchHistoryController.java b/src/main/java/com/hyetaekon/hyetaekon/searchHistory/controller/SearchHistoryController.java index c2d9c86..4c0be68 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/searchHistory/controller/SearchHistoryController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/searchHistory/controller/SearchHistoryController.java @@ -30,8 +30,9 @@ public ResponseEntity> getSearchHistories( * 특정 검색 기록 삭제 */ @DeleteMapping("/{historyId}") - public ResponseEntity deleteSearchHistory(@PathVariable String historyId - , @AuthenticationPrincipal CustomUserDetails userDetails) { + public ResponseEntity deleteSearchHistory( + @PathVariable("historyId") String historyId, + @AuthenticationPrincipal CustomUserDetails userDetails) { searchHistoryService.deleteSearchHistory(userDetails.getId(), historyId); return ResponseEntity.ok().build(); } From f97ce08ef9e84c253e12914ddc1d4b8ff5cfaef8 Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Tue, 13 May 2025 01:01:27 +0900 Subject: [PATCH 5/5] =?UTF-8?q?refactor:=20id=20=ED=95=B4=EC=8B=9C?= =?UTF-8?q?=ED=99=94=20=EA=B8=B0=EB=B0=98=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EA=B8=B0=EB=A1=9D=20=EC=A0=80=EC=9E=A5,?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C,=20=EC=82=AD=EC=A0=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Service/SearchHistoryService.java | 177 ++++++++++++------ 1 file changed, 122 insertions(+), 55 deletions(-) diff --git a/src/main/java/com/hyetaekon/hyetaekon/searchHistory/Service/SearchHistoryService.java b/src/main/java/com/hyetaekon/hyetaekon/searchHistory/Service/SearchHistoryService.java index d4951d7..b96c4b5 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/searchHistory/Service/SearchHistoryService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/searchHistory/Service/SearchHistoryService.java @@ -1,107 +1,174 @@ package com.hyetaekon.hyetaekon.searchHistory.Service; import com.hyetaekon.hyetaekon.searchHistory.Dto.SearchHistoryDto; -import com.hyetaekon.hyetaekon.searchHistory.Repository.SearchHistoryRepository; -import com.hyetaekon.hyetaekon.searchHistory.entity.SearchHistory; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.ListOperations; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.util.StringUtils; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; import java.util.List; -import java.util.Comparator; -import java.util.Set; -import java.util.stream.Collectors; +import java.util.concurrent.TimeUnit; @Slf4j @Service @RequiredArgsConstructor public class SearchHistoryService { - private final SearchHistoryRepository searchHistoryRepository; - private final RedisTemplate redisTemplate; - private static final int MAX_DISPLAY_COUNT = 6; - private static final long TTL_DAYS = 60; // 데이터 유지 기간(일) + private final RedisTemplate redisTemplate; + // Redis 키 관련 상수 + private static final String KEY_PREFIX = "searchHistory:"; + private static final int MAX_HISTORY_COUNT = 6; + private static final int TTL_DAYS = 60; /** * 검색 기록 저장 */ - @Transactional public void saveSearchHistory(Long userId, String searchTerm) { - // 검색어가 없거나 빈 문자열이면 저장하지 않음 - if (!StringUtils.hasText(searchTerm) || userId == 0L) { + if (userId == null || userId == 0L || searchTerm == null || searchTerm.trim().isEmpty()) { + log.debug("검색 기록 저장 건너뜀: 유효하지 않은 매개변수"); return; } - // 새로운 검색 기록 생성 및 저장 - SearchHistory newHistory = SearchHistory.of(userId, searchTerm); - searchHistoryRepository.save(newHistory); - log.debug("사용자 {} 검색 기록 저장: {}", userId, searchTerm); + try { + String key = generateKey(userId); + ListOperations listOps = redisTemplate.opsForList(); + + // 중복 검색어 제거 + listOps.remove(key, 0, searchTerm); + + // 새 검색어 추가 (왼쪽에서부터 = 최신 순) + listOps.leftPush(key, searchTerm); + + // 최대 6개로 제한 + listOps.trim(key, 0, MAX_HISTORY_COUNT - 1); + + // 만료 시간 설정 + redisTemplate.expire(key, TTL_DAYS, TimeUnit.DAYS); + + log.debug("사용자 {} 검색 기록 저장 완료: {}", userId, searchTerm); + } catch (Exception e) { + log.error("사용자 {} 검색 기록 저장 중 오류 발생: {}", userId, e.getMessage(), e); + } } /** - * 사용자의 검색 기록 조회 (최신 6개) + * 사용자 검색 기록 조회 */ - @Transactional(readOnly = true) public List getUserSearchHistories(Long userId) { - List histories = searchHistoryRepository.findByUserId(userId); - - // 최신 순으로 정렬하여 최대 6개 반환 - return histories.stream() - .sorted(Comparator.comparing(SearchHistory::getCreatedAt).reversed()) - .limit(MAX_DISPLAY_COUNT) - .map(SearchHistoryDto::from) - .collect(Collectors.toList()); + if (userId == null || userId == 0L) { + return new ArrayList<>(); + } + + try { + String key = generateKey(userId); + ListOperations listOps = redisTemplate.opsForList(); + + // 전체 검색 기록 조회 (최신순) + List searchTerms = listOps.range(key, 0, -1); + if (searchTerms == null || searchTerms.isEmpty()) { + return new ArrayList<>(); + } + + // DTO로 변환 (ID는 검색어의 해시값 사용) + List result = new ArrayList<>(); + for (int i = 0; i < searchTerms.size(); i++) { + String searchTerm = searchTerms.get(i); + // 고유 ID 생성: 검색어 자체의 해시코드 사용 (인덱스 추가로 동일 검색어 구분) + String id = String.valueOf(userId + "_" + i + "_" + searchTerm.hashCode()); + + result.add(SearchHistoryDto.builder() + .id(id) + .searchTerm(searchTerm) + .createdAt(LocalDateTime.now().minusMinutes(i).format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))) + .build()); + } + + return result; + } catch (Exception e) { + log.error("사용자 {} 검색 기록 조회 중 오류 발생: {}", userId, e.getMessage(), e); + return new ArrayList<>(); + } } /** - * 개별 검색 기록 삭제 - Redis 직접 접근 + * 특정 검색 기록 삭제 (인덱스 기반) */ - @Transactional public void deleteSearchHistory(Long userId, String historyId) { - if (!StringUtils.hasText(historyId)) { + if (userId == null || userId == 0L || historyId == null) { return; } - // historyId가 올바른 형식인지 검증 (userId:timestamp 형식) - if (!historyId.startsWith(userId + ":")) { - log.warn("잘못된 historyId 형식 또는 권한 없음: {}", historyId); - return; - } + try { + String key = generateKey(userId); + + // 현재 검색어 목록 가져오기 + List searchTerms = redisTemplate.opsForList().range(key, 0, -1); + if (searchTerms == null || searchTerms.isEmpty()) { + return; + } - // Redis에서 직접 키 삭제 - boolean deleted = Boolean.TRUE.equals(redisTemplate.delete(historyId)); + // ID 파싱하여 인덱스와 해시코드 추출 + String[] parts = historyId.split("_"); + if (parts.length < 3) { + log.warn("잘못된 히스토리 ID 형식: {}", historyId); + return; + } + + int index; + try { + index = Integer.parseInt(parts[1]); + } catch (NumberFormatException e) { + log.warn("히스토리 ID에서 인덱스 파싱 실패: {}", historyId); + return; + } + + // 인덱스 유효성 검사 + if (index < 0 || index >= searchTerms.size()) { + log.warn("유효하지 않은 인덱스: {}", index); + return; + } - if (deleted) { - log.debug("사용자 {} 검색 기록 삭제 성공: {}", userId, historyId); - } else { - log.warn("사용자 {} 검색 기록 삭제 실패: {}", userId, historyId); + // 해당 인덱스의 검색어 삭제 + String searchTerm = searchTerms.get(index); + redisTemplate.opsForList().remove(key, 0, searchTerm); + + log.debug("사용자 {} 검색 기록 삭제 완료, 검색어: {}", userId, searchTerm); + } catch (Exception e) { + log.error("사용자 {} 검색 기록 삭제 중 오류 발생: {}", userId, e.getMessage(), e); } } /** - * 사용자의 모든 검색 기록 삭제 + * 사용자 검색 기록 전체 삭제 */ - @Transactional public void deleteAllSearchHistories(Long userId) { - // userId로 시작하는 모든 키 패턴 조회 - String keyPattern = userId + ":*"; + if (userId == null || userId == 0L) { + return; + } + try { - // 패턴에 일치하는 모든 키 조회 - Set keys = redisTemplate.keys(keyPattern); + String key = generateKey(userId); + Boolean deleted = redisTemplate.delete(key); - if (keys != null && !keys.isEmpty()) { - // 모든 키 삭제 - redisTemplate.delete(keys); - log.debug("사용자 {} 검색 기록 전체 삭제 완료: {}개", userId, keys.size()); + if (Boolean.TRUE.equals(deleted)) { + log.debug("사용자 {} 검색 기록 전체 삭제 완료", userId); } else { - log.debug("사용자 {} 삭제할 검색 기록 없음", userId); + log.warn("사용자 {} 검색 기록 전체 삭제 실패: 키가 존재하지 않음", userId); } } catch (Exception e) { - log.error("사용자 {} 검색 기록 전체 삭제 중 오류 발생", userId, e); + log.error("사용자 {} 검색 기록 전체 삭제 중 오류 발생: {}", userId, e.getMessage(), e); } } -} + + /** + * 사용자별 Redis 키 생성 + */ + private String generateKey(Long userId) { + return KEY_PREFIX + userId; + } +} \ No newline at end of file