diff --git a/build.gradle.kts b/build.gradle.kts index 8c2425d..73a3d89 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -44,7 +44,7 @@ dependencies { // Kotlin implementation("org.jetbrains.kotlin:kotlin-reflect") - implementation("tools.jackson.module:jackson-module-kotlin") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") // Jackson Java 8 Date/Time implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") diff --git a/src/main/kotlin/com/stepbookstep/server/domain/book/domain/Book.kt b/src/main/kotlin/com/stepbookstep/server/domain/book/domain/Book.kt index e5e2469..e5933bc 100644 --- a/src/main/kotlin/com/stepbookstep/server/domain/book/domain/Book.kt +++ b/src/main/kotlin/com/stepbookstep/server/domain/book/domain/Book.kt @@ -1,5 +1,6 @@ package com.stepbookstep.server.domain.book.domain +import com.fasterxml.jackson.annotation.JsonProperty import jakarta.persistence.* import java.time.LocalDate import java.time.LocalDateTime @@ -82,6 +83,7 @@ class Book( val vocabLevel: VocabLevel = VocabLevel.EASY, @Column(name = "is_bestseller", nullable = false) + @param:JsonProperty("isBestseller") val isBestseller: Boolean = false, // ===== 시스템 필드 ===== diff --git a/src/main/kotlin/com/stepbookstep/server/domain/book/domain/BookRepository.kt b/src/main/kotlin/com/stepbookstep/server/domain/book/domain/BookRepository.kt index 11ba706..e4737b8 100644 --- a/src/main/kotlin/com/stepbookstep/server/domain/book/domain/BookRepository.kt +++ b/src/main/kotlin/com/stepbookstep/server/domain/book/domain/BookRepository.kt @@ -1,7 +1,5 @@ package com.stepbookstep.server.domain.book.domain -import org.springframework.data.domain.Page -import org.springframework.data.domain.Pageable import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaSpecificationExecutor import org.springframework.data.jpa.repository.Query @@ -41,4 +39,10 @@ interface BookRepository : JpaRepository, JpaSpecificationExecutor + + @Query("SELECT DISTINCT b.categoryId FROM Book b") + fun findDistinctCategoryIds(): List + + @Query("SELECT DISTINCT b.genreId FROM Book b WHERE b.genreId IS NOT NULL") + fun findDistinctGenreIds(): List } diff --git a/src/main/kotlin/com/stepbookstep/server/domain/home/application/HomeCacheService.kt b/src/main/kotlin/com/stepbookstep/server/domain/home/application/HomeCacheService.kt index 4ad4b20..8b08316 100644 --- a/src/main/kotlin/com/stepbookstep/server/domain/home/application/HomeCacheService.kt +++ b/src/main/kotlin/com/stepbookstep/server/domain/home/application/HomeCacheService.kt @@ -41,4 +41,14 @@ class HomeCacheService( fun getBooksByGenreId(genreId: Long): List { return bookRepository.findAllByGenreId(genreId) } + + @Cacheable(value = ["distinctCategoryIds"]) + fun getDistinctCategoryIds(): List { + return bookRepository.findDistinctCategoryIds() + } + + @Cacheable(value = ["distinctGenreIds"]) + fun getDistinctGenreIds(): List { + return bookRepository.findDistinctGenreIds() + } } diff --git a/src/main/kotlin/com/stepbookstep/server/domain/home/application/HomeQueryService.kt b/src/main/kotlin/com/stepbookstep/server/domain/home/application/HomeQueryService.kt index 9effbcf..d19c3f5 100644 --- a/src/main/kotlin/com/stepbookstep/server/domain/home/application/HomeQueryService.kt +++ b/src/main/kotlin/com/stepbookstep/server/domain/home/application/HomeQueryService.kt @@ -101,12 +101,9 @@ class HomeQueryService( } private fun selectRandomCategoryOrGenre(): SelectedBooks { - val allBooks = bookRepository.findAll() - - // categoryId가 있는 책들의 고유 categoryId 목록 - val categoryIds = allBooks.mapNotNull { it.categoryId }.distinct() - // genreId가 있는 책들의 고유 genreId 목록 - val genreIds = allBooks.mapNotNull { it.genreId }.distinct() + // 캐시된 DISTINCT ID 목록 조회 + val categoryIds = homeCacheService.getDistinctCategoryIds() + val genreIds = homeCacheService.getDistinctGenreIds() val allOptions = mutableListOf>() categoryIds.forEach { allOptions.add("category" to it) } @@ -118,8 +115,8 @@ class HomeQueryService( val selected = allOptions.random() val books = when (selected.first) { - "category" -> allBooks.filter { it.categoryId == selected.second } - "genre" -> allBooks.filter { it.genreId == selected.second } + "category" -> homeCacheService.getBooksByCategoryId(selected.second) + "genre" -> homeCacheService.getBooksByGenreId(selected.second) else -> emptyList() }.shuffled().take(20) diff --git a/src/main/kotlin/com/stepbookstep/server/domain/reading/application/ReadingGoalService.kt b/src/main/kotlin/com/stepbookstep/server/domain/reading/application/ReadingGoalService.kt index eef2de4..10b8986 100644 --- a/src/main/kotlin/com/stepbookstep/server/domain/reading/application/ReadingGoalService.kt +++ b/src/main/kotlin/com/stepbookstep/server/domain/reading/application/ReadingGoalService.kt @@ -12,6 +12,7 @@ import com.stepbookstep.server.domain.reading.domain.UserBook import com.stepbookstep.server.domain.reading.domain.UserBookRepository import com.stepbookstep.server.global.response.CustomException import com.stepbookstep.server.global.response.ErrorCode +import org.springframework.cache.annotation.CacheEvict import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.time.LocalDate @@ -30,6 +31,7 @@ class ReadingGoalService( * - 기존 활성 목표가 없으면 새로 생성 * - 기존 활성 목표가 있으면 수정 */ + @CacheEvict(value = ["userStatistics"], key = "#userId + '_' + T(java.time.Year).now().value") @Transactional fun upsertGoal( userId: Long, @@ -96,6 +98,7 @@ class ReadingGoalService( /** * 활성 목표 삭제 (비활성화) */ + @CacheEvict(value = ["userStatistics"], key = "#userId + '_' + T(java.time.Year).now().value") @Transactional fun deleteGoal(userId: Long, bookId: Long) { val existingGoal = readingGoalRepository.findByUserIdAndBookIdAndActiveTrue(userId, bookId) diff --git a/src/main/kotlin/com/stepbookstep/server/domain/reading/application/ReadingLogService.kt b/src/main/kotlin/com/stepbookstep/server/domain/reading/application/ReadingLogService.kt index a21a195..eb323f0 100644 --- a/src/main/kotlin/com/stepbookstep/server/domain/reading/application/ReadingLogService.kt +++ b/src/main/kotlin/com/stepbookstep/server/domain/reading/application/ReadingLogService.kt @@ -15,6 +15,7 @@ import com.stepbookstep.server.domain.reading.presentation.dto.GoalInfo import com.stepbookstep.server.domain.reading.presentation.dto.ReadingLogItem import com.stepbookstep.server.global.response.CustomException import com.stepbookstep.server.global.response.ErrorCode +import org.springframework.cache.annotation.CacheEvict import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.time.LocalDate @@ -28,6 +29,7 @@ class ReadingLogService( private val readingGoalRepository: ReadingGoalRepository ) { + @CacheEvict(value = ["userStatistics"], key = "#userId + '_' + T(java.time.Year).now().value") @Transactional fun createLog( userId: Long, diff --git a/src/main/kotlin/com/stepbookstep/server/domain/reading/application/StatisticService.kt b/src/main/kotlin/com/stepbookstep/server/domain/reading/application/StatisticService.kt index 68e10e5..add989c 100644 --- a/src/main/kotlin/com/stepbookstep/server/domain/reading/application/StatisticService.kt +++ b/src/main/kotlin/com/stepbookstep/server/domain/reading/application/StatisticService.kt @@ -3,6 +3,7 @@ package com.stepbookstep.server.domain.reading.application import com.stepbookstep.server.domain.book.domain.BookRepository import com.stepbookstep.server.domain.reading.domain.* import com.stepbookstep.server.domain.reading.presentation.dto.* +import org.springframework.cache.annotation.Cacheable import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.time.LocalDate @@ -21,6 +22,7 @@ class StatisticsService( /** * 전체 독서 통계 조회 */ + @Cacheable(value = ["userStatistics"], key = "#userId + '_' + #year") @Transactional(readOnly = true) fun getReadingStatistics(userId: Long, year: Int): ReadingStatisticsResponse { return ReadingStatisticsResponse( diff --git a/src/main/kotlin/com/stepbookstep/server/global/config/RedisCacheConfig.kt b/src/main/kotlin/com/stepbookstep/server/global/config/RedisCacheConfig.kt index 0de44a5..a9e7996 100644 --- a/src/main/kotlin/com/stepbookstep/server/global/config/RedisCacheConfig.kt +++ b/src/main/kotlin/com/stepbookstep/server/global/config/RedisCacheConfig.kt @@ -4,8 +4,9 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.SerializationFeature -import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator +import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import org.springframework.cache.CacheManager import org.springframework.cache.annotation.EnableCaching import org.springframework.context.annotation.Bean @@ -13,9 +14,8 @@ import org.springframework.context.annotation.Configuration import org.springframework.data.redis.cache.RedisCacheConfiguration import org.springframework.data.redis.cache.RedisCacheManager import org.springframework.data.redis.connection.RedisConnectionFactory -import org.springframework.data.redis.serializer.RedisSerializer +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer import org.springframework.data.redis.serializer.RedisSerializationContext -import org.springframework.data.redis.serializer.SerializationException import org.springframework.data.redis.serializer.StringRedisSerializer import java.time.Duration @@ -25,40 +25,15 @@ class RedisCacheConfig { @Bean fun cacheManager(redisConnectionFactory: RedisConnectionFactory): CacheManager { - val objectMapper = ObjectMapper() - objectMapper.registerModule(JavaTimeModule()) - objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) - objectMapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) - objectMapper.activateDefaultTyping( - BasicPolymorphicTypeValidator.builder() - .allowIfBaseType(Any::class.java) - .build(), - ObjectMapper.DefaultTyping.NON_FINAL, - JsonTypeInfo.As.PROPERTY - ) - - val serializer = object : RedisSerializer { - override fun serialize(t: Any?): ByteArray { - if (t == null) { - return ByteArray(0) - } - try { - return objectMapper.writeValueAsBytes(t) - } catch (e: Exception) { - throw SerializationException("Could not write JSON: " + e.message, e) - } - } - - override fun deserialize(bytes: ByteArray?): Any? { - if (bytes == null || bytes.isEmpty()) { - return null - } - try { - return objectMapper.readValue(bytes, Any::class.java) - } catch (e: Exception) { - throw SerializationException("Could not read JSON: " + e.message, e) - } - } + val objectMapper: ObjectMapper = jacksonObjectMapper().apply { + registerModule(JavaTimeModule()) + disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + activateDefaultTyping( + LaissezFaireSubTypeValidator.instance, + ObjectMapper.DefaultTyping.NON_FINAL, + JsonTypeInfo.As.PROPERTY + ) } val defaultConfig = RedisCacheConfiguration.defaultCacheConfig() @@ -67,14 +42,19 @@ class RedisCacheConfig { RedisSerializationContext.SerializationPair.fromSerializer(StringRedisSerializer()) ) .serializeValuesWith( - RedisSerializationContext.SerializationPair.fromSerializer(serializer) + RedisSerializationContext.SerializationPair.fromSerializer(GenericJackson2JsonRedisSerializer(objectMapper)) ) val cacheConfigurations = mapOf( "genreBooks" to defaultConfig.entryTtl(Duration.ofHours(6)), "bestsellerBooks" to defaultConfig.entryTtl(Duration.ofHours(12)), "bookDetail" to defaultConfig.entryTtl(Duration.ofHours(24)), - "booksByLevel" to defaultConfig.entryTtl(Duration.ofHours(1)) + "booksByLevel" to defaultConfig.entryTtl(Duration.ofHours(1)), + "userStatistics" to defaultConfig.entryTtl(Duration.ofMinutes(30)), + "distinctCategoryIds" to defaultConfig.entryTtl(Duration.ofHours(24)), + "distinctGenreIds" to defaultConfig.entryTtl(Duration.ofHours(24)), + "categoryBooks" to defaultConfig.entryTtl(Duration.ofHours(6)), + "genreIdBooks" to defaultConfig.entryTtl(Duration.ofHours(6)) ) return RedisCacheManager.builder(redisConnectionFactory)