Skip to content

Commit

Permalink
test: articleViewsRecords 케시 적용
Browse files Browse the repository at this point in the history
  • Loading branch information
belljun3395 committed Jan 15, 2025
1 parent 01f64f4 commit ba75729
Show file tree
Hide file tree
Showing 5 changed files with 135 additions and 124 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.few.api.repo.config
import com.few.api.repo.common.LocalCacheEventLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.ehcache.config.builders.CacheConfigurationBuilder
import org.ehcache.config.builders.ExpiryPolicyBuilder
import org.ehcache.config.builders.ResourcePoolsBuilder
import org.ehcache.config.units.EntryUnit
import org.ehcache.event.EventType
Expand All @@ -14,17 +15,19 @@ import org.springframework.cache.annotation.EnableCaching
import org.springframework.cache.jcache.JCacheCacheManager
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import java.time.Duration

@Configuration
@EnableCaching
class LocalCacheConfig {
class ApiLocalCacheConfig {
private val log = KotlinLogging.logger {}

companion object {
const val LOCAL_CM = "localCacheManager"
const val SELECT_ARTICLE_RECORD_CACHE = "selectArticleRecordCache"
const val SELECT_WORKBOOK_RECORD_CACHE = "selectWorkBookRecordCache"
const val SELECT_WRITER_CACHE = "selectWritersCache"
const val SELECT_ARTICLE_VIEWS_RECORD_SERVICE_CACHE = "selectArticleViewsRecordServiceCache"
}

@Bean(LOCAL_CM)
Expand Down Expand Up @@ -58,6 +61,18 @@ class LocalCacheConfig {
.withService(cacheEventListenerConfigurationConfig)
.build()

val cache10MinuteConfiguration =
CacheConfigurationBuilder
.newCacheConfigurationBuilder(
Any::class.java,
Any::class.java,
ResourcePoolsBuilder
.newResourcePoolsBuilder()
.heap(200, EntryUnit.ENTRIES)
).withService(cacheEventListenerConfigurationConfig)
.withExpiry(ExpiryPolicyBuilder.timeToLiveExpiration(Duration.ofMinutes(10)))
.build()

val selectArticleRecordCacheConfig: javax.cache.configuration.Configuration<Any, Any> =
Eh107Configuration.fromEhcacheCacheConfiguration(cache10Configuration)
val selectWorkBookRecordCacheConfig: javax.cache.configuration.Configuration<Any, Any> =
Expand All @@ -66,10 +81,14 @@ class LocalCacheConfig {
val selectWriterCacheConfig: javax.cache.configuration.Configuration<Any, Any> =
Eh107Configuration.fromEhcacheCacheConfiguration(cache5Configuration)

val selectArticleViewsRecordServiceCacheConfig: javax.cache.configuration.Configuration<Any, Any> =
Eh107Configuration.fromEhcacheCacheConfiguration(cache10MinuteConfiguration)

runCatching {
cacheManager.createCache(SELECT_ARTICLE_RECORD_CACHE, selectArticleRecordCacheConfig)
cacheManager.createCache(SELECT_WORKBOOK_RECORD_CACHE, selectWorkBookRecordCacheConfig)
cacheManager.createCache(SELECT_WRITER_CACHE, selectWriterCacheConfig)
cacheManager.createCache(SELECT_ARTICLE_VIEWS_RECORD_SERVICE_CACHE, selectArticleViewsRecordServiceCacheConfig)
}.onFailure {
log.error(it) { "Failed to create cache" }
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.few.api.repo.dao.article

import com.few.api.repo.config.LocalCacheConfig.Companion.LOCAL_CM
import com.few.api.repo.config.LocalCacheConfig.Companion.SELECT_ARTICLE_RECORD_CACHE
import com.few.api.repo.config.ApiLocalCacheConfig.Companion.LOCAL_CM
import com.few.api.repo.config.ApiLocalCacheConfig.Companion.SELECT_ARTICLE_RECORD_CACHE
import com.few.api.repo.dao.article.command.InsertFullArticleRecordCommand
import com.few.api.repo.dao.article.query.*
import com.few.api.repo.dao.article.record.*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.few.api.domain.article.usecase

import com.few.api.repo.config.ApiLocalCacheConfig.Companion.LOCAL_CM
import com.few.api.repo.config.ApiLocalCacheConfig.Companion.SELECT_ARTICLE_VIEWS_RECORD_SERVICE_CACHE
import com.few.api.repo.dao.article.ArticleViewCountDao
import com.few.api.repo.dao.article.query.SelectArticlesOrderByViewsQuery
import com.few.api.repo.dao.article.query.SelectRankByViewsQuery
import com.few.api.repo.dao.article.record.SelectArticleViewsRecord
import com.few.data.common.code.CategoryType
import org.springframework.cache.annotation.Cacheable
import org.springframework.stereotype.Service

@Service
class ArticleViewRecordsService(
private val articleViewCountDao: ArticleViewCountDao,
) {
@Cacheable(
keyGenerator = "articleViewRecordsServiceKeyGenerator",
cacheManager = LOCAL_CM,
cacheNames = [SELECT_ARTICLE_VIEWS_RECORD_SERVICE_CACHE]
)
fun execute(
preArticleId: Long,
categoryCd: Byte,
): MutableList<SelectArticleViewsRecord> {
/**
* 아티클 조회수 테이블에서 마지막 읽은 아티클 아이디, 카테고리를 기반으로 Offset(테이블 row 순위)을 구함
*/
val offset =
if (preArticleId <= 0) {
0L
} else {
articleViewCountDao.selectRankByViews(
SelectRankByViewsQuery(preArticleId)
) ?: 0
}

/**
* 구한 Offset을 기준으로 이번 스크롤에서 보여줄 아티클 11개를 뽑아옴
* 카테고리 별, 조회수 순 11개. 조회수가 같을 경우 최신 아티클이 우선순위를 가짐
*/
return articleViewCountDao
.selectArticlesOrderByViews(
SelectArticlesOrderByViewsQuery(
offset,
CategoryType.fromCode(categoryCd) ?: CategoryType.All
)
).toMutableList()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.few.api.domain.article.usecase

import org.springframework.cache.interceptor.KeyGenerator
import org.springframework.stereotype.Service
import java.lang.reflect.Method

@Service
class ArticleViewRecordsServiceKeyGenerator : KeyGenerator {
override fun generate(
target: Any,
method: Method,
vararg params: Any?,
): Any {
val preArticleId = params[0] as Long
val categoryCd = params[1] as Byte
return "$preArticleId ::$categoryCd"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,146 +4,70 @@ import com.few.api.domain.article.usecase.dto.*
import com.few.api.exception.common.NotFoundException
import com.few.api.repo.dao.article.ArticleDao
import com.few.api.repo.dao.article.ArticleMainCardDao
import com.few.api.repo.dao.article.ArticleViewCountDao
import com.few.api.repo.dao.article.query.SelectArticlesOrderByViewsQuery
import com.few.api.repo.dao.article.query.SelectRankByViewsQuery
import com.few.api.repo.dao.article.record.ArticleMainCardRecord
import com.few.api.repo.dao.article.record.SelectArticleContentsRecord
import com.few.api.repo.dao.article.record.SelectArticleViewsRecord
import com.few.data.common.code.CategoryType
import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Transactional
import java.util.*
import kotlin.Comparator

@Component
class BrowseArticlesUseCase(
private val articleViewCountDao: ArticleViewCountDao,
private val articleViewRecordsService: ArticleViewRecordsService,
private val articleMainCardDao: ArticleMainCardDao,
private val articleDao: ArticleDao,
) {

@Transactional(readOnly = true)
fun execute(useCaseIn: ReadArticlesUseCaseIn): ReadArticlesUseCaseOut {
/**
* 아티클 조회수 테이블에서 마지막 읽은 아티클 아이디, 카테고리를 기반으로 Offset(테이블 row 순위)을 구함
*/
val offset = if (useCaseIn.prevArticleId <= 0) {
0L
} else {
articleViewCountDao.selectRankByViews(
SelectRankByViewsQuery(useCaseIn.prevArticleId)
) ?: 0
}

/**
* 구한 Offset을 기준으로 이번 스크롤에서 보여줄 아티클 11개를 뽑아옴
* 카테고리 별, 조회수 순 11개. 조회수가 같을 경우 최신 아티클이 우선순위를 가짐
*/
val articleViewsRecords: MutableList<SelectArticleViewsRecord> = articleViewCountDao.selectArticlesOrderByViews(
SelectArticlesOrderByViewsQuery(
offset,
CategoryType.fromCode(useCaseIn.categoryCd) ?: CategoryType.All
)
).toMutableList()

/**
* 11개를 조회한 상황에서 11개가 조회되지 않았다면 마지막 스크롤로 판단
*/
val isLast = if (articleViewsRecords.size == 11) {
articleViewsRecords.removeAt(10)
false
} else {
true
}

/**
* ARTICLE_MAIN_CARD 테이블에서 이번 스크롤에서 보여줄 10개 아티클 조회 (TODO: 캐싱 적용)
*/
val articleMainCardRecords: Set<ArticleMainCardRecord> =
articleMainCardDao.selectArticleMainCardsRecord(articleViewsRecords.map { it.articleId }.toSet())

/**
* 아티클 컨텐츠는 ARTICLE_MAIN_CARD가 아닌 ARTICLE_IFO에서 조회 (TODO: 캐싱 적용)
*/
val selectArticleContentsRecords: List<SelectArticleContentsRecord> =
articleDao.selectArticleContents(articleMainCardRecords.map { it.articleId }.toSet())
setContentsToRecords(selectArticleContentsRecords, articleMainCardRecords)

/**
* 아티클 조회수 순, 조회수가 같을 경우 최신 아티클이 우선순위를 가지도록 정렬 (TODO: 삭제시 양향도 파악 필요)
*/
val sortedArticles = updateAndSortArticleViews(articleMainCardRecords, articleViewsRecords)

val articleUseCaseOuts: List<ReadArticleUseCaseOut> = sortedArticles.map { a ->
ReadArticleUseCaseOut(
id = a.articleId,
writer = WriterDetail(
id = a.writerId,
name = a.writerName,
imageUrl = a.writerImgUrl,
url = a.writerUrl
),
mainImageUrl = a.mainImageUrl,
title = a.articleTitle,
content = a.content,
problemIds = emptyList(),
category = CategoryType.fromCode(a.categoryCd)?.displayName
?: throw NotFoundException("article.invalid.category"),
createdAt = a.createdAt,
views = a.views,
workbooks = a.workbooks
.map { WorkbookDetail(it.id!!, it.title!!) }
)
}.toList()

return ReadArticlesUseCaseOut(articleUseCaseOuts, isLast)
}

private fun updateAndSortArticleViews(
articleRecords: Set<ArticleMainCardRecord>,
articleViewsRecords: List<SelectArticleViewsRecord>,
): Set<ArticleMainCardRecord> {
val sortedSet = TreeSet(
Comparator<ArticleMainCardRecord> { a1, a2 ->
// views 값이 null일 경우 0으로 간주
val views1 = a1.views ?: 0
val views2 = a2.views ?: 0
val articleViewsRecords = articleViewRecordsService.execute(useCaseIn.prevArticleId, useCaseIn.categoryCd)

// views 내림차순 정렬
val viewComparison = views2.compareTo(views1)

if (viewComparison != 0) {
viewComparison
} else {
// views가 같을 경우 articleId 내림차순 정렬(최신글)
val articleId1 = a1.articleId
val articleId2 = a2.articleId
articleId2.compareTo(articleId1)
val isLast =
when (articleViewsRecords.size) {
11 -> {
articleViewsRecords.removeAt(10)
false
}
10 -> {
false
}
else -> {
true
}
}
)

val viewsMap = articleViewsRecords.associateBy({ it.articleId }, { it.views })
val articleIds = articleViewsRecords.map { it.articleId }
val articleMainCardRecords: List<ArticleMainCardRecord> = articleMainCardDao.selectArticleMainCardsRecord(articleIds.toSet()).toList()
val selectArticleContentsRecords: List<SelectArticleContentsRecord> = articleDao.selectArticleContents(articleIds.toSet()).toList()

articleRecords.forEach { article ->
val updatedViews = viewsMap[article.articleId] ?: 0
article.views = updatedViews
sortedSet.add(article)
articleMainCardRecords.withIndex().forEach { (index, articleMainCardRecord) ->
articleMainCardRecord.content = selectArticleContentsRecords[index].content
}

return sortedSet
}

private fun setContentsToRecords(
articleContentsRecords: List<SelectArticleContentsRecord>,
articleMainCardRecords: Set<ArticleMainCardRecord>,
) {
val articleMainCardRecordsMap: Map<Long, ArticleMainCardRecord> =
articleMainCardRecords.associateBy { it.articleId }
val articleUseCaseOuts: List<ReadArticleUseCaseOut> =
articleMainCardRecords
.map { a ->
ReadArticleUseCaseOut(
id = a.articleId,
writer =
WriterDetail(
id = a.writerId,
name = a.writerName,
imageUrl = a.writerImgUrl,
url = a.writerUrl
),
mainImageUrl = a.mainImageUrl,
title = a.articleTitle,
content = a.content,
problemIds = emptyList(),
category =
CategoryType.fromCode(a.categoryCd)?.displayName
?: throw NotFoundException("article.invalid.category"),
createdAt = a.createdAt,
views = a.views,
workbooks =
a.workbooks
.map { WorkbookDetail(it.id!!, it.title!!) }
)
}.toList()

articleContentsRecords.map { articleContentRecord ->
articleMainCardRecordsMap[articleContentRecord.articleId]?.content = articleContentRecord.content
}
return ReadArticlesUseCaseOut(articleUseCaseOuts, isLast)
}
}

0 comments on commit ba75729

Please sign in to comment.