diff --git a/src/main/java/com/example/solidconnection/application/dto/ApplicantsResponse.java b/src/main/java/com/example/solidconnection/application/dto/ApplicantsResponse.java index fdb9c357c..10a794fc9 100644 --- a/src/main/java/com/example/solidconnection/application/dto/ApplicantsResponse.java +++ b/src/main/java/com/example/solidconnection/application/dto/ApplicantsResponse.java @@ -8,7 +8,7 @@ public record ApplicantsResponse( String koreanName, - int studentCapacity, + Integer studentCapacity, String region, String country, List applicants) { diff --git a/src/main/java/com/example/solidconnection/cache/ThunderingHerdCachingAspect.java b/src/main/java/com/example/solidconnection/cache/ThunderingHerdCachingAspect.java index 22652b1c1..1c88f1f22 100644 --- a/src/main/java/com/example/solidconnection/cache/ThunderingHerdCachingAspect.java +++ b/src/main/java/com/example/solidconnection/cache/ThunderingHerdCachingAspect.java @@ -63,7 +63,7 @@ public Object cache(ProceedingJoinPoint joinPoint, ThunderingHerdCaching thunder if (redisUtils.isCacheExpiringSoon(key, ttl, Double.valueOf(REFRESH_LIMIT_PERCENT.getValue()))) { log.info("Cache hit, but TTL is expiring soon. Key: {}, Thread: {}", key, Thread.currentThread().getName()); - return refreshCache(cachedValue, ttl, key); + return refreshCache(joinPoint, cacheManager, cachedValue, ttl, key); } log.info("Cache hit. Key: {}, Thread: {}", key, Thread.currentThread().getName()); @@ -88,14 +88,24 @@ private Object createCache(ProceedingJoinPoint joinPoint, CacheManager cacheMana ); } - private Object refreshCache(Object cachedValue, Long ttl, String key) { + private Object refreshCache(ProceedingJoinPoint joinPoint, CacheManager cacheManager, Object cachedValue, Long ttl, String key) { return executeWithLock( - redisUtils.getRefreshLockKey(key), + redisUtils.getCreateLockKey(key), () -> { log.info("갱신락 흭득하였습니다. Key: {}, Thread: {}", key, Thread.currentThread().getName()); - redisTemplate.opsForValue().getAndExpire(key, Duration.ofSeconds(ttl)); - log.info("TTL 갱신을 마쳤습니다. Key: {}, Thread: {}", key, Thread.currentThread().getName()); - return cachedValue; + try { + Object result = proceedJoinPoint(joinPoint); + cacheManager.put(key, result, ttl); + redisTemplate.convertAndSend(CREATE_CHANNEL.getValue(), key); + log.info("캐시 갱신을 마쳤습니다. Key: {}, Thread: {}", key, Thread.currentThread().getName()); + return result; + } catch (CustomException e) { + throw e; + } catch (RuntimeException e) { + log.warn("캐시 갱신 중 오류가 발생하여 기존 캐시값을 반환합니다. Key: {}, Thread: {}", + key, Thread.currentThread().getName(), e); + return cachedValue; + } }, () -> { log.info("갱신락 흭득에 실패하였습니다. 캐시의 값을 바로 반환합니다. Key: {}, Thread: {}", key, Thread.currentThread().getName()); diff --git a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java index 214da0838..5638c024a 100644 --- a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java +++ b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java @@ -88,6 +88,7 @@ public enum ErrorCode { SCHOOL_EMAIL_CONFIRM_CODE_DIFFERENT(HttpStatus.BAD_REQUEST.value(), "인증 코드가 일치하지 않습니다."), SCHOOL_EMAIL_VERIFICATION_INFO_SAVE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR.value(), "학교 이메일 인증 정보 저장에 실패했습니다."), SCHOOL_EMAIL_VERIFICATION_INFO_CORRUPTED(HttpStatus.INTERNAL_SERVER_ERROR.value(), "학교 이메일 인증 정보가 손상되었습니다. 인증 코드 발송을 다시 요청해주세요."), + SCHOOL_EMAIL_NOT_VERIFIED(HttpStatus.BAD_REQUEST.value(), "학교 이메일 인증이 필요합니다."), // s3 S3_SERVICE_EXCEPTION(HttpStatus.BAD_REQUEST.value(), "S3 서비스 에러 발생"), diff --git a/src/main/java/com/example/solidconnection/score/dto/GpaScoreStatusesResponse.java b/src/main/java/com/example/solidconnection/score/dto/GpaScoreStatusesResponse.java index 4d31d4252..a79f8500f 100644 --- a/src/main/java/com/example/solidconnection/score/dto/GpaScoreStatusesResponse.java +++ b/src/main/java/com/example/solidconnection/score/dto/GpaScoreStatusesResponse.java @@ -3,10 +3,14 @@ import java.util.List; public record GpaScoreStatusesResponse( + String homeUniversityName, List gpaScoreStatusResponseList ) { - public static GpaScoreStatusesResponse from(List gpaScoreStatusResponseList) { - return new GpaScoreStatusesResponse(gpaScoreStatusResponseList); + public static GpaScoreStatusesResponse of( + String homeUniversityName, + List gpaScoreStatusResponseList + ) { + return new GpaScoreStatusesResponse(homeUniversityName, gpaScoreStatusResponseList); } } diff --git a/src/main/java/com/example/solidconnection/score/service/ScoreService.java b/src/main/java/com/example/solidconnection/score/service/ScoreService.java index f1f055c81..871c955f4 100644 --- a/src/main/java/com/example/solidconnection/score/service/ScoreService.java +++ b/src/main/java/com/example/solidconnection/score/service/ScoreService.java @@ -20,6 +20,7 @@ import com.example.solidconnection.score.repository.LanguageTestScoreRepository; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.university.service.HomeUniversityQueryService; import java.util.List; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; @@ -35,6 +36,7 @@ public class ScoreService { private final S3Service s3Service; private final LanguageTestScoreRepository languageTestScoreRepository; private final SiteUserRepository siteUserRepository; + private final HomeUniversityQueryService homeUniversityQueryService; @Transactional public Long submitGpaScore(long siteUserId, GpaScoreRequest gpaScoreRequest, MultipartFile file) { @@ -63,13 +65,14 @@ public Long submitLanguageTestScore(long siteUserId, LanguageTestScoreRequest la public GpaScoreStatusesResponse getGpaScoreStatus(long siteUserId) { SiteUser siteUser = siteUserRepository.findById(siteUserId) .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); + String homeUniversityName = homeUniversityQueryService.findNameByRequiredId(siteUser.getHomeUniversityId()); List gpaScoreStatusResponseList = gpaScoreRepository.findBySiteUserId(siteUser.getId()) .stream() .map(GpaScoreStatusResponse::from) .toList(); - return GpaScoreStatusesResponse.from(gpaScoreStatusResponseList); + return GpaScoreStatusesResponse.of(homeUniversityName, gpaScoreStatusResponseList); } @Transactional(readOnly = true) diff --git a/src/main/java/com/example/solidconnection/siteuser/service/MyPageService.java b/src/main/java/com/example/solidconnection/siteuser/service/MyPageService.java index 69daa9273..95367668b 100644 --- a/src/main/java/com/example/solidconnection/siteuser/service/MyPageService.java +++ b/src/main/java/com/example/solidconnection/siteuser/service/MyPageService.java @@ -25,10 +25,9 @@ import com.example.solidconnection.siteuser.dto.PasswordUpdateRequest; import com.example.solidconnection.siteuser.repository.SiteUserRepository; import com.example.solidconnection.university.domain.HostUniversity; -import com.example.solidconnection.university.domain.HomeUniversity; import com.example.solidconnection.university.repository.HostUniversityRepository; -import com.example.solidconnection.university.repository.HomeUniversityRepository; import com.example.solidconnection.university.repository.LikedUnivApplyInfoRepository; +import com.example.solidconnection.university.service.HomeUniversityQueryService; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.List; @@ -51,7 +50,7 @@ public class MyPageService { private final CountryRepository countryRepository; private final MentorRepository mentorRepository; private final HostUniversityRepository hostUniversityRepository; - private final HomeUniversityRepository homeUniversityRepository; + private final HomeUniversityQueryService homeUniversityQueryService; private final S3Service s3Service; private final InterestedCountryService interestedCountryService; private final InterestedRegionService interestedRegionService; @@ -67,7 +66,7 @@ public MyPageResponse getMyPageInfo(long siteUserId) { List interestedCountries = null; String universityKoreanName = null; - String homeUniversityName = findHomeUniversityName(siteUser); + String homeUniversityName = homeUniversityQueryService.findNameByNullableId(siteUser.getHomeUniversityId()); if (siteUser.getRole() == Role.MENTEE) { interestedCountries = countryRepository.findKoreanNamesBySiteUserId(siteUser.getId()); } else if (siteUser.getRole() == Role.MENTOR) { @@ -86,17 +85,6 @@ public MyPageResponse getMyPageInfo(long siteUserId) { ); } - private String findHomeUniversityName(SiteUser siteUser) { - Long homeUniversityId = siteUser.getHomeUniversityId(); - if (homeUniversityId == null) { - return null; - } - - HomeUniversity homeUniversity = homeUniversityRepository.findById(homeUniversityId) - .orElseThrow(() -> new CustomException(UNIVERSITY_NOT_FOUND)); - return homeUniversity.getName(); - } - /* * 마이페이지 정보를 수정한다. * */ diff --git a/src/main/java/com/example/solidconnection/university/service/GeneralUnivApplyInfoRecommendService.java b/src/main/java/com/example/solidconnection/university/service/GeneralUnivApplyInfoRecommendService.java index 18978aa2c..3b2f4e3ed 100644 --- a/src/main/java/com/example/solidconnection/university/service/GeneralUnivApplyInfoRecommendService.java +++ b/src/main/java/com/example/solidconnection/university/service/GeneralUnivApplyInfoRecommendService.java @@ -2,36 +2,41 @@ import static com.example.solidconnection.university.service.UnivApplyInfoRecommendService.RECOMMEND_UNIV_APPLY_INFO_NUM; -import com.example.solidconnection.term.repository.TermRepository; +import com.example.solidconnection.cache.annotation.ThunderingHerdCaching; import com.example.solidconnection.university.domain.UnivApplyInfo; +import com.example.solidconnection.university.dto.UnivApplyInfoPreviewResponse; +import com.example.solidconnection.university.dto.UnivApplyInfoRecommendsResponse; import com.example.solidconnection.university.repository.UnivApplyInfoRepository; import java.util.List; -import lombok.Getter; import lombok.RequiredArgsConstructor; -import org.springframework.boot.context.event.ApplicationReadyEvent; -import org.springframework.context.event.EventListener; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor public class GeneralUnivApplyInfoRecommendService { + private static final long GENERAL_RECOMMEND_CACHE_TTL_SEC = 3600; + /* * 해당 시기에 열리는 대학교들 중 랜덤으로 선택해서 목록을 구성한다. * */ private final UnivApplyInfoRepository univApplyInfoRepository; - private final TermRepository termRepository; - @Getter - private List generalRecommends; + @Transactional(readOnly = true) + @ThunderingHerdCaching( + key = "university:recommend:general:{0}:{1}", + cacheManager = "customCacheManager", + ttlSec = GENERAL_RECOMMEND_CACHE_TTL_SEC + ) + public UnivApplyInfoRecommendsResponse getGeneralRecommends(long termId, String termName) { + Pageable page = PageRequest.of(0, RECOMMEND_UNIV_APPLY_INFO_NUM); + List generalRecommends = univApplyInfoRepository.findRandomByTermId(termId, page); - @EventListener(ApplicationReadyEvent.class) - public void init() { - termRepository.findByIsCurrentTrue().ifPresent(term -> { - Pageable page = PageRequest.of(0, RECOMMEND_UNIV_APPLY_INFO_NUM); - generalRecommends = univApplyInfoRepository.findRandomByTermId(term.getId(), page); - }); + return new UnivApplyInfoRecommendsResponse(generalRecommends.stream() + .map(univApplyInfo -> UnivApplyInfoPreviewResponse.of(univApplyInfo, termName)) + .toList()); } } diff --git a/src/main/java/com/example/solidconnection/university/service/HomeUniversityQueryService.java b/src/main/java/com/example/solidconnection/university/service/HomeUniversityQueryService.java new file mode 100644 index 000000000..6d489ae74 --- /dev/null +++ b/src/main/java/com/example/solidconnection/university/service/HomeUniversityQueryService.java @@ -0,0 +1,42 @@ +package com.example.solidconnection.university.service; + +import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_NOT_VERIFIED; +import static com.example.solidconnection.common.exception.ErrorCode.UNIVERSITY_NOT_FOUND; + +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.university.domain.HomeUniversity; +import com.example.solidconnection.university.repository.HomeUniversityRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class HomeUniversityQueryService { + + private final HomeUniversityRepository homeUniversityRepository; + + @Transactional(readOnly = true) + public String findNameByNullableId(Long homeUniversityId) { + if (homeUniversityId == null) { + return null; + } + + return findNameById(homeUniversityId); + } + + @Transactional(readOnly = true) + public String findNameByRequiredId(Long homeUniversityId) { + if (homeUniversityId == null) { + throw new CustomException(SCHOOL_EMAIL_NOT_VERIFIED); + } + + return findNameById(homeUniversityId); + } + + private String findNameById(Long homeUniversityId) { + HomeUniversity homeUniversity = homeUniversityRepository.findById(homeUniversityId) + .orElseThrow(() -> new CustomException(UNIVERSITY_NOT_FOUND)); + return homeUniversity.getName(); + } +} diff --git a/src/main/java/com/example/solidconnection/university/service/UnivApplyInfoRecommendService.java b/src/main/java/com/example/solidconnection/university/service/UnivApplyInfoRecommendService.java index 1ce568a71..57f1fe8b2 100644 --- a/src/main/java/com/example/solidconnection/university/service/UnivApplyInfoRecommendService.java +++ b/src/main/java/com/example/solidconnection/university/service/UnivApplyInfoRecommendService.java @@ -2,7 +2,6 @@ import static com.example.solidconnection.common.exception.ErrorCode.CURRENT_TERM_NOT_FOUND; -import com.example.solidconnection.cache.annotation.ThunderingHerdCaching; import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.term.domain.Term; import com.example.solidconnection.term.repository.TermRepository; @@ -47,7 +46,7 @@ public UnivApplyInfoRecommendsResponse getPersonalRecommends(long siteUserId) { // 맞춤 추천 대학교의 수가 6개보다 적다면, 일반 추천 대학교를 부족한 수 만큼 불러온다. if (trimmedRecommends.size() < RECOMMEND_UNIV_APPLY_INFO_NUM) { - trimmedRecommends.addAll(getGeneralRecommendsExcludingSelected(trimmedRecommends)); + return new UnivApplyInfoRecommendsResponse(getPersonalRecommendPreviewsWithGeneralFallback(trimmedRecommends, term)); } return new UnivApplyInfoRecommendsResponse(trimmedRecommends.stream() @@ -55,9 +54,29 @@ public UnivApplyInfoRecommendsResponse getPersonalRecommends(long siteUserId) { .toList()); } - private List getGeneralRecommendsExcludingSelected(List alreadyPicked) { - List generalRecommend = new ArrayList<>(generalUnivApplyInfoRecommendService.getGeneralRecommends()); - generalRecommend.removeAll(alreadyPicked); + private List getPersonalRecommendPreviewsWithGeneralFallback( + List personalRecommends, + Term term + ) { + List recommendPreviews = new ArrayList<>(personalRecommends.stream() + .map(univApplyInfo -> UnivApplyInfoPreviewResponse.of(univApplyInfo, term.getName())) + .toList()); + recommendPreviews.addAll(getGeneralRecommendsExcludingSelected(personalRecommends, term)); + + return recommendPreviews; + } + + private List getGeneralRecommendsExcludingSelected( + List alreadyPicked, + Term term + ) { + List alreadyPickedIds = alreadyPicked.stream() + .map(UnivApplyInfo::getId) + .toList(); + List generalRecommend = new ArrayList<>(generalUnivApplyInfoRecommendService + .getGeneralRecommends(term.getId(), term.getName()) + .recommendedUniversities()); + generalRecommend.removeIf(univApplyInfo -> alreadyPickedIds.contains(univApplyInfo.id())); Collections.shuffle(generalRecommend); int needed = RECOMMEND_UNIV_APPLY_INFO_NUM - alreadyPicked.size(); return generalRecommend.subList(0, Math.min(needed, generalRecommend.size())); @@ -67,15 +86,10 @@ private List getGeneralRecommendsExcludingSelected(List new CustomException(CURRENT_TERM_NOT_FOUND)); - List generalRecommends = new ArrayList<>(generalUnivApplyInfoRecommendService.getGeneralRecommends()); - - return new UnivApplyInfoRecommendsResponse(generalRecommends.stream() - .map(univApplyInfo -> UnivApplyInfoPreviewResponse.of(univApplyInfo, term.getName())) - .toList()); + return generalUnivApplyInfoRecommendService.getGeneralRecommends(term.getId(), term.getName()); } } diff --git a/src/main/java/com/example/solidconnection/util/RedisUtils.java b/src/main/java/com/example/solidconnection/util/RedisUtils.java index a18fe1791..a336920ac 100644 --- a/src/main/java/com/example/solidconnection/util/RedisUtils.java +++ b/src/main/java/com/example/solidconnection/util/RedisUtils.java @@ -58,6 +58,11 @@ public String getRefreshLockKey(String key) { public boolean isCacheExpiringSoon(String key, Long defaultTtl, Double percent) { Long leftTtl = redisTemplate.getExpire(key); - return defaultTtl != null && ((double) leftTtl / defaultTtl) * 100 < percent; + if (defaultTtl == null || defaultTtl <= 0 + || leftTtl == null || leftTtl < 0 + || percent == null || percent <= 0 || percent > 100) { + return false; + } + return ((double) leftTtl / defaultTtl) * 100 < percent; } } diff --git a/src/test/java/com/example/solidconnection/cache/ThunderingHerdCachingAspectTest.java b/src/test/java/com/example/solidconnection/cache/ThunderingHerdCachingAspectTest.java new file mode 100644 index 000000000..98cb18257 --- /dev/null +++ b/src/test/java/com/example/solidconnection/cache/ThunderingHerdCachingAspectTest.java @@ -0,0 +1,295 @@ +package com.example.solidconnection.cache; + +import static com.example.solidconnection.redis.RedisConstants.CREATE_LOCK_PREFIX; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.example.solidconnection.cache.annotation.ThunderingHerdCaching; +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.common.exception.ErrorCode; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.util.RedisUtils; +import java.time.Duration; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.data.redis.core.RedisTemplate; + +@TestContainerSpringBootTest +@DisplayName("ThunderingHerdCaching Aspect 테스트") +class ThunderingHerdCachingAspectTest { + + private static final String CACHE_KEY_PREFIX = "test:thundering:"; + private static final long TEST_CACHE_TTL_SEC = 20L; + + @Autowired + private TestCacheService testCacheService; + + @Autowired + private RedisTemplate redisTemplate; + + @Autowired + private RedisUtils redisUtils; + + @BeforeEach + void setUp() { + testCacheService.reset(); + } + + @Test + void 캐시가_만료_임박하면_갱신락을_획득한_요청이_값을_다시_계산한다() { + // given + String firstValue = testCacheService.getValue("refresh"); + redisTemplate.expire(cacheKey("refresh"), Duration.ofSeconds(1)); + + // when + String secondValue = testCacheService.getValue("refresh"); + + // then + assertAll( + () -> assertThat(firstValue).isEqualTo("value-1"), + () -> assertThat(secondValue).isEqualTo("value-2"), + () -> assertThat(redisTemplate.opsForValue().get(cacheKey("refresh"))).isEqualTo("value-2"), + () -> assertThat(testCacheService.getCallCount()).isEqualTo(2) + ); + } + + @Test + void 캐시가_만료_임박했지만_갱신락_획득에_실패하면_기존_캐시값을_반환한다() { + // given + String firstValue = testCacheService.getValue("locked"); + redisTemplate.expire(cacheKey("locked"), Duration.ofSeconds(1)); + redisTemplate.opsForValue().set(createLockKey("locked"), "lock", Duration.ofSeconds(5)); + + // when + String secondValue = testCacheService.getValue("locked"); + + // then + assertAll( + () -> assertThat(secondValue).isEqualTo(firstValue), + () -> assertThat(redisTemplate.opsForValue().get(cacheKey("locked"))).isEqualTo(firstValue), + () -> assertThat(testCacheService.getCallCount()).isEqualTo(1), + () -> assertThat(testCacheService.getInvocationCount()).isEqualTo(1) + ); + } + + @Test + void 캐시_갱신_중_오류가_발생하면_기존_캐시값을_반환하고_TTL을_연장하지_않는다() { + // given + String firstValue = testCacheService.getValue("failed"); + redisTemplate.expire(cacheKey("failed"), Duration.ofSeconds(1)); + testCacheService.failWithRuntimeException(); + + // when + String secondValue = testCacheService.getValue("failed"); + + // then + Long ttlMillis = redisTemplate.getExpire(cacheKey("failed"), TimeUnit.MILLISECONDS); + assertAll( + () -> assertThat(secondValue).isEqualTo(firstValue), + () -> assertThat(redisTemplate.opsForValue().get(cacheKey("failed"))).isEqualTo(firstValue), + () -> assertThat(testCacheService.getCallCount()).isEqualTo(1), + () -> assertThat(testCacheService.getInvocationCount()).isEqualTo(2), + () -> assertThat(ttlMillis).isLessThan(TEST_CACHE_TTL_SEC * 1000) + ); + } + + @Test + void 캐시_갱신_중_CustomException이_발생하면_기존_캐시값으로_fallback하지_않고_예외를_전파한다() { + // given + testCacheService.getValue("custom-exception"); + redisTemplate.expire(cacheKey("custom-exception"), Duration.ofSeconds(1)); + testCacheService.failWithCustomException(); + + // when & then + assertAll( + () -> assertThatThrownBy(() -> testCacheService.getValue("custom-exception")) + .isInstanceOf(CustomException.class), + () -> assertThat(testCacheService.getCallCount()).isEqualTo(1), + () -> assertThat(testCacheService.getInvocationCount()).isEqualTo(2) + ); + } + + @Test + void 만료_시간이_없는_캐시는_만료_임박으로_판단하지_않고_기존_캐시값을_반환한다() { + // given + String firstValue = testCacheService.getValue("no-expire"); + redisTemplate.persist(cacheKey("no-expire")); + + // when + String secondValue = testCacheService.getValue("no-expire"); + + // then + assertAll( + () -> assertThat(secondValue).isEqualTo(firstValue), + () -> assertThat(testCacheService.getCallCount()).isEqualTo(1), + () -> assertThat(testCacheService.getInvocationCount()).isEqualTo(1), + () -> assertThat(redisTemplate.getExpire(cacheKey("no-expire"))).isEqualTo(-1) + ); + } + + @Test + void 만료_임박_비율이_유효하지_않으면_만료_임박으로_판단하지_않는다() { + // given + String key = cacheKey("invalid-percent"); + redisTemplate.opsForValue().set(key, "value", Duration.ofSeconds(1)); + + // when & then + assertAll( + () -> assertThat(redisUtils.isCacheExpiringSoon(key, TEST_CACHE_TTL_SEC, 0.0)).isFalse(), + () -> assertThat(redisUtils.isCacheExpiringSoon(key, TEST_CACHE_TTL_SEC, -1.0)).isFalse(), + () -> assertThat(redisUtils.isCacheExpiringSoon(key, TEST_CACHE_TTL_SEC, 101.0)).isFalse() + ); + } + + @Test + void 캐시_갱신_중_기존_캐시가_만료되어도_생성락으로_중복_계산을_막는다() throws Exception { + // given + String firstValue = testCacheService.getValue("expired-during-refresh"); + redisTemplate.expire(cacheKey("expired-during-refresh"), Duration.ofSeconds(1)); + testCacheService.blockNextInvocation(); + + ExecutorService refreshExecutor = Executors.newSingleThreadExecutor(); + ExecutorService waitingExecutor = Executors.newSingleThreadExecutor(); + + try { + Future refreshResult = refreshExecutor.submit(() -> testCacheService.getValue("expired-during-refresh")); + testCacheService.awaitBlockedInvocation(); + await().atMost(Duration.ofSeconds(3)) + .untilAsserted(() -> assertThat(redisTemplate.hasKey(cacheKey("expired-during-refresh"))).isFalse()); + + // when + Future waitingResult = waitingExecutor.submit(() -> testCacheService.getValue("expired-during-refresh")); + await().during(Duration.ofMillis(200)) + .atMost(Duration.ofSeconds(1)) + .untilAsserted(() -> assertThat(waitingResult.isDone()).isFalse()); + testCacheService.releaseBlockedInvocation(); + + // then + assertAll( + () -> assertThat(firstValue).isEqualTo("value-1"), + () -> assertThat(refreshResult.get(3, TimeUnit.SECONDS)).isEqualTo("value-2"), + () -> assertThat(waitingResult.get(3, TimeUnit.SECONDS)).isEqualTo("value-2"), + () -> assertThat(redisTemplate.opsForValue().get(cacheKey("expired-during-refresh"))).isEqualTo("value-2"), + () -> assertThat(testCacheService.getCallCount()).isEqualTo(2), + () -> assertThat(testCacheService.getInvocationCount()).isEqualTo(2) + ); + } finally { + testCacheService.releaseBlockedInvocation(); + refreshExecutor.shutdownNow(); + waitingExecutor.shutdownNow(); + } + } + + private String cacheKey(String key) { + return CACHE_KEY_PREFIX + key; + } + + private String createLockKey(String key) { + return CREATE_LOCK_PREFIX.getValue() + cacheKey(key); + } + + @TestConfiguration + static class TestCacheConfig { + + @Bean + TestCacheService testCacheService() { + return new TestCacheService(); + } + } + + static class TestCacheService { + + private final AtomicInteger callCount = new AtomicInteger(); + private final AtomicInteger invocationCount = new AtomicInteger(); + private volatile boolean failWithRuntimeException; + private volatile boolean failWithCustomException; + private volatile boolean blockNextInvocation; + private volatile CountDownLatch blockedInvocationStarted; + private volatile CountDownLatch blockedInvocationRelease; + + @ThunderingHerdCaching( + key = CACHE_KEY_PREFIX + "{0}", + cacheManager = "customCacheManager", + ttlSec = TEST_CACHE_TTL_SEC + ) + public String getValue(String key) { + invocationCount.incrementAndGet(); + awaitIfBlocked(); + if (failWithCustomException) { + throw new CustomException(ErrorCode.INVALID_INPUT); + } + if (failWithRuntimeException) { + throw new IllegalStateException("refresh failed"); + } + return "value-" + callCount.incrementAndGet(); + } + + void reset() { + callCount.set(0); + invocationCount.set(0); + failWithRuntimeException = false; + failWithCustomException = false; + blockNextInvocation = false; + blockedInvocationStarted = null; + blockedInvocationRelease = null; + } + + int getCallCount() { + return callCount.get(); + } + + int getInvocationCount() { + return invocationCount.get(); + } + + void failWithRuntimeException() { + failWithRuntimeException = true; + } + + void failWithCustomException() { + failWithCustomException = true; + } + + void blockNextInvocation() { + blockedInvocationStarted = new CountDownLatch(1); + blockedInvocationRelease = new CountDownLatch(1); + blockNextInvocation = true; + } + + void awaitBlockedInvocation() throws InterruptedException { + assertThat(blockedInvocationStarted.await(3, TimeUnit.SECONDS)).isTrue(); + } + + void releaseBlockedInvocation() { + if (blockedInvocationRelease != null) { + blockedInvocationRelease.countDown(); + } + } + + private void awaitIfBlocked() { + if (!blockNextInvocation) { + return; + } + blockNextInvocation = false; + blockedInvocationStarted.countDown(); + try { + blockedInvocationRelease.await(3, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + } + } +} diff --git a/src/test/java/com/example/solidconnection/score/service/ScoreServiceTest.java b/src/test/java/com/example/solidconnection/score/service/ScoreServiceTest.java index ddf7a722e..c4d25ce9c 100644 --- a/src/test/java/com/example/solidconnection/score/service/ScoreServiceTest.java +++ b/src/test/java/com/example/solidconnection/score/service/ScoreServiceTest.java @@ -1,9 +1,11 @@ package com.example.solidconnection.score.service; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.BDDMockito.given; import com.example.solidconnection.common.VerifyStatus; +import com.example.solidconnection.common.exception.ErrorCode; import com.example.solidconnection.s3.domain.UploadPath; import com.example.solidconnection.s3.dto.UploadedFileUrlResponse; import com.example.solidconnection.s3.service.S3Service; @@ -20,7 +22,9 @@ import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.fixture.SiteUserFixture; import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.university.domain.HomeUniversity; import com.example.solidconnection.university.domain.LanguageTestType; +import com.example.solidconnection.university.fixture.HomeUniversityFixture; import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -54,11 +58,16 @@ class ScoreServiceTest { @Autowired private LanguageTestScoreFixture languageTestScoreFixture; + @Autowired + private HomeUniversityFixture homeUniversityFixture; + + private HomeUniversity homeUniversity; private SiteUser user; @BeforeEach void setUp() { - user = siteUserFixture.사용자(); + homeUniversity = homeUniversityFixture.인하대학교(); + user = siteUserFixture.국내_대학_정보_소지_사용자(homeUniversity.getId()); } @Test @@ -76,6 +85,15 @@ void setUp() { assertThat(response.gpaScoreStatusResponseList()).hasSize(scores.size()); } + @Test + void GPA_점수_상태를_조회할_때_사용자의_모학교명을_반환한다() { + // when + GpaScoreStatusesResponse response = scoreService.getGpaScoreStatus(user.getId()); + + // then + assertThat(response.homeUniversityName()).isEqualTo(homeUniversity.getName()); + } + @Test void GPA_점수가_없는_경우_빈_리스트를_반환한다() { // when @@ -85,6 +103,17 @@ void setUp() { assertThat(response.gpaScoreStatusResponseList()).isEmpty(); } + @Test + void 모학교가_없는_사용자의_GPA_점수_상태를_조회하면_예외가_발생한다() { + // given + user = siteUserFixture.사용자(); + + // when + // then + assertThatThrownBy(() -> scoreService.getGpaScoreStatus(user.getId())) + .hasMessage(ErrorCode.SCHOOL_EMAIL_NOT_VERIFIED.getMessage()); + } + @Test void 어학_시험_점수_상태를_조회한다() { // given diff --git a/src/test/java/com/example/solidconnection/university/service/GeneralUnivApplyInfoRecommendServiceTest.java b/src/test/java/com/example/solidconnection/university/service/GeneralUnivApplyInfoRecommendServiceTest.java index 7bf901a0a..7bc40a266 100644 --- a/src/test/java/com/example/solidconnection/university/service/GeneralUnivApplyInfoRecommendServiceTest.java +++ b/src/test/java/com/example/solidconnection/university/service/GeneralUnivApplyInfoRecommendServiceTest.java @@ -4,11 +4,15 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; +import com.example.solidconnection.cache.manager.CustomCacheManager; import com.example.solidconnection.support.TestContainerSpringBootTest; import com.example.solidconnection.term.domain.Term; import com.example.solidconnection.term.fixture.TermFixture; import com.example.solidconnection.university.domain.UnivApplyInfo; +import com.example.solidconnection.university.dto.UnivApplyInfoPreviewResponse; +import com.example.solidconnection.university.dto.UnivApplyInfoRecommendsResponse; import com.example.solidconnection.university.fixture.UnivApplyInfoFixture; +import com.example.solidconnection.university.repository.UnivApplyInfoRepository; import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -25,39 +29,90 @@ class GeneralUnivApplyInfoRecommendServiceTest { @Autowired private UnivApplyInfoFixture univApplyInfoFixture; + @Autowired + private UnivApplyInfoRepository univApplyInfoRepository; + + @Autowired + private CustomCacheManager cacheManager; + @Autowired private TermFixture termFixture; private Term term; + private List currentTermUnivApplyInfos; @BeforeEach void setUp() { term = termFixture.현재_학기("2025-2"); + cacheManager.evictUsingPrefix("university:recommend:general:" + term.getId()); - univApplyInfoFixture.괌대학_A_지원_정보(term.getId()); - univApplyInfoFixture.버지니아공과대학_지원_정보(term.getId()); - univApplyInfoFixture.네바다주립대학_라스베이거스_지원_정보(term.getId()); - univApplyInfoFixture.메모리얼대학_세인트존스_A_지원_정보(term.getId()); - univApplyInfoFixture.서던덴마크대학교_지원_정보(term.getId()); - univApplyInfoFixture.코펜하겐IT대학_지원_정보(term.getId()); - univApplyInfoFixture.그라츠대학_지원_정보(term.getId()); - univApplyInfoFixture.그라츠공과대학_지원_정보(term.getId()); - univApplyInfoFixture.린츠_카톨릭대학_지원_정보(term.getId()); - univApplyInfoFixture.메이지대학_지원_정보(term.getId()); - generalUnivApplyInfoRecommendService.init(); + currentTermUnivApplyInfos = List.of( + univApplyInfoFixture.괌대학_A_지원_정보(term.getId()), + univApplyInfoFixture.버지니아공과대학_지원_정보(term.getId()), + univApplyInfoFixture.네바다주립대학_라스베이거스_지원_정보(term.getId()), + univApplyInfoFixture.메모리얼대학_세인트존스_A_지원_정보(term.getId()), + univApplyInfoFixture.서던덴마크대학교_지원_정보(term.getId()), + univApplyInfoFixture.코펜하겐IT대학_지원_정보(term.getId()) + ); } @Test void 모집_시기의_대학_지원_정보_중에서_랜덤하게_N개를_추천_목록으로_구성한다() { + // when + UnivApplyInfoRecommendsResponse response = generalUnivApplyInfoRecommendService.getGeneralRecommends( + term.getId(), + term.getName() + ); + List currentTermUnivApplyInfoIds = currentTermUnivApplyInfos.stream() + .map(UnivApplyInfo::getId) + .toList(); + + // then + assertAll( + () -> assertThat(response.recommendedUniversities()).hasSize(RECOMMEND_UNIV_APPLY_INFO_NUM), + () -> assertThat(response.recommendedUniversities()) + .extracting(UnivApplyInfoPreviewResponse::id) + .isSubsetOf(currentTermUnivApplyInfoIds) + ); + } + + @Test + void 캐시를_삭제하면_서버_재기동_없이_DB_기준으로_추천_목록을_다시_구성한다() { // given - List universities = generalUnivApplyInfoRecommendService.getGeneralRecommends(); + UnivApplyInfo deletedUnivApplyInfo = currentTermUnivApplyInfos.get(0); + UnivApplyInfoRecommendsResponse cachedResponse = generalUnivApplyInfoRecommendService.getGeneralRecommends( + term.getId(), + term.getName() + ); + + univApplyInfoRepository.delete(deletedUnivApplyInfo); + UnivApplyInfo addedUnivApplyInfo = univApplyInfoFixture.그라츠대학_지원_정보(term.getId()); + + // when + UnivApplyInfoRecommendsResponse responseBeforeCacheEvict = generalUnivApplyInfoRecommendService.getGeneralRecommends( + term.getId(), + term.getName() + ); + cacheManager.evictUsingPrefix("university:recommend:general:" + term.getId()); + UnivApplyInfoRecommendsResponse responseAfterCacheEvict = generalUnivApplyInfoRecommendService.getGeneralRecommends( + term.getId(), + term.getName() + ); - // when & then + // then assertAll( - () -> assertThat(universities) - .extracting("termId") - .allMatch(term.getId()::equals), - () -> assertThat(universities).hasSize(RECOMMEND_UNIV_APPLY_INFO_NUM) + () -> assertThat(cachedResponse.recommendedUniversities()) + .extracting(UnivApplyInfoPreviewResponse::id) + .contains(deletedUnivApplyInfo.getId()), + () -> assertThat(responseBeforeCacheEvict.recommendedUniversities()) + .extracting(UnivApplyInfoPreviewResponse::id) + .contains(deletedUnivApplyInfo.getId()) + .doesNotContain(addedUnivApplyInfo.getId()), + () -> assertThat(responseAfterCacheEvict.recommendedUniversities()) + .hasSize(RECOMMEND_UNIV_APPLY_INFO_NUM) + .extracting(UnivApplyInfoPreviewResponse::id) + .contains(addedUnivApplyInfo.getId()) + .doesNotContain(deletedUnivApplyInfo.getId()) ); } } diff --git a/src/test/java/com/example/solidconnection/university/service/UnivApplyInfoRecommendServiceTest.java b/src/test/java/com/example/solidconnection/university/service/UnivApplyInfoRecommendServiceTest.java index f6b425e3f..d67b0a922 100644 --- a/src/test/java/com/example/solidconnection/university/service/UnivApplyInfoRecommendServiceTest.java +++ b/src/test/java/com/example/solidconnection/university/service/UnivApplyInfoRecommendServiceTest.java @@ -78,7 +78,6 @@ void setUp() { univApplyInfoFixture.그라츠공과대학_지원_정보(term.getId()); univApplyInfoFixture.린츠_카톨릭대학_지원_정보(term.getId()); univApplyInfoFixture.메이지대학_지원_정보(term.getId()); - generalUnivApplyInfoRecommendService.init(); } @Test @@ -148,9 +147,8 @@ void setUp() { assertThat(response.recommendedUniversities()) .hasSize(RECOMMEND_UNIV_APPLY_INFO_NUM) .containsExactlyInAnyOrderElementsOf( - generalUnivApplyInfoRecommendService.getGeneralRecommends().stream() - .map(univApplyInfo -> UnivApplyInfoPreviewResponse.of(univApplyInfo, term.getName())) - .toList() + generalUnivApplyInfoRecommendService.getGeneralRecommends(term.getId(), term.getName()) + .recommendedUniversities() ); } @@ -163,9 +161,8 @@ void setUp() { assertThat(response.recommendedUniversities()) .hasSize(RECOMMEND_UNIV_APPLY_INFO_NUM) .containsExactlyInAnyOrderElementsOf( - generalUnivApplyInfoRecommendService.getGeneralRecommends().stream() - .map(univApplyInfo -> UnivApplyInfoPreviewResponse.of(univApplyInfo, term.getName())) - .toList() + generalUnivApplyInfoRecommendService.getGeneralRecommends(term.getId(), term.getName()) + .recommendedUniversities() ); } }