diff --git a/api/src/main/kotlin/com/few/api/config/ApiLocalCacheConfig.kt b/api/src/main/kotlin/com/few/api/config/ApiLocalCacheConfig.kt index 08343bca7..70142101a 100644 --- a/api/src/main/kotlin/com/few/api/config/ApiLocalCacheConfig.kt +++ b/api/src/main/kotlin/com/few/api/config/ApiLocalCacheConfig.kt @@ -3,6 +3,7 @@ package com.few.api.config import com.few.api.domain.common.repo.event.ApiLocalCacheEventListener 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 @@ -14,6 +15,7 @@ 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 @@ -25,6 +27,9 @@ class ApiLocalCacheConfig { const val SELECT_ARTICLE_RECORD_CACHE = "selectArticleRecordCache" const val SELECT_WORKBOOK_RECORD_CACHE = "selectWorkBookRecordCache" const val SELECT_WRITER_CACHE = "selectWritersCache" + const val ARTICLE_MAIN_CARD_RECORD_CACHE = "articleMainCardRecordCache" + const val ARTICLE_CONTENT_CACHE = "articleContentCache" + const val SELECT_ARTICLE_VIEWS_RECORD_SERVICE_CACHE = "selectArticleViewsRecordServiceCache" } @Bean(LOCAL_CM) @@ -63,6 +68,18 @@ class ApiLocalCacheConfig { ).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 = Eh107Configuration.fromEhcacheCacheConfiguration(cache10Configuration) val selectWorkBookRecordCacheConfig: javax.cache.configuration.Configuration = @@ -71,10 +88,22 @@ class ApiLocalCacheConfig { val selectWriterCacheConfig: javax.cache.configuration.Configuration = Eh107Configuration.fromEhcacheCacheConfiguration(cache5Configuration) + val articleContentCacheConfig: javax.cache.configuration.Configuration = + Eh107Configuration.fromEhcacheCacheConfiguration(cache10MinuteConfiguration) + + val articleMainCardRecordCacheConfig: javax.cache.configuration.Configuration = + Eh107Configuration.fromEhcacheCacheConfiguration(cache10MinuteConfiguration) + + val selectArticleViewsRecordServiceCacheConfig: javax.cache.configuration.Configuration = + 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(ARTICLE_CONTENT_CACHE, articleContentCacheConfig) + cacheManager.createCache(ARTICLE_MAIN_CARD_RECORD_CACHE, articleMainCardRecordCacheConfig) + cacheManager.createCache(SELECT_ARTICLE_VIEWS_RECORD_SERVICE_CACHE, selectArticleViewsRecordServiceCacheConfig) }.onFailure { log.error(it) { "Failed to create cache" } } diff --git a/api/src/main/kotlin/com/few/api/domain/article/repo/ArticleDao.kt b/api/src/main/kotlin/com/few/api/domain/article/repo/ArticleDao.kt index 60ac23d99..6f4845a07 100644 --- a/api/src/main/kotlin/com/few/api/domain/article/repo/ArticleDao.kt +++ b/api/src/main/kotlin/com/few/api/domain/article/repo/ArticleDao.kt @@ -1,5 +1,6 @@ package com.few.api.domain.article.repo +import com.few.api.config.ApiLocalCacheConfig.Companion.ARTICLE_CONTENT_CACHE import com.few.api.config.ApiLocalCacheConfig.Companion.LOCAL_CM import com.few.api.config.ApiLocalCacheConfig.Companion.SELECT_ARTICLE_RECORD_CACHE import com.few.api.domain.article.repo.command.InsertFullArticleRecordCommand @@ -127,6 +128,17 @@ class ArticleDao( .and(MappingWorkbookArticle.MAPPING_WORKBOOK_ARTICLE.DAY_COL.eq(query.day)) .query + @Cacheable(key = "#articleId", cacheManager = LOCAL_CM, cacheNames = [ARTICLE_CONTENT_CACHE]) + fun selectArticleContent(articleId: Long) = + dslContext + .select( + ArticleIfo.ARTICLE_IFO.ARTICLE_MST_ID.`as`(SelectArticleContentsRecord::articleId.name), + ArticleIfo.ARTICLE_IFO.CONTENT.`as`(SelectArticleContentsRecord::content.name), + ).from(ArticleIfo.ARTICLE_IFO) + .where(ArticleIfo.ARTICLE_IFO.ARTICLE_MST_ID.eq(articleId)) + .and(ArticleIfo.ARTICLE_IFO.DELETED_AT.isNull) + .fetchOneInto(SelectArticleContentsRecord::class.java) + fun selectArticleContents(articleIds: Set): List = selectArticleContentsQuery(articleIds) .fetchInto(SelectArticleContentsRecord::class.java) diff --git a/api/src/main/kotlin/com/few/api/domain/article/repo/ArticleMainCardDao.kt b/api/src/main/kotlin/com/few/api/domain/article/repo/ArticleMainCardDao.kt index 52b696060..890195bda 100644 --- a/api/src/main/kotlin/com/few/api/domain/article/repo/ArticleMainCardDao.kt +++ b/api/src/main/kotlin/com/few/api/domain/article/repo/ArticleMainCardDao.kt @@ -1,5 +1,7 @@ package com.few.api.domain.article.repo +import com.few.api.config.ApiLocalCacheConfig.Companion.ARTICLE_MAIN_CARD_RECORD_CACHE +import com.few.api.config.ApiLocalCacheConfig.Companion.LOCAL_CM import com.few.api.domain.article.repo.command.ArticleMainCardExcludeWorkbookCommand import com.few.api.domain.article.repo.command.UpdateArticleMainCardWorkbookCommand import com.few.api.domain.article.repo.record.ArticleMainCardRecord @@ -8,6 +10,7 @@ import com.few.api.domain.article.repo.support.CommonJsonMapper import jooq.jooq_dsl.tables.ArticleMainCard.ARTICLE_MAIN_CARD import org.jooq.* import org.jooq.impl.DSL.* +import org.springframework.cache.annotation.Cacheable import org.springframework.stereotype.Repository @Repository @@ -16,6 +19,35 @@ class ArticleMainCardDao( private val commonJsonMapper: CommonJsonMapper, private val articleMainCardMapper: ArticleMainCardMapper, ) { + @Cacheable(key = "#articleId", cacheManager = LOCAL_CM, cacheNames = [ARTICLE_MAIN_CARD_RECORD_CACHE]) + fun selectArticleMainCardsRecord(articleId: Long): ArticleMainCardRecord? = + dslContext + .select( + ARTICLE_MAIN_CARD.ID.`as`(ArticleMainCardRecord::articleId.name), + ARTICLE_MAIN_CARD.TITLE.`as`(ArticleMainCardRecord::articleTitle.name), + ARTICLE_MAIN_CARD.MAIN_IMAGE_URL.`as`(ArticleMainCardRecord::mainImageUrl.name), + ARTICLE_MAIN_CARD.CATEGORY_CD.`as`(ArticleMainCardRecord::categoryCd.name), + ARTICLE_MAIN_CARD.CREATED_AT.`as`(ArticleMainCardRecord::createdAt.name), + ARTICLE_MAIN_CARD.WRITER_ID.`as`(ArticleMainCardRecord::writerId.name), + ARTICLE_MAIN_CARD.WRITER_EMAIL.`as`(ArticleMainCardRecord::writerEmail.name), + jsonGetAttributeAsText( + ARTICLE_MAIN_CARD.WRITER_DESCRIPTION, + "name", + ).`as`(ArticleMainCardRecord::writerName.name), + jsonGetAttribute( + ARTICLE_MAIN_CARD.WRITER_DESCRIPTION, + "url", + ).`as`(ArticleMainCardRecord::writerUrl.name), + jsonGetAttribute( + ARTICLE_MAIN_CARD.WRITER_DESCRIPTION, + "imageUrl", + ).`as`(ArticleMainCardRecord::writerImgUrl.name), + ARTICLE_MAIN_CARD.WORKBOOKS.`as`(ArticleMainCardRecord::workbooks.name), + ).from(ARTICLE_MAIN_CARD) + .where(ARTICLE_MAIN_CARD.ID.eq(articleId)) + .fetch(articleMainCardMapper) + .firstOrNull() + fun selectArticleMainCardsRecord(articleIds: Set): Set = selectArticleMainCardsRecordQuery(articleIds) .fetch(articleMainCardMapper) diff --git a/api/src/main/kotlin/com/few/api/domain/article/usecase/ArticleViewRecordsService.kt b/api/src/main/kotlin/com/few/api/domain/article/usecase/ArticleViewRecordsService.kt new file mode 100644 index 000000000..e535ebf9e --- /dev/null +++ b/api/src/main/kotlin/com/few/api/domain/article/usecase/ArticleViewRecordsService.kt @@ -0,0 +1,50 @@ +package com.few.api.domain.article.usecase + +import com.few.api.config.ApiLocalCacheConfig.Companion.LOCAL_CM +import com.few.api.config.ApiLocalCacheConfig.Companion.SELECT_ARTICLE_VIEWS_RECORD_SERVICE_CACHE +import com.few.api.domain.article.repo.ArticleViewCountDao +import com.few.api.domain.article.repo.query.SelectArticlesOrderByViewsQuery +import com.few.api.domain.article.repo.query.SelectRankByViewsQuery +import com.few.api.domain.article.repo.record.SelectArticleViewsRecord +import com.few.api.domain.common.vo.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 { + /** + * 아티클 조회수 테이블에서 마지막 읽은 아티클 아이디, 카테고리를 기반으로 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() + } +} \ No newline at end of file diff --git a/api/src/main/kotlin/com/few/api/domain/article/usecase/ArticleViewRecordsServiceKeyGenerator.kt b/api/src/main/kotlin/com/few/api/domain/article/usecase/ArticleViewRecordsServiceKeyGenerator.kt new file mode 100644 index 000000000..ff4629ac4 --- /dev/null +++ b/api/src/main/kotlin/com/few/api/domain/article/usecase/ArticleViewRecordsServiceKeyGenerator.kt @@ -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" + } +} \ No newline at end of file diff --git a/api/src/main/kotlin/com/few/api/domain/article/usecase/BrowseArticlesUseCase.kt b/api/src/main/kotlin/com/few/api/domain/article/usecase/BrowseArticlesUseCase.kt index 113664d31..9f669798c 100644 --- a/api/src/main/kotlin/com/few/api/domain/article/usecase/BrowseArticlesUseCase.kt +++ b/api/src/main/kotlin/com/few/api/domain/article/usecase/BrowseArticlesUseCase.kt @@ -3,83 +3,63 @@ package com.few.api.domain.article.usecase import com.few.api.config.jooq.ApiTransactional import com.few.api.domain.article.repo.ArticleDao import com.few.api.domain.article.repo.ArticleMainCardDao -import com.few.api.domain.article.repo.ArticleViewCountDao -import com.few.api.domain.article.repo.query.SelectArticlesOrderByViewsQuery -import com.few.api.domain.article.repo.query.SelectRankByViewsQuery import com.few.api.domain.article.repo.record.ArticleMainCardRecord import com.few.api.domain.article.repo.record.SelectArticleContentsRecord -import com.few.api.domain.article.repo.record.SelectArticleViewsRecord import com.few.api.domain.article.usecase.dto.* import com.few.api.domain.common.exception.NotFoundException import com.few.api.domain.common.vo.CategoryType import org.springframework.stereotype.Component 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, ) { @ApiTransactional(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 = - articleViewCountDao - .selectArticlesOrderByViews( - SelectArticlesOrderByViewsQuery( - offset, - CategoryType.fromCode(useCaseIn.categoryCd) ?: CategoryType.All, - ), - ).toMutableList() + val articleViewsRecords = articleViewRecordsService.execute(useCaseIn.prevArticleId, useCaseIn.categoryCd) /** * 11개를 조회한 상황에서 11개가 조회되지 않았다면 마지막 스크롤로 판단 */ val isLast = - if (articleViewsRecords.size == 11) { - articleViewsRecords.removeAt(10) - false - } else { - true + when (articleViewsRecords.size) { + 11 -> { + articleViewsRecords.removeAt(10) + false + } + 10 -> { + false + } + else -> { + true + } } + val articleIds = articleViewsRecords.map { it.articleId }.toList() /** - * ARTICLE_MAIN_CARD 테이블에서 이번 스크롤에서 보여줄 10개 아티클 조회 (TODO: 캐싱 적용) + * ARTICLE_MAIN_CARD 테이블에서 이번 스크롤에서 보여줄 10개 아티클 조회 */ - val articleMainCardRecords: Set = - articleMainCardDao.selectArticleMainCardsRecord(articleViewsRecords.map { it.articleId }.toSet()) + val articleMainCardRecords: List = + articleIds.mapNotNull { + articleMainCardDao.selectArticleMainCardsRecord( + it, + ) + } /** * 아티클 컨텐츠는 ARTICLE_MAIN_CARD가 아닌 ARTICLE_IFO에서 조회 (TODO: 캐싱 적용) */ - val selectArticleContentsRecords: List = - articleDao.selectArticleContents(articleMainCardRecords.map { it.articleId }.toSet()) - setContentsToRecords(selectArticleContentsRecords, articleMainCardRecords) + val selectArticleContentsRecords: List = articleIds.mapNotNull { articleDao.selectArticleContent(it) } - /** - * 아티클 조회수 순, 조회수가 같을 경우 최신 아티클이 우선순위를 가지도록 정렬 (TODO: 삭제시 양향도 파악 필요) - */ - val sortedArticles = updateAndSortArticleViews(articleMainCardRecords, articleViewsRecords) + articleMainCardRecords.withIndex().forEach { (index, articleMainCardRecord) -> + articleMainCardRecord.content = selectArticleContentsRecords[index].content + } val articleUseCaseOuts: List = - sortedArticles + articleMainCardRecords .map { a -> ReadArticleUseCaseOut( id = a.articleId, @@ -107,52 +87,4 @@ class BrowseArticlesUseCase( return ReadArticlesUseCaseOut(articleUseCaseOuts, isLast) } - - private fun updateAndSortArticleViews( - articleRecords: Set, - articleViewsRecords: List, - ): Set { - val sortedSet = - TreeSet( - Comparator { a1, a2 -> - // views 값이 null일 경우 0으로 간주 - val views1 = a1.views ?: 0 - val views2 = a2.views ?: 0 - - // 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 viewsMap = articleViewsRecords.associateBy({ it.articleId }, { it.views }) - - articleRecords.forEach { article -> - val updatedViews = viewsMap[article.articleId] ?: 0 - article.views = updatedViews - sortedSet.add(article) - } - - return sortedSet - } - - private fun setContentsToRecords( - articleContentsRecords: List, - articleMainCardRecords: Set, - ) { - val articleMainCardRecordsMap: Map = - articleMainCardRecords.associateBy { it.articleId } - - articleContentsRecords.map { articleContentRecord -> - articleMainCardRecordsMap[articleContentRecord.articleId]?.content = articleContentRecord.content - } - } } \ No newline at end of file