From 4dfbfde23fbc1532b8b15942ae713d83ad1704c7 Mon Sep 17 00:00:00 2001 From: jinhyeong jang Date: Fri, 12 Jun 2026 19:44:15 +0900 Subject: [PATCH] Add subject query caching and warm-up --- build.gradle | 2 + .../inu/timetable/config/CacheConfig.java | 34 +++ .../controller/AdminSubjectController.java | 9 +- .../controller/SubjectController.java | 63 +---- .../timetable/dto/SubjectFilterCriteria.java | 53 +++++ .../timetable/dto/SubjectSearchCriteria.java | 15 ++ .../event/SubjectDataChangedEvent.java | 4 + .../timetable/service/ExcelParseService.java | 11 +- .../service/OfficialSubjectImportService.java | 4 + .../timetable/service/PdfParseService.java | 8 +- .../service/SubjectAdminService.java | 10 + .../service/SubjectCacheEvictionService.java | 30 +++ .../timetable/service/SubjectCacheNames.java | 24 ++ .../service/SubjectCacheWarmupService.java | 132 +++++++++++ .../service/SubjectFilterCacheService.java | 55 +++++ .../service/SubjectQueryService.java | 49 ++++ .../service/SubjectSearchCacheService.java | 46 ++++ src/main/resources/application.yml | 9 + .../inu/timetable/config/CacheConfigTest.java | 24 ++ .../OfficialSubjectImportServiceTest.java | 9 +- .../service/SubjectAdminServiceTest.java | 11 +- .../SubjectCacheWarmupServiceTest.java | 70 ++++++ .../service/SubjectQueryServiceCacheTest.java | 219 ++++++++++++++++++ src/test/resources/application.yml | 5 + 24 files changed, 839 insertions(+), 57 deletions(-) create mode 100644 src/main/java/inu/timetable/config/CacheConfig.java create mode 100644 src/main/java/inu/timetable/dto/SubjectFilterCriteria.java create mode 100644 src/main/java/inu/timetable/dto/SubjectSearchCriteria.java create mode 100644 src/main/java/inu/timetable/event/SubjectDataChangedEvent.java create mode 100644 src/main/java/inu/timetable/service/SubjectCacheEvictionService.java create mode 100644 src/main/java/inu/timetable/service/SubjectCacheNames.java create mode 100644 src/main/java/inu/timetable/service/SubjectCacheWarmupService.java create mode 100644 src/main/java/inu/timetable/service/SubjectFilterCacheService.java create mode 100644 src/main/java/inu/timetable/service/SubjectQueryService.java create mode 100644 src/main/java/inu/timetable/service/SubjectSearchCacheService.java create mode 100644 src/test/java/inu/timetable/config/CacheConfigTest.java create mode 100644 src/test/java/inu/timetable/service/SubjectCacheWarmupServiceTest.java create mode 100644 src/test/java/inu/timetable/service/SubjectQueryServiceCacheTest.java diff --git a/build.gradle b/build.gradle index 63f5eed..7c440f1 100644 --- a/build.gradle +++ b/build.gradle @@ -33,6 +33,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + implementation 'org.springframework.boot:spring-boot-starter-cache' implementation 'org.springframework.security:spring-security-crypto' implementation 'org.springframework.ai:spring-ai-pdf-document-reader' implementation 'org.springframework.boot:spring-boot-starter-webflux' @@ -40,6 +41,7 @@ dependencies { implementation 'com.fasterxml.jackson.core:jackson-databind:2.15.2' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0' implementation 'me.paulschwarz:spring-dotenv:4.0.0' + implementation 'com.github.ben-manes.caffeine:caffeine' implementation 'org.apache.poi:poi:5.2.5' implementation 'org.apache.poi:poi-ooxml:5.2.5' diff --git a/src/main/java/inu/timetable/config/CacheConfig.java b/src/main/java/inu/timetable/config/CacheConfig.java new file mode 100644 index 0000000..e2b4bf8 --- /dev/null +++ b/src/main/java/inu/timetable/config/CacheConfig.java @@ -0,0 +1,34 @@ +package inu.timetable.config; + +import com.github.benmanes.caffeine.cache.Caffeine; +import inu.timetable.service.SubjectCacheNames; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.convert.DurationStyle; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Duration; + +@Configuration +@EnableCaching +public class CacheConfig { + + @Bean + public CacheManager cacheManager( + @Value("${subject.cache.maximum-size:1000}") long maximumSize, + @Value("${subject.cache.expire-after-write:10m}") String expireAfterWrite) { + CaffeineCacheManager cacheManager = new CaffeineCacheManager(SubjectCacheNames.ALL.toArray(String[]::new)); + cacheManager.setCaffeine(Caffeine.newBuilder() + .maximumSize(maximumSize) + .expireAfterWrite(parseDuration(expireAfterWrite)) + .recordStats()); + return cacheManager; + } + + private Duration parseDuration(String value) { + return DurationStyle.detectAndParse(value); + } +} diff --git a/src/main/java/inu/timetable/controller/AdminSubjectController.java b/src/main/java/inu/timetable/controller/AdminSubjectController.java index e5ba1b1..be54bd4 100644 --- a/src/main/java/inu/timetable/controller/AdminSubjectController.java +++ b/src/main/java/inu/timetable/controller/AdminSubjectController.java @@ -7,6 +7,7 @@ import inu.timetable.entity.Subject; import inu.timetable.enums.ClassMethod; import inu.timetable.enums.SubjectType; +import inu.timetable.event.SubjectDataChangedEvent; import inu.timetable.repository.SubjectRepository; import inu.timetable.service.AdminAccessGuard; import inu.timetable.service.AdminOperationLockService; @@ -15,6 +16,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -46,6 +48,7 @@ public class AdminSubjectController { private final AdminOperationLockService adminOperationLockService; private final SubjectAdminService subjectAdminService; private final OfficialSubjectImportService officialSubjectImportService; + private final ApplicationEventPublisher eventPublisher; @PostMapping public ResponseEntity createSubject( @@ -133,7 +136,11 @@ public List addSubjectsManually( subjects.add(subject); } - return subjectRepository.saveAll(subjects); + List savedSubjects = subjectRepository.saveAll(subjects); + if (!savedSubjects.isEmpty()) { + eventPublisher.publishEvent(new SubjectDataChangedEvent("manual-subject-import")); + } + return savedSubjects; } private List parseTime(String timeString) { diff --git a/src/main/java/inu/timetable/controller/SubjectController.java b/src/main/java/inu/timetable/controller/SubjectController.java index 74a4862..c4c433c 100644 --- a/src/main/java/inu/timetable/controller/SubjectController.java +++ b/src/main/java/inu/timetable/controller/SubjectController.java @@ -2,8 +2,10 @@ import inu.timetable.entity.Subject; import inu.timetable.enums.SubjectType; -import inu.timetable.repository.SubjectRepository; import inu.timetable.dto.SubjectDto; +import inu.timetable.dto.SubjectFilterCriteria; +import inu.timetable.repository.SubjectRepository; +import inu.timetable.service.SubjectQueryService; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -11,7 +13,6 @@ import org.springframework.web.bind.annotation.*; import java.util.List; -import java.util.stream.Collectors; @RestController @RequestMapping("/api/subjects") @@ -19,6 +20,7 @@ public class SubjectController { private final SubjectRepository subjectRepository; + private final SubjectQueryService subjectQueryService; @GetMapping public Page getAllSubjects( @@ -30,7 +32,7 @@ public Page getAllSubjects( @GetMapping("/count") public long getCount() { - return subjectRepository.countByActiveTrue(); + return subjectQueryService.countActiveSubjects(); } @GetMapping("/type/{type}") @@ -45,12 +47,12 @@ public List getSubjectsByDepartment(@PathVariable String department) { @GetMapping("/departments") public List getAllDepartments() { - return subjectRepository.findDistinctDepartments(); + return subjectQueryService.findDistinctDepartments(); } @GetMapping("/grades") public List getAllGrades() { - return subjectRepository.findDistinctGrades(); + return subjectQueryService.findDistinctGrades(); } @GetMapping("/grade/{grade}") @@ -67,30 +69,14 @@ public List getSubjectsByProfessor(@PathVariable String professor) { public List searchSubjects( @RequestParam String keyword, @RequestParam(required = false) Integer grade) { - List subjects; - if (grade != null) { - subjects = subjectRepository.findBySubjectNameContainingAndGradeAndActiveTrue(keyword, grade); - } else { - subjects = subjectRepository.findBySubjectNameContainingAndActiveTrue(keyword); - } - return subjects.stream() - .map(SubjectDto::from) - .collect(Collectors.toList()); + return subjectQueryService.searchBySubjectName(keyword, grade); } @GetMapping("/search/professor") public List searchByProfessor( @RequestParam String keyword, @RequestParam(required = false) Integer grade) { - List subjects; - if (grade != null) { - subjects = subjectRepository.findByProfessorContainingAndGradeAndActiveTrue(keyword, grade); - } else { - subjects = subjectRepository.findByProfessorContainingAndActiveTrue(keyword); - } - return subjects.stream() - .map(SubjectDto::from) - .collect(Collectors.toList()); + return subjectQueryService.searchByProfessor(keyword, grade); } @GetMapping("/filter") @@ -107,36 +93,9 @@ public Page filterSubjects( @RequestParam(required = false) Integer credits, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size) { - - System.out.println(">>> [API] Received request for /filter with page=" + page + ", size=" + size); - - Pageable pageable = PageRequest.of(page, size); - - // 1단계: 필터로 과목 ID 조회 (페이지네이션 적용) - Page subjectIdPage = subjectRepository.findIdsWithFilters( + return subjectQueryService.filterSubjects(SubjectFilterCriteria.of( subjectName, professor, department, dayOfWeek, - startTime, endTime, subjectType, grade, isNight, credits, pageable); - - // 2단계: 조회된 ID로 과목 상세 정보와 시간표를 함께 조회 - List subjectIds = subjectIdPage.getContent(); - System.out.println(">>> [DB] Found " + subjectIds.size() + " subject IDs for this page."); - - if (subjectIds.isEmpty()) { - System.out.println(">>> [API] Returning empty page."); - return new org.springframework.data.domain.PageImpl<>(new java.util.ArrayList<>(), pageable, - subjectIdPage.getTotalElements()); - } - - List subjectsWithSchedules = subjectRepository.findWithSchedulesByIds(subjectIds); - System.out.println(">>> [API] Returning page with " + subjectsWithSchedules.size() + " subjects."); - - // DTO로 변환 - List subjectDtos = subjectsWithSchedules.stream() - .map(SubjectDto::from) - .collect(Collectors.toList()); - - // Page 객체는 유지하되, 내용물만 교체 - return new org.springframework.data.domain.PageImpl<>(subjectDtos, pageable, subjectIdPage.getTotalElements()); + startTime, endTime, subjectType, grade, isNight, credits, page, size)); } } diff --git a/src/main/java/inu/timetable/dto/SubjectFilterCriteria.java b/src/main/java/inu/timetable/dto/SubjectFilterCriteria.java new file mode 100644 index 0000000..ea56925 --- /dev/null +++ b/src/main/java/inu/timetable/dto/SubjectFilterCriteria.java @@ -0,0 +1,53 @@ +package inu.timetable.dto; + +import inu.timetable.enums.SubjectType; + +public record SubjectFilterCriteria( + String subjectName, + String professor, + String department, + String dayOfWeek, + Double startTime, + Double endTime, + SubjectType subjectType, + Integer grade, + Boolean isNight, + Integer credits, + int page, + int size) { + + public static SubjectFilterCriteria of( + String subjectName, + String professor, + String department, + String dayOfWeek, + Double startTime, + Double endTime, + SubjectType subjectType, + Integer grade, + Boolean isNight, + Integer credits, + int page, + int size) { + return new SubjectFilterCriteria( + trimToNull(subjectName), + trimToNull(professor), + trimToNull(department), + trimToNull(dayOfWeek), + startTime, + endTime, + subjectType, + grade, + isNight, + credits, + page, + size); + } + + private static String trimToNull(String value) { + if (value == null || value.isBlank()) { + return null; + } + return value.trim(); + } +} diff --git a/src/main/java/inu/timetable/dto/SubjectSearchCriteria.java b/src/main/java/inu/timetable/dto/SubjectSearchCriteria.java new file mode 100644 index 0000000..cac35ba --- /dev/null +++ b/src/main/java/inu/timetable/dto/SubjectSearchCriteria.java @@ -0,0 +1,15 @@ +package inu.timetable.dto; + +public record SubjectSearchCriteria(String keyword, Integer grade) { + + public static SubjectSearchCriteria of(String keyword, Integer grade) { + return new SubjectSearchCriteria(trim(keyword), grade); + } + + private static String trim(String value) { + if (value == null) { + return null; + } + return value.trim(); + } +} diff --git a/src/main/java/inu/timetable/event/SubjectDataChangedEvent.java b/src/main/java/inu/timetable/event/SubjectDataChangedEvent.java new file mode 100644 index 0000000..451cedd --- /dev/null +++ b/src/main/java/inu/timetable/event/SubjectDataChangedEvent.java @@ -0,0 +1,4 @@ +package inu.timetable.event; + +public record SubjectDataChangedEvent(String source) { +} diff --git a/src/main/java/inu/timetable/service/ExcelParseService.java b/src/main/java/inu/timetable/service/ExcelParseService.java index 550f022..bcdf8e4 100644 --- a/src/main/java/inu/timetable/service/ExcelParseService.java +++ b/src/main/java/inu/timetable/service/ExcelParseService.java @@ -6,12 +6,19 @@ import inu.timetable.entity.Subject; import inu.timetable.enums.ClassMethod; import inu.timetable.enums.SubjectType; +import inu.timetable.event.SubjectDataChangedEvent; import inu.timetable.repository.SubjectRepository; import inu.timetable.repository.UserRepository; import inu.timetable.repository.UserTimetableRepository; import inu.timetable.repository.WishlistRepository; import lombok.RequiredArgsConstructor; -import org.apache.poi.ss.usermodel.*; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.DataFormatter; +import org.apache.poi.ss.usermodel.DateUtil; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.springframework.context.ApplicationEventPublisher; import org.apache.poi.xssf.usermodel.XSSFWorkbook; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -31,6 +38,7 @@ public class ExcelParseService { private final WishlistRepository wishlistRepository; private final UserTimetableRepository userTimetableRepository; private final UserRepository userRepository; + private final ApplicationEventPublisher eventPublisher; private final WebClient webClient = WebClient.builder().build(); private final ObjectMapper objectMapper = new ObjectMapper(); @@ -124,6 +132,7 @@ public int parseAndSaveSubjectsIncremental(MultipartFile file) throws IOExceptio if (!newSubjects.isEmpty()) { List savedSubjects = subjectRepository.saveAll(newSubjects); System.out.println("성공적으로 저장된 과목 수: " + savedSubjects.size()); + eventPublisher.publishEvent(new SubjectDataChangedEvent("excel-incremental-import")); } else { System.out.println("저장할 새로운 과목이 없습니다."); } diff --git a/src/main/java/inu/timetable/service/OfficialSubjectImportService.java b/src/main/java/inu/timetable/service/OfficialSubjectImportService.java index b21b683..165c2a5 100644 --- a/src/main/java/inu/timetable/service/OfficialSubjectImportService.java +++ b/src/main/java/inu/timetable/service/OfficialSubjectImportService.java @@ -5,8 +5,10 @@ import inu.timetable.entity.Subject; import inu.timetable.enums.ClassMethod; import inu.timetable.enums.SubjectType; +import inu.timetable.event.SubjectDataChangedEvent; import inu.timetable.repository.SubjectRepository; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.apache.poi.ss.usermodel.Cell; import org.apache.poi.ss.usermodel.DataFormatter; import org.apache.poi.ss.usermodel.Row; @@ -44,6 +46,7 @@ public class OfficialSubjectImportService { private static final String DAYS = "월화수목금토일"; private final SubjectRepository subjectRepository; + private final ApplicationEventPublisher eventPublisher; @Transactional(readOnly = true) public OfficialSubjectImportResponse preview(MultipartFile file, String semester) throws IOException { @@ -80,6 +83,7 @@ public OfficialSubjectImportResponse apply(MultipartFile file, String semester, } } + eventPublisher.publishEvent(new SubjectDataChangedEvent("official-subject-import")); return toResponse(true, normalizedSemester, records, diff, deactivateMissing); } diff --git a/src/main/java/inu/timetable/service/PdfParseService.java b/src/main/java/inu/timetable/service/PdfParseService.java index 3a6612a..5c4bfa5 100644 --- a/src/main/java/inu/timetable/service/PdfParseService.java +++ b/src/main/java/inu/timetable/service/PdfParseService.java @@ -6,6 +6,7 @@ import inu.timetable.entity.Subject; import inu.timetable.enums.ClassMethod; import inu.timetable.enums.SubjectType; +import inu.timetable.event.SubjectDataChangedEvent; import inu.timetable.repository.SubjectRepository; import inu.timetable.repository.WishlistRepository; import java.io.IOException; @@ -25,6 +26,7 @@ import org.springframework.ai.reader.pdf.PagePdfDocumentReader; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.core.io.InputStreamResource; import org.springframework.core.io.Resource; import org.springframework.stereotype.Service; @@ -37,6 +39,7 @@ public class PdfParseService { private final SubjectRepository subjectRepository; private final WishlistRepository wishlistRepository; private final DepartmentMappingService departmentMappingService; + private final ApplicationEventPublisher eventPublisher; private final WebClient webClient = WebClient.builder().build(); private final ObjectMapper objectMapper = new ObjectMapper(); @@ -46,10 +49,12 @@ public class PdfParseService { @Autowired public PdfParseService(SubjectRepository subjectRepository, WishlistRepository wishlistRepository, - DepartmentMappingService departmentMappingService) { + DepartmentMappingService departmentMappingService, + ApplicationEventPublisher eventPublisher) { this.subjectRepository = subjectRepository; this.wishlistRepository = wishlistRepository; this.departmentMappingService = departmentMappingService; + this.eventPublisher = eventPublisher; } /** @@ -88,6 +93,7 @@ public int parseAndSaveSubjectsIncremental(MultipartFile file) throws IOExceptio if (!newSubjects.isEmpty()) { List savedSubjects = subjectRepository.saveAll(newSubjects); System.out.println("성공적으로 저장된 과목 수: " + savedSubjects.size()); + eventPublisher.publishEvent(new SubjectDataChangedEvent("pdf-incremental-import")); } else { System.out.println("저장할 새로운 과목이 없습니다."); } diff --git a/src/main/java/inu/timetable/service/SubjectAdminService.java b/src/main/java/inu/timetable/service/SubjectAdminService.java index 497e763..d167842 100644 --- a/src/main/java/inu/timetable/service/SubjectAdminService.java +++ b/src/main/java/inu/timetable/service/SubjectAdminService.java @@ -4,8 +4,10 @@ import inu.timetable.dto.SubjectManagementResponse; import inu.timetable.entity.Schedule; import inu.timetable.entity.Subject; +import inu.timetable.event.SubjectDataChangedEvent; import inu.timetable.repository.SubjectRepository; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @@ -19,6 +21,7 @@ public class SubjectAdminService { private final SubjectRepository subjectRepository; + private final ApplicationEventPublisher eventPublisher; @Transactional(readOnly = true) public SubjectManagementResponse getSubject(Long id) { @@ -33,6 +36,7 @@ public SubjectManagementResponse createSubject(SubjectManagementRequest request) applyRequest(subject, request); Subject savedSubject = subjectRepository.save(subject); + publishSubjectDataChanged("admin-create"); return SubjectManagementResponse.from(savedSubject); } @@ -40,6 +44,7 @@ public SubjectManagementResponse createSubject(SubjectManagementRequest request) public SubjectManagementResponse updateSubject(Long id, SubjectManagementRequest request) { Subject subject = findSubject(id); applyRequest(subject, request); + publishSubjectDataChanged("admin-update"); return SubjectManagementResponse.from(subject); } @@ -49,6 +54,7 @@ public void deleteSubject(Long id) { try { subjectRepository.delete(subject); subjectRepository.flush(); + publishSubjectDataChanged("admin-delete"); } catch (DataIntegrityViolationException exception) { throw new ResponseStatusException( HttpStatus.CONFLICT, @@ -100,4 +106,8 @@ private String trimToNull(String value) { } return value.trim(); } + + private void publishSubjectDataChanged(String source) { + eventPublisher.publishEvent(new SubjectDataChangedEvent(source)); + } } diff --git a/src/main/java/inu/timetable/service/SubjectCacheEvictionService.java b/src/main/java/inu/timetable/service/SubjectCacheEvictionService.java new file mode 100644 index 0000000..301a7d2 --- /dev/null +++ b/src/main/java/inu/timetable/service/SubjectCacheEvictionService.java @@ -0,0 +1,30 @@ +package inu.timetable.service; + +import inu.timetable.event.SubjectDataChangedEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.stereotype.Service; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Service +@RequiredArgsConstructor +public class SubjectCacheEvictionService { + + private final CacheManager cacheManager; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + public void evictAfterSubjectDataChanged(SubjectDataChangedEvent event) { + evictAllSubjectReadCaches(); + } + + public void evictAllSubjectReadCaches() { + for (String cacheName : SubjectCacheNames.ALL) { + Cache cache = cacheManager.getCache(cacheName); + if (cache != null) { + cache.clear(); + } + } + } +} diff --git a/src/main/java/inu/timetable/service/SubjectCacheNames.java b/src/main/java/inu/timetable/service/SubjectCacheNames.java new file mode 100644 index 0000000..8cf8800 --- /dev/null +++ b/src/main/java/inu/timetable/service/SubjectCacheNames.java @@ -0,0 +1,24 @@ +package inu.timetable.service; + +import java.util.List; + +public final class SubjectCacheNames { + + public static final String ACTIVE_SUBJECT_COUNT = "activeSubjectCount"; + public static final String SUBJECT_FILTERS = "subjectFilters"; + public static final String SUBJECT_NAME_SEARCH = "subjectNameSearch"; + public static final String SUBJECT_PROFESSOR_SEARCH = "subjectProfessorSearch"; + public static final String SUBJECT_DEPARTMENTS = "subjectDepartments"; + public static final String SUBJECT_GRADES = "subjectGrades"; + + public static final List ALL = List.of( + ACTIVE_SUBJECT_COUNT, + SUBJECT_FILTERS, + SUBJECT_NAME_SEARCH, + SUBJECT_PROFESSOR_SEARCH, + SUBJECT_DEPARTMENTS, + SUBJECT_GRADES); + + private SubjectCacheNames() { + } +} diff --git a/src/main/java/inu/timetable/service/SubjectCacheWarmupService.java b/src/main/java/inu/timetable/service/SubjectCacheWarmupService.java new file mode 100644 index 0000000..226c8c2 --- /dev/null +++ b/src/main/java/inu/timetable/service/SubjectCacheWarmupService.java @@ -0,0 +1,132 @@ +package inu.timetable.service; + +import inu.timetable.dto.SubjectFilterCriteria; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorCompletionService; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +@Slf4j +@Service +public class SubjectCacheWarmupService { + + private final SubjectQueryService subjectQueryService; + private final boolean enabled; + private final int concurrency; + private final int pageSize; + + @Autowired + public SubjectCacheWarmupService( + SubjectQueryService subjectQueryService, + @Value("${subject.cache.warm-up.enabled:true}") boolean enabled, + @Value("${subject.cache.warm-up.concurrency:4}") int concurrency, + @Value("${subject.cache.warm-up.page-size:20}") int pageSize) { + this.subjectQueryService = subjectQueryService; + this.enabled = enabled; + this.concurrency = Math.max(1, concurrency); + this.pageSize = Math.max(1, Math.min(pageSize, SubjectFilterCacheService.MAX_CACHEABLE_PAGE_SIZE)); + } + + @EventListener(ApplicationReadyEvent.class) + public void warmUpAfterApplicationReady() { + warmUp(); + } + + public WarmupResult warmUp() { + if (!enabled) { + return WarmupResult.disabled(); + } + + List tasks = buildWarmupTasks(); + ExecutorService executor = Executors.newFixedThreadPool(Math.max(1, Math.min(concurrency, tasks.size()))); + ExecutorCompletionService completionService = new ExecutorCompletionService<>(executor); + try { + for (WarmupTask task : tasks) { + completionService.submit(task); + } + + int succeeded = 0; + int failed = 0; + for (int i = 0; i < tasks.size(); i++) { + try { + completionService.take().get(); + succeeded += 1; + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + failed += tasks.size() - i; + break; + } catch (ExecutionException exception) { + failed += 1; + log.warn("Subject cache warm-up task failed", exception.getCause()); + } + } + + log.info("Subject cache warm-up completed: {} succeeded, {} failed", succeeded, failed); + return new WarmupResult(false, tasks.size(), succeeded, failed); + } finally { + executor.shutdownNow(); + } + } + + private List buildWarmupTasks() { + List tasks = new ArrayList<>(); + tasks.add(new WarmupTask("active-count", () -> { + subjectQueryService.countActiveSubjects(); + return "active-count"; + })); + tasks.add(new WarmupTask("departments", () -> { + subjectQueryService.findDistinctDepartments(); + return "departments"; + })); + tasks.add(new WarmupTask("default-filter", () -> { + subjectQueryService.filterSubjects(SubjectFilterCriteria.of( + null, null, null, null, + null, null, null, null, null, null, 0, pageSize)); + return "default-filter"; + })); + + for (Integer grade : loadGrades()) { + tasks.add(new WarmupTask("grade-filter-" + grade, () -> { + subjectQueryService.filterSubjects(SubjectFilterCriteria.of( + null, null, null, null, + null, null, null, grade, null, null, 0, pageSize)); + return "grade-filter-" + grade; + })); + } + return tasks; + } + + private List loadGrades() { + try { + return subjectQueryService.findDistinctGrades(); + } catch (RuntimeException exception) { + log.warn("Subject cache warm-up could not load grades", exception); + return List.of(); + } + } + + private record WarmupTask(String name, Callable callable) implements Callable { + + @Override + public String call() throws Exception { + return callable.call(); + } + } + + public record WarmupResult(boolean skipped, int submitted, int succeeded, int failed) { + + static WarmupResult disabled() { + return new WarmupResult(true, 0, 0, 0); + } + } +} diff --git a/src/main/java/inu/timetable/service/SubjectFilterCacheService.java b/src/main/java/inu/timetable/service/SubjectFilterCacheService.java new file mode 100644 index 0000000..ce8adee --- /dev/null +++ b/src/main/java/inu/timetable/service/SubjectFilterCacheService.java @@ -0,0 +1,55 @@ +package inu.timetable.service; + +import inu.timetable.dto.SubjectDto; +import inu.timetable.dto.SubjectFilterCriteria; +import inu.timetable.repository.SubjectRepository; +import lombok.RequiredArgsConstructor; +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.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class SubjectFilterCacheService { + + static final int MAX_CACHEABLE_PAGE_SIZE = 100; + + private final SubjectRepository subjectRepository; + + @Cacheable( + cacheNames = SubjectCacheNames.SUBJECT_FILTERS, + key = "#criteria", + condition = "#criteria.size() <= " + MAX_CACHEABLE_PAGE_SIZE) + public Page filterSubjects(SubjectFilterCriteria criteria) { + Pageable pageable = PageRequest.of(criteria.page(), criteria.size()); + Page subjectIdPage = subjectRepository.findIdsWithFilters( + criteria.subjectName(), + criteria.professor(), + criteria.department(), + criteria.dayOfWeek(), + criteria.startTime(), + criteria.endTime(), + criteria.subjectType(), + criteria.grade(), + criteria.isNight(), + criteria.credits(), + pageable); + + List subjectIds = subjectIdPage.getContent(); + if (subjectIds.isEmpty()) { + return new PageImpl<>(List.of(), pageable, subjectIdPage.getTotalElements()); + } + + List subjectDtos = subjectRepository.findWithSchedulesByIds(subjectIds).stream() + .map(SubjectDto::from) + .toList(); + return new PageImpl<>(subjectDtos, pageable, subjectIdPage.getTotalElements()); + } +} diff --git a/src/main/java/inu/timetable/service/SubjectQueryService.java b/src/main/java/inu/timetable/service/SubjectQueryService.java new file mode 100644 index 0000000..38cf4da --- /dev/null +++ b/src/main/java/inu/timetable/service/SubjectQueryService.java @@ -0,0 +1,49 @@ +package inu.timetable.service; + +import inu.timetable.dto.SubjectDto; +import inu.timetable.dto.SubjectFilterCriteria; +import inu.timetable.repository.SubjectRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class SubjectQueryService { + + private final SubjectRepository subjectRepository; + private final SubjectFilterCacheService subjectFilterCacheService; + private final SubjectSearchCacheService subjectSearchCacheService; + + @Cacheable(cacheNames = SubjectCacheNames.ACTIVE_SUBJECT_COUNT, key = "'active'") + public long countActiveSubjects() { + return subjectRepository.countByActiveTrue(); + } + + @Cacheable(cacheNames = SubjectCacheNames.SUBJECT_DEPARTMENTS, key = "'all'") + public List findDistinctDepartments() { + return subjectRepository.findDistinctDepartments(); + } + + @Cacheable(cacheNames = SubjectCacheNames.SUBJECT_GRADES, key = "'all'") + public List findDistinctGrades() { + return subjectRepository.findDistinctGrades(); + } + + public List searchBySubjectName(String keyword, Integer grade) { + return subjectSearchCacheService.searchBySubjectName(keyword, grade); + } + + public List searchByProfessor(String keyword, Integer grade) { + return subjectSearchCacheService.searchByProfessor(keyword, grade); + } + + public Page filterSubjects(SubjectFilterCriteria criteria) { + return subjectFilterCacheService.filterSubjects(criteria); + } +} diff --git a/src/main/java/inu/timetable/service/SubjectSearchCacheService.java b/src/main/java/inu/timetable/service/SubjectSearchCacheService.java new file mode 100644 index 0000000..7cf5379 --- /dev/null +++ b/src/main/java/inu/timetable/service/SubjectSearchCacheService.java @@ -0,0 +1,46 @@ +package inu.timetable.service; + +import inu.timetable.dto.SubjectDto; +import inu.timetable.dto.SubjectSearchCriteria; +import inu.timetable.entity.Subject; +import inu.timetable.repository.SubjectRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class SubjectSearchCacheService { + + private final SubjectRepository subjectRepository; + + @Cacheable( + cacheNames = SubjectCacheNames.SUBJECT_NAME_SEARCH, + key = "T(inu.timetable.dto.SubjectSearchCriteria).of(#keyword, #grade)") + public List searchBySubjectName(String keyword, Integer grade) { + SubjectSearchCriteria criteria = SubjectSearchCriteria.of(keyword, grade); + List subjects = grade == null + ? subjectRepository.findBySubjectNameContainingAndActiveTrue(criteria.keyword()) + : subjectRepository.findBySubjectNameContainingAndGradeAndActiveTrue(criteria.keyword(), criteria.grade()); + return subjects.stream() + .map(SubjectDto::from) + .toList(); + } + + @Cacheable( + cacheNames = SubjectCacheNames.SUBJECT_PROFESSOR_SEARCH, + key = "T(inu.timetable.dto.SubjectSearchCriteria).of(#keyword, #grade)") + public List searchByProfessor(String keyword, Integer grade) { + SubjectSearchCriteria criteria = SubjectSearchCriteria.of(keyword, grade); + List subjects = grade == null + ? subjectRepository.findByProfessorContainingAndActiveTrue(criteria.keyword()) + : subjectRepository.findByProfessorContainingAndGradeAndActiveTrue(criteria.keyword(), criteria.grade()); + return subjects.stream() + .map(SubjectDto::from) + .toList(); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 38b917b..4059b92 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -51,6 +51,15 @@ admin: max-failures: ${ADMIN_LOGIN_MAX_FAILURES:5} lock-minutes: ${ADMIN_LOGIN_LOCK_MINUTES:10} +subject: + cache: + maximum-size: ${SUBJECT_CACHE_MAXIMUM_SIZE:1000} + expire-after-write: ${SUBJECT_CACHE_EXPIRE_AFTER_WRITE:10m} + warm-up: + enabled: ${SUBJECT_CACHE_WARM_UP_ENABLED:true} + concurrency: ${SUBJECT_CACHE_WARM_UP_CONCURRENCY:4} + page-size: ${SUBJECT_CACHE_WARM_UP_PAGE_SIZE:20} + # Actuator 모니터링 설정 management: endpoints: diff --git a/src/test/java/inu/timetable/config/CacheConfigTest.java b/src/test/java/inu/timetable/config/CacheConfigTest.java new file mode 100644 index 0000000..eac15b9 --- /dev/null +++ b/src/test/java/inu/timetable/config/CacheConfigTest.java @@ -0,0 +1,24 @@ +package inu.timetable.config; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.cache.CacheManager; +import org.springframework.cache.caffeine.CaffeineCacheManager; + +import static org.assertj.core.api.Assertions.assertThat; + +class CacheConfigTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withUserConfiguration(CacheConfig.class) + .withPropertyValues("subject.cache.expire-after-write=10m"); + + @Test + void usesCaffeineCacheManagerByDefault() { + contextRunner.run(context -> { + CacheManager cacheManager = context.getBean(CacheManager.class); + + assertThat(cacheManager).isInstanceOf(CaffeineCacheManager.class); + }); + } +} diff --git a/src/test/java/inu/timetable/service/OfficialSubjectImportServiceTest.java b/src/test/java/inu/timetable/service/OfficialSubjectImportServiceTest.java index 8bb8687..f53e6cf 100644 --- a/src/test/java/inu/timetable/service/OfficialSubjectImportServiceTest.java +++ b/src/test/java/inu/timetable/service/OfficialSubjectImportServiceTest.java @@ -5,6 +5,7 @@ import inu.timetable.entity.Subject; import inu.timetable.enums.ClassMethod; import inu.timetable.enums.SubjectType; +import inu.timetable.event.SubjectDataChangedEvent; import inu.timetable.repository.SubjectRepository; import org.apache.poi.ss.usermodel.Row; import org.apache.poi.ss.usermodel.Sheet; @@ -15,6 +16,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.mock.web.MockMultipartFile; import java.io.ByteArrayOutputStream; @@ -22,6 +24,7 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -32,11 +35,14 @@ class OfficialSubjectImportServiceTest { @Mock private SubjectRepository subjectRepository; + @Mock + private ApplicationEventPublisher eventPublisher; + private OfficialSubjectImportService officialSubjectImportService; @BeforeEach void setUp() { - officialSubjectImportService = new OfficialSubjectImportService(subjectRepository); + officialSubjectImportService = new OfficialSubjectImportService(subjectRepository, eventPublisher); } @Test @@ -68,6 +74,7 @@ void applyDeactivatesMissingExistingSubject() throws Exception { assertThat(legacyUnkeyed.getActive()).isFalse(); assertThat(response.getRemovedCount()).isEqualTo(2); verify(subjectRepository).saveAll(anyList()); + verify(eventPublisher).publishEvent(any(SubjectDataChangedEvent.class)); } @Test diff --git a/src/test/java/inu/timetable/service/SubjectAdminServiceTest.java b/src/test/java/inu/timetable/service/SubjectAdminServiceTest.java index 736b775..eeac4e0 100644 --- a/src/test/java/inu/timetable/service/SubjectAdminServiceTest.java +++ b/src/test/java/inu/timetable/service/SubjectAdminServiceTest.java @@ -6,6 +6,7 @@ import inu.timetable.entity.Subject; import inu.timetable.enums.ClassMethod; import inu.timetable.enums.SubjectType; +import inu.timetable.event.SubjectDataChangedEvent; import inu.timetable.repository.SubjectRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -13,6 +14,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpStatus; import org.springframework.web.server.ResponseStatusException; @@ -25,6 +27,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -34,11 +37,14 @@ class SubjectAdminServiceTest { @Mock private SubjectRepository subjectRepository; + @Mock + private ApplicationEventPublisher eventPublisher; + private SubjectAdminService subjectAdminService; @BeforeEach void setUp() { - subjectAdminService = new SubjectAdminService(subjectRepository); + subjectAdminService = new SubjectAdminService(subjectRepository, eventPublisher); } @Test @@ -60,6 +66,7 @@ void createSubjectSavesSubjectWithSchedules() { assertThat(savedSubject.getSubjectType()).isEqualTo(SubjectType.전심); assertThat(savedSubject.getSchedules()).hasSize(1); assertThat(savedSubject.getSchedules().get(0).getSubject()).isSameAs(savedSubject); + verify(eventPublisher).publishEvent(any(SubjectDataChangedEvent.class)); } @Test @@ -80,6 +87,7 @@ void updateSubjectReplacesExistingSchedules() { assertThat(subject.getSchedules()).extracting(Schedule::getDayOfWeek) .containsExactly("화", "목"); assertThat(subject.getSchedules()).allSatisfy(schedule -> assertThat(schedule.getSubject()).isSameAs(subject)); + verify(eventPublisher).publishEvent(any(SubjectDataChangedEvent.class)); } @Test @@ -108,6 +116,7 @@ void deleteSubjectReturnsConflictWhenSubjectIsReferenced() { .isEqualTo(HttpStatus.CONFLICT)); verify(subjectRepository).delete(subject); + verify(eventPublisher, never()).publishEvent(any(SubjectDataChangedEvent.class)); } private SubjectManagementRequest sampleRequest() { diff --git a/src/test/java/inu/timetable/service/SubjectCacheWarmupServiceTest.java b/src/test/java/inu/timetable/service/SubjectCacheWarmupServiceTest.java new file mode 100644 index 0000000..85a901e --- /dev/null +++ b/src/test/java/inu/timetable/service/SubjectCacheWarmupServiceTest.java @@ -0,0 +1,70 @@ +package inu.timetable.service; + +import inu.timetable.dto.SubjectFilterCriteria; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class SubjectCacheWarmupServiceTest { + + @Test + void warmUpSubmitsCommonAndGradeFilterQueries() { + SubjectQueryService subjectQueryService = mock(SubjectQueryService.class); + when(subjectQueryService.findDistinctGrades()).thenReturn(List.of(1, 2)); + SubjectCacheWarmupService warmupService = new SubjectCacheWarmupService( + subjectQueryService, true, 2, 20); + + SubjectCacheWarmupService.WarmupResult result = warmupService.warmUp(); + + assertThat(result.skipped()).isFalse(); + assertThat(result.submitted()).isEqualTo(5); + assertThat(result.succeeded()).isEqualTo(5); + assertThat(result.failed()).isZero(); + verify(subjectQueryService).findDistinctGrades(); + verify(subjectQueryService).countActiveSubjects(); + verify(subjectQueryService).findDistinctDepartments(); + verify(subjectQueryService).filterSubjects(SubjectFilterCriteria.of( + null, null, null, null, + null, null, null, null, null, null, 0, 20)); + verify(subjectQueryService).filterSubjects(SubjectFilterCriteria.of( + null, null, null, null, + null, null, null, 1, null, null, 0, 20)); + verify(subjectQueryService).filterSubjects(SubjectFilterCriteria.of( + null, null, null, null, + null, null, null, 2, null, null, 0, 20)); + } + + @Test + void disabledWarmUpDoesNothing() { + SubjectQueryService subjectQueryService = mock(SubjectQueryService.class); + SubjectCacheWarmupService warmupService = new SubjectCacheWarmupService( + subjectQueryService, false, 2, 20); + + SubjectCacheWarmupService.WarmupResult result = warmupService.warmUp(); + + assertThat(result.skipped()).isTrue(); + verifyNoInteractions(subjectQueryService); + } + + @Test + void warmUpCapsConfiguredPageSizeToCacheableLimit() { + SubjectQueryService subjectQueryService = mock(SubjectQueryService.class); + when(subjectQueryService.findDistinctGrades()).thenReturn(List.of()); + SubjectCacheWarmupService warmupService = new SubjectCacheWarmupService( + subjectQueryService, true, 0, 200); + + SubjectCacheWarmupService.WarmupResult result = warmupService.warmUp(); + + assertThat(result.succeeded()).isEqualTo(3); + verify(subjectQueryService).filterSubjects(SubjectFilterCriteria.of( + null, null, null, null, + null, null, null, null, null, null, 0, + SubjectFilterCacheService.MAX_CACHEABLE_PAGE_SIZE)); + } +} diff --git a/src/test/java/inu/timetable/service/SubjectQueryServiceCacheTest.java b/src/test/java/inu/timetable/service/SubjectQueryServiceCacheTest.java new file mode 100644 index 0000000..82e0f89 --- /dev/null +++ b/src/test/java/inu/timetable/service/SubjectQueryServiceCacheTest.java @@ -0,0 +1,219 @@ +package inu.timetable.service; + +import inu.timetable.config.CacheConfig; +import inu.timetable.dto.SubjectDto; +import inu.timetable.dto.SubjectFilterCriteria; +import inu.timetable.entity.Schedule; +import inu.timetable.entity.Subject; +import inu.timetable.enums.ClassMethod; +import inu.timetable.enums.SubjectType; +import inu.timetable.event.SubjectDataChangedEvent; +import inu.timetable.repository.SubjectRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringBootTest(classes = { + JacksonAutoConfiguration.class, + CacheConfig.class, + SubjectCacheEvictionService.class, + SubjectFilterCacheService.class, + SubjectSearchCacheService.class, + SubjectQueryService.class +}, properties = { + "subject.cache.maximum-size=100", + "subject.cache.expire-after-write=10m" +}) +class SubjectQueryServiceCacheTest { + + @Autowired + private SubjectQueryService subjectQueryService; + + @Autowired + private SubjectCacheEvictionService subjectCacheEvictionService; + + @Autowired + private ApplicationEventPublisher eventPublisher; + + @MockitoBean + private SubjectRepository subjectRepository; + + @BeforeEach + void setUp() { + reset(subjectRepository); + subjectCacheEvictionService.evictAllSubjectReadCaches(); + } + + @Test + void searchSubjectsCachesSameCriteriaUntilSubjectDataChanges() { + when(subjectRepository.findBySubjectNameContainingAndActiveTrue("자료")) + .thenReturn(List.of(subject(1L, "자료구조")), List.of(subject(2L, "자료구조응용"))); + + List first = subjectQueryService.searchBySubjectName(" 자료 ", null); + List second = subjectQueryService.searchBySubjectName("자료", null); + + assertThat(first).extracting(SubjectDto::getSubjectName).containsExactly("자료구조"); + assertThat(second).extracting(SubjectDto::getSubjectName).containsExactly("자료구조"); + verify(subjectRepository, times(1)).findBySubjectNameContainingAndActiveTrue("자료"); + + eventPublisher.publishEvent(new SubjectDataChangedEvent("test")); + assertThat(subjectQueryService.searchBySubjectName("자료", null)) + .extracting(SubjectDto::getSubjectName) + .containsExactly("자료구조응용"); + verify(subjectRepository, times(2)).findBySubjectNameContainingAndActiveTrue("자료"); + } + + @Test + void countActiveSubjectsCachesUntilSubjectDataChanges() { + when(subjectRepository.countByActiveTrue()).thenReturn(2894L, 3000L); + + assertThat(subjectQueryService.countActiveSubjects()).isEqualTo(2894L); + assertThat(subjectQueryService.countActiveSubjects()).isEqualTo(2894L); + verify(subjectRepository, times(1)).countByActiveTrue(); + + eventPublisher.publishEvent(new SubjectDataChangedEvent("test")); + + assertThat(subjectQueryService.countActiveSubjects()).isEqualTo(3000L); + verify(subjectRepository, times(2)).countByActiveTrue(); + } + + @Test + void filterSubjectsCachesSameCriteriaUntilSubjectDataChanges() { + Pageable pageable = PageRequest.of(0, 20); + SubjectFilterCriteria criteria = SubjectFilterCriteria.of( + "자료", null, "컴퓨터공학부", null, + null, null, SubjectType.전심, 2, null, null, 0, 20); + when(subjectRepository.findIdsWithFilters( + nullable(String.class), + nullable(String.class), + nullable(String.class), + nullable(String.class), + nullable(Double.class), + nullable(Double.class), + nullable(SubjectType.class), + nullable(Integer.class), + nullable(Boolean.class), + nullable(Integer.class), + any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(1L), pageable, 1)); + when(subjectRepository.findWithSchedulesByIds(List.of(1L))) + .thenReturn(List.of(subject(1L, "자료구조"))); + + Page first = subjectQueryService.filterSubjects(criteria); + Page second = subjectQueryService.filterSubjects(SubjectFilterCriteria.of( + "자료", null, "컴퓨터공학부", null, + null, null, SubjectType.전심, 2, null, null, 0, 20)); + + assertThat(first.getContent()).hasSize(1); + assertThat(second.getContent()).hasSize(1); + verify(subjectRepository, times(1)).findIdsWithFilters( + nullable(String.class), + nullable(String.class), + nullable(String.class), + nullable(String.class), + nullable(Double.class), + nullable(Double.class), + nullable(SubjectType.class), + nullable(Integer.class), + nullable(Boolean.class), + nullable(Integer.class), + any(Pageable.class)); + verify(subjectRepository, times(1)).findWithSchedulesByIds(List.of(1L)); + + eventPublisher.publishEvent(new SubjectDataChangedEvent("test")); + subjectQueryService.filterSubjects(criteria); + + verify(subjectRepository, times(2)).findIdsWithFilters( + nullable(String.class), + nullable(String.class), + nullable(String.class), + nullable(String.class), + nullable(Double.class), + nullable(Double.class), + nullable(SubjectType.class), + nullable(Integer.class), + nullable(Boolean.class), + nullable(Integer.class), + any(Pageable.class)); + verify(subjectRepository, times(2)).findWithSchedulesByIds(List.of(1L)); + } + + @Test + void filterSubjectsDoesNotCacheOversizedPages() { + Pageable pageable = PageRequest.of(0, 101); + SubjectFilterCriteria criteria = SubjectFilterCriteria.of( + null, null, null, null, + null, null, null, null, null, null, 0, 101); + when(subjectRepository.findIdsWithFilters( + nullable(String.class), + nullable(String.class), + nullable(String.class), + nullable(String.class), + nullable(Double.class), + nullable(Double.class), + nullable(SubjectType.class), + nullable(Integer.class), + nullable(Boolean.class), + nullable(Integer.class), + any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(), pageable, 0)); + + subjectQueryService.filterSubjects(criteria); + subjectQueryService.filterSubjects(criteria); + + verify(subjectRepository, times(2)).findIdsWithFilters( + nullable(String.class), + nullable(String.class), + nullable(String.class), + nullable(String.class), + nullable(Double.class), + nullable(Double.class), + nullable(SubjectType.class), + nullable(Integer.class), + nullable(Boolean.class), + nullable(Integer.class), + any(Pageable.class)); + } + + private Subject subject(Long id, String subjectName) { + Subject subject = Subject.builder() + .id(id) + .subjectName(subjectName) + .credits(3) + .professor("김교수") + .department("컴퓨터공학부") + .grade(2) + .subjectType(SubjectType.전심) + .classMethod(ClassMethod.OFFLINE) + .isNight(false) + .schedules(new ArrayList<>()) + .build(); + subject.getSchedules().add(Schedule.builder() + .id(10L) + .subject(subject) + .dayOfWeek("월") + .startTime(1.0) + .endTime(2.5) + .build()); + return subject; + } +} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index f0bb715..90bfbcb 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -16,3 +16,8 @@ spring: gemini: api: key: test-key + +subject: + cache: + warm-up: + enabled: false