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 e6e7990..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,15 +14,16 @@ public class PublicServiceDataScheduler { private final PublicServiceDataService publicServiceDataService; + private final ServiceCacheManager serviceCacheManager; /** - * 매일 새벽 2시에 공공서비스 데이터 전체 동기화 실행 + * 매주 월요일 새벽 2시에 공공서비스 데이터 전체 동기화 실행 * 1. 서비스 목록 동기화 * 2. 서비스 상세정보 동기화 * 3. 서비스 지원조건 동기화 * 4. 더 이상 사용되지 않는 데이터 정리 */ - @Scheduled(cron = "0 0 2 * * ?") + @Scheduled(cron = "0 0 2 ? * MON") public void syncAllPublicServiceData() { log.info("공공서비스 데이터 전체 동기화 스케줄러 시작"); @@ -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..fe8d47b --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/RecentVisitService.java @@ -0,0 +1,152 @@ +package com.hyetaekon.hyetaekon.publicservice.service; + +import com.hyetaekon.hyetaekon.bookmark.repository.BookmarkRepository; +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.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("모든 서비스 기본 정보 캐시 삭제 완료"); + } +} 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..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,85 +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.stream.Collectors; +import java.util.concurrent.TimeUnit; @Slf4j @Service @RequiredArgsConstructor public class SearchHistoryService { - private final SearchHistoryRepository searchHistoryRepository; - private static final int MAX_DISPLAY_COUNT = 6; + 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; } - // 검색어 중복 제거를 위한 기존 검색 기록 확인 - List existingHistories = searchHistoryRepository.findByUserId(userId); + 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); - // 이미 동일한 검색어가 있으면 삭제 - existingHistories.stream() - .filter(history -> history.getSearchTerm().equals(searchTerm)) - .forEach(history -> searchHistoryRepository.deleteById(history.getId())); + // 만료 시간 설정 + redisTemplate.expire(key, TTL_DAYS, TimeUnit.DAYS); - // 새로운 검색 기록 저장 - SearchHistory newHistory = SearchHistory.of(userId, searchTerm); - searchHistoryRepository.save(newHistory); - log.debug("사용자 {} 검색 기록 저장: {}", userId, searchTerm); + 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<>(); + } } /** - * 개별 검색 기록 삭제 + * 특정 검색 기록 삭제 (인덱스 기반) */ - @Transactional public void deleteSearchHistory(Long userId, String historyId) { - if (!StringUtils.hasText(historyId)) { + if (userId == null || userId == 0L || historyId == null) { return; } - // 사용자의 검색 기록만 삭제하기 위해 사용자 ID도 함께 확인 - searchHistoryRepository.deleteByUserIdAndId(userId, historyId); - log.debug("사용자 {} 검색 기록 삭제: {}", userId, historyId); + + try { + String key = generateKey(userId); + + // 현재 검색어 목록 가져오기 + List searchTerms = redisTemplate.opsForList().range(key, 0, -1); + if (searchTerms == null || searchTerms.isEmpty()) { + return; + } + + // 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; + } + + // 해당 인덱스의 검색어 삭제 + 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) { - List histories = searchHistoryRepository.findByUserId(userId); - searchHistoryRepository.deleteAll(histories); - log.debug("사용자 {} 검색 기록 전체 삭제", userId); + if (userId == null || userId == 0L) { + return; + } + + try { + String key = generateKey(userId); + Boolean deleted = redisTemplate.delete(key); + + if (Boolean.TRUE.equals(deleted)) { + log.debug("사용자 {} 검색 기록 전체 삭제 완료", userId); + } else { + log.warn("사용자 {} 검색 기록 전체 삭제 실패: 키가 존재하지 않음", userId); + } + } catch (Exception e) { + log.error("사용자 {} 검색 기록 전체 삭제 중 오류 발생: {}", userId, e.getMessage(), e); + } + } + + /** + * 사용자별 Redis 키 생성 + */ + private String generateKey(Long userId) { + return KEY_PREFIX + userId; } -} +} \ No newline at end of file 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(); }