Skip to content
Merged
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -82,6 +83,7 @@ class Book(
val vocabLevel: VocabLevel = VocabLevel.EASY,

@Column(name = "is_bestseller", nullable = false)
@param:JsonProperty("isBestseller")
val isBestseller: Boolean = false,

// ===== 시스템 필드 =====
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -41,4 +39,10 @@ interface BookRepository : JpaRepository<Book, Long>, JpaSpecificationExecutor<B

@Query("SELECT b FROM Book b WHERE b.genreId = :genreId")
fun findAllByGenreId(@Param("genreId") genreId: Long): List<Book>

@Query("SELECT DISTINCT b.categoryId FROM Book b")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DISTINCT 쿼리로 불필요한 객체 생성을 제거하고, 메모리 사용까지 감소해서 좋은 리팩토링 같아요~👍

fun findDistinctCategoryIds(): List<Long>

@Query("SELECT DISTINCT b.genreId FROM Book b WHERE b.genreId IS NOT NULL")
fun findDistinctGenreIds(): List<Long>
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,14 @@ class HomeCacheService(
fun getBooksByGenreId(genreId: Long): List<Book> {
return bookRepository.findAllByGenreId(genreId)
}

@Cacheable(value = ["distinctCategoryIds"])
fun getDistinctCategoryIds(): List<Long> {
return bookRepository.findDistinctCategoryIds()
}

@Cacheable(value = ["distinctGenreIds"])
fun getDistinctGenreIds(): List<Long> {
return bookRepository.findDistinctGenreIds()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Pair<String, Long>>()
categoryIds.forEach { allOptions.add("category" to it) }
Expand All @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -30,6 +31,7 @@ class ReadingGoalService(
* - 기존 활성 목표가 없으면 새로 생성
* - 기존 활성 목표가 있으면 수정
*/
@CacheEvict(value = ["userStatistics"], key = "#userId + '_' + T(java.time.Year).now().value")
@Transactional
fun upsertGoal(
userId: Long,
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -21,6 +22,7 @@ class StatisticsService(
/**
* 전체 독서 통계 조회
*/
@Cacheable(value = ["userStatistics"], key = "#userId + '_' + #year")
@Transactional(readOnly = true)
fun getReadingStatistics(userId: Long, year: Int): ReadingStatisticsResponse {
return ReadingStatisticsResponse(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,18 @@ 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
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

Expand All @@ -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<Any> {
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()
Expand All @@ -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)
Expand Down