diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/region/AdminRegionIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/region/AdminRegionIntegrationTest.kt index fe5cb75d9..1cbd98a82 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/integration/region/AdminRegionIntegrationTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/region/AdminRegionIntegrationTest.kt @@ -1,6 +1,7 @@ package app.integration.region import app.IntegrationTestSupport +import app.bottlenote.alcohols.domain.RegionRepository import app.bottlenote.alcohols.dto.request.AdminRegionCreateRequest import app.bottlenote.alcohols.dto.request.AdminRegionSortOrderRequest import app.bottlenote.alcohols.dto.request.AdminRegionUpdateRequest @@ -21,6 +22,9 @@ class AdminRegionIntegrationTest : IntegrationTestSupport() { @Autowired private lateinit var regionTestFactory: RegionTestFactory + @Autowired + private lateinit var regionRepository: RegionRepository + private lateinit var accessToken: String @BeforeEach @@ -133,6 +137,33 @@ class AdminRegionIntegrationTest : IntegrationTestSupport() { .bodyJson() .extractingPath("$.data.code").isEqualTo("REGION_UPDATED") } + + @Test + @DisplayName("지역 수정 시 lastModifyAt이 갱신된다") + fun updateRefreshesLastModifyAt() { + val region = regionTestFactory.persistRoot("스코트랜드", "ScotlandTypo", 10) + val before = regionRepository.findById(region.id!!).orElseThrow().lastModifyAt + Thread.sleep(1100) + + val request = AdminRegionUpdateRequest.builder() + .korName("스코틀랜드") + .engName("Scotland") + .description("정정") + .sortOrder(10) + .build() + + assertThat( + mockMvcTester + .put() + .uri("/v1/regions/${region.id}") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ).hasStatusOk() + + val after = regionRepository.findById(region.id!!).orElseThrow().lastModifyAt + assertThat(after).isAfter(before) + } } @Nested diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/RegionsItem.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/RegionsItem.java index aa1cd68e4..60a2075c3 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/RegionsItem.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/RegionsItem.java @@ -1,9 +1,11 @@ package app.bottlenote.alcohols.dto.response; import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; import lombok.Getter; @Getter +@EqualsAndHashCode @AllArgsConstructor(staticName = "of") public class RegionsItem { private final Long regionId; diff --git a/bottlenote-product-api/build.gradle b/bottlenote-product-api/build.gradle index 20cf50081..c24aad70c 100644 --- a/bottlenote-product-api/build.gradle +++ b/bottlenote-product-api/build.gradle @@ -20,6 +20,7 @@ dependencies { // ===== Spring Boot Web ===== implementation libs.spring.boot.starter.web implementation libs.spring.boot.starter.validation + implementation libs.spring.boot.starter.data.jpa // OpenAPI 스펙 생성 (swagger-ui는 설정으로 비활성) implementation libs.springdoc.openapi.starter.webmvc.ui diff --git a/bottlenote-product-api/src/main/java/app/bottlenote/alcohols/service/RegionCacheRefreshService.java b/bottlenote-product-api/src/main/java/app/bottlenote/alcohols/service/RegionCacheRefreshService.java new file mode 100644 index 000000000..d1b4eabf9 --- /dev/null +++ b/bottlenote-product-api/src/main/java/app/bottlenote/alcohols/service/RegionCacheRefreshService.java @@ -0,0 +1,46 @@ +package app.bottlenote.alcohols.service; + +import app.bottlenote.alcohols.domain.RegionRepository; +import app.bottlenote.alcohols.dto.response.RegionsItem; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class RegionCacheRefreshService { + + private static final String REGION_CACHE_NAME = "local_cache_alcohol_region_information"; + + private final RegionRepository regionRepository; + private final CacheManager cacheManager; + private List lastKnownRegions; + + @EventListener(ApplicationReadyEvent.class) + @Transactional(readOnly = true) + public void initializeRevision() { + refresh(); + } + + @Scheduled(cron = "${schedules.region.cache.refresh.cron:0 */5 * * * *}") + @Transactional(readOnly = true) + public synchronized void refresh() { + List currentRegions = regionRepository.findAllRegionsResponse(); + if (lastKnownRegions != null && !lastKnownRegions.equals(currentRegions)) { + Cache cache = cacheManager.getCache(REGION_CACHE_NAME); + if (cache != null) { + cache.clear(); + log.info("지역 캐시를 갱신했습니다."); + } + } + lastKnownRegions = currentRegions; + } +} diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/service/RegionCacheRefreshServiceTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/service/RegionCacheRefreshServiceTest.java new file mode 100644 index 000000000..a35f3565e --- /dev/null +++ b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/service/RegionCacheRefreshServiceTest.java @@ -0,0 +1,79 @@ +package app.bottlenote.alcohols.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import app.bottlenote.alcohols.domain.Region; +import app.bottlenote.alcohols.fixture.InMemoryRegionRepository; +import java.time.LocalDateTime; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.cache.interceptor.SimpleKey; +import org.springframework.test.util.ReflectionTestUtils; + +@Tag("unit") +@DisplayName("[unit] RegionCacheRefreshService") +class RegionCacheRefreshServiceTest { + + private static final String REGION_CACHE_NAME = "local_cache_alcohol_region_information"; + + private final InMemoryRegionRepository regionRepository = new InMemoryRegionRepository(); + private final CacheManager cacheManager = new ConcurrentMapCacheManager(REGION_CACHE_NAME); + private RegionCacheRefreshService service; + + @BeforeEach + void setUp() { + service = new RegionCacheRefreshService(regionRepository, cacheManager); + } + + @Test + @DisplayName("지역이 추가되면 Product 지역 캐시를 비운다") + void refresh_whenRegionAdded_clearsRegionCache() { + service.refresh(); + + Cache cache = cacheManager.getCache(REGION_CACHE_NAME); + cache.put(SimpleKey.EMPTY, "cached-regions"); + regionRepository.save(Region.builder().korName("스코틀랜드").engName("Scotland").build()); + + service.refresh(); + + assertThat(cache.get(SimpleKey.EMPTY)).isNull(); + } + + @Test + @DisplayName("같은 최종 수정 시각에 지역 내용이 변경되면 Product 지역 캐시를 비운다") + void refresh_whenRegionContentChangedWithSameLastModifyAt_clearsRegionCache() { + Region region = Region.builder().korName("스코틀랜드").engName("Scotland").build(); + regionRepository.save(region); + ReflectionTestUtils.setField(region, "lastModifyAt", LocalDateTime.of(2026, 7, 29, 12, 5)); + service.refresh(); + + Cache cache = cacheManager.getCache(REGION_CACHE_NAME); + cache.put(SimpleKey.EMPTY, "cached-regions"); + region.update("스코틀랜드", "Scotland", null, "변경된 설명", null, null, null); + + service.refresh(); + + assertThat(cache.get(SimpleKey.EMPTY)).isNull(); + } + + @Test + @DisplayName("지역이 삭제되면 Product 지역 캐시를 비운다") + void refresh_whenRegionDeleted_clearsRegionCache() { + Region region = Region.builder().korName("스코틀랜드").engName("Scotland").build(); + regionRepository.save(region); + service.refresh(); + + Cache cache = cacheManager.getCache(REGION_CACHE_NAME); + cache.put(SimpleKey.EMPTY, "cached-regions"); + regionRepository.delete(region); + + service.refresh(); + + assertThat(cache.get(SimpleKey.EMPTY)).isNull(); + } +} diff --git a/bottlenote-test-support/src/main/java/app/bottlenote/alcohols/fixture/InMemoryRegionRepository.java b/bottlenote-test-support/src/main/java/app/bottlenote/alcohols/fixture/InMemoryRegionRepository.java index d9364285e..3b2bc34b0 100644 --- a/bottlenote-test-support/src/main/java/app/bottlenote/alcohols/fixture/InMemoryRegionRepository.java +++ b/bottlenote-test-support/src/main/java/app/bottlenote/alcohols/fixture/InMemoryRegionRepository.java @@ -46,6 +46,7 @@ public List findAllRegionsResponse() { .toList(); } + @Override public Page findAllRegions(String keyword, Pageable pageable) { List filtered =