diff --git a/src/main/java/com/clova/anifriends/domain/recruitment/dto/response/FindRecruitmentsResponse.java b/src/main/java/com/clova/anifriends/domain/recruitment/dto/response/FindRecruitmentsResponse.java index 07e5f310..dcdf2b5c 100644 --- a/src/main/java/com/clova/anifriends/domain/recruitment/dto/response/FindRecruitmentsResponse.java +++ b/src/main/java/com/clova/anifriends/domain/recruitment/dto/response/FindRecruitmentsResponse.java @@ -11,12 +11,6 @@ public record FindRecruitmentsResponse( List recruitments, PageInfo pageInfo) { - public static FindRecruitmentsResponse fromCached( - Slice cachedRecruitments, Long count) { - PageInfo pageInfo = PageInfo.of(count, cachedRecruitments.hasNext()); - return new FindRecruitmentsResponse(cachedRecruitments.getContent(), pageInfo); - } - public record FindRecruitmentResponse( Long recruitmentId, String recruitmentTitle, diff --git a/src/main/java/com/clova/anifriends/domain/recruitment/repository/RecruitmentCacheRepository.java b/src/main/java/com/clova/anifriends/domain/recruitment/repository/RecruitmentCacheRepository.java index a0850b66..3ceab8e7 100644 --- a/src/main/java/com/clova/anifriends/domain/recruitment/repository/RecruitmentCacheRepository.java +++ b/src/main/java/com/clova/anifriends/domain/recruitment/repository/RecruitmentCacheRepository.java @@ -1,27 +1,17 @@ package com.clova.anifriends.domain.recruitment.repository; import com.clova.anifriends.domain.recruitment.Recruitment; -import com.clova.anifriends.domain.recruitment.dto.response.FindRecruitmentsResponse.FindRecruitmentResponse; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Slice; +import com.clova.anifriends.domain.recruitment.dto.response.FindRecruitmentsResponse; public interface RecruitmentCacheRepository { - void saveRecruitment(Recruitment recruitment); + long getTotalNumberOfRecruitments(); - Slice findRecruitments(Pageable pageable); + void saveRecruitment(Recruitment recruitment); - void updateRecruitment(Recruitment recruitment); + long deleteRecruitment(Recruitment recruitment); - void deleteRecruitment(Recruitment recruitment); + FindRecruitmentsResponse findRecruitments(int size); void closeRecruitmentsIfNeedToBe(); - - Long getRecruitmentCount(); - - void saveRecruitmentCount(Long count); - - void increaseRecruitmentCount(); - - void decreaseToRecruitmentCount(); } diff --git a/src/main/java/com/clova/anifriends/domain/recruitment/repository/RecruitmentRedisRepository.java b/src/main/java/com/clova/anifriends/domain/recruitment/repository/RecruitmentRedisRepository.java index f1e49295..8e70f263 100644 --- a/src/main/java/com/clova/anifriends/domain/recruitment/repository/RecruitmentRedisRepository.java +++ b/src/main/java/com/clova/anifriends/domain/recruitment/repository/RecruitmentRedisRepository.java @@ -1,6 +1,8 @@ package com.clova.anifriends.domain.recruitment.repository; +import com.clova.anifriends.domain.common.PageInfo; import com.clova.anifriends.domain.recruitment.Recruitment; +import com.clova.anifriends.domain.recruitment.dto.response.FindRecruitmentsResponse; import com.clova.anifriends.domain.recruitment.dto.response.FindRecruitmentsResponse.FindRecruitmentResponse; import java.time.LocalDateTime; import java.time.ZoneOffset; @@ -8,11 +10,9 @@ import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.Optional; import java.util.Set; -import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Slice; -import org.springframework.data.domain.SliceImpl; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.ValueOperations; import org.springframework.data.redis.core.ZSetOperations; @@ -24,10 +24,7 @@ public class RecruitmentRedisRepository implements RecruitmentCacheRepository { private static final String RECRUITMENT_KEY = "recruitment"; private static final String RECRUITMENT_COUNT_KEY = "recruitment:count"; private static final int UNTIL_LAST_ELEMENT = -1; - private static final Long RECRUITMENT_COUNT_NO_CACHE = -1L; - private static final Long COUNT_ONE = 1L; private static final int MAX_CACHED_SIZE = 30; - private static final int PAGE_SIZE = 20; private static final int ZERO = 0; public static final ZoneOffset CREATED_AT_SCORE_TIME_ZONE = ZoneOffset.UTC; @@ -43,89 +40,82 @@ public RecruitmentRedisRepository( this.recruitmentRepository = recruitmentRepository; } + /** + * 봉사 모집글을 캐시에 저장하고 캐싱된 카운트를 증가시킵니다. + * + * @param recruitment + */ @Override public void saveRecruitment(final Recruitment recruitment) { FindRecruitmentResponse recruitmentResponse = FindRecruitmentResponse.from(recruitment); long createdAtScore = getCreatedAtScore(recruitment); cachedRecruitments.add(RECRUITMENT_KEY, recruitmentResponse, createdAtScore); + cachedRecruitmentsCount.increment(RECRUITMENT_COUNT_KEY); + trimCache(); + } - popUntilCachedSize(); + private long getCreatedAtScore(Recruitment recruitment) { + return recruitment.getCreatedAt().toEpochSecond(CREATED_AT_SCORE_TIME_ZONE); } - private void popUntilCachedSize() { - Set recruitments - = cachedRecruitments.range(RECRUITMENT_KEY, ZERO, UNTIL_LAST_ELEMENT); - if (Objects.nonNull(recruitments)) { - int needToRemoveSize = recruitments.size() - MAX_CACHED_SIZE; - needToRemoveSize = Math.max(needToRemoveSize, ZERO); - cachedRecruitments.popMin(RECRUITMENT_KEY, needToRemoveSize); - } + private void trimCache() { + cachedRecruitments.removeRange(RECRUITMENT_KEY, ZERO, -MAX_CACHED_SIZE - 1L); } /** - * 캐시된 Recruitment dto 리스트를 size만큼 조회합니다. size가 지정된 최대 size를 초과하는 경우 지정된 최대 size만큼 조회해옵니다. + * 캐시된 Recruitment dto 리스트를 첫번째 요소부터 size만큼 조회합니다. size가 최대 캐싱 사이즈를 초과하는 경우 db에서 조회합니다. * - * @param pageable 조회해 올 리스트 pageable. 최대 사이즈 20 - * @return 캐시된 Recruitment dto 리스트 + * @param size 조회할 사이즈 + * @return FindRecruitmentsResponse 캐시 혹은 db에서 조회한 결과 */ @Override - public Slice findRecruitments(Pageable pageable) { - long size = pageable.getPageSize(); - if (size > PAGE_SIZE) { - size = PAGE_SIZE; + public FindRecruitmentsResponse findRecruitments(int size) { + Set recruitments = Objects.requireNonNull( + cachedRecruitments.reverseRange(RECRUITMENT_KEY, ZERO, size - 1L)); + long count = getTotalNumberOfRecruitments(); + PageInfo pageInfo = PageInfo.of(count, count > size); + if (recruitments.size() >= size) { + List content = recruitments.stream() + .limit(size) + .toList(); + return new FindRecruitmentsResponse(content, pageInfo); } - Set recruitments - = cachedRecruitments.reverseRange(RECRUITMENT_KEY, ZERO, size); - if (Objects.isNull(recruitments)) { - return new SliceImpl<>(List.of()); - } - List content = recruitments.stream() - .limit(size) - .toList(); - boolean hasNext = recruitments.size() > size; - return new SliceImpl<>(content, pageable, hasNext); - } - @Override - public void updateRecruitment(final Recruitment recruitment) { - long createdAtScore = getCreatedAtScore(recruitment); - Set recruitments = cachedRecruitments.rangeByScore( - RECRUITMENT_KEY, createdAtScore, createdAtScore); - if (Objects.nonNull(recruitments)) { - Optional oldCachedRecruitment = recruitments.stream() - .filter(findRecruitmentResponse -> isEqualsId(recruitment, findRecruitmentResponse)) - .findFirst(); - oldCachedRecruitment.ifPresent(oldRecruitment -> { - cachedRecruitments.remove(RECRUITMENT_KEY, oldRecruitment); - FindRecruitmentResponse updatedRecruitment - = FindRecruitmentResponse.from(recruitment); - cachedRecruitments.add(RECRUITMENT_KEY, updatedRecruitment, createdAtScore); - }); - } - } - - private long getCreatedAtScore(Recruitment recruitment) { - return recruitment.getCreatedAt().toEpochSecond(CREATED_AT_SCORE_TIME_ZONE); + PageRequest pageRequest = PageRequest.of(ZERO, size); + List content = getRecruitmentsV2(pageRequest) + .map(FindRecruitmentResponse::from) + .toList(); + return new FindRecruitmentsResponse(content, pageInfo); } - private boolean isEqualsId( - Recruitment recruitment, - FindRecruitmentResponse findRecruitmentResponse) { - return findRecruitmentResponse.recruitmentId().equals(recruitment.getRecruitmentId()); + private Slice getRecruitmentsV2(PageRequest pageRequest) { + return recruitmentRepository.findRecruitmentsV2(null, null, + null, null, null, null, null, pageRequest); } + /** + * 캐시된 봉사 모집글을 제거하고 카운트를 감소시킵니다. + * + * @param recruitment + * @return + */ @Override - public void deleteRecruitment(final Recruitment recruitment) { + public long deleteRecruitment(final Recruitment recruitment) { FindRecruitmentResponse recruitmentResponse = FindRecruitmentResponse.from(recruitment); - cachedRecruitments.remove(RECRUITMENT_KEY, recruitmentResponse); + Long number = cachedRecruitments.remove(RECRUITMENT_KEY, recruitmentResponse); + cachedRecruitmentsCount.decrement(RECRUITMENT_COUNT_KEY); + return Objects.isNull(number) ? 0 : number; } + /** + * 캐시된 봉사 모집글 중 모집 기간이 종료된 요소를 업데이트합니다. + */ @Override public void closeRecruitmentsIfNeedToBe() { LocalDateTime now = LocalDateTime.now(); Set findRecruitments = cachedRecruitments.range(RECRUITMENT_KEY, ZERO, UNTIL_LAST_ELEMENT); - if(Objects.nonNull(findRecruitments)) { + if (Objects.nonNull(findRecruitments)) { Map cachedKeyAndUpdatedValue = new HashMap<>(); findRecruitments.stream() @@ -169,50 +159,13 @@ private FindRecruitmentResponse closeCachedRecruitment(FindRecruitmentResponse r } @Override - public Long getRecruitmentCount() { + public long getTotalNumberOfRecruitments() { Object cachedCount = cachedRecruitmentsCount.get(RECRUITMENT_COUNT_KEY); - - if (Objects.isNull(cachedCount)) { - return RECRUITMENT_COUNT_NO_CACHE; - } - - if (cachedCount instanceof Long) { - return (Long) cachedCount; - } else { - return ((Integer) cachedCount).longValue(); - } - } - - @Override - public void saveRecruitmentCount( - Long count - ) { - cachedRecruitmentsCount.set(RECRUITMENT_COUNT_KEY, count); - } - - @Override - public void increaseRecruitmentCount() { - Object cachedCount = cachedRecruitmentsCount.get(RECRUITMENT_COUNT_KEY); - if (Objects.nonNull(cachedCount)) { - saveRecruitmentCount((Integer) cachedCount + COUNT_ONE); - } - - long dbRecruitmentCount = recruitmentRepository.count(); - - saveRecruitmentCount(dbRecruitmentCount); - } - - @Override - public void decreaseToRecruitmentCount() { - Object cachedCount = cachedRecruitmentsCount.get(RECRUITMENT_COUNT_KEY); - - if (Objects.nonNull(cachedCount)) { - saveRecruitmentCount((Integer) cachedCount - COUNT_ONE); + return ((Integer) cachedCount).longValue(); } - - long dbRecruitmentCount = recruitmentRepository.count(); - - saveRecruitmentCount(dbRecruitmentCount); + long dbCount = recruitmentRepository.count(); + cachedRecruitmentsCount.set(RECRUITMENT_COUNT_KEY, dbCount); + return dbCount; } } diff --git a/src/main/java/com/clova/anifriends/domain/recruitment/service/RecruitmentService.java b/src/main/java/com/clova/anifriends/domain/recruitment/service/RecruitmentService.java index 44abc3e3..f1eb0799 100644 --- a/src/main/java/com/clova/anifriends/domain/recruitment/service/RecruitmentService.java +++ b/src/main/java/com/clova/anifriends/domain/recruitment/service/RecruitmentService.java @@ -6,7 +6,6 @@ import com.clova.anifriends.domain.recruitment.dto.response.FindRecruitmentDetailResponse; import com.clova.anifriends.domain.recruitment.dto.response.FindRecruitmentsByShelterResponse; import com.clova.anifriends.domain.recruitment.dto.response.FindRecruitmentsResponse; -import com.clova.anifriends.domain.recruitment.dto.response.FindRecruitmentsResponse.FindRecruitmentResponse; import com.clova.anifriends.domain.recruitment.dto.response.FindShelterRecruitmentsResponse; import com.clova.anifriends.domain.recruitment.dto.response.RegisterRecruitmentResponse; import com.clova.anifriends.domain.recruitment.exception.RecruitmentNotFoundException; @@ -31,8 +30,6 @@ @RequiredArgsConstructor public class RecruitmentService { - private static final Long RECRUITMENT_COUNT_NO_CACHE = -1L; - private final ShelterRepository shelterRepository; private final RecruitmentRepository recruitmentRepository; private final ApplicationEventPublisher applicationEventPublisher; @@ -61,7 +58,6 @@ public RegisterRecruitmentResponse registerRecruitment( recruitmentRepository.save(recruitment); recruitmentCacheRepository.saveRecruitment(recruitment); - recruitmentCacheRepository.increaseRecruitmentCount(); return RegisterRecruitmentResponse.from(recruitment); } @@ -144,37 +140,17 @@ public FindRecruitmentsResponse findRecruitmentsV2( Long recruitmentId, Pageable pageable ) { - Long count = recruitmentCacheRepository.getRecruitmentCount(); - if (findRecruitmentsWithoutCondition(keyword, startDate, endDate, isClosed, - keywordCondition, recruitmentId)) { - if (Objects.equals(count, RECRUITMENT_COUNT_NO_CACHE)) { - count = recruitmentRepository.countFindRecruitmentsV2( - keyword, - startDate, - endDate, - isClosed, - keywordCondition - ); - recruitmentCacheRepository.saveRecruitmentCount(count); - } - } else { - count = recruitmentRepository.countFindRecruitmentsV2( - keyword, - startDate, - endDate, - isClosed, - keywordCondition - ); + if (isFirstPage(keyword, startDate, endDate, isClosed, keywordCondition, recruitmentId)) { + return recruitmentCacheRepository.findRecruitments(pageable.getPageSize()); } - if (findRecruitmentsWithoutCondition(keyword, startDate, endDate, isClosed, - keywordCondition, recruitmentId)) { - Slice cachedRecruitments - = recruitmentCacheRepository.findRecruitments(pageable); - if (canTrustCached(cachedRecruitments)) { - return FindRecruitmentsResponse.fromCached(cachedRecruitments, count); - } - } + long count = recruitmentRepository.countFindRecruitmentsV2( + keyword, + startDate, + endDate, + isClosed, + keywordCondition + ); Slice recruitments = recruitmentRepository.findRecruitmentsV2( keyword, startDate, @@ -187,11 +163,7 @@ public FindRecruitmentsResponse findRecruitmentsV2( return FindRecruitmentsResponse.fromV2(recruitments, count); } - private boolean canTrustCached(Slice cachedRecruitments) { - return cachedRecruitments.hasNext(); - } - - private boolean findRecruitmentsWithoutCondition(String keyword, LocalDate startDate, + private boolean isFirstPage(String keyword, LocalDate startDate, LocalDate endDate, Boolean isClosed, KeywordCondition keywordCondition, Long recruitmentId) { return Objects.isNull(keyword) && Objects.isNull(keywordCondition) && Objects.isNull(startDate) && Objects.isNull(endDate) && Objects.isNull(isClosed) @@ -201,8 +173,11 @@ private boolean findRecruitmentsWithoutCondition(String keyword, LocalDate start @Transactional public void closeRecruitment(Long shelterId, Long recruitmentId) { Recruitment recruitment = getRecruitmentByShelter(shelterId, recruitmentId); - recruitmentCacheRepository.updateRecruitment(recruitment); + long deleted = recruitmentCacheRepository.deleteRecruitment(recruitment); recruitment.closeRecruitment(); + if(deleted > 0) { + recruitmentCacheRepository.saveRecruitment(recruitment); + } } @Transactional @@ -218,6 +193,7 @@ public void updateRecruitment( List imageUrls ) { Recruitment recruitment = getRecruitmentByShelterWithImages(shelterId, recruitmentId); + long deleted = recruitmentCacheRepository.deleteRecruitment(recruitment); List imagesToDelete = recruitment.findImagesToDelete(imageUrls); applicationEventPublisher.publishEvent(new ImageDeletionEvent(imagesToDelete)); @@ -231,7 +207,9 @@ public void updateRecruitment( content, imageUrls ); - recruitmentCacheRepository.updateRecruitment(recruitment); + if(deleted > 0) { + recruitmentCacheRepository.saveRecruitment(recruitment); + } } @Transactional @@ -244,7 +222,6 @@ public void deleteRecruitment(Long shelterId, Long recruitmentId) { recruitmentRepository.delete(recruitment); recruitmentCacheRepository.deleteRecruitment(recruitment); - recruitmentCacheRepository.decreaseToRecruitmentCount(); } private Recruitment getRecruitmentByShelterWithImages(Long shelterId, Long recruitmentId) { diff --git a/src/test/java/com/clova/anifriends/domain/recruitment/repository/RecruitmentRedisRepositoryTest.java b/src/test/java/com/clova/anifriends/domain/recruitment/repository/RecruitmentRedisRepositoryTest.java index 10249521..45ac0d16 100644 --- a/src/test/java/com/clova/anifriends/domain/recruitment/repository/RecruitmentRedisRepositoryTest.java +++ b/src/test/java/com/clova/anifriends/domain/recruitment/repository/RecruitmentRedisRepositoryTest.java @@ -4,6 +4,7 @@ import com.clova.anifriends.base.BaseIntegrationTest; import com.clova.anifriends.domain.recruitment.Recruitment; +import com.clova.anifriends.domain.recruitment.dto.response.FindRecruitmentsResponse; import com.clova.anifriends.domain.recruitment.dto.response.FindRecruitmentsResponse.FindRecruitmentResponse; import com.clova.anifriends.domain.recruitment.support.fixture.RecruitmentFixture; import com.clova.anifriends.domain.recruitment.vo.RecruitmentInfo; @@ -16,6 +17,9 @@ import java.util.List; import java.util.Optional; import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.stream.IntStream; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -24,7 +28,6 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Slice; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.ZSetOperations; import org.springframework.test.util.ReflectionTestUtils; @@ -50,8 +53,8 @@ void tearDown() { } @Nested - @DisplayName("pushNewRecruitment 메서드 호출 시") - class PushNewRecruitment { + @DisplayName("saveRecruitment 메서드 호출 시") + class SaveRecruitmentTest { Shelter shelter; @@ -73,15 +76,32 @@ void registerRecruitment() { //then PageRequest pageRequest = PageRequest.of(0, 20); - Slice recruitments = recruitmentRedisRepository.findRecruitments( - pageRequest); - List content = recruitments.getContent(); + FindRecruitmentsResponse response = recruitmentRedisRepository.findRecruitments( + pageRequest.getPageSize() + ); + List content = response.recruitments(); assertThat(content).hasSize(1); FindRecruitmentResponse recruitmentResponse = content.get(0); assertThat(recruitmentResponse.recruitmentId()) .isEqualTo(recruitment.getRecruitmentId()); } + @Test + @DisplayName("성공: 카운트 값이 1 증가한다.") + void increaseCachedCount() { + //given + Recruitment recruitment = RecruitmentFixture.recruitment(shelter); + recruitmentRepository.save(recruitment); + long cachedCount = recruitmentRedisRepository.getTotalNumberOfRecruitments(); + + //when + recruitmentRedisRepository.saveRecruitment(recruitment); + + //then + assertThat(recruitmentRedisRepository.getTotalNumberOfRecruitments()) + .isEqualTo(cachedCount + 1); + } + @Test @DisplayName("성공: 새로운 Recruitment 100개 생성, 30개 캐싱") void overflowTest() { @@ -103,9 +123,9 @@ void overflowTest() { //then List recruitmentIdsDesc = recruitments.stream() + .sorted((a, b) -> b.getCreatedAt().compareTo(a.getCreatedAt())) + .limit(30) .map(Recruitment::getRecruitmentId) - .filter(i -> i > 70) - .sorted(Comparator.reverseOrder()) .toList(); Set findRecruitments = redisTemplate.opsForZSet() @@ -113,13 +133,49 @@ void overflowTest() { assertThat(findRecruitments).hasSize(30); assertThat(findRecruitments).map(FindRecruitmentResponse::recruitmentId) .containsExactlyElementsOf(recruitmentIdsDesc); + } + @Test + @DisplayName("성공: 동시에 새로운 봉사 모집글이 추가되는 경우 손실되지 않는다.") + void doseNotLost() throws InterruptedException { + //given + int count = 100; + int cachedSize = 30; + List recruitments = IntStream.range(0, count) + .mapToObj(i -> RecruitmentFixture.recruitment(shelter)) + .toList(); + recruitmentRepository.saveAll(recruitments); + LocalDateTime now = LocalDateTime.now(); + int hour = 0; + for (Recruitment recruitment : recruitments) { + ReflectionTestUtils.setField(recruitment, "createdAt", now.plusHours(hour++)); + } + + ExecutorService executorService = Executors.newFixedThreadPool(count); + CountDownLatch latch = new CountDownLatch(count); + //when + for(int i=0; i { + try { + recruitmentRedisRepository.saveRecruitment(recruitments.get(finalI)); + } finally { + latch.countDown(); + } + }); + } + latch.await(); + + //then + Set result = redisTemplate.opsForZSet() + .range(RECRUITMENT_KEY, ZERO, ALL_ELEMENT); + assertThat(result).hasSize(cachedSize); } } @Nested - @DisplayName("findAll 메서드 호출 시") + @DisplayName("findRecruitments 메서드 호출 시") class FindAllTest { Shelter shelter; @@ -131,22 +187,22 @@ void setUp() { } @Test - @DisplayName("성공: 캐싱된 목록이 없을 시 빈 리스트 반환") + @DisplayName("성공: 봉사 모집글이 없으면 빈 리스트를 반환한다.") void findAllWhenEmpty() { //given PageRequest pageRequest = PageRequest.of(0, 20); //when - Slice recruitments = recruitmentRedisRepository.findRecruitments( - pageRequest); + FindRecruitmentsResponse response = recruitmentRedisRepository.findRecruitments( + pageRequest.getPageSize() + ); //then - assertThat(recruitments.getContent()) - .isEmpty(); + assertThat(response.recruitments()).isEmpty(); } @Test - @DisplayName("성공: 캐싱된 목록이 있을 시 캐싱된 리스트 반환") + @DisplayName("성공: 봉사 모집글이 있으면 사이즈 조회하여 반환한다.") void findAllWhenCached() { //given List recruitments = new ArrayList<>(); @@ -165,8 +221,9 @@ void findAllWhenCached() { PageRequest pageRequest = PageRequest.of(0, 20); //when - Slice cachedRecruitments - = recruitmentRedisRepository.findRecruitments(pageRequest); + FindRecruitmentsResponse response = recruitmentRedisRepository.findRecruitments( + pageRequest.getPageSize() + ); //then List recruitmentIdsDesc = recruitments.stream() @@ -175,17 +232,17 @@ void findAllWhenCached() { .sorted(Comparator.reverseOrder()) .toList(); - assertThat(cachedRecruitments.getContent()) + assertThat(response.recruitments()) .hasSize(20) .map(FindRecruitmentResponse::recruitmentId) .containsExactlyElementsOf(recruitmentIdsDesc); } @Test - @DisplayName("성공: 캐싱된 목록은 최대 20개까지만 조회 가능") - void findAllMax20() { + @DisplayName("성공: 캐싱할 최대 크기보다 크면 DB에서 조회한다.") + void findAllOverMaxCacheSize_ThenFindDB() { //given - int pageSize = 30; + int pageSize = 50; PageRequest pageRequest = PageRequest.of(0, pageSize); List recruitments = RecruitmentFixture.recruitments(shelter, 100); recruitmentRepository.saveAll(recruitments); @@ -197,141 +254,63 @@ void findAllMax20() { } //when - Slice cachedRecruitments = recruitmentRedisRepository.findRecruitments( - pageRequest); - - //then - assertThat(cachedRecruitments.getContent()) - .hasSizeLessThan(pageSize) - .hasSize(20); - assertThat(cachedRecruitments.hasNext()).isTrue(); - } - - @Test - @DisplayName("성공: 캐싱된 목록이 요청 개수 이하인 경우 hasNext는 false") - void findAllWhenLTRequestPageSizeThenHasNextIsFalse() { - //given - int pageSize = 10; - PageRequest pageRequest = PageRequest.of(0, pageSize); - List recruitments = RecruitmentFixture.recruitments(shelter, 10); - recruitmentRepository.saveAll(recruitments); - int hour = 0; - LocalDateTime now = LocalDateTime.now(); - for (Recruitment recruitment : recruitments) { - ReflectionTestUtils.setField(recruitment, "createdAt", now.plusHours(hour++)); - recruitmentRedisRepository.saveRecruitment(recruitment); - } - - //when - Slice cachedRecruitments = recruitmentRedisRepository.findRecruitments( - pageRequest); + FindRecruitmentsResponse response = recruitmentRedisRepository.findRecruitments( + pageRequest.getPageSize() + ); //then - assertThat(cachedRecruitments.hasNext()).isFalse(); + assertThat(response.recruitments()).hasSize(50); + assertThat(response.pageInfo().hasNext()).isTrue(); } } @Nested - @DisplayName("updateCachedRecruitments 메서드 호출 시") - class UpdateCachedRecruitmentsTest { + @DisplayName("deleteCachedRecruitment 메서드 실행 시") + class DeleteCachedRecruitmentTest { Shelter shelter; - List recruitments; @BeforeEach void setUp() { shelter = ShelterFixture.shelter(); shelterRepository.save(shelter); - recruitments = RecruitmentFixture.recruitments(shelter, 40); - recruitmentRepository.saveAll(recruitments); } @Test - @DisplayName("성공: 캐시된 Recruitment 업데이트 됨") - void updateCachedRecruitment() { - //given - int hour = 0; - LocalDateTime now = LocalDateTime.now(); - for (Recruitment recruitment : recruitments) { - ReflectionTestUtils.setField(recruitment, "createdAt", now.plusHours(hour++)); - recruitmentRedisRepository.saveRecruitment(recruitment); - } - Recruitment needToUpdateRecruitment = recruitments.get(20); - FindRecruitmentResponse oldCachedRecruitment - = FindRecruitmentResponse.from(needToUpdateRecruitment); - needToUpdateRecruitment.updateRecruitment("update", null, null, null, null, null, null); - - //when - recruitmentRedisRepository.updateRecruitment(needToUpdateRecruitment); - - //then - PageRequest pageRequest = PageRequest.of(0, 20); - Slice cachedRecruitments = recruitmentRedisRepository.findRecruitments( - pageRequest); - FindRecruitmentResponse newCachedRecruitment - = FindRecruitmentResponse.from(needToUpdateRecruitment); - assertThat(cachedRecruitments.getContent()) - .contains(newCachedRecruitment) - .doesNotContain(oldCachedRecruitment); - } - - @Test - @DisplayName("성공: 캐시된 Recruitment 없는 경우 무시됨") - void ignoreIfCachedRecruitmentDoesNotExists() { + @DisplayName("성공: 캐시된 Recruitment 삭제 됨") + void deleteCachedRecruitment() { //given - int hour = 0; - LocalDateTime now = LocalDateTime.now(); - for (Recruitment recruitment : recruitments) { - ReflectionTestUtils.setField(recruitment, "createdAt", now.plusHours(hour++)); - recruitmentRedisRepository.saveRecruitment(recruitment); - } - Recruitment needToUpdateRecruitment = recruitments.get(0); - needToUpdateRecruitment.updateRecruitment("update", null, null, null, null, null, null); + Recruitment recruitment = RecruitmentFixture.recruitment(shelter); + recruitmentRepository.save(recruitment); + recruitmentRedisRepository.saveRecruitment(recruitment); //when - recruitmentRedisRepository.updateRecruitment(needToUpdateRecruitment); + recruitmentRedisRepository.deleteRecruitment(recruitment); //then - long createdAtScore - = needToUpdateRecruitment.getCreatedAt().toEpochSecond(ZoneOffset.UTC); ZSetOperations cachedRecruitments = redisTemplate.opsForZSet(); + long createdAtScore = recruitment.getCreatedAt().toEpochSecond(ZoneOffset.UTC); Set recruitments = cachedRecruitments.rangeByScore( RECRUITMENT_KEY, createdAtScore, createdAtScore); assertThat(recruitments).isEmpty(); } - } - - @Nested - @DisplayName("deleteCachedRecruitment 메서드 실행 시") - class DeleteCachedRecruitmentTest { - - Shelter shelter; - - @BeforeEach - void setUp() { - shelter = ShelterFixture.shelter(); - shelterRepository.save(shelter); - } @Test - @DisplayName("성공: 캐시된 Recruitment 삭제 됨") - void deleteCachedRecruitment() { + @DisplayName("성공: 카운트 값이 1 감소한다.") + void decreaseCachedCount() { //given Recruitment recruitment = RecruitmentFixture.recruitment(shelter); recruitmentRepository.save(recruitment); recruitmentRedisRepository.saveRecruitment(recruitment); + long cachedCount = recruitmentRedisRepository.getTotalNumberOfRecruitments(); //when recruitmentRedisRepository.deleteRecruitment(recruitment); //then - ZSetOperations cachedRecruitments - = redisTemplate.opsForZSet(); - long createdAtScore = recruitment.getCreatedAt().toEpochSecond(ZoneOffset.UTC); - Set recruitments = cachedRecruitments.rangeByScore( - RECRUITMENT_KEY, createdAtScore, createdAtScore); - assertThat(recruitments).isEmpty(); + assertThat(recruitmentRedisRepository.getTotalNumberOfRecruitments()) + .isEqualTo(cachedCount - 1); } @Test @@ -428,11 +407,11 @@ void closeRecruitmentsIfNeedToBeButAlreadyClosed() { } @Nested - @DisplayName("getRecruitmentsCount 메서드 호출 시") + @DisplayName("getTotalNumberOfRecruitments 메서드 호출 시") class GetRecruitmentsCountTest { @Test - @DisplayName("성공: redis에 없으면 -1을 반환한다.") + @DisplayName("성공: 요소의 총 개수를 반환한다.") void getCachedRecruitmentCountWhenNotExistInRedis() { // given Shelter shelter = ShelterFixture.shelter(); @@ -441,10 +420,10 @@ void getCachedRecruitmentCountWhenNotExistInRedis() { recruitmentRepository.save(recruitment); // when - Long recruitmentCount = recruitmentRedisRepository.getRecruitmentCount(); + Long recruitmentCount = recruitmentRedisRepository.getTotalNumberOfRecruitments(); // then - assertThat(recruitmentCount).isEqualTo(-1L); + assertThat(recruitmentCount).isEqualTo(1); } } } diff --git a/src/test/java/com/clova/anifriends/domain/recruitment/service/RecruitmentIntegrationTest.java b/src/test/java/com/clova/anifriends/domain/recruitment/service/RecruitmentIntegrationTest.java index b35eb2c0..b4daec5f 100644 --- a/src/test/java/com/clova/anifriends/domain/recruitment/service/RecruitmentIntegrationTest.java +++ b/src/test/java/com/clova/anifriends/domain/recruitment/service/RecruitmentIntegrationTest.java @@ -63,6 +63,8 @@ void plusOneToRecruitmentCount() { Recruitment recruitment = RecruitmentFixture.recruitment(shelter); shelterRepository.save(shelter); recruitmentRepository.save(recruitment); + recruitmentCacheRepository.saveRecruitment(recruitment); + long count = recruitmentCacheRepository.getTotalNumberOfRecruitments(); // when recruitmentService.registerRecruitment( @@ -77,8 +79,8 @@ void plusOneToRecruitmentCount() { ); // then - assertThat(recruitmentRepository.count()).isEqualTo( - recruitmentCacheRepository.getRecruitmentCount()); + assertThat(recruitmentCacheRepository.getTotalNumberOfRecruitments()) + .isEqualTo(count + 1); } } @@ -179,6 +181,7 @@ void MinusOneToRecruitmentCount() { // given Recruitment recruitment = RecruitmentFixture.recruitment(shelter); Recruitment savedRecruitment = recruitmentRepository.save(recruitment); + long count = recruitmentCacheRepository.getTotalNumberOfRecruitments(); // when recruitmentService.deleteRecruitment( @@ -188,7 +191,7 @@ void MinusOneToRecruitmentCount() { // then assertThat(recruitmentRepository.count()).isEqualTo( - recruitmentCacheRepository.getRecruitmentCount()); + recruitmentCacheRepository.getTotalNumberOfRecruitments()); } } diff --git a/src/test/java/com/clova/anifriends/domain/recruitment/service/RecruitmentServiceTest.java b/src/test/java/com/clova/anifriends/domain/recruitment/service/RecruitmentServiceTest.java index ab5a7fd9..54e55b02 100644 --- a/src/test/java/com/clova/anifriends/domain/recruitment/service/RecruitmentServiceTest.java +++ b/src/test/java/com/clova/anifriends/domain/recruitment/service/RecruitmentServiceTest.java @@ -253,7 +253,6 @@ void findRecruitments() { given(recruitmentRepository.countFindRecruitmentsV2(keyword, startDate, endDate, isClosed, keywordCondition)) .willReturn(Long.valueOf(recruitments.getSize())); - given(recruitmentCacheRepository.getRecruitmentCount()).willReturn(-1L); //when FindRecruitmentsResponse recruitmentsByVolunteer @@ -298,7 +297,6 @@ void findRecruitmentsWithOtherConditions() { given(recruitmentRepository.countFindRecruitmentsV2(keyword, startDate, endDate, isClosed, keywordCondition)) .willReturn(Long.valueOf(recruitments.getSize())); - given(recruitmentCacheRepository.getRecruitmentCount()).willReturn(-1L); //when FindRecruitmentsResponse recruitmentsByVolunteer @@ -328,7 +326,6 @@ void findRecruitmentsWithAnotherCondition() { .willReturn(recruitments); given(recruitmentRepository.countFindRecruitmentsV2(keyword, startDate, endDate, isClosed, keywordCondition)).willReturn(Long.valueOf(recruitments.getSize())); - given(recruitmentCacheRepository.getRecruitmentCount()).willReturn(-1L); //when FindRecruitmentsResponse recruitmentsByVolunteer @@ -355,7 +352,7 @@ class WithoutCondition { final Long nullRecruitmentId = null; @Test - @DisplayName("성공: 캐시된 봉사 모집글 목록 사이즈가 요청 사이즈를 초과하는 경우 캐시된 목록을 이용") + @DisplayName("성공: 캐시 저장소에서 조회를 수행한다.") void findRecruitmentsWhenCached() { //given List recruitments = RecruitmentFixture.recruitments(shelter, 20); @@ -363,13 +360,12 @@ void findRecruitmentsWhenCached() { .map(FindRecruitmentResponse::from).toList(); PageRequest pageRequest = PageRequest.of(0, 10); boolean hasNext = true; - SliceImpl cachedRecruitments - = new SliceImpl<>(recruitmentResponses, pageRequest, hasNext); + FindRecruitmentsResponse response = new FindRecruitmentsResponse( + recruitmentResponses, + PageInfo.of(recruitments.size(), hasNext)); - given(recruitmentCacheRepository.findRecruitments(any(PageRequest.class))) - .willReturn(cachedRecruitments); - given(recruitmentCacheRepository.getRecruitmentCount()).willReturn(10L); - given(recruitmentCacheRepository.getRecruitmentCount()).willReturn(10L); + given(recruitmentCacheRepository.findRecruitments(pageRequest.getPageSize())) + .willReturn(response); //when FindRecruitmentsResponse recruitmentsV2 = recruitmentService.findRecruitmentsV2( @@ -377,45 +373,12 @@ void findRecruitmentsWhenCached() { nullCreatedAt, nullRecruitmentId, pageRequest); //then - then(recruitmentCacheRepository).should().findRecruitments(pageRequest); + then(recruitmentCacheRepository).should() + .findRecruitments(pageRequest.getPageSize()); then(recruitmentRepository).should(times(0)) .findRecruitmentsV2(nullKeyword, nullStartDate, nullEndDate, nullIsClosed, nullKeywordCondition, nullCreatedAt, nullRecruitmentId, pageRequest); } - - @Test - @DisplayName("성공: 캐신된 봉사 모집글 사이즈가 요청 사이즈 이하인 경우 캐시된 목록을 이용하지 않음") - void findRecruitmentsWhenCachedSizeLTRequestPageSize() { - //given - List recruitments = RecruitmentFixture.recruitments(shelter, 10); - List recruitmentResponses = recruitments.stream() - .map(FindRecruitmentResponse::from).toList(); - PageRequest pageRequest = PageRequest.of(0, 10); - boolean hasNext = false; - SliceImpl cachedRecruitments = new SliceImpl<>( - recruitmentResponses, pageRequest, hasNext); - SliceImpl findRecruitments = new SliceImpl<>(recruitments, pageRequest, - hasNext); - - given(recruitmentCacheRepository.findRecruitments(pageRequest)) - .willReturn(cachedRecruitments); - given(recruitmentCacheRepository.getRecruitmentCount()).willReturn(10L); - given(recruitmentRepository.findRecruitmentsV2(nullKeyword, nullStartDate, - nullEndDate, nullIsClosed, nullKeywordCondition, nullCreatedAt, nullRecruitmentId, - pageRequest)).willReturn(findRecruitments); - given(recruitmentCacheRepository.getRecruitmentCount()).willReturn(10L); - - //when - FindRecruitmentsResponse recruitmentsV2 = recruitmentService.findRecruitmentsV2( - nullKeyword, nullStartDate, nullEndDate, - nullIsClosed, nullKeywordCondition, nullCreatedAt, nullRecruitmentId, pageRequest); - - //then - then(recruitmentCacheRepository).should().findRecruitments(pageRequest); - then(recruitmentRepository).should() - .findRecruitmentsV2(nullKeyword, nullStartDate, nullEndDate, nullIsClosed, - nullKeywordCondition, nullCreatedAt, nullRecruitmentId, pageRequest); - } } } @@ -553,6 +516,40 @@ void exceptionWhenRecruitmentNotFound() { //then assertThat(exception).isInstanceOf(RecruitmentNotFoundException.class); } + + @Test + @DisplayName("성공: 캐시 되어있던 봉사 모집글이 아니면 캐시 추가를 호출하지 않는다.") + void doesNotInvokeCacheSave_WhenNotExistsInCache() { + //given + given(recruitmentRepository.findByShelterIdAndRecruitmentId(anyLong(), anyLong())) + .willReturn(Optional.ofNullable(recruitment)); + given(recruitmentCacheRepository.deleteRecruitment(any())) + .willReturn(0L); + + //when + recruitmentService.closeRecruitment(1L, 1L); + + //then + then(recruitmentCacheRepository).should(times(0)) + .saveRecruitment(any()); + } + + @Test + @DisplayName("성공: 캐시 되어있던 봉사 모집글이면 캐시 추가를 호출한다.") + void invokeCacheSave_WhenExistsInCache() { + //given + given(recruitmentRepository.findByShelterIdAndRecruitmentId(anyLong(), anyLong())) + .willReturn(Optional.ofNullable(recruitment)); + given(recruitmentCacheRepository.deleteRecruitment(any())) + .willReturn(1L); + + //when + recruitmentService.closeRecruitment(1L, 1L); + + //then + then(recruitmentCacheRepository).should(times(1)). + saveRecruitment(any()); + } } @Nested @@ -624,6 +621,56 @@ void exceptionWhenRecruitmentNotFound() { //then assertThat(exception).isInstanceOf(RecruitmentNotFoundException.class); } + + @Test + @DisplayName("성공: 캐시 되어있던 봉사 모집글이 아니면 캐시 추가를 호출하지 않는다.") + void doesNotInvokeCacheSave_WhenNotExistsInCache() { + //given + String newTitle = recruitment.getTitle() + "a"; + LocalDateTime newStartTime = recruitment.getStartTime().plusDays(1); + LocalDateTime newEndTime = recruitment.getEndTime().plusDays(1); + LocalDateTime newDeadline = recruitment.getDeadline().plusDays(1); + int newCapacity = recruitment.getCapacity() + 1; + String newContent = recruitment.getContent() + "a"; + List newImageUrls = List.of("a1", "a2"); + + given(recruitmentRepository.findByShelterIdAndRecruitmentIdWithImages(anyLong(), + anyLong())).willReturn(Optional.ofNullable(recruitment)); + given(recruitmentCacheRepository.deleteRecruitment(any())).willReturn(0L); + + //when + recruitmentService.updateRecruitment(1L, 1L, + newTitle, newStartTime, newEndTime, newDeadline, newCapacity, newContent, + newImageUrls); + + //then + then(recruitmentCacheRepository).should(times(0)).saveRecruitment(any()); + } + + @Test + @DisplayName("성공: 캐시 되어있던 봉사 모집글이면 캐시 추가를 호출한다.") + void invokeCacheSave_WhenExistsInCache() { + //given + String newTitle = recruitment.getTitle() + "a"; + LocalDateTime newStartTime = recruitment.getStartTime().plusDays(1); + LocalDateTime newEndTime = recruitment.getEndTime().plusDays(1); + LocalDateTime newDeadline = recruitment.getDeadline().plusDays(1); + int newCapacity = recruitment.getCapacity() + 1; + String newContent = recruitment.getContent() + "a"; + List newImageUrls = List.of("a1", "a2"); + + given(recruitmentRepository.findByShelterIdAndRecruitmentIdWithImages(anyLong(), + anyLong())).willReturn(Optional.ofNullable(recruitment)); + given(recruitmentCacheRepository.deleteRecruitment(any())).willReturn(1L); + + //when + recruitmentService.updateRecruitment(1L, 1L, + newTitle, newStartTime, newEndTime, newDeadline, newCapacity, newContent, + newImageUrls); + + //then + then(recruitmentCacheRepository).should(times(1)).saveRecruitment(any()); + } } @Nested