diff --git a/service/build.gradle.kts b/service/build.gradle.kts index 58d8821a..21c8c4ea 100644 --- a/service/build.gradle.kts +++ b/service/build.gradle.kts @@ -8,6 +8,10 @@ dependencies { // API docs implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13") + // Cache + implementation("org.springframework.boot:spring-boot-starter-cache") + implementation("com.github.ben-manes.caffeine:caffeine") + // DB runtimeOnly("com.h2database:h2") runtimeOnly("com.mysql:mysql-connector-j") diff --git a/service/src/main/kotlin/com/sangdol/roomescape/RoomescapeApplication.kt b/service/src/main/kotlin/com/sangdol/roomescape/RoomescapeApplication.kt index c8a3ee8a..b44858ca 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/RoomescapeApplication.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/RoomescapeApplication.kt @@ -3,8 +3,10 @@ package com.sangdol.roomescape import org.springframework.boot.Banner import org.springframework.boot.SpringApplication import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.cache.annotation.EnableCaching import java.util.* +@EnableCaching @SpringBootApplication( scanBasePackages = ["com.sangdol.roomescape", "com.sangdol.common"] ) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/theme/business/AdminThemeService.kt b/service/src/main/kotlin/com/sangdol/roomescape/theme/business/AdminThemeService.kt index 43c04df7..7496a2ae 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/theme/business/AdminThemeService.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/theme/business/AdminThemeService.kt @@ -3,22 +3,18 @@ package com.sangdol.roomescape.theme.business import com.sangdol.common.persistence.IDGenerator import com.sangdol.roomescape.admin.business.AdminService import com.sangdol.roomescape.common.types.AuditingInfo -import com.sangdol.roomescape.theme.dto.ThemeDetailResponse -import com.sangdol.roomescape.theme.dto.ThemeSummaryListResponse -import com.sangdol.roomescape.theme.dto.ThemeNameListResponse -import com.sangdol.roomescape.theme.dto.ThemeCreateRequest -import com.sangdol.roomescape.theme.dto.ThemeCreateResponse -import com.sangdol.roomescape.theme.dto.ThemeUpdateRequest +import com.sangdol.roomescape.theme.dto.* import com.sangdol.roomescape.theme.exception.ThemeErrorCode import com.sangdol.roomescape.theme.exception.ThemeException import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository import com.sangdol.roomescape.theme.mapper.toDetailResponse -import com.sangdol.roomescape.theme.mapper.toSummaryListResponse import com.sangdol.roomescape.theme.mapper.toEntity import com.sangdol.roomescape.theme.mapper.toNameListResponse +import com.sangdol.roomescape.theme.mapper.toSummaryListResponse import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.cache.annotation.CacheEvict import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -81,7 +77,7 @@ class AdminThemeService( } } - + @CacheEvict(cacheNames = ["theme-details"], key = "#id") @Transactional fun deleteTheme(id: Long) { log.info { "[deleteTheme] 테마 삭제 시작: id=${id}" } @@ -93,6 +89,7 @@ class AdminThemeService( } } + @CacheEvict(cacheNames = ["theme-details"], key = "#id") @Transactional fun updateTheme(id: Long, request: ThemeUpdateRequest) { log.info { "[updateTheme] 테마 수정 시작: id=${id}, request=${request}" } diff --git a/service/src/main/kotlin/com/sangdol/roomescape/theme/business/ThemeService.kt b/service/src/main/kotlin/com/sangdol/roomescape/theme/business/ThemeService.kt index 50e38130..75824632 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/theme/business/ThemeService.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/theme/business/ThemeService.kt @@ -10,6 +10,8 @@ import com.sangdol.roomescape.theme.mapper.toInfoResponse import com.sangdol.roomescape.theme.mapper.toListResponse import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging +import io.micrometer.core.instrument.MeterRegistry +import org.springframework.cache.annotation.Cacheable import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -21,13 +23,19 @@ private val log: KLogger = KotlinLogging.logger {} @Service class ThemeService( - private val themeRepository: ThemeRepository + private val themeRepository: ThemeRepository, + meterRegistry: MeterRegistry ) { + private val themeDetailQueryRequestCount = meterRegistry.counter("theme.detail.query.requested") + + @Cacheable(cacheNames = ["theme-details"], key="#id") @Transactional(readOnly = true) fun findInfoById(id: Long): ThemeInfoResponse { log.info { "[findInfoById] 테마 조회 시작: id=$id" } - val theme = themeRepository.findByIdOrNull(id) ?: run { + val theme = themeRepository.findByIdOrNull(id)?.also { + themeDetailQueryRequestCount.increment() + } ?: run { log.warn { "[updateTheme] 테마 조회 실패: id=$id" } throw ThemeException(ThemeErrorCode.THEME_NOT_FOUND) } diff --git a/service/src/main/resources/application.yaml b/service/src/main/resources/application.yaml index 4565a7bc..544301c7 100644 --- a/service/src/main/resources/application.yaml +++ b/service/src/main/resources/application.yaml @@ -16,6 +16,9 @@ spring: jdbc: batch_size: ${JDBC_BATCH_SIZE:100} order_inserts: true + cache: + type: caffeine + cache-names: ${CACHE_NAMES:theme-details} management: endpoints: diff --git a/service/src/test/kotlin/com/sangdol/roomescape/auth/business/LoginHistoryEventListenerTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/auth/LoginHistoryEventListenerTest.kt similarity index 98% rename from service/src/test/kotlin/com/sangdol/roomescape/auth/business/LoginHistoryEventListenerTest.kt rename to service/src/test/kotlin/com/sangdol/roomescape/auth/LoginHistoryEventListenerTest.kt index 55e993b0..3b8a9427 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/auth/business/LoginHistoryEventListenerTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/auth/LoginHistoryEventListenerTest.kt @@ -1,4 +1,4 @@ -package com.sangdol.roomescape.auth.business +package com.sangdol.roomescape.auth import com.sangdol.roomescape.auth.business.domain.PrincipalType import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryEntity @@ -68,4 +68,4 @@ class LoginHistoryEventListenerTest : FunSpec() { return batch } -} +} \ No newline at end of file diff --git a/service/src/test/kotlin/com/sangdol/roomescape/reservation/business/event/ReservationEventListenerTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationEventListenerTest.kt similarity index 90% rename from service/src/test/kotlin/com/sangdol/roomescape/reservation/business/event/ReservationEventListenerTest.kt rename to service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationEventListenerTest.kt index 0e472a63..0f4e969f 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/reservation/business/event/ReservationEventListenerTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationEventListenerTest.kt @@ -1,12 +1,13 @@ -package com.sangdol.roomescape.reservation.business.event +package com.sangdol.roomescape.reservation +import com.sangdol.roomescape.reservation.business.event.ReservationConfirmEvent +import com.sangdol.roomescape.reservation.business.event.ReservationEventListener import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationRepository import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus import com.sangdol.roomescape.supports.FunSpecSpringbootTest import io.kotest.assertions.assertSoftly -import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe import org.springframework.data.repository.findByIdOrNull @@ -42,4 +43,4 @@ class ReservationEventListenerTest( } } } -} +} \ No newline at end of file diff --git a/service/src/test/kotlin/com/sangdol/roomescape/theme/AdminThemeApiTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/theme/AdminThemeApiTest.kt index d65b60e6..8a375ee4 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/theme/AdminThemeApiTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/theme/AdminThemeApiTest.kt @@ -6,21 +6,28 @@ import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType import com.sangdol.roomescape.auth.exception.AuthErrorCode import com.sangdol.roomescape.supports.* import com.sangdol.roomescape.supports.ThemeFixture.createRequest +import com.sangdol.roomescape.theme.business.AdminThemeService import com.sangdol.roomescape.theme.business.MIN_DURATION import com.sangdol.roomescape.theme.business.MIN_PARTICIPANTS import com.sangdol.roomescape.theme.business.MIN_PRICE +import com.sangdol.roomescape.theme.business.ThemeService +import com.sangdol.roomescape.theme.dto.ThemeInfoResponse import com.sangdol.roomescape.theme.exception.ThemeErrorCode import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository import com.sangdol.roomescape.theme.dto.ThemeUpdateRequest +import io.kotest.assertions.assertSoftly import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe import org.hamcrest.CoreMatchers.equalTo +import org.springframework.cache.CacheManager import org.springframework.data.repository.findByIdOrNull import org.springframework.http.HttpMethod class AdminThemeApiTest( - private val themeRepository: ThemeRepository + private val themeRepository: ThemeRepository, + private val themeService: ThemeService, + private val cacheManager: CacheManager ) : FunSpecSpringbootTest() { init { @@ -482,14 +489,19 @@ class AdminThemeApiTest( } } - test("정상 삭제") { + test("정상 삭제 및 캐시 제거 확인") { val token = testAuthUtil.defaultHqAdminLogin().second val createdTheme = initialize("테스트를 위한 테마 생성") { dummyInitializer.createTheme() } + initialize("테마 캐시 추가") { + themeService.findInfoById(createdTheme.id) + cacheManager.getCache("theme-details")?.get(createdTheme.id, ThemeInfoResponse::class.java).shouldNotBeNull() + } + runTest( - token = testAuthUtil.defaultHqAdminLogin().second, + token = token, on = { delete("/admin/themes/${createdTheme.id}") }, @@ -498,6 +510,7 @@ class AdminThemeApiTest( } ).also { themeRepository.findByIdOrNull(createdTheme.id) shouldBe null + cacheManager.getCache("theme-details")?.get(createdTheme.id, ThemeInfoResponse::class.java) shouldBe null } } @@ -566,7 +579,7 @@ class AdminThemeApiTest( val updateRequest = ThemeUpdateRequest(name = "modified") - test("정상 수정 및 감사 정보 변경 확인") { + test("정상 수정 및 감사 정보 & 캐시 변경 확인") { val createdThemeId: Long = initialize("테스트를 위한 관리자1의 테마 생성") { runTest( token = testAuthUtil.defaultHqAdminLogin().second, @@ -582,6 +595,11 @@ class AdminThemeApiTest( ).extract().path("data.id") } + initialize("테마 캐시 추가") { + themeService.findInfoById(createdThemeId) + cacheManager.getCache("theme-details")?.get(createdThemeId, ThemeInfoResponse::class.java).shouldNotBeNull() + } + val (otherAdmin, otherAdminToken) = initialize("감사 정보 변경 확인을 위한 관리자2 로그인") { testAuthUtil.adminLogin( AdminFixture.createHqAdmin(permissionLevel = AdminPermissionLevel.WRITABLE) @@ -604,6 +622,12 @@ class AdminThemeApiTest( updatedTheme.name shouldBe updateRequest.name updatedTheme.updatedBy shouldBe otherAdmin.id + + + // 캐시 제거 확인 + assertSoftly(cacheManager.getCache("theme-details")?.get(createdThemeId, ThemeInfoResponse::class.java)) { + this shouldBe null + } } } diff --git a/service/src/test/kotlin/com/sangdol/roomescape/theme/ThemeApiTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/theme/ThemeApiTest.kt index 3051cd56..5ab5912c 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/theme/ThemeApiTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/theme/ThemeApiTest.kt @@ -10,23 +10,32 @@ import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository import com.sangdol.roomescape.theme.mapper.toEntity import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity +import io.kotest.assertions.assertSoftly import io.kotest.matchers.collections.shouldContainInOrder import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.comparables.shouldBeLessThan +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe import org.hamcrest.CoreMatchers.equalTo +import org.springframework.cache.CacheManager import org.springframework.http.HttpMethod import java.time.LocalDate class ThemeApiTest( - private val themeRepository: ThemeRepository + private val themeRepository: ThemeRepository, + private val cacheManager: CacheManager ) : FunSpecSpringbootTest() { init { context("ID로 테마 정보를 조회한다.") { - test("정상 응답") { + test("정상 응답 및 캐시 저장 확인") { val createdTheme: ThemeEntity = initialize("조회를 위한 테마 생성") { dummyInitializer.createTheme() } + cacheManager.getCache("theme-details")?.get(createdTheme.id, ThemeInfoResponse::class.java).also { + it shouldBe null + } + runTest( on = { get("/themes/${createdTheme.id}") @@ -43,6 +52,15 @@ class ThemeApiTest( ) } ) + + assertSoftly(cacheManager.getCache("theme-details")) { + this.shouldNotBeNull() + + val themeFromCache = this.get(createdTheme.id, ThemeInfoResponse::class.java) + + themeFromCache.shouldNotBeNull() + themeFromCache.id shouldBe createdTheme.id + } } test("테마가 없으면 실패한다.") { diff --git a/service/src/test/kotlin/com/sangdol/roomescape/theme/ThemeConcurrencyTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/theme/ThemeConcurrencyTest.kt new file mode 100644 index 00000000..98e03582 --- /dev/null +++ b/service/src/test/kotlin/com/sangdol/roomescape/theme/ThemeConcurrencyTest.kt @@ -0,0 +1,68 @@ +package com.sangdol.roomescape.theme + +import com.ninjasquad.springmockk.MockkBean +import com.sangdol.roomescape.supports.FunSpecSpringbootTest +import com.sangdol.roomescape.supports.IDGenerator +import com.sangdol.roomescape.supports.initialize +import com.sangdol.roomescape.theme.business.ThemeService +import com.sangdol.roomescape.theme.infrastructure.persistence.Difficulty +import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity +import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository +import io.mockk.every +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.withContext +import org.springframework.data.repository.findByIdOrNull +import java.util.concurrent.CountDownLatch + +class ThemeConcurrencyTest( + private val themeService: ThemeService, + @MockkBean private val themeRepository: ThemeRepository, +) : FunSpecSpringbootTest() { + + init { + test("동일한 테마에 대한 반복 조회 요청시, DB 요청은 1회만 발생한다.") { + val entity = ThemeEntity( + id = IDGenerator.create(), + name = "테스트입니다.", + description = "테스트에요!", + thumbnailUrl = "http://localhost:8080/hello", + difficulty = Difficulty.VERY_EASY, + price = 10000, + minParticipants = 3, + maxParticipants = 5, + availableMinutes = 90, + expectedMinutesFrom = 70, + expectedMinutesTo = 80, + isActive = true + ) + + + every { + themeRepository.findByIdOrNull(entity.id) + } returns entity + + initialize("캐시 등록") { + themeService.findInfoById(entity.id) + } + + val requestCount = 64 + withContext(Dispatchers.IO) { + val latch = CountDownLatch(requestCount) + + (1..requestCount).map { + async { + latch.countDown() + latch.await() + themeService.findInfoById(entity.id) + } + } + } + + verify(exactly = 1) { + themeRepository.findByIdOrNull(entity.id) + } + } + } +} diff --git a/service/src/test/resources/application-test.yaml b/service/src/test/resources/application-test.yaml index c4e369dc..9e129ebd 100644 --- a/service/src/test/resources/application-test.yaml +++ b/service/src/test/resources/application-test.yaml @@ -18,6 +18,9 @@ spring: init: mode: always schema-locations: classpath:schema/schema-mysql.sql + cache: + type: caffeine + cache-names: ${CACHE_NAMES:theme-details} security: jwt: