Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<UnivApplyInfo> generalRecommends;
@Transactional(readOnly = true)
@ThunderingHerdCaching(
key = "university:recommend:general:{0}:{1}",
cacheManager = "customCacheManager",
ttlSec = GENERAL_RECOMMEND_CACHE_TTL_SEC

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recompute cached recommendations instead of extending stale values

For this @ThunderingHerdCaching cache, ttlSec does not bound staleness under traffic: ThunderingHerdCachingAspect.refreshCache handles the last-10%-of-TTL path by calling getAndExpire and returning cachedValue, not by proceeding to rebuild from DB. When a popular /univ-apply-infos/recommend key is hit near expiry, a deleted or updated university can keep being served indefinitely unless an explicit prefix eviction runs, so the new 1-hour TTL does not provide the intended freshness window.

Useful? React with 👍 / 👎.

@Hexeong Hexeong Jun 26, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 부분은 별도로 #801 에서 작업할 예정입니다 :)

)
public UnivApplyInfoRecommendsResponse getGeneralRecommends(long termId, String termName) {
Pageable page = PageRequest.of(0, RECOMMEND_UNIV_APPLY_INFO_NUM);
List<UnivApplyInfo> 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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -47,17 +46,37 @@ 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()
.map(univApplyInfo -> UnivApplyInfoPreviewResponse.of(univApplyInfo, term.getName()))
.toList());
}

private List<UnivApplyInfo> getGeneralRecommendsExcludingSelected(List<UnivApplyInfo> alreadyPicked) {
List<UnivApplyInfo> generalRecommend = new ArrayList<>(generalUnivApplyInfoRecommendService.getGeneralRecommends());
generalRecommend.removeAll(alreadyPicked);
private List<UnivApplyInfoPreviewResponse> getPersonalRecommendPreviewsWithGeneralFallback(
List<UnivApplyInfo> personalRecommends,
Term term
) {
List<UnivApplyInfoPreviewResponse> recommendPreviews = new ArrayList<>(personalRecommends.stream()
.map(univApplyInfo -> UnivApplyInfoPreviewResponse.of(univApplyInfo, term.getName()))
.toList());
recommendPreviews.addAll(getGeneralRecommendsExcludingSelected(personalRecommends, term));

return recommendPreviews;
}

private List<UnivApplyInfoPreviewResponse> getGeneralRecommendsExcludingSelected(
List<UnivApplyInfo> alreadyPicked,
Term term
) {
List<Long> alreadyPickedIds = alreadyPicked.stream()
.map(UnivApplyInfo::getId)
.toList();
List<UnivApplyInfoPreviewResponse> 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()));
Expand All @@ -67,15 +86,10 @@ private List<UnivApplyInfo> getGeneralRecommendsExcludingSelected(List<UnivApply
* 공통 추천 대학교를 불러온다.
* */
@Transactional(readOnly = true)
@ThunderingHerdCaching(key = "university:recommend:general", cacheManager = "customCacheManager", ttlSec = 86400)
public UnivApplyInfoRecommendsResponse getGeneralRecommends() {
Term term = termRepository.findByIsCurrentTrue()
.orElseThrow(() -> new CustomException(CURRENT_TERM_NOT_FOUND));

List<UnivApplyInfo> 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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<UnivApplyInfo> 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())
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

@Test
void 모집_시기의_대학_지원_정보_중에서_랜덤하게_N개를_추천_목록으로_구성한다() {
// when
UnivApplyInfoRecommendsResponse response = generalUnivApplyInfoRecommendService.getGeneralRecommends(
term.getId(),
term.getName()
);
List<Long> 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<UnivApplyInfo> 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())
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,6 @@ void setUp() {
univApplyInfoFixture.그라츠공과대학_지원_정보(term.getId());
univApplyInfoFixture.린츠_카톨릭대학_지원_정보(term.getId());
univApplyInfoFixture.메이지대학_지원_정보(term.getId());
generalUnivApplyInfoRecommendService.init();
}

@Test
Expand Down Expand Up @@ -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()
);
}

Expand All @@ -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()
);
}
}
Loading