From 1f60d8f6d8daa13e98f8a18c3cf8ec386214651d Mon Sep 17 00:00:00 2001 From: passionryu Date: Sun, 27 Jul 2025 00:34:42 +0900 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20[=EC=84=B1=EB=8A=A5=20?= =?UTF-8?q?=ED=96=A5=EC=83=81]=20:=20=ED=9E=88=EC=8A=A4=ED=86=A0=EB=A6=AC?= =?UTF-8?q?=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=BA=90=EC=8B=B1=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Kakao/Manager/KakaoMessageManager.java | 9 +- .../Domain/SubServices/DeprecatedCodes | 65 ++++++ .../FeedBack/Service/FeedBackService.java | 12 +- .../Controller/MoreNewsController.java | 7 +- .../MoreNews/Service/HistoryService.java | 132 ++++++++++++ .../MoreNews/Service/MoreNewsService.java | 197 +++--------------- .../Global/Aop/ExecutionTimeAspect.java | 27 +++ .../Global/Config/RedisConfig.java | 168 ++++++++++++--- .../RedisCacheManagerConfig.java | 81 +++++++ redis/redis-cache.conf | 18 ++ 10 files changed, 512 insertions(+), 204 deletions(-) create mode 100644 SpringBoot/src/main/java/Baemin/News_Deliver/Domain/SubServices/DeprecatedCodes create mode 100644 SpringBoot/src/main/java/Baemin/News_Deliver/Domain/SubServices/MoreNews/Service/HistoryService.java create mode 100644 SpringBoot/src/main/java/Baemin/News_Deliver/Global/Aop/ExecutionTimeAspect.java create mode 100644 SpringBoot/src/main/java/Baemin/News_Deliver/Global/DataCachingSystem/RedisCacheManagerConfig.java create mode 100644 redis/redis-cache.conf diff --git a/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/Kakao/Manager/KakaoMessageManager.java b/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/Kakao/Manager/KakaoMessageManager.java index d9ecc35..c424c4d 100644 --- a/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/Kakao/Manager/KakaoMessageManager.java +++ b/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/Kakao/Manager/KakaoMessageManager.java @@ -17,6 +17,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheEvict; import org.springframework.http.*; import org.springframework.stereotype.Component; import org.springframework.util.LinkedMultiValueMap; @@ -121,10 +122,13 @@ public void getNewsEsDocumentList_Fixed(Long userId) { /** * 전송된 뉴스 정보를 히스토리로 저장합니다. 중복 뉴스는 저장하지 않습니다. * + * 히스토리 저장 시 히스토리 조회 캐시 삭제 + * * @param newsList 뉴스 리스트 * @param settings 해당 뉴스에 적용된 사용자 설정들 * @return 저장이 이루어진 경우 true, 아무 것도 저장되지 않았으면 false */ + @CacheEvict(value = "groupedNewsHistory", allEntries = true) public boolean saveHistory(List newsList, List settings) { if (newsList == null || newsList.isEmpty()) { log.warn("해당 키워드로 검색된 뉴스가 없습니다."); @@ -219,11 +223,6 @@ public boolean sendSingleKakaoMessage(String accessToken, List n */ public void processSetting(String accessToken, SettingDTO setting) { - /* deprecated */ -// // 뉴스 검색 -// List newsList = kakaoNewsService.searchNews( -// setting.getSettingKeywords(), setting.getBlockKeywords()); - // 뉴스 검색 List newsList = kakaoNewsService.searchNewsWithFallback( setting.getSettingKeywords(), setting.getBlockKeywords()); diff --git a/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/SubServices/DeprecatedCodes b/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/SubServices/DeprecatedCodes new file mode 100644 index 0000000..57f52a8 --- /dev/null +++ b/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/SubServices/DeprecatedCodes @@ -0,0 +1,65 @@ + + /** + * 내 히스토리 조회하기 메서드 (페이지 네이션 적용) + * + * @param page 시작 페이지 + * @param size 페이지 사이즈 + * @return 페이지 네이션이 적용된 히스토리 + */ + public PageResponse getGroupedNewsHistory(int page, int size) { + Long userId = 1L; + + // 1. 모든 히스토리 조회 + List allHistories = historyRepository.findAllBySetting_User_Id(userId); + + // 2. 히스토리 ID 수집 → Feedback 일괄 조회 + List historyIds = allHistories.stream() + .map(History::getId) + .collect(Collectors.toList()); + + Map feedbackMap = feedbackRepository.findAllById(historyIds) + .stream() + .collect(Collectors.toMap(fb -> fb.getHistory().getId(), fb -> fb)); + + // 3. 그룹핑: settingId + publishedAt(HOUR) + Map> grouped = allHistories.stream() + .collect(Collectors.groupingBy(h -> { + Long settingId = h.getSetting().getId(); + LocalDateTime truncatedPublishedAt = h.getPublishedAt().truncatedTo(ChronoUnit.HOURS); + return settingId + "_" + truncatedPublishedAt; + })); + + // 4. DTO 변환 + List groupedList = grouped.entrySet().stream() + .map(entry -> { + List histories = entry.getValue(); + History any = histories.get(0); + + List newsResponses = histories.stream() + .map(history -> { + Feedback feedback = feedbackMap.get(history.getId()); + return NewsHistoryResponse.from(history, feedback); + }) + .toList(); + + return GroupedNewsHistoryResponse.builder() + .settingId(any.getSetting().getId()) + .publishedAt(any.getPublishedAt().truncatedTo(ChronoUnit.HOURS)) + .settingKeyword(any.getSettingKeyword()) + .blockKeyword(any.getBlockKeyword()) + .newsList(newsResponses) + .build(); + }) + .sorted(Comparator.comparing(GroupedNewsHistoryResponse::getPublishedAt).reversed()) + .collect(Collectors.toList()); + + // 5. 페이지네이션 + int fromIndex = page * size; + int toIndex = Math.min(fromIndex + size, groupedList.size()); + + if (fromIndex >= groupedList.size()) { + return Collections.emptyList(); + } + + return groupedList.subList(fromIndex, toIndex); + } \ No newline at end of file diff --git a/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/SubServices/FeedBack/Service/FeedBackService.java b/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/SubServices/FeedBack/Service/FeedBackService.java index f5af899..c72ca42 100644 --- a/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/SubServices/FeedBack/Service/FeedBackService.java +++ b/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/SubServices/FeedBack/Service/FeedBackService.java @@ -9,6 +9,7 @@ import Baemin.News_Deliver.Global.Exception.ErrorCode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheEvict; import org.springframework.stereotype.Service; import java.util.Optional; @@ -26,13 +27,15 @@ public class FeedBackService { /** * 키워드 반영도 피드백 메서드 * + * 빠르게 구현해야 해서 캐시는 전부 삭제로 임시 구현 + * * @param request 피드백 요청 DTO * @return 피드백 결과 반환 */ + @CacheEvict(value = "groupedNewsHistory", allEntries = true) public Long keywordFeedBack(FeedbackRequest request){ - // 임시 하드코딩 - // Long userId = 1L; + log.info("[CacheEvict] 피드백 API 호출로 인한, 히스토리 캐시 삭제"); /* 피드백 객체 반환 */ Optional optionalFeedback = feedbackRepository.findById(request.getHistoryId()); @@ -77,11 +80,16 @@ public Long keywordFeedBack(FeedbackRequest request){ /** * 콘텐츠 품질 피드백 메서드 * + * 빠르게 구현해야 해서 캐시는 전부 삭제로 임시 구현 + * * @param request 피드백 요청 DTO * @return 피드백 결과 반환 */ + @CacheEvict(value = "groupedNewsHistory", allEntries = true) public Long contentQualityFeedback(FeedbackRequest request) { + log.info("[CacheEvict] 피드백 API 호출로 인한, 히스토리 캐시 삭제"); + // 피드백 조회 Optional optionalFeedback = feedbackRepository.findById(request.getHistoryId()); diff --git a/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/SubServices/MoreNews/Controller/MoreNewsController.java b/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/SubServices/MoreNews/Controller/MoreNewsController.java index fd0e554..49dabdb 100644 --- a/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/SubServices/MoreNews/Controller/MoreNewsController.java +++ b/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/SubServices/MoreNews/Controller/MoreNewsController.java @@ -2,6 +2,7 @@ import Baemin.News_Deliver.Domain.SubServices.MoreNews.DTO.GroupedNewsHistoryResponse; import Baemin.News_Deliver.Domain.SubServices.MoreNews.DTO.PageResponse; +import Baemin.News_Deliver.Domain.SubServices.MoreNews.Service.HistoryService; import Baemin.News_Deliver.Domain.SubServices.MoreNews.Service.MoreNewsService; import Baemin.News_Deliver.Global.News.ElasticSearch.dto.NewsEsDocument; import Baemin.News_Deliver.Global.ResponseObject.ApiResponseWrapper; @@ -9,6 +10,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; @@ -16,6 +18,7 @@ import java.io.IOException; import java.util.List; +@Slf4j @RestController @RequiredArgsConstructor @RequestMapping("/sub/history") @@ -23,6 +26,7 @@ public class MoreNewsController { private final MoreNewsService moreNewsService; + private final HistoryService historyService; /** * 뉴스 더보기 API @@ -67,8 +71,9 @@ public ResponseEntity groupedList = moreNewsService.getGroupedNewsHistory(page, size, authentication); + PageResponse groupedList = historyService.getGroupedNewsHistory(page, size, authentication); return ResponseEntity.ok(new ApiResponseWrapper<>(groupedList, "히스토리가 성공적으로 조회되었습니다.")); } diff --git a/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/SubServices/MoreNews/Service/HistoryService.java b/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/SubServices/MoreNews/Service/HistoryService.java new file mode 100644 index 0000000..a08382d --- /dev/null +++ b/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/SubServices/MoreNews/Service/HistoryService.java @@ -0,0 +1,132 @@ +package Baemin.News_Deliver.Domain.SubServices.MoreNews.Service; + +import Baemin.News_Deliver.Domain.Auth.Service.AuthService; +import Baemin.News_Deliver.Domain.Kakao.entity.History; +import Baemin.News_Deliver.Domain.Kakao.repository.HistoryRepository; +import Baemin.News_Deliver.Domain.SubServices.FeedBack.Entity.Feedback; +import Baemin.News_Deliver.Domain.SubServices.FeedBack.Repository.FeedbackRepository; +import Baemin.News_Deliver.Domain.SubServices.MoreNews.DTO.GroupedNewsHistoryResponse; +import Baemin.News_Deliver.Domain.SubServices.MoreNews.DTO.NewsHistoryResponse; +import Baemin.News_Deliver.Domain.SubServices.MoreNews.DTO.PageResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class HistoryService { + + private final HistoryRepository historyRepository; + private final FeedbackRepository feedbackRepository; + private final AuthService authService; + + /** + * 내 히스토리 조회하기 메서드 Ver2.0 + * + * Updated + * Why : 프론트 레이어에서 페이지 네이셔닝을 위한 정보 부족 + * How : 반환 DTO에 페이지네이션 정보 추가 + * When : 2025-07-21 + * Who : 류성열 + * + * @param page 현재 페이지 번호 (0부터 시작) + * @param size 페이지당 아이템 수 + * @param authentication 로그인 인증 객체 (카카오 ID 포함) + * @return 페이지 정보 + 그룹핑된 뉴스 히스토리 데이터 + * + * // @CacheEvict(cacheNames = "groupedNewsHistory", allEntries = true) + */ + @Cacheable( + value = "groupedNewsHistory", // Redis에서 사용할 캐시 이름 + key = "'user:' + #authentication.name + ':page:' + #page + ':size:' + #size" // 캐시 Key + ) + public PageResponse getGroupedNewsHistory(int page, int size, Authentication authentication) { + + /* 캐시 미스 로그 */ + log.info("[Cache Miss] {}번 유저 내 히스토리 조회 시 캐시 미스 : DB를 조회합니다.",authentication.getName()); + + // 1. 인증 객체에서 카카오 ID 추출 → 유저 ID 조회 + String kakaoId = authentication.getName(); + Long userId = authService.findByKakaoId(kakaoId).getId(); + + // 2. 해당 유저가 수신한 모든 뉴스 히스토리 조회 + List allHistories = historyRepository.findAllBySetting_User_Id(userId); + + // 3. 히스토리 ID 리스트 생성 + List historyIds = allHistories.stream() + .map(History::getId) + .toList(); + + // 4. 피드백 정보를 미리 조회하여 Map으로 변환 + Map feedbackMap = feedbackRepository.findAllById(historyIds) + .stream() + .collect(Collectors.toMap(fb -> fb.getHistory().getId(), fb -> fb)); + + // 5. 히스토리를 "설정 ID + 시간(시 단위)" 기준으로 그룹핑 + Map> grouped = allHistories.stream() + .collect(Collectors.groupingBy(h -> { + Long settingId = h.getSetting().getId(); + LocalDateTime truncatedPublishedAt = h.getPublishedAt().truncatedTo(ChronoUnit.HOURS); + return settingId + "_" + truncatedPublishedAt; + })); + + // 6. 그룹핑된 데이터 가공 → GroupedNewsHistoryResponse로 변환 + List groupedList = grouped.entrySet().stream() + .map(entry -> { + List histories = entry.getValue(); + History any = histories.get(0); // 대표 히스토리 하나 선택 + + // 각 뉴스 히스토리마다 Feedback과 함께 DTO로 변환 + List newsResponses = histories.stream() + .map(history -> { + Feedback feedback = feedbackMap.get(history.getId()); + return NewsHistoryResponse.from(history, feedback); + }) + .toList(); + + // 그룹 단위로 응답 객체 생성 + return GroupedNewsHistoryResponse.builder() + .settingId(any.getSetting().getId()) + .publishedAt(any.getPublishedAt().truncatedTo(ChronoUnit.HOURS)) + .settingKeyword(any.getSettingKeyword()) + .blockKeyword(any.getBlockKeyword()) + .newsList(newsResponses) + .build(); + }) + // 최신순으로 정렬 (publishedAt 기준) + .sorted(Comparator.comparing(GroupedNewsHistoryResponse::getPublishedAt).reversed()) + .toList(); + + // 7. 페이지네이션 처리 (subList 사용) + int total = groupedList.size(); + int fromIndex = page * size; + int toIndex = Math.min(fromIndex + size, total); + + // 요청한 페이지 범위를 벗어난 경우 빈 리스트 반환 + List paginated = fromIndex >= total + ? Collections.emptyList() + : groupedList.subList(fromIndex, toIndex); + + // 8. 최종 응답 DTO 반환 (페이지 정보 포함) + return PageResponse.builder() + .data(paginated) + .currentPage(page) + .pageSize(size) + .totalPages((int) Math.ceil((double) total / size)) + .totalElements(total) + .build(); + } + + +} diff --git a/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/SubServices/MoreNews/Service/MoreNewsService.java b/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/SubServices/MoreNews/Service/MoreNewsService.java index 761666b..761fceb 100644 --- a/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/SubServices/MoreNews/Service/MoreNewsService.java +++ b/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/SubServices/MoreNews/Service/MoreNewsService.java @@ -1,14 +1,8 @@ package Baemin.News_Deliver.Domain.SubServices.MoreNews.Service; -import Baemin.News_Deliver.Domain.Auth.Service.AuthService; import Baemin.News_Deliver.Domain.Kakao.entity.History; import Baemin.News_Deliver.Domain.Kakao.repository.HistoryRepository; import Baemin.News_Deliver.Domain.SubServices.Exception.SubServicesException; -import Baemin.News_Deliver.Domain.SubServices.FeedBack.Entity.Feedback; -import Baemin.News_Deliver.Domain.SubServices.FeedBack.Repository.FeedbackRepository; -import Baemin.News_Deliver.Domain.SubServices.MoreNews.DTO.GroupedNewsHistoryResponse; -import Baemin.News_Deliver.Domain.SubServices.MoreNews.DTO.NewsHistoryResponse; -import Baemin.News_Deliver.Domain.SubServices.MoreNews.DTO.PageResponse; import Baemin.News_Deliver.Global.Exception.ErrorCode; import Baemin.News_Deliver.Global.News.ElasticSearch.dto.NewsEsDocument; import co.elastic.clients.elasticsearch.ElasticsearchClient; @@ -20,13 +14,12 @@ import co.elastic.clients.json.JsonData; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.security.core.Authentication; import org.springframework.stereotype.Service; import java.io.IOException; import java.time.LocalDateTime; -import java.time.temporal.ChronoUnit; -import java.util.*; +import java.util.Arrays; +import java.util.List; import java.util.stream.Collectors; @Slf4j @@ -35,11 +28,7 @@ public class MoreNewsService { private final HistoryRepository historyRepository; - private final FeedbackRepository feedbackRepository; private final ElasticsearchClient client; - private final AuthService authService; - - // ======================= 뉴스 추가 검색 메서드 ========================= /** * 뉴스 추가 검색 메서드 @@ -95,6 +84,8 @@ public List getMoreNews(Long historyId) throws IOException { ) ); + + // 제외 키워드 쿼리 Query excludeKeywordQuery = Query.of(q -> q .bool(b -> b @@ -121,14 +112,35 @@ public List getMoreNews(Long historyId) throws IOException { ) ); + Query finalQuery; + if (blockKeywords != null && !blockKeywords.isEmpty()) { + // excludeKeywordQuery 만들고 mustNot에 추가 + finalQuery = Query.of(q -> q + .bool(b -> b + .must(includeKeywordQuery) + .must(dateFilter) + .mustNot(excludeKeywordQuery) + ) + ); + } else { + // mustNot 제외 + finalQuery = Query.of(q -> q + .bool(b -> b + .must(includeKeywordQuery) + .must(dateFilter) + ) + ); + } + + // 전체 쿼리 (키워드와 블랙 키워드를 포함한 전체 쿼리) - Query finalQuery = Query.of(q -> q - .bool(b -> b - .must(includeKeywordQuery) - .must(dateFilter) - .mustNot(excludeKeywordQuery) - ) - ); +// Query finalQuery = Query.of(q -> q +// .bool(b -> b +// .must(includeKeywordQuery) +// .must(dateFilter) +// .mustNot(excludeKeywordQuery) +// ) +// ); // 검색 엔진 SearchRequest request = SearchRequest.of(s -> s @@ -155,149 +167,4 @@ public List getMoreNews(Long historyId) throws IOException { .toList(); } - // ======================= 내 히스토리 조회하기 메서드 ========================= - - /** - * 내 히스토리 조회하기 메서드 Ver2.0 - * - * Updated - * Why : 프론트 레이어에서 페이지 네이셔닝을 위한 정보 부족 - * How : 반환 DTO에 페이지네이션 정보 추가 - * When : 2025-07-21 - * Who : 류성열 - * - * @param page 현재 페이지 - * @param size 페이지 사이즈 - * @return 페이지 정보 + 페이지 데이터 - */ - public PageResponse getGroupedNewsHistory(int page, int size, Authentication authentication) { - - // 인증 객체에서 카카오 ID 추출 - String kakaoId = authentication.getName(); - Long userId = authService.findByKakaoId(kakaoId).getId(); - - List allHistories = historyRepository.findAllBySetting_User_Id(userId); - - List historyIds = allHistories.stream() - .map(History::getId) - .toList(); - - Map feedbackMap = feedbackRepository.findAllById(historyIds) - .stream() - .collect(Collectors.toMap(fb -> fb.getHistory().getId(), fb -> fb)); - - Map> grouped = allHistories.stream() - .collect(Collectors.groupingBy(h -> { - Long settingId = h.getSetting().getId(); - LocalDateTime truncatedPublishedAt = h.getPublishedAt().truncatedTo(ChronoUnit.HOURS); - return settingId + "_" + truncatedPublishedAt; - })); - - List groupedList = grouped.entrySet().stream() - .map(entry -> { - List histories = entry.getValue(); - History any = histories.get(0); - - List newsResponses = histories.stream() - .map(history -> { - Feedback feedback = feedbackMap.get(history.getId()); - return NewsHistoryResponse.from(history, feedback); - }) - .toList(); - - return GroupedNewsHistoryResponse.builder() - .settingId(any.getSetting().getId()) - .publishedAt(any.getPublishedAt().truncatedTo(ChronoUnit.HOURS)) - .settingKeyword(any.getSettingKeyword()) - .blockKeyword(any.getBlockKeyword()) - .newsList(newsResponses) - .build(); - }) - .sorted(Comparator.comparing(GroupedNewsHistoryResponse::getPublishedAt).reversed()) - .toList(); - - int total = groupedList.size(); - int fromIndex = page * size; - int toIndex = Math.min(fromIndex + size, total); - - List paginated = fromIndex >= total - ? Collections.emptyList() - : groupedList.subList(fromIndex, toIndex); - - return PageResponse.builder() - .data(paginated) - .currentPage(page) - .pageSize(size) - .totalPages((int) Math.ceil((double) total / size)) - .totalElements(total) - .build(); - } - - // ======================= Deprecated ========================= - - /** - * 내 히스토리 조회하기 메서드 (페이지 네이션 적용) - * - * @param page 시작 페이지 - * @param size 페이지 사이즈 - * @return 페이지 네이션이 적용된 히스토리 - */ -// public PageResponse getGroupedNewsHistory(int page, int size) { -// Long userId = 1L; -// -// // 1. 모든 히스토리 조회 -// List allHistories = historyRepository.findAllBySetting_User_Id(userId); -// -// // 2. 히스토리 ID 수집 → Feedback 일괄 조회 -// List historyIds = allHistories.stream() -// .map(History::getId) -// .collect(Collectors.toList()); -// -// Map feedbackMap = feedbackRepository.findAllById(historyIds) -// .stream() -// .collect(Collectors.toMap(fb -> fb.getHistory().getId(), fb -> fb)); -// -// // 3. 그룹핑: settingId + publishedAt(HOUR) -// Map> grouped = allHistories.stream() -// .collect(Collectors.groupingBy(h -> { -// Long settingId = h.getSetting().getId(); -// LocalDateTime truncatedPublishedAt = h.getPublishedAt().truncatedTo(ChronoUnit.HOURS); -// return settingId + "_" + truncatedPublishedAt; -// })); -// -// // 4. DTO 변환 -// List groupedList = grouped.entrySet().stream() -// .map(entry -> { -// List histories = entry.getValue(); -// History any = histories.get(0); -// -// List newsResponses = histories.stream() -// .map(history -> { -// Feedback feedback = feedbackMap.get(history.getId()); -// return NewsHistoryResponse.from(history, feedback); -// }) -// .toList(); -// -// return GroupedNewsHistoryResponse.builder() -// .settingId(any.getSetting().getId()) -// .publishedAt(any.getPublishedAt().truncatedTo(ChronoUnit.HOURS)) -// .settingKeyword(any.getSettingKeyword()) -// .blockKeyword(any.getBlockKeyword()) -// .newsList(newsResponses) -// .build(); -// }) -// .sorted(Comparator.comparing(GroupedNewsHistoryResponse::getPublishedAt).reversed()) -// .collect(Collectors.toList()); -// -// // 5. 페이지네이션 -// int fromIndex = page * size; -// int toIndex = Math.min(fromIndex + size, groupedList.size()); -// -// if (fromIndex >= groupedList.size()) { -// return Collections.emptyList(); -// } -// -// return groupedList.subList(fromIndex, toIndex); -// } - } diff --git a/SpringBoot/src/main/java/Baemin/News_Deliver/Global/Aop/ExecutionTimeAspect.java b/SpringBoot/src/main/java/Baemin/News_Deliver/Global/Aop/ExecutionTimeAspect.java new file mode 100644 index 0000000..c04a2de --- /dev/null +++ b/SpringBoot/src/main/java/Baemin/News_Deliver/Global/Aop/ExecutionTimeAspect.java @@ -0,0 +1,27 @@ +package Baemin.News_Deliver.Global.Aop; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +@Aspect +@Component +public class ExecutionTimeAspect { + + private static final Logger log = LoggerFactory.getLogger(ExecutionTimeAspect.class); + + @Around("execution(* Baemin.News_Deliver.Domain.SubServices.MoreNews.Controller..*(..))") + public Object logExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable { + long start = System.currentTimeMillis(); + + Object proceed = joinPoint.proceed(); + + long executionTime = System.currentTimeMillis() - start; + + log.info("{} executed in {} ms", joinPoint.getSignature(), executionTime); + return proceed; + } +} diff --git a/SpringBoot/src/main/java/Baemin/News_Deliver/Global/Config/RedisConfig.java b/SpringBoot/src/main/java/Baemin/News_Deliver/Global/Config/RedisConfig.java index 0a15fe0..70c9c97 100644 --- a/SpringBoot/src/main/java/Baemin/News_Deliver/Global/Config/RedisConfig.java +++ b/SpringBoot/src/main/java/Baemin/News_Deliver/Global/Config/RedisConfig.java @@ -12,44 +12,51 @@ import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; -import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; @Configuration public class RedisConfig { + // Session Redis 1 @Value("${redis.session1.host}") private String session1Host; @Value("${redis.session1.port}") private int session1Port; + // Session Redis 2 @Value("${redis.session2.host}") private String session2Host; @Value("${redis.session2.port}") private int session2Port; - // Redis Session1 (JWT 토큰 저장용) - Primary - @Bean(name = "redisSession1ConnectionFactory") + // Cache Redis + @Value("${redis.cache.host}") + private String cacheHost; + + @Value("${redis.cache.port}") + private int cachePort; + + /* ---------------- Session1 Redis 설정 (Primary) ---------------- */ + @Primary + @Bean(name = "redisSession1ConnectionFactory") public RedisConnectionFactory redisSession1ConnectionFactory() { return new LettuceConnectionFactory(session1Host, session1Port); } - @Bean(name = "redisSession1Template") @Primary + @Bean(name = "redisSession1Template") public RedisTemplate redisSession1Template() { RedisTemplate template = new RedisTemplate<>(); template.setConnectionFactory(redisSession1ConnectionFactory()); - template.setKeySerializer(new StringRedisSerializer()); - template.setValueSerializer(new StringRedisSerializer()); - template.setHashKeySerializer(new StringRedisSerializer()); - template.setHashValueSerializer(new StringRedisSerializer()); + setStringSerializers(template); return template; } - // Redis Session2 (JWT 토큰 백업용) + /* ---------------- Session2 Redis 설정 (Backup) ---------------- */ + @Bean(name = "redisSession2ConnectionFactory") public RedisConnectionFactory redisSession2ConnectionFactory() { return new LettuceConnectionFactory(session2Host, session2Port); @@ -59,39 +66,23 @@ public RedisConnectionFactory redisSession2ConnectionFactory() { public RedisTemplate redisSession2Template() { RedisTemplate template = new RedisTemplate<>(); template.setConnectionFactory(redisSession2ConnectionFactory()); - template.setKeySerializer(new StringRedisSerializer()); - template.setValueSerializer(new StringRedisSerializer()); - template.setHashKeySerializer(new StringRedisSerializer()); - template.setHashValueSerializer(new StringRedisSerializer()); + setStringSerializers(template); return template; } + /* ---------------- Cache Redis 설정 (JSON 직렬화) ---------------- */ + @Bean(name = "redisCacheConnectionFactory") - public RedisConnectionFactory redisCacheConnectionFactory( - @Value("${redis.cache.host}") String cacheHost, - @Value("${redis.cache.port}") int cachePort - ) { + public RedisConnectionFactory redisCacheConnectionFactory() { return new LettuceConnectionFactory(cacheHost, cachePort); } - @Bean - public ObjectMapper objectMapper() { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new JavaTimeModule()); - mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); - return mapper; - } - - /** - * RedisTemplate (캐시 전용: 예, 뉴스 검색 결과 캐싱 등) — JSON 직렬화 기반 - */ @Bean(name = "redisCacheTemplate") public RedisTemplate redisCacheTemplate( @Qualifier("redisCacheConnectionFactory") RedisConnectionFactory connectionFactory, ObjectMapper objectMapper ) { - GenericJackson2JsonRedisSerializer serializer = - new GenericJackson2JsonRedisSerializer(objectMapper); + GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(objectMapper); RedisTemplate template = new RedisTemplate<>(); template.setConnectionFactory(connectionFactory); @@ -101,4 +92,119 @@ public RedisTemplate redisCacheTemplate( template.setHashValueSerializer(serializer); return template; } -} \ No newline at end of file + + /* ---------------- 공통 ObjectMapper (LocalDate 등 지원) ---------------- */ + + @Bean + public ObjectMapper objectMapper() { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new JavaTimeModule()); + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + return mapper; + } + + /* ---------------- 공통 문자열 직렬화 설정 ---------------- */ + + private void setStringSerializers(RedisTemplate template) { + StringRedisSerializer stringSerializer = new StringRedisSerializer(); + template.setKeySerializer(stringSerializer); + template.setValueSerializer(stringSerializer); + template.setHashKeySerializer(stringSerializer); + template.setHashValueSerializer(stringSerializer); + } +} + +// ======================= Deprecated ========================= +// ======================= Deprecated ========================= +// ======================= Deprecated ========================= +// ======================= Deprecated ========================= +// ======================= Deprecated ========================= + +// +//@Configuration +//public class RedisConfig { +// +// @Value("${redis.session1.host}") +// private String session1Host; +// +// @Value("${redis.session1.port}") +// private int session1Port; +// +// @Value("${redis.session2.host}") +// private String session2Host; +// +// @Value("${redis.session2.port}") +// private int session2Port; +// +// // Redis Session1 (JWT 토큰 저장용) - Primary +// @Bean(name = "redisSession1ConnectionFactory") +// @Primary +// public RedisConnectionFactory redisSession1ConnectionFactory() { +// return new LettuceConnectionFactory(session1Host, session1Port); +// } +// +// @Bean(name = "redisSession1Template") +// @Primary +// public RedisTemplate redisSession1Template() { +// RedisTemplate template = new RedisTemplate<>(); +// template.setConnectionFactory(redisSession1ConnectionFactory()); +// template.setKeySerializer(new StringRedisSerializer()); +// template.setValueSerializer(new StringRedisSerializer()); +// template.setHashKeySerializer(new StringRedisSerializer()); +// template.setHashValueSerializer(new StringRedisSerializer()); +// return template; +// } +// +// // Redis Session2 (JWT 토큰 백업용) +// @Bean(name = "redisSession2ConnectionFactory") +// public RedisConnectionFactory redisSession2ConnectionFactory() { +// return new LettuceConnectionFactory(session2Host, session2Port); +// } +// +// @Bean(name = "redisSession2Template") +// public RedisTemplate redisSession2Template() { +// RedisTemplate template = new RedisTemplate<>(); +// template.setConnectionFactory(redisSession2ConnectionFactory()); +// template.setKeySerializer(new StringRedisSerializer()); +// template.setValueSerializer(new StringRedisSerializer()); +// template.setHashKeySerializer(new StringRedisSerializer()); +// template.setHashValueSerializer(new StringRedisSerializer()); +// return template; +// } +// +// @Bean(name = "redisCacheConnectionFactory") +// public RedisConnectionFactory redisCacheConnectionFactory( +// @Value("${redis.cache.host}") String cacheHost, +// @Value("${redis.cache.port}") int cachePort +// ) { +// return new LettuceConnectionFactory(cacheHost, cachePort); +// } +// +// @Bean +// public ObjectMapper objectMapper() { +// ObjectMapper mapper = new ObjectMapper(); +// mapper.registerModule(new JavaTimeModule()); +// mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); +// return mapper; +// } +// +// /** +// * RedisTemplate (캐시 전용: 예, 뉴스 검색 결과 캐싱 등) — JSON 직렬화 기반 +// */ +// @Bean(name = "redisCacheTemplate") +// public RedisTemplate redisCacheTemplate( +// @Qualifier("redisCacheConnectionFactory") RedisConnectionFactory connectionFactory, +// ObjectMapper objectMapper +// ) { +// GenericJackson2JsonRedisSerializer serializer = +// new GenericJackson2JsonRedisSerializer(objectMapper); +// +// RedisTemplate template = new RedisTemplate<>(); +// template.setConnectionFactory(connectionFactory); +// template.setKeySerializer(new StringRedisSerializer()); +// template.setValueSerializer(serializer); +// template.setHashKeySerializer(new StringRedisSerializer()); +// template.setHashValueSerializer(serializer); +// return template; +// } +//} \ No newline at end of file diff --git a/SpringBoot/src/main/java/Baemin/News_Deliver/Global/DataCachingSystem/RedisCacheManagerConfig.java b/SpringBoot/src/main/java/Baemin/News_Deliver/Global/DataCachingSystem/RedisCacheManagerConfig.java new file mode 100644 index 0000000..5ca374f --- /dev/null +++ b/SpringBoot/src/main/java/Baemin/News_Deliver/Global/DataCachingSystem/RedisCacheManagerConfig.java @@ -0,0 +1,81 @@ +package Baemin.News_Deliver.Global.DataCachingSystem; + +import Baemin.News_Deliver.Domain.SubServices.MoreNews.DTO.GroupedNewsHistoryResponse; +import Baemin.News_Deliver.Domain.SubServices.MoreNews.DTO.PageResponse; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.cache.CacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; + +@Configuration +public class RedisCacheManagerConfig { + + /** + * 캐시 메니저 메서드 + * + * 이를 통해, Spring Cache 어노테이션 사용 가능 + * + * @param connectionFactory 레디스 서버와의 연결 객체 + * @return 캐시 결과 반환 + */ + @Bean(name = "redisCacheManager") + public CacheManager redisCacheManager( + @Qualifier("redisCacheConnectionFactory") RedisConnectionFactory connectionFactory) { + + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); // ISO-8601 포맷 유지 + + // ======================= Default ========================= + + // Generic default (모든 캐시에 무난하게 적용될 기본값) + GenericJackson2JsonRedisSerializer genericSerializer = new GenericJackson2JsonRedisSerializer(objectMapper); + + RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(Duration.ofMinutes(30)) // TTL 30분 + .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) + .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(genericSerializer)); + + // ======================= 히스토리 조회 캐시 설정 ========================= + + JavaType pageResponseType = objectMapper.getTypeFactory() + .constructParametricType(PageResponse.class, GroupedNewsHistoryResponse.class); + + Jackson2JsonRedisSerializer getGroupedNewsHistory = new Jackson2JsonRedisSerializer<>(pageResponseType); + getGroupedNewsHistory.setObjectMapper(objectMapper); + + RedisCacheConfiguration getGroupedNewsHistoryConfig = RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(Duration.ofHours(6)) // TTL 6시간 + .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) + .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(getGroupedNewsHistory)); + + // ======================= 커스터 마이징 캐시 설정 ========================= + + // 개발자 커스터마이징 캐시 생성 + Map cacheConfigurations = new HashMap<>(); + cacheConfigurations.put("groupedNewsHistory", getGroupedNewsHistoryConfig); + + // =======================내 히스토리 조회하기 캐싱 전략 ========================= + + // 캐시 최종 반환 + return RedisCacheManager.builder(connectionFactory) + .cacheDefaults(defaultConfig) // 기본 캐시 + .withInitialCacheConfigurations(cacheConfigurations) // 커스터마이즈 된 캐시 + .build(); + } +} diff --git a/redis/redis-cache.conf b/redis/redis-cache.conf new file mode 100644 index 0000000..66a3514 --- /dev/null +++ b/redis/redis-cache.conf @@ -0,0 +1,18 @@ +# Cache Redis + +# 외부 접근 방지 +bind 0.0.0.0 +protected-mode yes + +# PW : X +#requirepass 1234 + +# 캐싱 Redis는 적은 메모리와 LRU 정책 조합이 효율적 +maxmemory 256mb +maxmemory-policy allkeys-lru + +# 캐시는 영속성이 불필요하므로 AOF 비활성화 +appendonly no + +# RDB 스냅샷 비활성화 +save "" From 4abc91ebe2d0d46f25b397a7cf737601c40bc64c Mon Sep 17 00:00:00 2001 From: passionryu Date: Sun, 27 Jul 2025 01:13:17 +0900 Subject: [PATCH 2/3] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20[=EC=84=B1=EB=8A=A5=20?= =?UTF-8?q?=ED=96=A5=EC=83=81]=20:=20=EB=89=B4=EC=8A=A4=20=EB=8D=94?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=BA=90?= =?UTF-8?q?=EC=8B=B1=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MoreNews/Service/HistoryService.java | 3 +- .../MoreNews/Service/MoreNewsService.java | 30 ++++++++----------- .../RedisCacheManagerConfig.java | 11 ++++++- 3 files changed, 24 insertions(+), 20 deletions(-) diff --git a/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/SubServices/MoreNews/Service/HistoryService.java b/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/SubServices/MoreNews/Service/HistoryService.java index a08382d..d05daea 100644 --- a/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/SubServices/MoreNews/Service/HistoryService.java +++ b/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/SubServices/MoreNews/Service/HistoryService.java @@ -49,7 +49,8 @@ public class HistoryService { */ @Cacheable( value = "groupedNewsHistory", // Redis에서 사용할 캐시 이름 - key = "'user:' + #authentication.name + ':page:' + #page + ':size:' + #size" // 캐시 Key + key = "'user:' + #authentication.name + ':page:' + #page + ':size:' + #size", // 캐시 Key + cacheManager = "redisCacheManager" ) public PageResponse getGroupedNewsHistory(int page, int size, Authentication authentication) { diff --git a/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/SubServices/MoreNews/Service/MoreNewsService.java b/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/SubServices/MoreNews/Service/MoreNewsService.java index 761fceb..93669eb 100644 --- a/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/SubServices/MoreNews/Service/MoreNewsService.java +++ b/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/SubServices/MoreNews/Service/MoreNewsService.java @@ -14,6 +14,7 @@ import co.elastic.clients.json.JsonData; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import java.io.IOException; @@ -37,32 +38,37 @@ public class MoreNewsService { * @return 15개의 뉴스 기사 리스트 * @throws IOException IO 예외 */ + @Cacheable( + value = "moreNewsByHistory", + key = "#historyId", + cacheManager = "redisCacheManager" + ) public List getMoreNews(Long historyId) throws IOException { + log.info("[Cache Miss] {}번 히스토리 뉴스 더보기 캐시 미스 : ES를 가동합니다.", historyId); + /* 히스토리 객체 반환 */ History history = historyRepository.findById(historyId) .orElseThrow(() -> new SubServicesException(ErrorCode.HISTORY_NOT_FOUND)); - log.info("히스토리 객체 반환 성공 : {}", history); /* 히스토리 세부 정보 반환 */ String settingKeyword = history.getSettingKeyword(); // 설정 키워드 반환 String blockKeyword = history.getBlockKeyword(); // 제외 키워드 LocalDateTime publishedAt = history.getPublishedAt(); // 날짜 - log.info("히스토리 세부 정보 반환 (settingKeyword,publishedAt, blockKeyword) : {},{},{}", settingKeyword,publishedAt, blockKeyword); // settingKeyword 문자열을 리스트로 변환 List settingKeywords = Arrays.stream(settingKeyword.split(",")) .map(String::trim) // 공백 제거 .filter(s -> !s.isEmpty()) // 빈 문자열 제거 .toList(); - log.info("설정 리스트 반환 : {}", settingKeywords); + log.info("설정 리스트 : {}", settingKeywords); // blockKeyword 문자열을 리스트로 변환 List blockKeywords = Arrays.stream(blockKeyword.split(",")) .map(String::trim) .filter(s -> !s.isEmpty()) .toList(); - log.info("제외 리스트 반환 : {}", blockKeywords); + log.info("제외 리스트 : {}", blockKeywords); /* Elastic Search 검색 엔진 호출 : List 반환 */ @@ -84,8 +90,6 @@ public List getMoreNews(Long historyId) throws IOException { ) ); - - // 제외 키워드 쿼리 Query excludeKeywordQuery = Query.of(q -> q .bool(b -> b @@ -132,16 +136,6 @@ public List getMoreNews(Long historyId) throws IOException { ); } - - // 전체 쿼리 (키워드와 블랙 키워드를 포함한 전체 쿼리) -// Query finalQuery = Query.of(q -> q -// .bool(b -> b -// .must(includeKeywordQuery) -// .must(dateFilter) -// .mustNot(excludeKeywordQuery) -// ) -// ); - // 검색 엔진 SearchRequest request = SearchRequest.of(s -> s .index("news-index-nori") @@ -151,14 +145,14 @@ public List getMoreNews(Long historyId) throws IOException { .score(sc -> sc.order(co.elastic.clients.elasticsearch._types.SortOrder.Desc)) ) ); - log.info("검색 엔진 결과 : {}", request); + //log.info("검색 엔진 결과 : {}", request); SearchResponse response = client.search(request, NewsEsDocument.class); log.info("SearchResponse response : {}", response); response.hits().hits().forEach(hit -> { assert hit.source() != null; - log.info(" {} | score: {}", hit.source().getTitle(), hit.score()); + // log.info(" {} | score: {}", hit.source().getTitle(), hit.score()); }); // 그대로 ES 문서 리스트 반환 diff --git a/SpringBoot/src/main/java/Baemin/News_Deliver/Global/DataCachingSystem/RedisCacheManagerConfig.java b/SpringBoot/src/main/java/Baemin/News_Deliver/Global/DataCachingSystem/RedisCacheManagerConfig.java index 5ca374f..944c576 100644 --- a/SpringBoot/src/main/java/Baemin/News_Deliver/Global/DataCachingSystem/RedisCacheManagerConfig.java +++ b/SpringBoot/src/main/java/Baemin/News_Deliver/Global/DataCachingSystem/RedisCacheManagerConfig.java @@ -64,12 +64,21 @@ public CacheManager redisCacheManager( .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(getGroupedNewsHistory)); + // ======================= 뉴스 더보기 캐시 설정 ========================= + + RedisCacheConfiguration moreNewsByHistoryConfig = RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(Duration.ofDays(3)) // TTL 3일 + .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) + .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(genericSerializer)); + // ======================= 커스터 마이징 캐시 설정 ========================= // 개발자 커스터마이징 캐시 생성 Map cacheConfigurations = new HashMap<>(); cacheConfigurations.put("groupedNewsHistory", getGroupedNewsHistoryConfig); - + cacheConfigurations.put("moreNewsByHistory", moreNewsByHistoryConfig); + + // =======================내 히스토리 조회하기 캐싱 전략 ========================= // 캐시 최종 반환 From abf21cd5e5241ce7c255c9e379819cd6a1306754 Mon Sep 17 00:00:00 2001 From: passionryu Date: Sun, 27 Jul 2025 02:04:11 +0900 Subject: [PATCH 3/3] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20[=EC=84=B1=EB=8A=A5=20?= =?UTF-8?q?=ED=96=A5=EC=83=81]=20:=20=EC=9C=A0=EC=A0=80=20Setting=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=BA=90=EC=8B=B1=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Kakao/Manager/KakaoMessageManager.java | 1 + .../Domain/Mypage/service/SettingService.java | 19 ++++++++++++++++++- .../Global/Aop/ExecutionTimeAspect.java | 3 ++- .../RedisCacheManagerConfig.java | 19 ++++++++++++++++++- 4 files changed, 39 insertions(+), 3 deletions(-) diff --git a/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/Kakao/Manager/KakaoMessageManager.java b/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/Kakao/Manager/KakaoMessageManager.java index c424c4d..f6988a6 100644 --- a/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/Kakao/Manager/KakaoMessageManager.java +++ b/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/Kakao/Manager/KakaoMessageManager.java @@ -184,6 +184,7 @@ public boolean saveHistory(List newsList, List setti * @param newsList 뉴스 리스트 * @return T,F */ + @CacheEvict(value = "groupedNewsHistory", allEntries = true) public boolean sendSingleKakaoMessage(String accessToken, List newsList) { try { diff --git a/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/Mypage/service/SettingService.java b/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/Mypage/service/SettingService.java index 0cf6cef..ee4937b 100644 --- a/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/Mypage/service/SettingService.java +++ b/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/Mypage/service/SettingService.java @@ -15,12 +15,13 @@ import Baemin.News_Deliver.Global.Exception.ErrorCode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.List; -import java.util.Optional; /** * 뉴스 배달 설정(Setting) 도메인 서비스 @@ -59,7 +60,11 @@ public class SettingService { * @return 생성된 Setting ID */ @Transactional + @CacheEvict(value = "userSettingCache", key = "'user:' + #settingDTO.userId") public Long saveSetting(SettingDTO settingDTO) { + + log.info("[Cache Evict] {}번 유저 Setting 캐시 삭제(세팅 저장 API 호출)",settingDTO.getUserId()); + User user = userRepository.findById(settingDTO.getUserId()) .orElseThrow(() -> new SettingException(ErrorCode.USER_NOT_FOUND)); @@ -76,6 +81,7 @@ public Long saveSetting(SettingDTO settingDTO) { .build(); /** + * Add for Bug_Fix * What : Setting 객체 저장 코드 * Why : Setting 데이터 저장 없이, 자식 관계인 SettingKeyword는 저장되는 상황 발생 -> Error 발생 * When : 2025-07-21 @@ -103,7 +109,11 @@ public Long saveSetting(SettingDTO settingDTO) { * @return 204 No Content 응답 */ @Transactional + @CacheEvict(value = "userSettingCache", key = "'user:' + #settingDTO.userId") public void updateSetting(SettingDTO settingDTO) { + + log.info("[Cache Evict] {}번 유저 Setting 캐시 삭제(세팅 수정 API 호출)",settingDTO.getUserId()); + Setting setting = settingRepository.findById(settingDTO.getId()) .orElseThrow(() -> new SettingException(ErrorCode.SETTING_NOT_FOUND)); @@ -142,7 +152,11 @@ public void updateSetting(SettingDTO settingDTO) { * @return 사용자 설정 리스트 */ @Transactional(readOnly = true) + @Cacheable(value = "userSettingCache", key = "'user:' + #userId") public List getAllSettingsByUserId(Long userId) { + + log.info("[Cache Miss] {}번 유저 Setting 캐시 miss(세팅 조회 API 호출)",userId); + User user = userRepository.findById(userId) .orElseThrow(() -> new SettingException(ErrorCode.USER_NOT_FOUND)); @@ -164,7 +178,10 @@ public List getAllSettingsByUserId(Long userId) { * @return 204 No Content 응답 */ @Transactional + @CacheEvict(value = "userSettingCache", key = "'user:' + #userId") public void deleteSetting(Long settingId, Long userId) { + + log.info("[Cache Evict] {}번 유저 Setting 캐시 삭제(세팅 삭제 API 호출)",userId); Setting setting = settingRepository.findById(settingId) .orElseThrow(() -> new SettingException(ErrorCode.SETTING_NOT_FOUND)); diff --git a/SpringBoot/src/main/java/Baemin/News_Deliver/Global/Aop/ExecutionTimeAspect.java b/SpringBoot/src/main/java/Baemin/News_Deliver/Global/Aop/ExecutionTimeAspect.java index c04a2de..f732b1d 100644 --- a/SpringBoot/src/main/java/Baemin/News_Deliver/Global/Aop/ExecutionTimeAspect.java +++ b/SpringBoot/src/main/java/Baemin/News_Deliver/Global/Aop/ExecutionTimeAspect.java @@ -13,7 +13,8 @@ public class ExecutionTimeAspect { private static final Logger log = LoggerFactory.getLogger(ExecutionTimeAspect.class); - @Around("execution(* Baemin.News_Deliver.Domain.SubServices.MoreNews.Controller..*(..))") + @Around("execution(* Baemin.News_Deliver.Domain.SubServices.MoreNews.Controller..*(..)) || " + + "execution(* Baemin.News_Deliver.Domain.Mypage.Controller..*(..))") public Object logExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable { long start = System.currentTimeMillis(); diff --git a/SpringBoot/src/main/java/Baemin/News_Deliver/Global/DataCachingSystem/RedisCacheManagerConfig.java b/SpringBoot/src/main/java/Baemin/News_Deliver/Global/DataCachingSystem/RedisCacheManagerConfig.java index 944c576..2830565 100644 --- a/SpringBoot/src/main/java/Baemin/News_Deliver/Global/DataCachingSystem/RedisCacheManagerConfig.java +++ b/SpringBoot/src/main/java/Baemin/News_Deliver/Global/DataCachingSystem/RedisCacheManagerConfig.java @@ -1,5 +1,6 @@ package Baemin.News_Deliver.Global.DataCachingSystem; +import Baemin.News_Deliver.Domain.Mypage.DTO.SettingDTO; import Baemin.News_Deliver.Domain.SubServices.MoreNews.DTO.GroupedNewsHistoryResponse; import Baemin.News_Deliver.Domain.SubServices.MoreNews.DTO.PageResponse; import com.fasterxml.jackson.databind.JavaType; @@ -20,6 +21,7 @@ import java.time.Duration; import java.util.HashMap; +import java.util.List; import java.util.Map; @Configuration @@ -71,13 +73,28 @@ public CacheManager redisCacheManager( .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(genericSerializer)); + // ======================= 설정 페이지 캐시 설정 ========================= + + JavaType settingListType = objectMapper.getTypeFactory() + .constructCollectionType(List.class, SettingDTO.class); + + Jackson2JsonRedisSerializer> settingDTOSerializer = new Jackson2JsonRedisSerializer<>(settingListType); + settingDTOSerializer.setObjectMapper(objectMapper); + + RedisCacheConfiguration userSettingCacheConfig = RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(Duration.ofMinutes(15)) // TTL 15분 + .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) + .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(settingDTOSerializer)); + + //.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(genericSerializer)); + // ======================= 커스터 마이징 캐시 설정 ========================= // 개발자 커스터마이징 캐시 생성 Map cacheConfigurations = new HashMap<>(); cacheConfigurations.put("groupedNewsHistory", getGroupedNewsHistoryConfig); cacheConfigurations.put("moreNewsByHistory", moreNewsByHistoryConfig); - + cacheConfigurations.put("userSettingCache", userSettingCacheConfig); // =======================내 히스토리 조회하기 캐싱 전략 =========================