diff --git a/api-repo/src/main/kotlin/com/few/api/repo/config/LocalCacheConfig.kt b/api-repo/src/main/kotlin/com/few/api/repo/config/LocalCacheConfig.kt index 05179bfb2..9ee3a86ee 100644 --- a/api-repo/src/main/kotlin/com/few/api/repo/config/LocalCacheConfig.kt +++ b/api-repo/src/main/kotlin/com/few/api/repo/config/LocalCacheConfig.kt @@ -23,6 +23,8 @@ class LocalCacheConfig { 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" } @Bean(LOCAL_CM) @@ -38,19 +40,36 @@ class LocalCacheConfig { ) val cacheManager = EhcacheCachingProvider().cacheManager - val cacheConfigurationBuilder = CacheConfigurationBuilder.newCacheConfigurationBuilder( + val cache10Configuration = CacheConfigurationBuilder.newCacheConfigurationBuilder( Any::class.java, Any::class.java, ResourcePoolsBuilder.newResourcePoolsBuilder() - .heap(50, EntryUnit.ENTRIES) + .heap(10, EntryUnit.ENTRIES) + ) + .withService(cacheEventListenerConfigurationConfig) + .build() + + val cache5Configuration = CacheConfigurationBuilder.newCacheConfigurationBuilder( + Any::class.java, + Any::class.java, + ResourcePoolsBuilder.newResourcePoolsBuilder() + .heap(5, EntryUnit.ENTRIES) ) .withService(cacheEventListenerConfigurationConfig) .build() val selectArticleRecordCacheConfig: javax.cache.configuration.Configuration = - Eh107Configuration.fromEhcacheCacheConfiguration(cacheConfigurationBuilder) + Eh107Configuration.fromEhcacheCacheConfiguration(cache10Configuration) + val selectWorkBookRecordCacheConfig: javax.cache.configuration.Configuration = + Eh107Configuration.fromEhcacheCacheConfiguration(cache10Configuration) + + val selectWriterCacheConfig: javax.cache.configuration.Configuration = + Eh107Configuration.fromEhcacheCacheConfiguration(cache5Configuration) + runCatching { cacheManager.createCache(SELECT_ARTICLE_RECORD_CACHE, selectArticleRecordCacheConfig) + cacheManager.createCache(SELECT_WORKBOOK_RECORD_CACHE, selectWorkBookRecordCacheConfig) + cacheManager.createCache(SELECT_WRITER_CACHE, selectWriterCacheConfig) }.onFailure { log.error(it) { "Failed to create cache" } } diff --git a/api-repo/src/main/kotlin/com/few/api/repo/dao/member/MemberCacheManager.kt b/api-repo/src/main/kotlin/com/few/api/repo/dao/member/MemberCacheManager.kt new file mode 100644 index 000000000..fd5dcc93a --- /dev/null +++ b/api-repo/src/main/kotlin/com/few/api/repo/dao/member/MemberCacheManager.kt @@ -0,0 +1,40 @@ +package com.few.api.repo.dao.member + +import com.few.api.repo.config.LocalCacheConfig.Companion.SELECT_WRITER_CACHE +import com.few.api.repo.dao.member.record.WriterRecord +import org.springframework.cache.CacheManager +import org.springframework.stereotype.Service +import javax.cache.Cache + +@Suppress("UNCHECKED_CAST") +@Service +class MemberCacheManager( + private val cacheManager: CacheManager, +) { + + private var selectWriterCache: Cache = cacheManager.getCache(SELECT_WRITER_CACHE)?.nativeCache as Cache + + fun getAllWriterValues(): List { + val values = mutableListOf() + selectWriterCache.iterator().forEach { + values.add(it.value as WriterRecord) + } + return values + } + + fun getAllWriterValues(keys: List): List { + val values = mutableListOf() + keys.forEach { + selectWriterCache.get(it)?.let { value -> + values.add(value as WriterRecord) + } + } + return values + } + + fun addSelectWorkBookCache(records: List) { + records.forEach { + selectWriterCache.put(it.writerId, it) + } + } +} \ No newline at end of file diff --git a/api-repo/src/main/kotlin/com/few/api/repo/dao/member/MemberDao.kt b/api-repo/src/main/kotlin/com/few/api/repo/dao/member/MemberDao.kt index 311722798..bddf87640 100644 --- a/api-repo/src/main/kotlin/com/few/api/repo/dao/member/MemberDao.kt +++ b/api-repo/src/main/kotlin/com/few/api/repo/dao/member/MemberDao.kt @@ -1,5 +1,7 @@ package com.few.api.repo.dao.member +import com.few.api.repo.config.LocalCacheConfig.Companion.LOCAL_CM +import com.few.api.repo.config.LocalCacheConfig.Companion.SELECT_WRITER_CACHE import com.few.api.repo.dao.member.command.InsertMemberCommand import com.few.api.repo.dao.member.query.SelectMemberByEmailQuery import com.few.api.repo.dao.member.query.SelectWriterQuery @@ -10,13 +12,16 @@ import com.few.data.common.code.MemberType import jooq.jooq_dsl.tables.Member import org.jooq.DSLContext import org.jooq.impl.DSL +import org.springframework.cache.annotation.Cacheable import org.springframework.stereotype.Repository @Repository class MemberDao( private val dslContext: DSLContext, + private val cacheManager: MemberCacheManager, ) { + @Cacheable(key = "#query.writerId", cacheManager = LOCAL_CM, cacheNames = [SELECT_WRITER_CACHE]) fun selectWriter(query: SelectWriterQuery): WriterRecord? { val writerId = query.writerId @@ -32,18 +37,34 @@ class MemberDao( .fetchOneInto(WriterRecord::class.java) } + /** + * 작가 목록 조회 쿼리 + * query의 writerIds에 해당하는 작가 목록을 조회한다. + * 이때 먼저 cache에 작가 정보가 있는지 확인하고 없는 경우에만 DB에서 조회한다. + * 조회 이후에는 cache에 저장한다. + */ fun selectWriters(query: SelectWritersQuery): List { - return dslContext.select( + val cachedValues = cacheManager.getAllWriterValues().filter { it.writerId in query.writerIds } + val cachedIdS = cachedValues.map { it.writerId } + val notCachedIds = query.writerIds.filter { it !in cachedIdS } + + val fetchedValue = dslContext.select( Member.MEMBER.ID.`as`(WriterRecord::writerId.name), - DSL.jsonGetAttributeAsText(Member.MEMBER.DESCRIPTION, "name").`as`(WriterRecord::name.name), + DSL.jsonGetAttributeAsText(Member.MEMBER.DESCRIPTION, "name") + .`as`(WriterRecord::name.name), DSL.jsonGetAttribute(Member.MEMBER.DESCRIPTION, "url").`as`(WriterRecord::url.name) ) .from(Member.MEMBER) - .where(Member.MEMBER.ID.`in`(query.writerIds)) + .where(Member.MEMBER.ID.`in`(notCachedIds)) .and(Member.MEMBER.TYPE_CD.eq(MemberType.WRITER.code)) .and(Member.MEMBER.DELETED_AT.isNull) .orderBy(Member.MEMBER.ID.asc()) - .fetchInto(WriterRecord::class.java) + .fetchInto(WriterRecord::class.java).let { + cacheManager.addSelectWorkBookCache(it) + return@let it + } + + return cachedValues + fetchedValue } fun selectMemberByEmail(query: SelectMemberByEmailQuery): MemberIdRecord? { diff --git a/api-repo/src/main/kotlin/com/few/api/repo/dao/workbook/WorkBookCacheManager.kt b/api-repo/src/main/kotlin/com/few/api/repo/dao/workbook/WorkBookCacheManager.kt new file mode 100644 index 000000000..603423dd4 --- /dev/null +++ b/api-repo/src/main/kotlin/com/few/api/repo/dao/workbook/WorkBookCacheManager.kt @@ -0,0 +1,24 @@ +package com.few.api.repo.dao.workbook + +import com.few.api.repo.config.LocalCacheConfig.Companion.SELECT_WORKBOOK_RECORD_CACHE +import com.few.api.repo.dao.workbook.record.SelectWorkBookRecord +import org.springframework.cache.CacheManager +import org.springframework.stereotype.Service +import javax.cache.Cache + +@Suppress("UNCHECKED_CAST") +@Service +class WorkBookCacheManager( + private val cacheManager: CacheManager, +) { + + private var selectWorkBookCache: Cache = cacheManager.getCache(SELECT_WORKBOOK_RECORD_CACHE)?.nativeCache as Cache + + fun getAllSelectWorkBookValues(): List { + val values = mutableListOf() + selectWorkBookCache.iterator().forEach { + values.add(it.value as SelectWorkBookRecord) + } + return values + } +} \ No newline at end of file diff --git a/api-repo/src/main/kotlin/com/few/api/repo/dao/workbook/WorkbookDao.kt b/api-repo/src/main/kotlin/com/few/api/repo/dao/workbook/WorkbookDao.kt index 7efdbb5cc..a27ab95db 100644 --- a/api-repo/src/main/kotlin/com/few/api/repo/dao/workbook/WorkbookDao.kt +++ b/api-repo/src/main/kotlin/com/few/api/repo/dao/workbook/WorkbookDao.kt @@ -1,5 +1,7 @@ package com.few.api.repo.dao.workbook +import com.few.api.repo.config.LocalCacheConfig +import com.few.api.repo.config.LocalCacheConfig.Companion.LOCAL_CM import com.few.api.repo.dao.workbook.command.InsertWorkBookCommand import com.few.api.repo.dao.workbook.command.MapWorkBookToArticleCommand import com.few.api.repo.dao.workbook.query.SelectWorkBookRecordQuery @@ -8,12 +10,14 @@ import com.few.data.common.code.CategoryType import jooq.jooq_dsl.tables.MappingWorkbookArticle import jooq.jooq_dsl.tables.Workbook import org.jooq.DSLContext +import org.springframework.cache.annotation.Cacheable import org.springframework.stereotype.Repository @Repository class WorkbookDao( private val dslContext: DSLContext, ) { + @Cacheable(key = "#query.id", cacheManager = LOCAL_CM, cacheNames = [LocalCacheConfig.SELECT_WORKBOOK_RECORD_CACHE]) fun selectWorkBook(query: SelectWorkBookRecordQuery): SelectWorkBookRecord? { return dslContext.select( Workbook.WORKBOOK.ID.`as`(SelectWorkBookRecord::id.name), diff --git a/api/src/main/kotlin/com/few/api/domain/article/usecase/ReadArticlesUseCase.kt b/api/src/main/kotlin/com/few/api/domain/article/usecase/ReadArticlesUseCase.kt new file mode 100644 index 000000000..3b0df3b7f --- /dev/null +++ b/api/src/main/kotlin/com/few/api/domain/article/usecase/ReadArticlesUseCase.kt @@ -0,0 +1,15 @@ +package com.few.api.domain.article.usecase + +import com.few.api.domain.article.usecase.dto.ReadArticlesUseCaseIn +import com.few.api.domain.article.usecase.dto.ReadArticlesUseCaseOut +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional + +@Component +class ReadArticlesUseCase { + + @Transactional(readOnly = true) + fun execute(useCaseIn: ReadArticlesUseCaseIn): ReadArticlesUseCaseOut { + return ReadArticlesUseCaseOut(emptyList()) // TODO: impl + } +} \ No newline at end of file diff --git a/api/src/main/kotlin/com/few/api/domain/article/usecase/dto/ReadArticleUseCaseOut.kt b/api/src/main/kotlin/com/few/api/domain/article/usecase/dto/ReadArticleUseCaseOut.kt index 7425affa2..60aa85b8e 100644 --- a/api/src/main/kotlin/com/few/api/domain/article/usecase/dto/ReadArticleUseCaseOut.kt +++ b/api/src/main/kotlin/com/few/api/domain/article/usecase/dto/ReadArticleUseCaseOut.kt @@ -12,10 +12,16 @@ data class ReadArticleUseCaseOut( val category: String, val createdAt: LocalDateTime, val views: Long, + val includedWorkbooks: List = emptyList(), ) data class WriterDetail( val id: Long, val name: String, val url: URL, +) + +data class WorkbookDetail( + val id: Long, + val title: String, ) \ No newline at end of file diff --git a/api/src/main/kotlin/com/few/api/domain/article/usecase/dto/ReadArticlesUseCaseIn.kt b/api/src/main/kotlin/com/few/api/domain/article/usecase/dto/ReadArticlesUseCaseIn.kt new file mode 100644 index 000000000..0b144cd51 --- /dev/null +++ b/api/src/main/kotlin/com/few/api/domain/article/usecase/dto/ReadArticlesUseCaseIn.kt @@ -0,0 +1,5 @@ +package com.few.api.domain.article.usecase.dto + +data class ReadArticlesUseCaseIn( + val prevArticleId: Long, +) \ No newline at end of file diff --git a/api/src/main/kotlin/com/few/api/domain/article/usecase/dto/ReadArticlesUseCaseOut.kt b/api/src/main/kotlin/com/few/api/domain/article/usecase/dto/ReadArticlesUseCaseOut.kt new file mode 100644 index 000000000..fb2aedb64 --- /dev/null +++ b/api/src/main/kotlin/com/few/api/domain/article/usecase/dto/ReadArticlesUseCaseOut.kt @@ -0,0 +1,5 @@ +package com.few.api.domain.article.usecase.dto + +data class ReadArticlesUseCaseOut( + val articles: List, +) \ No newline at end of file diff --git a/api/src/main/kotlin/com/few/api/web/controller/article/ArticleController.kt b/api/src/main/kotlin/com/few/api/web/controller/article/ArticleController.kt index 0eeff9c42..6aa1fd04d 100644 --- a/api/src/main/kotlin/com/few/api/web/controller/article/ArticleController.kt +++ b/api/src/main/kotlin/com/few/api/web/controller/article/ArticleController.kt @@ -2,23 +2,26 @@ package com.few.api.web.controller.article import com.few.api.domain.article.usecase.dto.ReadArticleUseCaseIn import com.few.api.domain.article.usecase.ReadArticleUseCase +import com.few.api.domain.article.usecase.ReadArticlesUseCase +import com.few.api.domain.article.usecase.dto.ReadArticlesUseCaseIn import com.few.api.web.controller.article.response.ReadArticleResponse +import com.few.api.web.controller.article.response.ReadArticlesResponse +import com.few.api.web.controller.article.response.WorkbookInfo +import com.few.api.web.controller.article.response.WriterInfo import com.few.api.web.support.ApiResponse import com.few.api.web.support.ApiResponseGenerator import jakarta.validation.constraints.Min import org.springframework.http.HttpStatus import org.springframework.http.MediaType import org.springframework.validation.annotation.Validated -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.* @Validated @RestController @RequestMapping(value = ["/api/v1/articles"], produces = [MediaType.APPLICATION_JSON_VALUE]) class ArticleController( private val readArticleUseCase: ReadArticleUseCase, + private val readArticlesUseCase: ReadArticlesUseCase, ) { @GetMapping("/{articleId}") @@ -31,8 +34,58 @@ class ArticleController( readArticleUseCase.execute(useCaseIn) } - return ReadArticleResponse(useCaseOut).let { - ApiResponseGenerator.success(it, HttpStatus.OK) - } + val response = ReadArticleResponse( + id = useCaseOut.id, + title = useCaseOut.title, + writer = WriterInfo( + useCaseOut.writer.id, + useCaseOut.writer.name, + useCaseOut.writer.url + ), + content = useCaseOut.content, + problemIds = useCaseOut.problemIds, + category = useCaseOut.category, + createdAt = useCaseOut.createdAt, + views = useCaseOut.views + ) + + return ApiResponseGenerator.success(response, HttpStatus.OK) + } + + @GetMapping + fun readArticles( + @RequestParam( + required = false, + defaultValue = "0" + ) prevArticleId: Long, + ): ApiResponse> { + val useCaseOut = readArticlesUseCase.execute(ReadArticlesUseCaseIn(prevArticleId)) + + val articles: List = useCaseOut.articles.map { a -> + ReadArticleResponse( + id = a.id, + title = a.title, + writer = WriterInfo( + a.writer.id, + a.writer.name, + a.writer.url + ), + content = a.content, + problemIds = a.problemIds, + category = a.category, + createdAt = a.createdAt, + views = a.views, + includedWorkbooks = a.includedWorkbooks.map { w -> + WorkbookInfo( + id = w.id, + title = w.title + ) + } + ) + }.toList() + + val response = ReadArticlesResponse(articles, articles.size != 10) // TODO refactor 'isLast' + + return ApiResponseGenerator.success(response, HttpStatus.OK) } } \ No newline at end of file diff --git a/api/src/main/kotlin/com/few/api/web/controller/article/response/ReadArticleResponse.kt b/api/src/main/kotlin/com/few/api/web/controller/article/response/ReadArticleResponse.kt index 3d6dd1e2c..5ae6c7a76 100644 --- a/api/src/main/kotlin/com/few/api/web/controller/article/response/ReadArticleResponse.kt +++ b/api/src/main/kotlin/com/few/api/web/controller/article/response/ReadArticleResponse.kt @@ -1,6 +1,5 @@ package com.few.api.web.controller.article.response -import com.few.api.domain.article.usecase.dto.ReadArticleUseCaseOut import java.net.URL import java.time.LocalDateTime @@ -13,27 +12,16 @@ data class ReadArticleResponse( val category: String, val createdAt: LocalDateTime, val views: Long, -) { - constructor( - useCaseOut: ReadArticleUseCaseOut, - ) : this( - id = useCaseOut.id, - writer = WriterInfo( - id = useCaseOut.writer.id, - name = useCaseOut.writer.name, - url = useCaseOut.writer.url - ), - title = useCaseOut.title, - content = useCaseOut.content, - problemIds = useCaseOut.problemIds, - category = useCaseOut.category, - createdAt = useCaseOut.createdAt, - views = useCaseOut.views - ) -} + val includedWorkbooks: List = emptyList(), +) data class WriterInfo( val id: Long, val name: String, val url: URL, +) + +data class WorkbookInfo( + val id: Long, + val title: String, ) \ No newline at end of file diff --git a/api/src/main/kotlin/com/few/api/web/controller/article/response/ReadArticlesResponse.kt b/api/src/main/kotlin/com/few/api/web/controller/article/response/ReadArticlesResponse.kt new file mode 100644 index 000000000..80a139400 --- /dev/null +++ b/api/src/main/kotlin/com/few/api/web/controller/article/response/ReadArticlesResponse.kt @@ -0,0 +1,6 @@ +package com.few.api.web.controller.article.response + +data class ReadArticlesResponse( + val articles: List, + val isLast: Boolean, +) \ No newline at end of file diff --git a/api/src/test/kotlin/com/few/api/web/controller/article/ArticleControllerTest.kt b/api/src/test/kotlin/com/few/api/web/controller/article/ArticleControllerTest.kt index 3c1059b58..4807efdcb 100644 --- a/api/src/test/kotlin/com/few/api/web/controller/article/ArticleControllerTest.kt +++ b/api/src/test/kotlin/com/few/api/web/controller/article/ArticleControllerTest.kt @@ -5,10 +5,9 @@ import com.epages.restdocs.apispec.ResourceDocumentation.parameterWithName import com.epages.restdocs.apispec.ResourceSnippetParameters import com.epages.restdocs.apispec.Schema import com.fasterxml.jackson.databind.ObjectMapper -import com.few.api.domain.article.usecase.dto.ReadArticleUseCaseIn -import com.few.api.domain.article.usecase.dto.ReadArticleUseCaseOut -import com.few.api.domain.article.usecase.dto.WriterDetail import com.few.api.domain.article.usecase.ReadArticleUseCase +import com.few.api.domain.article.usecase.ReadArticlesUseCase +import com.few.api.domain.article.usecase.dto.* import com.few.api.web.controller.ControllerTestSpec import com.few.api.web.controller.description.Description import com.few.api.web.controller.helper.* @@ -43,6 +42,9 @@ class ArticleControllerTest : ControllerTestSpec() { @MockBean private lateinit var readArticleUseCase: ReadArticleUseCase + @MockBean + private lateinit var readArticlesUseCase: ReadArticlesUseCase + companion object { private val BASE_URL = "/api/v1/articles" private val TAG = "ArticleController" @@ -96,7 +98,7 @@ class ArticleControllerTest : ControllerTestSpec() { ResourceSnippetParameters.builder().description("아티클 Id로 아티클 조회") .summary(api.toIdentifier()).privateResource(false).deprecated(false) .tag(TAG).requestSchema(Schema.schema(api.toRequestSchema())) - .pathParameters(parameterWithName("articleId").description("학습지 Id")) + .pathParameters(parameterWithName("articleId").description("아티클 Id")) .responseSchema(Schema.schema(api.toResponseSchema())).responseFields( *Description.describe( arrayOf( @@ -123,7 +125,101 @@ class ArticleControllerTest : ControllerTestSpec() { PayloadDocumentation.fieldWithPath("data.createdAt") .fieldWithString("아티클 생성일"), PayloadDocumentation.fieldWithPath("data.views") - .fieldWithNumber("아티클 조회수") + .fieldWithNumber("아티클 조회수"), + PayloadDocumentation.fieldWithPath("data.includedWorkbooks") + .fieldWithArray("아티클이 포함된 학습지 정보(해당 API에서 사용되지 않음)") + ) + ) + ).build() + ) + ) + ) + } + + @Test + @DisplayName("[GET] /api/v1/articles?prevArticleId={prevArticleId}") + fun readArticles() { + // given + val api = "ReadArticles" + val uri = UriComponentsBuilder.newInstance() + .path("$BASE_URL") + .queryParam("prevArticleId", 1L) + .build() + .toUriString() + // set usecase mock + val prevArticleId = 1L + `when`(readArticlesUseCase.execute(ReadArticlesUseCaseIn(prevArticleId))).thenReturn( + ReadArticlesUseCaseOut( + listOf( + ReadArticleUseCaseOut( + id = 1L, + writer = WriterDetail( + id = 1L, + name = "안나포", + url = URL("http://localhost:8080/api/v1/writers/1") + ), + title = "ETF(상장 지수 펀드)란? 모르면 손해라고?", + content = CategoryType.fromCode(0)!!.name, + problemIds = listOf(1L, 2L, 3L), + category = "경제", + createdAt = LocalDateTime.now(), + views = 1L, + includedWorkbooks = listOf( + WorkbookDetail(1L, "사소한 것들의 역사"), + WorkbookDetail(2L, "인모스트 경제레터") + ) + ) + ) + ) + ) + + // when + this.webTestClient.get().uri(uri).accept(MediaType.APPLICATION_JSON) + .exchange().expectStatus().isOk().expectBody().consumeWith( + WebTestClientRestDocumentation.document( + api.toIdentifier(), + ResourceDocumentation.resource( + ResourceSnippetParameters.builder().description("아티 목록 10개씩 조회(조회수 기반 정렬)") + .summary(api.toIdentifier()).privateResource(false).deprecated(false) + .tag(TAG).requestSchema(Schema.schema(api.toRequestSchema())) + .queryParameters(parameterWithName("prevArticleId").description("이전까지 조회한 아티클 Id")) + .responseSchema(Schema.schema(api.toResponseSchema())).responseFields( + *Description.describe( + arrayOf( + PayloadDocumentation.fieldWithPath("data") + .fieldWithObject("data"), + PayloadDocumentation.fieldWithPath("data.isLast") + .fieldWithBoolean("마지막 스크롤 유무"), + PayloadDocumentation.fieldWithPath("data.articles") + .fieldWithArray("아티클 목록"), + PayloadDocumentation.fieldWithPath("data.articles[].id") + .fieldWithNumber("아티클 Id"), + PayloadDocumentation.fieldWithPath("data.articles[].writer") + .fieldWithObject("아티클 작가"), + PayloadDocumentation.fieldWithPath("data.articles[].writer.id") + .fieldWithNumber("아티클 작가 Id"), + PayloadDocumentation.fieldWithPath("data.articles[].writer.name") + .fieldWithString("아티클 작가 이름"), + PayloadDocumentation.fieldWithPath("data.articles[].writer.url") + .fieldWithString("아티클 작가 링크"), + PayloadDocumentation.fieldWithPath("data.articles[].title") + .fieldWithString("아티클 제목"), + PayloadDocumentation.fieldWithPath("data.articles[].content") + .fieldWithString("아티클 내용"), + PayloadDocumentation.fieldWithPath("data.articles[].problemIds") + .fieldWithArray("아티클 문제 목록"), + PayloadDocumentation.fieldWithPath("data.articles[].category") + .fieldWithString("아티클 카테고리"), + PayloadDocumentation.fieldWithPath("data.articles[].createdAt") + .fieldWithString("아티클 생성일"), + PayloadDocumentation.fieldWithPath("data.articles[].views") + .fieldWithNumber("아티클 조회수"), + PayloadDocumentation.fieldWithPath("data.articles[].includedWorkbooks") + .fieldWithArray("아티클이 포함된 학습지 정보"), + PayloadDocumentation.fieldWithPath("data.articles[].includedWorkbooks[].id") + .fieldWithNumber("아티클이 포함된 학습지 정보(학습지ID)"), + PayloadDocumentation.fieldWithPath("data.articles[].includedWorkbooks[].title") + .fieldWithString("아티클이 포함된 학습지 정보(학습지 제목)") ) ) ).build()