diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index 3cf8f4742..89c767b76 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -36,6 +36,7 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-thymeleaf") implementation("org.springframework.boot:spring-boot-starter-mail") + implementation("org.springframework.boot:spring-boot-starter-cache") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${swaggerVersion}") // Spring Security @@ -79,6 +80,9 @@ dependencies { annotationProcessor("org.projectlombok:lombok") testCompileOnly("org.projectlombok:lombok") testAnnotationProcessor("org.projectlombok:lombok") + + // Caffeine + implementation("com.github.ben-manes.caffeine:caffeine") } tasks.test { diff --git a/backend/src/main/java/com/festago/common/cache/CacheInvalidator.java b/backend/src/main/java/com/festago/common/cache/CacheInvalidator.java new file mode 100644 index 000000000..424961fcb --- /dev/null +++ b/backend/src/main/java/com/festago/common/cache/CacheInvalidator.java @@ -0,0 +1,23 @@ +package com.festago.common.cache; + +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.CacheManager; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class CacheInvalidator { + + private final CacheManager cacheManager; + + public void invalidate(String cacheName) { + Optional.ofNullable(cacheManager.getCache(cacheName)) + .ifPresentOrElse(cache -> { + cache.invalidate(); + log.info("{} 캐시를 초기화 했습니다.", cacheName); + }, () -> log.error("{} 캐시를 찾을 수 없습니다.", cacheName)); + } +} diff --git a/backend/src/main/java/com/festago/common/cache/CacheStatsLogger.java b/backend/src/main/java/com/festago/common/cache/CacheStatsLogger.java new file mode 100644 index 000000000..3fb6d9832 --- /dev/null +++ b/backend/src/main/java/com/festago/common/cache/CacheStatsLogger.java @@ -0,0 +1,30 @@ +package com.festago.common.cache; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.cache.caffeine.CaffeineCache; +import org.springframework.context.annotation.Profile; +import org.springframework.context.event.ContextClosedEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +@Profile({"!test"}) +@Slf4j +@Component +@RequiredArgsConstructor +public class CacheStatsLogger { + + private final CacheManager cacheManager; + + @EventListener(ContextClosedEvent.class) + public void logCacheStats() { + for (String cacheName : cacheManager.getCacheNames()) { + Cache cache = cacheManager.getCache(cacheName); + if (cache instanceof CaffeineCache caffeineCache) { + log.info("CacheName={} CacheStats={}", cacheName, caffeineCache.getNativeCache().stats()); + } + } + } +} diff --git a/backend/src/main/java/com/festago/config/CacheConfig.java b/backend/src/main/java/com/festago/config/CacheConfig.java new file mode 100644 index 000000000..390032a53 --- /dev/null +++ b/backend/src/main/java/com/festago/config/CacheConfig.java @@ -0,0 +1,21 @@ +package com.festago.config; + +import java.util.List; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.support.SimpleCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableCaching +public class CacheConfig { + + @Bean + public CacheManager cacheManager(List caches) { + SimpleCacheManager cacheManager = new SimpleCacheManager(); + cacheManager.setCaches(caches); + return cacheManager; + } +} diff --git a/backend/src/main/java/com/festago/school/application/v1/SchoolFestivalsV1CacheInvalidateScheduler.java b/backend/src/main/java/com/festago/school/application/v1/SchoolFestivalsV1CacheInvalidateScheduler.java new file mode 100644 index 000000000..b2997b80c --- /dev/null +++ b/backend/src/main/java/com/festago/school/application/v1/SchoolFestivalsV1CacheInvalidateScheduler.java @@ -0,0 +1,20 @@ +package com.festago.school.application.v1; + +import com.festago.common.cache.CacheInvalidator; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class SchoolFestivalsV1CacheInvalidateScheduler { + + private final CacheInvalidator cacheInvalidator; + + // 매일 정각마다 캐시 초기화 + @Scheduled(cron = "0 0 0 * * *") + public void invalidate() { + cacheInvalidator.invalidate(SchoolFestivalsV1QueryService.SCHOOL_FESTIVALS_V1_CACHE_NAME); + cacheInvalidator.invalidate(SchoolFestivalsV1QueryService.PAST_SCHOOL_FESTIVALS_V1_CACHE_NAME); + } +} diff --git a/backend/src/main/java/com/festago/school/application/v1/SchoolFestivalsV1QueryService.java b/backend/src/main/java/com/festago/school/application/v1/SchoolFestivalsV1QueryService.java new file mode 100644 index 000000000..ec1136b85 --- /dev/null +++ b/backend/src/main/java/com/festago/school/application/v1/SchoolFestivalsV1QueryService.java @@ -0,0 +1,35 @@ +package com.festago.school.application.v1; + +import com.festago.school.dto.v1.SchoolFestivalV1Response; +import com.festago.school.repository.v1.SchoolFestivalsV1QueryDslRepository; +import java.time.Clock; +import java.time.LocalDate; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class SchoolFestivalsV1QueryService { + + public static final String SCHOOL_FESTIVALS_V1_CACHE_NAME = "schoolFestivalsV1"; + public static final String PAST_SCHOOL_FESTIVALS_V1_CACHE_NAME = "pastSchoolFestivalsV1"; + + private final SchoolFestivalsV1QueryDslRepository schoolFestivalsV1QueryDslRepository; + private final Clock clock; + + @Cacheable(cacheNames = SCHOOL_FESTIVALS_V1_CACHE_NAME, key = "#schoolId") + public List findFestivalsBySchoolId(Long schoolId) { + LocalDate now = LocalDate.now(clock); + return schoolFestivalsV1QueryDslRepository.findFestivalsBySchoolId(schoolId, now); + } + + @Cacheable(cacheNames = PAST_SCHOOL_FESTIVALS_V1_CACHE_NAME, key = "#schoolId") + public List findPastFestivalsBySchoolId(Long schoolId) { + LocalDate now = LocalDate.now(clock); + return schoolFestivalsV1QueryDslRepository.findPastFestivalsBySchoolId(schoolId, now); + } +} diff --git a/backend/src/main/java/com/festago/school/infrastructure/SchoolFestivalsV1CacheConfig.java b/backend/src/main/java/com/festago/school/infrastructure/SchoolFestivalsV1CacheConfig.java new file mode 100644 index 000000000..b67733694 --- /dev/null +++ b/backend/src/main/java/com/festago/school/infrastructure/SchoolFestivalsV1CacheConfig.java @@ -0,0 +1,38 @@ +package com.festago.school.infrastructure; + +import com.festago.school.application.v1.SchoolFestivalsV1QueryService; +import com.github.benmanes.caffeine.cache.Caffeine; +import java.util.concurrent.TimeUnit; +import org.springframework.cache.Cache; +import org.springframework.cache.caffeine.CaffeineCache; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SchoolFestivalsV1CacheConfig { + + private static final long EXPIRED_AFTER_WRITE = 30; + private static final long MAXIMUM_SIZE = 1_000; + + @Bean + public Cache schoolFestivalsV1Cache() { + return new CaffeineCache(SchoolFestivalsV1QueryService.SCHOOL_FESTIVALS_V1_CACHE_NAME, + Caffeine.newBuilder() + .recordStats() + .expireAfterWrite(EXPIRED_AFTER_WRITE, TimeUnit.MINUTES) + .maximumSize(MAXIMUM_SIZE) + .build() + ); + } + + @Bean + public Cache pastSchoolFestivalsV1Cache() { + return new CaffeineCache(SchoolFestivalsV1QueryService.PAST_SCHOOL_FESTIVALS_V1_CACHE_NAME, + Caffeine.newBuilder() + .recordStats() + .expireAfterWrite(EXPIRED_AFTER_WRITE, TimeUnit.MINUTES) + .maximumSize(MAXIMUM_SIZE) + .build() + ); + } +} diff --git a/backend/src/main/java/com/festago/school/repository/v1/SchoolFestivalsV1QueryDslRepository.java b/backend/src/main/java/com/festago/school/repository/v1/SchoolFestivalsV1QueryDslRepository.java new file mode 100644 index 000000000..bf183410b --- /dev/null +++ b/backend/src/main/java/com/festago/school/repository/v1/SchoolFestivalsV1QueryDslRepository.java @@ -0,0 +1,64 @@ +package com.festago.school.repository.v1; + +import static com.festago.festival.domain.QFestival.festival; +import static com.festago.festival.domain.QFestivalQueryInfo.festivalQueryInfo; + +import com.festago.common.querydsl.QueryDslHelper; +import com.festago.school.dto.v1.QSchoolFestivalV1Response; +import com.festago.school.dto.v1.SchoolFestivalV1Response; +import java.time.LocalDate; +import java.util.Comparator; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class SchoolFestivalsV1QueryDslRepository { + + private final QueryDslHelper queryDslHelper; + + public List findFestivalsBySchoolId( + Long schoolId, + LocalDate today + ) { + return queryDslHelper.select( + new QSchoolFestivalV1Response( + festival.id, + festival.name, + festival.startDate, + festival.endDate, + festival.thumbnail, + festivalQueryInfo.artistInfo + ) + ) + .from(festival) + .leftJoin(festivalQueryInfo).on(festivalQueryInfo.festivalId.eq(festival.id)) + .where(festival.school.id.eq(schoolId).and(festival.endDate.goe(today))) + .stream() + .sorted(Comparator.comparing(SchoolFestivalV1Response::startDate)) + .toList(); + } + + public List findPastFestivalsBySchoolId( + Long schoolId, + LocalDate today + ) { + return queryDslHelper.select( + new QSchoolFestivalV1Response( + festival.id, + festival.name, + festival.startDate, + festival.endDate, + festival.thumbnail, + festivalQueryInfo.artistInfo + ) + ) + .from(festival) + .leftJoin(festivalQueryInfo).on(festivalQueryInfo.festivalId.eq(festival.id)) + .where(festival.school.id.eq(schoolId).and(festival.endDate.lt(today))) + .stream() + .sorted(Comparator.comparing(SchoolFestivalV1Response::endDate).reversed()) + .toList(); + } +} diff --git a/backend/src/test/java/com/festago/school/application/integration/SchoolFestivalsV1QueryServiceTest.java b/backend/src/test/java/com/festago/school/application/integration/SchoolFestivalsV1QueryServiceTest.java new file mode 100644 index 000000000..a296bec86 --- /dev/null +++ b/backend/src/test/java/com/festago/school/application/integration/SchoolFestivalsV1QueryServiceTest.java @@ -0,0 +1,186 @@ +package com.festago.school.application.integration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +import com.festago.festival.domain.Festival; +import com.festago.festival.repository.FestivalRepository; +import com.festago.school.application.v1.SchoolFestivalsV1QueryService; +import com.festago.school.domain.School; +import com.festago.school.dto.v1.SchoolFestivalV1Response; +import com.festago.school.repository.SchoolRepository; +import com.festago.support.ApplicationIntegrationTest; +import com.festago.support.TimeInstantProvider; +import com.festago.support.fixture.FestivalFixture; +import com.festago.support.fixture.SchoolFixture; +import java.time.Clock; +import java.time.LocalDate; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class SchoolFestivalsV1QueryServiceTest extends ApplicationIntegrationTest { + + @Autowired + SchoolFestivalsV1QueryService schoolFestivalsV1QueryService; + + @Autowired + SchoolRepository schoolRepository; + + @Autowired + FestivalRepository festivalRepository; + + @Autowired + Clock clock; + + School 테코대학교; + School 우테대학교; + Festival 테코대학교_6월_15일_6월_15일_축제; + Festival 테코대학교_6월_16일_6월_17일_축제; + Festival 테코대학교_6월_17일_6월_18일_축제; + Festival 테코대학교_6월_19일_6월_20일_축제; + LocalDate _6월_15일 = LocalDate.parse("2077-06-15"); + LocalDate _6월_16일 = LocalDate.parse("2077-06-16"); + LocalDate _6월_17일 = LocalDate.parse("2077-06-17"); + LocalDate _6월_18일 = LocalDate.parse("2077-06-18"); + LocalDate _6월_19일 = LocalDate.parse("2077-06-19"); + LocalDate _6월_20일 = LocalDate.parse("2077-06-20"); + LocalDate _6월_21일 = LocalDate.parse("2077-06-21"); + + /** + * 축제는 다음과 같이 존재한다.
테코대학교 6월 15일 ~ 6월 15일
테코대학교 6월 16일 ~ 6월 17일
테코대학교 6월 17일 ~ 6월 18일
테코대학교 + * 6월 19일 ~ 6월 20일
또한 우테대학교에는 축제가 존재하지 않는다
+ */ + @BeforeEach + void setUp() { + 테코대학교 = schoolRepository.save(SchoolFixture.builder().name("테코대학교").build()); + 우테대학교 = schoolRepository.save(SchoolFixture.builder().name("우테대학교").build()); + 테코대학교_6월_15일_6월_15일_축제 = festivalRepository.save( + FestivalFixture.builder().startDate(_6월_15일).endDate(_6월_15일).school(테코대학교).build() + ); + 테코대학교_6월_19일_6월_20일_축제 = festivalRepository.save( + FestivalFixture.builder().startDate(_6월_19일).endDate(_6월_20일).school(테코대학교).build() + ); + 테코대학교_6월_17일_6월_18일_축제 = festivalRepository.save( + FestivalFixture.builder().startDate(_6월_17일).endDate(_6월_18일).school(테코대학교).build() + ); + 테코대학교_6월_16일_6월_17일_축제 = festivalRepository.save( + FestivalFixture.builder().startDate(_6월_16일).endDate(_6월_17일).school(테코대학교).build() + ); + } + + @Nested + class findFestivalsBySchoolId { + + @Test + void 진행_중_진행_예정_축제가_없으면_빈_리스트가_반환된다() { + // given + given(clock.instant()) + .willReturn(TimeInstantProvider.from(_6월_15일)); + + // when + var actual = schoolFestivalsV1QueryService.findFestivalsBySchoolId(우테대학교.getId()); + + // then + assertThat(actual).isEmpty(); + } + + @Test + void 진행_중_진행_예정_축제_조회는_시작일_오름차순으로_정렬된다() { + // given + given(clock.instant()) + .willReturn(TimeInstantProvider.from(_6월_15일)); + + // when + var actual = schoolFestivalsV1QueryService.findFestivalsBySchoolId(테코대학교.getId()); + + // then + assertThat(actual) + .map(SchoolFestivalV1Response::id) + .containsExactly( + 테코대학교_6월_15일_6월_15일_축제.getId(), + 테코대학교_6월_16일_6월_17일_축제.getId(), + 테코대학교_6월_17일_6월_18일_축제.getId(), + 테코대학교_6월_19일_6월_20일_축제.getId() + ); + } + + @Test + void 진행_중_진행_예정_축제는_종료일을_포함한다() { + // given + given(clock.instant()) + .willReturn(TimeInstantProvider.from(_6월_18일)); + + // when + var actual = schoolFestivalsV1QueryService.findFestivalsBySchoolId(테코대학교.getId()); + + // then + assertThat(actual) + .map(SchoolFestivalV1Response::id) + .containsExactly( + 테코대학교_6월_17일_6월_18일_축제.getId(), + 테코대학교_6월_19일_6월_20일_축제.getId() + ); + } + } + + @Nested + class findPastFestivalsBySchoolId { + + @Test + void 과거_축제가_없으면_빈_리스트가_반환된다() { + // given + given(clock.instant()) + .willReturn(TimeInstantProvider.from(_6월_15일)); + + // when + var actual = schoolFestivalsV1QueryService.findPastFestivalsBySchoolId(우테대학교.getId()); + + // then + assertThat(actual).isEmpty(); + } + + @Test + void 과거_축제_조회는_종료일_내림차순으로_정렬된다() { + // given + given(clock.instant()) + .willReturn(TimeInstantProvider.from(_6월_21일)); + + // when + var actual = schoolFestivalsV1QueryService.findPastFestivalsBySchoolId(테코대학교.getId()); + + // then + assertThat(actual) + .map(SchoolFestivalV1Response::id) + .containsExactly( + 테코대학교_6월_19일_6월_20일_축제.getId(), + 테코대학교_6월_17일_6월_18일_축제.getId(), + 테코대학교_6월_16일_6월_17일_축제.getId(), + 테코대학교_6월_15일_6월_15일_축제.getId() + ); + } + + @Test + void 진행_중_진행_예정_축제는_종료일을_포함하지_않는다() { + // given + given(clock.instant()) + .willReturn(TimeInstantProvider.from(_6월_18일)); + + // when + var actual = schoolFestivalsV1QueryService.findPastFestivalsBySchoolId(테코대학교.getId()); + + // then + assertThat(actual) + .map(SchoolFestivalV1Response::id) + .containsExactly( + 테코대학교_6월_16일_6월_17일_축제.getId(), + 테코대학교_6월_15일_6월_15일_축제.getId() + ); + } + } +} diff --git a/backend/src/test/java/com/festago/school/infrastructure/SchoolFestivalsV1CacheConfigTest.java b/backend/src/test/java/com/festago/school/infrastructure/SchoolFestivalsV1CacheConfigTest.java new file mode 100644 index 000000000..21e59421e --- /dev/null +++ b/backend/src/test/java/com/festago/school/infrastructure/SchoolFestivalsV1CacheConfigTest.java @@ -0,0 +1,96 @@ +package com.festago.school.infrastructure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +import com.festago.festival.repository.FestivalRepository; +import com.festago.school.application.v1.SchoolFestivalsV1QueryService; +import com.festago.school.domain.School; +import com.festago.school.repository.SchoolRepository; +import com.festago.school.repository.v1.SchoolFestivalsV1QueryDslRepository; +import com.festago.support.ApplicationIntegrationTest; +import com.festago.support.TimeInstantProvider; +import com.festago.support.fixture.FestivalFixture; +import com.festago.support.fixture.SchoolFixture; +import java.time.Clock; +import java.time.LocalDate; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class SchoolFestivalsV1CacheConfigTest extends ApplicationIntegrationTest { + + @Autowired + SchoolFestivalsV1QueryService schoolFestivalsV1QueryService; + + @Autowired + SchoolFestivalsV1QueryDslRepository schoolFestivalsV1QueryDslRepository; + + @Autowired + SchoolRepository schoolRepository; + + @Autowired + FestivalRepository festivalRepository; + + @Autowired + Clock clock; + + School 테코대학교; + LocalDate _6월_15일 = LocalDate.parse("2077-06-15"); + LocalDate _6월_16일 = LocalDate.parse("2077-06-16"); + + @BeforeEach + void setUp() { + 테코대학교 = schoolRepository.save(SchoolFixture.builder().name("테코대학교").build()); + festivalRepository.save( + FestivalFixture.builder().startDate(_6월_15일).endDate(_6월_15일).school(테코대학교).build() + ); + } + + @Nested + class findFestivalsBySchoolId { + + @Test + void 캐싱이_적용되어야_한다() { + // given + given(clock.instant()) + .willReturn(TimeInstantProvider.from(_6월_15일)); + var expect = schoolFestivalsV1QueryService.findFestivalsBySchoolId(테코대학교.getId()); + + // when + festivalRepository.save( + FestivalFixture.builder().startDate(_6월_15일).endDate(_6월_15일).school(테코대학교).build() + ); + var actual = schoolFestivalsV1QueryService.findFestivalsBySchoolId(테코대학교.getId()); + + // then + assertThat(actual).isEqualTo(expect); + } + } + + @Nested + class findPastFestivalsBySchoolId { + + @Test + void 캐싱이_적용되어야_한다() { + // given + given(clock.instant()) + .willReturn(TimeInstantProvider.from(_6월_16일)); + var expect = schoolFestivalsV1QueryService.findPastFestivalsBySchoolId(테코대학교.getId()); + + // when + festivalRepository.save( + FestivalFixture.builder().startDate(_6월_15일).endDate(_6월_15일).school(테코대학교).build() + ); + var actual = schoolFestivalsV1QueryService.findPastFestivalsBySchoolId(테코대학교.getId()); + + // then + assertThat(actual).isEqualTo(expect); + } + } +} diff --git a/backend/src/test/java/com/festago/support/ApplicationIntegrationTest.java b/backend/src/test/java/com/festago/support/ApplicationIntegrationTest.java index f76220f21..1c2d5b5df 100644 --- a/backend/src/test/java/com/festago/support/ApplicationIntegrationTest.java +++ b/backend/src/test/java/com/festago/support/ApplicationIntegrationTest.java @@ -7,7 +7,8 @@ @SpringBootTest @TestExecutionListeners(value = { ResetMockTestExecutionListener.class, - DatabaseClearTestExecutionListener.class + DatabaseClearTestExecutionListener.class, + CacheClearTestExecutionListener.class }, mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS) @Import({TestTimeConfig.class}) public abstract class ApplicationIntegrationTest { diff --git a/backend/src/test/java/com/festago/support/CacheClearTestExecutionListener.java b/backend/src/test/java/com/festago/support/CacheClearTestExecutionListener.java new file mode 100644 index 000000000..38fa25475 --- /dev/null +++ b/backend/src/test/java/com/festago/support/CacheClearTestExecutionListener.java @@ -0,0 +1,22 @@ +package com.festago.support; + +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.context.ApplicationContext; +import org.springframework.test.context.TestContext; +import org.springframework.test.context.TestExecutionListener; + +public class CacheClearTestExecutionListener implements TestExecutionListener { + + @Override + public void beforeTestMethod(TestContext testContext) { + ApplicationContext applicationContext = testContext.getApplicationContext(); + CacheManager cacheManager = applicationContext.getBean(CacheManager.class); + for (String cacheName : cacheManager.getCacheNames()) { + Cache cache = cacheManager.getCache(cacheName); + if (cache != null) { + cache.invalidate(); + } + } + } +}