From 44823ee831f269de911d7014273e9176e28b3467 Mon Sep 17 00:00:00 2001 From: zaman Date: Mon, 12 Oct 2026 11:10:29 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(article):=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EC=88=98=20=EC=A7=91=EA=B3=84=20=EB=B0=8F=20=EC=9D=B8=EA=B8=B0?= =?UTF-8?q?=20=EB=AC=B8=EC=84=9C=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 문서 조회(GET, 제목 조회 포함) 시 조회수 증가, 같은 조회자의 30분 내 재조회는 집계하지 않음 - 조회수는 Redis에 쌓았다가 주기적으로 articles.view_count에 반영 (벌크 UPDATE라 최근 변경·수정 시각에 영향 없음) - 문서 조회 응답에 viewCount 포함, GET /api/v1/articles/popular 추가 - Redis 집계 장애가 문서 조회를 막지 않도록 실패 시 기존 응답 유지 Closes #26 --- .../article/controller/ArticleController.kt | 46 +++++++++- .../article/dto/response/ArticleResDto.kt | 3 + .../dto/response/PopularArticleResDto.kt | 18 ++++ .../domain/article/entity/ArticleJpaEntity.kt | 13 +++ .../article/repository/ArticleRepository.kt | 26 ++++++ .../article/service/ArticleViewCounter.kt | 80 +++++++++++++++++ .../service/ArticleViewFlushScheduler.kt | 39 ++++++++ .../service/QueryPopularArticlesService.kt | 7 ++ .../service/TrackArticleViewService.kt | 11 +++ .../impl/QueryPopularArticlesServiceImpl.kt | 17 ++++ .../impl/TrackArticleViewServiceImpl.kt | 48 ++++++++++ .../global/config/ArticleViewEnvironment.kt | 13 +++ src/main/resources/application.yaml.example | 5 ++ ...ticleRepositoryViewCountIntegrationTest.kt | 73 +++++++++++++++ .../article/service/ArticleViewCounterTest.kt | 90 +++++++++++++++++++ .../service/ArticleViewFlushSchedulerTest.kt | 63 +++++++++++++ .../impl/TrackArticleViewServiceImplTest.kt | 66 ++++++++++++++ 17 files changed, 614 insertions(+), 4 deletions(-) create mode 100644 src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/dto/response/PopularArticleResDto.kt create mode 100644 src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/ArticleViewCounter.kt create mode 100644 src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/ArticleViewFlushScheduler.kt create mode 100644 src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/QueryPopularArticlesService.kt create mode 100644 src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/TrackArticleViewService.kt create mode 100644 src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/impl/QueryPopularArticlesServiceImpl.kt create mode 100644 src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/impl/TrackArticleViewServiceImpl.kt create mode 100644 src/main/kotlin/io/github/wntopia/gikipedia/server/global/config/ArticleViewEnvironment.kt create mode 100644 src/test/kotlin/io/github/wntopia/gikipedia/server/domain/article/repository/ArticleRepositoryViewCountIntegrationTest.kt create mode 100644 src/test/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/ArticleViewCounterTest.kt create mode 100644 src/test/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/ArticleViewFlushSchedulerTest.kt create mode 100644 src/test/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/impl/TrackArticleViewServiceImplTest.kt diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/controller/ArticleController.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/controller/ArticleController.kt index cdef66b..3aae2e0 100644 --- a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/controller/ArticleController.kt +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/controller/ArticleController.kt @@ -6,12 +6,15 @@ import io.github.wntopia.gikipedia.server.domain.article.dto.request.UpdateArtic import io.github.wntopia.gikipedia.server.domain.article.dto.request.UpdateArticleReqDto import io.github.wntopia.gikipedia.server.domain.article.dto.response.ArticleResDto import io.github.wntopia.gikipedia.server.domain.article.dto.response.LookupArticleTitlesResDto +import io.github.wntopia.gikipedia.server.domain.article.dto.response.PopularArticleResDto import io.github.wntopia.gikipedia.server.domain.article.dto.response.RecentChangeResDto import io.github.wntopia.gikipedia.server.domain.article.service.CreateArticleService import io.github.wntopia.gikipedia.server.domain.article.service.LookupArticleTitlesService import io.github.wntopia.gikipedia.server.domain.article.service.QueryArticleByTitleService import io.github.wntopia.gikipedia.server.domain.article.service.QueryArticleService +import io.github.wntopia.gikipedia.server.domain.article.service.QueryPopularArticlesService import io.github.wntopia.gikipedia.server.domain.article.service.QueryRecentChangesService +import io.github.wntopia.gikipedia.server.domain.article.service.TrackArticleViewService import io.github.wntopia.gikipedia.server.domain.article.service.UpdateArticleImageService import io.github.wntopia.gikipedia.server.domain.article.service.UpdateArticleService import io.github.wntopia.gikipedia.server.domain.link.dto.response.BacklinkResDto @@ -22,6 +25,7 @@ import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponses import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpSession import jakarta.validation.Valid import jakarta.validation.constraints.Max @@ -54,6 +58,8 @@ class ArticleController( private val lookupArticleTitlesService: LookupArticleTitlesService, private val queryBacklinksService: QueryBacklinksService, private val queryRecentChangesService: QueryRecentChangesService, + private val queryPopularArticlesService: QueryPopularArticlesService, + private val trackArticleViewService: TrackArticleViewService, ) { @Operation( summary = "문서 생성", @@ -81,7 +87,8 @@ class ArticleController( summary = "제목으로 문서 조회", description = "위키 링크(`[[문서명]]`)의 제목으로 문서를 조회한다. 제목은 생성 시와 같은 규칙(앞뒤 공백 제거, " + - "유니코드 정규화, 연속 공백 축소, 대소문자 무시)으로 정규화해 찾는다. 로그인 없이 호출할 수 있다.", + "유니코드 정규화, 연속 공백 축소, 대소문자 무시)으로 정규화해 찾는다. 로그인 없이 호출할 수 있고, " + + "문서 조회와 같이 조회수가 집계된다.", ) @ApiResponses( value = [ @@ -92,7 +99,28 @@ class ArticleController( @GetMapping("/by-title") fun queryByTitle( @Parameter(description = "문서 제목", example = "기숙사 생활 안내") @RequestParam title: String, - ): ArticleResDto = queryArticleByTitleService.execute(title) + @Parameter(hidden = true) request: HttpServletRequest, + ): ArticleResDto = withViewCount(queryArticleByTitleService.execute(title), request) + + @Operation( + summary = "인기 문서 목록 조회", + description = + "누적 조회수가 많은 문서를 조회수 순(전체 기간)으로 반환한다. 조회수는 약 30초 주기로 반영되므로 목록이 " + + "그만큼 늦게 갱신될 수 있다. 로그인 없이 호출할 수 있다.", + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "조회 성공"), + ApiResponse(responseCode = "400", description = "size가 범위(1~50)를 벗어남"), + ], + ) + @GetMapping("/popular") + fun popular( + @Parameter(description = "가져올 개수 (1~50, 기본 10)", example = "10") + @RequestParam(defaultValue = "10") + @Min(1) + @Max(50) size: Int, + ): List = queryPopularArticlesService.execute(size) @Operation( summary = "최근 변경 문서 목록 조회", @@ -164,7 +192,9 @@ class ArticleController( @Operation( summary = "문서 조회", - description = "문서의 최신 내용을 조회한다. 로그인 없이 호출할 수 있다.", + description = + "문서의 최신 내용을 조회한다. 로그인 없이 호출할 수 있다. 조회하면 조회수가 늘어나며(같은 사용자의 30분 내 재조회는 " + + "집계하지 않는다), 응답의 `viewCount`에 반영된 누적 조회수가 담긴다.", ) @ApiResponses( value = [ @@ -175,7 +205,8 @@ class ArticleController( @GetMapping("/{articleId}") fun query( @Parameter(description = "문서 ID", example = "1") @PathVariable articleId: Long, - ): ArticleResDto = queryArticleService.execute(articleId) + @Parameter(hidden = true) request: HttpServletRequest, + ): ArticleResDto = withViewCount(queryArticleService.execute(articleId), request) @Operation( summary = "문서 수정", @@ -225,4 +256,11 @@ class ArticleController( @Valid @ModelAttribute reqDto: UpdateArticleImageReqDto, @Parameter(hidden = true) session: HttpSession, ): ArticleResDto = updateArticleImageService.execute(articleId, reqDto, session) + + /** 조회를 집계하고 응답에 누적 조회수를 싣는다. 집계에 실패하면 응답의 기존 값을 그대로 둔다. */ + private fun withViewCount( + article: ArticleResDto, + request: HttpServletRequest, + ): ArticleResDto = + trackArticleViewService.execute(article.id, request)?.let { article.copy(viewCount = it) } ?: article } diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/dto/response/ArticleResDto.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/dto/response/ArticleResDto.kt index fafdf53..527a01b 100644 --- a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/dto/response/ArticleResDto.kt +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/dto/response/ArticleResDto.kt @@ -21,6 +21,8 @@ data class ArticleResDto( val updatedAt: Instant?, @field:Schema(description = "문서 분류 (분류가 없으면 null)") val category: CategoryResDto? = null, + @field:Schema(description = "누적 조회수. 문서 조회(GET) 응답에서만 실시간 값이고, 그 밖의 응답에서는 저장된 값이거나 0이다.", example = "128") + val viewCount: Long = 0, ) { companion object { fun from(entity: ArticleJpaEntity): ArticleResDto = @@ -32,6 +34,7 @@ data class ArticleResDto( createdAt = entity.createdAt, updatedAt = entity.updatedAt, category = entity.category?.let { CategoryResDto(requireNotNull(it.id), it.name) }, + viewCount = entity.viewCount, ) fun from(entity: ArticleMongoEntity): ArticleResDto = diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/dto/response/PopularArticleResDto.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/dto/response/PopularArticleResDto.kt new file mode 100644 index 0000000..a938b4b --- /dev/null +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/dto/response/PopularArticleResDto.kt @@ -0,0 +1,18 @@ +package io.github.wntopia.gikipedia.server.domain.article.dto.response + +import io.github.wntopia.gikipedia.server.domain.article.repository.ArticlePopularView +import io.swagger.v3.oas.annotations.media.Schema + +data class PopularArticleResDto( + @field:Schema(description = "문서 ID", example = "1") + val id: Long, + @field:Schema(description = "문서 제목") + val title: String, + @field:Schema(description = "누적 조회수", example = "128") + val viewCount: Long, +) { + companion object { + fun from(view: ArticlePopularView): PopularArticleResDto = + PopularArticleResDto(view.id, view.title, view.viewCount) + } +} diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/entity/ArticleJpaEntity.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/entity/ArticleJpaEntity.kt index b874557..761b486 100644 --- a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/entity/ArticleJpaEntity.kt +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/entity/ArticleJpaEntity.kt @@ -33,6 +33,19 @@ class ArticleJpaEntity( var content: String = content protected set + /** + * 누적 조회수. 이 필드로는 쓰지 않는다(`updatable = false`) — 문서 수정이 오래된 값으로 덮어써 집계를 지우지 않도록, + * 오직 [ArticleRepository.addViewCount]의 벌크 UPDATE로만 늘어난다(그래서 `updatedAt`도 바뀌지 않는다). + */ + @field:Column( + name = "view_count", + nullable = false, + updatable = false, + columnDefinition = "bigint not null default 0", + ) + var viewCount: Long = 0 + protected set + @field:Column(name = "image_url", length = 2048) var imageUrl: String? = imageUrl protected set diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/repository/ArticleRepository.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/repository/ArticleRepository.kt index 2253423..67bc7a2 100644 --- a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/repository/ArticleRepository.kt +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/repository/ArticleRepository.kt @@ -6,6 +6,7 @@ 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.Lock +import org.springframework.data.jpa.repository.Modifying import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param @@ -21,6 +22,25 @@ interface ArticleRepository : JpaRepository { @Param("id") id: Long, ): ArticleJpaEntity? + /** 조회수 flush용. 엔티티를 거치지 않는 벌크 UPDATE라 `updatedAt`(최근 변경)을 건드리지 않는다. */ + @Modifying + @Query("UPDATE ArticleJpaEntity a SET a.viewCount = a.viewCount + :delta WHERE a.id = :id") + fun addViewCount( + @Param("id") id: Long, + @Param("delta") delta: Long, + ): Int + + @Query("SELECT a.viewCount FROM ArticleJpaEntity a WHERE a.id = :id") + fun findViewCountById( + @Param("id") id: Long, + ): Long? + + /** 조회수 순(전체 기간) 상위 문서. */ + @Query( + "SELECT a.id AS id, a.title AS title, a.viewCount AS viewCount FROM ArticleJpaEntity a ORDER BY a.viewCount DESC, a.id ASC", + ) + fun findPopular(pageable: Pageable): List + /** 분류에 속한 문서를 최근 수정 순으로. 본문은 읽지 않는다. */ @Query( value = @@ -89,3 +109,9 @@ interface ArticleRecentView { val createdAt: java.time.Instant val updatedAt: java.time.Instant } + +interface ArticlePopularView { + val id: Long + val title: String + val viewCount: Long +} diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/ArticleViewCounter.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/ArticleViewCounter.kt new file mode 100644 index 0000000..8e7c6df --- /dev/null +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/ArticleViewCounter.kt @@ -0,0 +1,80 @@ +package io.github.wntopia.gikipedia.server.domain.article.service + +import io.github.wntopia.gikipedia.server.domain.article.repository.ArticleRepository +import io.github.wntopia.gikipedia.server.global.config.ArticleViewEnvironment +import org.springframework.data.redis.core.StringRedisTemplate +import org.springframework.stereotype.Component +import java.time.Duration + +/** + * 문서 조회수 집계. 읽기 경로(Redis → Mongo)가 MySQL을 건드리지 않도록 누적 조회수를 Redis에도 둔다. + * + * - `article:views:total`(hash): 문서별 누적 조회수. 비어 있으면 MySQL 값(+아직 반영 안 된 증가분)으로 채운다. + * - `article:views:pending`(hash): MySQL에 아직 반영하지 않은 증가분. [ArticleViewFlushScheduler]가 주기적으로 비운다. + * - `article:views:seen:{문서}:{조회자}`: 중복 집계를 막는 키. TTL 동안 같은 조회자의 재조회는 세지 않는다. + */ +@Component +class ArticleViewCounter( + private val redis: StringRedisTemplate, + private val articleRepository: ArticleRepository, + private val environment: ArticleViewEnvironment, +) { + /** + * 조회를 기록하고 현재 누적 조회수를 돌려준다. 같은 조회자가 중복 집계 시간 안에 다시 열면 증가시키지 않는다. + * 조회수 때문에 문서 조회가 실패하면 안 되므로 Redis 오류는 호출 측에서 처리한다. + */ + fun recordView( + articleId: Long, + viewerKey: String, + ): Long { + val total = ensureTotal(articleId) + val firstView = + redis + .opsForValue() + .setIfAbsent(seenKey(articleId, viewerKey), "1", Duration.ofMinutes(environment.dedupeMinutes)) == true + if (!firstView) return total + + redis.opsForHash().increment(PENDING_KEY, articleId.toString(), 1) + return redis.opsForHash().increment(TOTAL_KEY, articleId.toString(), 1) + } + + /** 증가분 반영을 위해 pending을 가져가고 비운다. 반환값은 문서 id → 증가분. */ + fun drainPending(): Map { + if (redis.hasKey(PROCESSING_KEY) != true) { + if (redis.hasKey(PENDING_KEY) != true) return emptyMap() + redis.rename(PENDING_KEY, PROCESSING_KEY) + } + return redis + .opsForHash() + .entries(PROCESSING_KEY) + .mapNotNull { (id, delta) -> id.toLongOrNull()?.let { it to delta.toLong() } } + .toMap() + } + + /** [drainPending]으로 가져간 증가분을 MySQL에 반영한 뒤 호출한다. */ + fun confirmDrained() { + redis.delete(PROCESSING_KEY) + } + + private fun ensureTotal(articleId: Long): Long { + val hash = redis.opsForHash() + val field = articleId.toString() + hash.get(TOTAL_KEY, field)?.let { return it.toLong() } + + val pending = hash.get(PENDING_KEY, field)?.toLong() ?: 0 + val seeded = (articleRepository.findViewCountById(articleId) ?: 0) + pending + hash.putIfAbsent(TOTAL_KEY, field, seeded.toString()) + return hash.get(TOTAL_KEY, field)?.toLong() ?: seeded + } + + private fun seenKey( + articleId: Long, + viewerKey: String, + ) = "article:views:seen:$articleId:$viewerKey" + + companion object { + private const val TOTAL_KEY = "article:views:total" + private const val PENDING_KEY = "article:views:pending" + private const val PROCESSING_KEY = "article:views:processing" + } +} diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/ArticleViewFlushScheduler.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/ArticleViewFlushScheduler.kt new file mode 100644 index 0000000..1858d24 --- /dev/null +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/ArticleViewFlushScheduler.kt @@ -0,0 +1,39 @@ +package io.github.wntopia.gikipedia.server.domain.article.service + +import io.github.wntopia.gikipedia.server.domain.article.repository.ArticleRepository +import org.slf4j.LoggerFactory +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component +import org.springframework.transaction.support.TransactionTemplate + +/** + * Redis에 쌓인 조회수 증가분을 주기적으로 MySQL `articles.view_count`에 반영한다. + * + * 한 트랜잭션으로 모두 반영한 뒤에 증가분을 지운다. 반영 직후 지우기 전에 프로세스가 죽으면 다음 실행에서 같은 + * 증가분을 한 번 더 반영할 수 있다(최대 한 주기 분량의 과집계 — 유실보다 낫다고 보고 허용한다). + */ +@Component +class ArticleViewFlushScheduler( + private val articleViewCounter: ArticleViewCounter, + private val articleRepository: ArticleRepository, + private val transactionTemplate: TransactionTemplate, +) { + private val log = LoggerFactory.getLogger(javaClass) + + @Scheduled(fixedDelayString = "\${article.views.flush-interval-ms:30000}") + fun flush() { + try { + val deltas = articleViewCounter.drainPending() + if (deltas.isEmpty()) return + + transactionTemplate.executeWithoutResult { + deltas.forEach { (articleId, delta) -> articleRepository.addViewCount(articleId, delta) } + } + articleViewCounter.confirmDrained() + log.debug("조회수 반영 완료 (articles={})", deltas.size) + } catch (e: Exception) { + // 증가분은 processing 키에 남아 있어 다음 실행에서 이어서 반영된다. + log.warn("조회수 반영 실패 — 다음 주기에 다시 시도한다", e) + } + } +} diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/QueryPopularArticlesService.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/QueryPopularArticlesService.kt new file mode 100644 index 0000000..9925f3f --- /dev/null +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/QueryPopularArticlesService.kt @@ -0,0 +1,7 @@ +package io.github.wntopia.gikipedia.server.domain.article.service + +import io.github.wntopia.gikipedia.server.domain.article.dto.response.PopularArticleResDto + +interface QueryPopularArticlesService { + fun execute(size: Int): List +} diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/TrackArticleViewService.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/TrackArticleViewService.kt new file mode 100644 index 0000000..87c9c4a --- /dev/null +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/TrackArticleViewService.kt @@ -0,0 +1,11 @@ +package io.github.wntopia.gikipedia.server.domain.article.service + +import jakarta.servlet.http.HttpServletRequest + +interface TrackArticleViewService { + /** 조회를 기록하고 현재 누적 조회수를 반환한다. 집계에 실패해도 예외를 던지지 않는다(null). */ + fun execute( + articleId: Long, + request: HttpServletRequest, + ): Long? +} diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/impl/QueryPopularArticlesServiceImpl.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/impl/QueryPopularArticlesServiceImpl.kt new file mode 100644 index 0000000..0b118c0 --- /dev/null +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/impl/QueryPopularArticlesServiceImpl.kt @@ -0,0 +1,17 @@ +package io.github.wntopia.gikipedia.server.domain.article.service.impl + +import io.github.wntopia.gikipedia.server.domain.article.dto.response.PopularArticleResDto +import io.github.wntopia.gikipedia.server.domain.article.repository.ArticleRepository +import io.github.wntopia.gikipedia.server.domain.article.service.QueryPopularArticlesService +import org.springframework.data.domain.PageRequest +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class QueryPopularArticlesServiceImpl( + private val articleRepository: ArticleRepository, +) : QueryPopularArticlesService { + @Transactional(readOnly = true) + override fun execute(size: Int): List = + articleRepository.findPopular(PageRequest.of(0, size)).map(PopularArticleResDto::from) +} diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/impl/TrackArticleViewServiceImpl.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/impl/TrackArticleViewServiceImpl.kt new file mode 100644 index 0000000..deb93be --- /dev/null +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/impl/TrackArticleViewServiceImpl.kt @@ -0,0 +1,48 @@ +package io.github.wntopia.gikipedia.server.domain.article.service.impl + +import io.github.wntopia.gikipedia.server.domain.article.service.ArticleViewCounter +import io.github.wntopia.gikipedia.server.domain.article.service.TrackArticleViewService +import io.github.wntopia.gikipedia.server.global.security.session.AuthenticationReader +import jakarta.servlet.http.HttpServletRequest +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import java.security.MessageDigest + +@Service +class TrackArticleViewServiceImpl( + private val articleViewCounter: ArticleViewCounter, + private val authenticationReader: AuthenticationReader, +) : TrackArticleViewService { + private val log = LoggerFactory.getLogger(javaClass) + + override fun execute( + articleId: Long, + request: HttpServletRequest, + ): Long? = + try { + articleViewCounter.recordView(articleId, viewerKey(request)) + } catch (e: Exception) { + // 조회수는 부가 정보 — 집계(Redis) 장애가 문서 조회를 막아서는 안 된다. + log.warn("조회수 집계 실패 (articleId={})", articleId, e) + null + } + + /** + * 로그인했으면 계정 id, 아니면 접속 IP와 User-Agent의 해시. 세션을 새로 만들지 않는다 — 비로그인 조회마다 오래 남는 + * 세션이 Redis에 쌓이는 것을 피하기 위함이다. 리버스 프록시 뒤라면 프록시가 실제 클라이언트 주소를 넘기도록 + * (`server.forward-headers-strategy`) 설정해야 비로그인 조회자가 구분된다. + */ + private fun viewerKey(request: HttpServletRequest): String { + val session = request.getSession(false) + val userId = session?.let { authenticationReader.getCurrentUser(it)?.id } + if (userId != null) return "u:$userId" + + val raw = "${request.remoteAddr}|${request.getHeader("User-Agent").orEmpty()}" + val digest = MessageDigest.getInstance("SHA-256").digest(raw.toByteArray()) + return "a:" + digest.take(DIGEST_BYTES).joinToString("") { "%02x".format(it) } + } + + companion object { + private const val DIGEST_BYTES = 12 + } +} diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/global/config/ArticleViewEnvironment.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/global/config/ArticleViewEnvironment.kt new file mode 100644 index 0000000..d234ecf --- /dev/null +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/global/config/ArticleViewEnvironment.kt @@ -0,0 +1,13 @@ +package io.github.wntopia.gikipedia.server.global.config + +import org.springframework.boot.context.properties.ConfigurationProperties + +/** + * 조회수 집계 튜닝 값. `application.yaml`의 `article.views` 하위에서 조정한다. Redis → MySQL 반영 주기는 + * `article.views.flush-interval-ms`(기본 30초)로 [io.github.wntopia.gikipedia.server.domain.article.service.ArticleViewFlushScheduler] 가 직접 읽는다. + */ +@ConfigurationProperties(prefix = "article.views") +data class ArticleViewEnvironment( + /** 같은 사용자가 같은 문서를 다시 열어도 조회수를 올리지 않는 시간(분). */ + val dedupeMinutes: Long = 30, +) diff --git a/src/main/resources/application.yaml.example b/src/main/resources/application.yaml.example index 33866ec..48d2f88 100644 --- a/src/main/resources/application.yaml.example +++ b/src/main/resources/application.yaml.example @@ -73,6 +73,11 @@ article: compaction: # 원본 히스토리 row를 삭제하는 파괴적 배치라, 문제 생기면 재배포만으로 끌 수 있도록 플래그로 둔다. enabled: ${ARTICLE_HISTORY_COMPACTION_ENABLED:true} + views: + # 같은 사용자가 같은 문서를 다시 열어도 조회수를 올리지 않는 시간(분) + dedupe-minutes: ${ARTICLE_VIEWS_DEDUPE_MINUTES:30} + # Redis에 쌓인 조회수를 MySQL에 반영하는 주기(밀리초). 인기 문서 목록이 이만큼 늦게 갱신된다. + flush-interval-ms: ${ARTICLE_VIEWS_FLUSH_INTERVAL_MS:30000} read: # redis-mongo: Redis→Mongo 순으로 조회(신규 경로). mysql: 기존처럼 MySQL 직접 조회. # 문제 생길 때 재배포만으로 되돌릴 수 있도록 플래그로 둔다. diff --git a/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/article/repository/ArticleRepositoryViewCountIntegrationTest.kt b/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/article/repository/ArticleRepositoryViewCountIntegrationTest.kt new file mode 100644 index 0000000..a3c72ab --- /dev/null +++ b/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/article/repository/ArticleRepositoryViewCountIntegrationTest.kt @@ -0,0 +1,73 @@ +package io.github.wntopia.gikipedia.server.domain.article.repository + +import io.github.wntopia.gikipedia.server.domain.article.entity.ArticleJpaEntity +import io.github.wntopia.gikipedia.server.global.config.JpaAuditingConfig +import jakarta.persistence.EntityManager +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest +import org.springframework.context.annotation.Import +import org.springframework.data.domain.PageRequest + +@DataJpaTest +@Import(JpaAuditingConfig::class) +class ArticleRepositoryViewCountIntegrationTest { + @Autowired + private lateinit var articleRepository: ArticleRepository + + @Autowired + private lateinit var entityManager: EntityManager + + private fun article(title: String) = articleRepository.saveAndFlush(ArticleJpaEntity(title = title, content = "x")) + + @Test + fun `벌크 증가는 조회수만 늘리고 수정 시각은 건드리지 않는다`() { + val saved = article("A") + val id = requireNotNull(saved.id) + // 메모리의 Instant는 나노초, DB에서 읽은 값은 마이크로초라 비교 기준도 DB에서 읽은 값으로 맞춘다. + entityManager.clear() + val updatedAtBefore = articleRepository.findById(id).get().updatedAt + entityManager.clear() + + assertThat(articleRepository.addViewCount(id, 3)).isEqualTo(1) + assertThat(articleRepository.addViewCount(id, 2)).isEqualTo(1) + entityManager.clear() + + assertThat(articleRepository.findViewCountById(id)).isEqualTo(5) + assertThat(articleRepository.findById(id).get().updatedAt).isEqualTo(updatedAtBefore) + } + + @Test + fun `문서를 수정해 저장해도 반영된 조회수를 오래된 값으로 덮어쓰지 않는다`() { + val saved = article("A") + val id = requireNotNull(saved.id) + entityManager.clear() + val stale = articleRepository.findById(id).get() // viewCount = 0으로 로드 + + articleRepository.addViewCount(id, 9) // 그 사이 조회수가 반영됨 + stale.update("수정", null) + articleRepository.saveAndFlush(stale) + entityManager.clear() + + assertThat(articleRepository.findViewCountById(id)).isEqualTo(9) + } + + @Test + fun `인기 문서는 조회수 내림차순이고 같으면 id 순이다`() { + val low = requireNotNull(article("낮음").id) + val high = requireNotNull(article("높음").id) + val tieFirst = requireNotNull(article("동점1").id) + val tieSecond = requireNotNull(article("동점2").id) + articleRepository.addViewCount(low, 1) + articleRepository.addViewCount(high, 10) + articleRepository.addViewCount(tieFirst, 5) + articleRepository.addViewCount(tieSecond, 5) + entityManager.clear() + + val popular = articleRepository.findPopular(PageRequest.of(0, 3)) + + assertThat(popular.map { it.title }).containsExactly("높음", "동점1", "동점2") + assertThat(popular.first().viewCount).isEqualTo(10) + } +} diff --git a/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/ArticleViewCounterTest.kt b/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/ArticleViewCounterTest.kt new file mode 100644 index 0000000..4757725 --- /dev/null +++ b/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/ArticleViewCounterTest.kt @@ -0,0 +1,90 @@ +package io.github.wntopia.gikipedia.server.domain.article.service + +import io.github.wntopia.gikipedia.server.domain.article.repository.ArticleRepository +import io.github.wntopia.gikipedia.server.global.config.ArticleViewEnvironment +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.data.redis.core.HashOperations +import org.springframework.data.redis.core.StringRedisTemplate +import org.springframework.data.redis.core.ValueOperations +import java.time.Duration + +class ArticleViewCounterTest { + private val redis = mock() + private val values = mock>() + private val hash = mock>() + private val articleRepository = mock() + private val counter = ArticleViewCounter(redis, articleRepository, ArticleViewEnvironment(dedupeMinutes = 30)) + + @BeforeEach + fun setUp() { + whenever(redis.opsForValue()).thenReturn(values) + whenever(redis.opsForHash()).thenReturn(hash) + } + + @Test + @DisplayName("처음 보는 조회자의 조회는 누적과 미반영 증가분을 1씩 올리고 새 누적 값을 돌려준다") + fun countsFirstView() { + whenever(hash.get("article:views:total", "1")).thenReturn("5") + whenever( + values.setIfAbsent(eq("article:views:seen:1:u:10"), eq("1"), eq(Duration.ofMinutes(30))), + ).thenReturn(true) + whenever(hash.increment("article:views:total", "1", 1)).thenReturn(6) + + assertThat(counter.recordView(1L, "u:10")).isEqualTo(6) + verify(hash).increment("article:views:pending", "1", 1) + } + + @Test + @DisplayName("중복 집계 시간 안의 재조회는 올리지 않고 현재 값만 돌려준다") + fun ignoresRepeatView() { + whenever(hash.get("article:views:total", "1")).thenReturn("5") + whenever(values.setIfAbsent(any(), any(), any())).thenReturn(false) + + assertThat(counter.recordView(1L, "u:10")).isEqualTo(5) + verify(hash, never()).increment(any(), any(), any()) + } + + @Test + @DisplayName("Redis에 누적 값이 없으면 MySQL 값과 미반영 증가분을 합쳐 채운다") + fun seedsTotalFromDatabase() { + whenever(hash.get("article:views:total", "1")).thenReturn(null, "107") + whenever(hash.get("article:views:pending", "1")).thenReturn("7") + whenever(articleRepository.findViewCountById(1L)).thenReturn(100L) + whenever(values.setIfAbsent(any(), any(), any())).thenReturn(false) + + assertThat(counter.recordView(1L, "u:10")).isEqualTo(107) + verify(hash).putIfAbsent("article:views:total", "1", "107") + } + + @Test + @DisplayName("미반영 증가분이 없으면 비어 있는 결과를 돌려주고, 있으면 processing으로 옮겨 읽는다") + fun drainsPending() { + whenever(redis.hasKey("article:views:processing")).thenReturn(false) + whenever(redis.hasKey("article:views:pending")).thenReturn(false) + assertThat(counter.drainPending()).isEmpty() + + whenever(redis.hasKey("article:views:pending")).thenReturn(true) + whenever(hash.entries("article:views:processing")).thenReturn(mapOf("1" to "3", "2" to "4")) + assertThat(counter.drainPending()).containsExactlyInAnyOrderEntriesOf(mapOf(1L to 3L, 2L to 4L)) + verify(redis).rename("article:views:pending", "article:views:processing") + } + + @Test + @DisplayName("이전에 반영하다 만 processing이 남아 있으면 그것부터 다시 읽는다") + fun resumesLeftoverProcessing() { + whenever(redis.hasKey("article:views:processing")).thenReturn(true) + whenever(hash.entries("article:views:processing")).thenReturn(mapOf("1" to "3")) + + assertThat(counter.drainPending()).containsExactlyEntriesOf(mapOf(1L to 3L)) + verify(redis, never()).rename(any(), any()) + } +} diff --git a/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/ArticleViewFlushSchedulerTest.kt b/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/ArticleViewFlushSchedulerTest.kt new file mode 100644 index 0000000..d8776bd --- /dev/null +++ b/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/ArticleViewFlushSchedulerTest.kt @@ -0,0 +1,63 @@ +package io.github.wntopia.gikipedia.server.domain.article.service + +import io.github.wntopia.gikipedia.server.domain.article.repository.ArticleRepository +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.transaction.TransactionStatus +import org.springframework.transaction.support.TransactionTemplate +import java.util.function.Consumer + +class ArticleViewFlushSchedulerTest { + private val counter = mock() + private val articleRepository = mock() + private val transactionTemplate = mock() + private val scheduler = ArticleViewFlushScheduler(counter, articleRepository, transactionTemplate) + + private fun runCallbacksInline() { + whenever(transactionTemplate.executeWithoutResult(any>())).thenAnswer { + it.getArgument>(0).accept(mock()) + null + } + } + + @Test + @DisplayName("증가분을 문서별로 MySQL에 반영한 뒤 Redis의 증가분을 지운다") + fun flushesAndConfirms() { + whenever(counter.drainPending()).thenReturn(mapOf(1L to 3L, 2L to 4L)) + runCallbacksInline() + + scheduler.flush() + + verify(articleRepository).addViewCount(1L, 3L) + verify(articleRepository).addViewCount(2L, 4L) + verify(counter).confirmDrained() + } + + @Test + @DisplayName("반영할 증가분이 없으면 아무것도 하지 않는다") + fun noopWhenNothingPending() { + whenever(counter.drainPending()).thenReturn(emptyMap()) + + scheduler.flush() + + verify(articleRepository, never()).addViewCount(any(), any()) + verify(counter, never()).confirmDrained() + } + + @Test + @DisplayName("반영이 실패하면 증가분을 지우지 않아 다음 주기에 이어서 반영한다") + fun keepsPendingOnFailure() { + whenever(counter.drainPending()).thenReturn(mapOf(1L to 3L)) + whenever(transactionTemplate.executeWithoutResult(any>())) + .thenThrow(RuntimeException("db down")) + + scheduler.flush() + + verify(counter, never()).confirmDrained() + } +} diff --git a/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/impl/TrackArticleViewServiceImplTest.kt b/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/impl/TrackArticleViewServiceImplTest.kt new file mode 100644 index 0000000..5c95b15 --- /dev/null +++ b/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/impl/TrackArticleViewServiceImplTest.kt @@ -0,0 +1,66 @@ +package io.github.wntopia.gikipedia.server.domain.article.service.impl + +import io.github.wntopia.gikipedia.server.domain.article.service.ArticleViewCounter +import io.github.wntopia.gikipedia.server.domain.auth.dto.response.DataGsmUserInfoResDto +import io.github.wntopia.gikipedia.server.global.security.session.AuthenticationReader +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpSession +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class TrackArticleViewServiceImplTest { + private val counter = mock() + private val authenticationReader = mock() + private val service = TrackArticleViewServiceImpl(counter, authenticationReader) + + private fun request( + session: HttpSession?, + ip: String = "10.0.0.1", + ) = mock().also { + whenever(it.getSession(false)).thenReturn(session) + whenever(it.remoteAddr).thenReturn(ip) + whenever(it.getHeader("User-Agent")).thenReturn("JUnit") + } + + @Test + @DisplayName("로그인한 사용자는 계정 id로 구분한다") + fun keysByUserId() { + val session = mock() + whenever( + authenticationReader.getCurrentUser(session), + ).thenReturn(DataGsmUserInfoResDto(10L, null, "USER", true, null)) + whenever(counter.recordView(eq(1L), eq("u:10"))).thenReturn(7L) + + assertThat(service.execute(1L, request(session))).isEqualTo(7L) + } + + @Test + @DisplayName("비로그인은 접속 IP와 User-Agent 해시로 구분하고, 같은 입력이면 같은 키다") + fun keysByHashedClientForAnonymous() { + whenever(counter.recordView(any(), any())).thenReturn(1L) + + service.execute(1L, request(null)) + service.execute(1L, request(null)) + service.execute(1L, request(null, ip = "10.0.0.2")) + + val keys = argumentCaptor() + verify(counter, org.mockito.kotlin.times(3)).recordView(any(), keys.capture()) + assertThat(keys.firstValue).startsWith("a:").isEqualTo(keys.secondValue).isNotEqualTo(keys.thirdValue) + assertThat(keys.firstValue).doesNotContain("10.0.0.1") + } + + @Test + @DisplayName("집계(Redis)가 실패해도 예외 없이 null을 돌려준다") + fun swallowsCounterFailure() { + whenever(counter.recordView(any(), any())).thenThrow(RuntimeException("redis down")) + + assertThat(service.execute(1L, request(null))).isNull() + } +} From e68d61d88c091768000c8ca6b47f8eb1ae420d69 Mon Sep 17 00:00:00 2001 From: zaman Date: Mon, 12 Oct 2026 11:57:09 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(article):=20=EC=A1=B0=ED=9A=8C=EC=88=98?= =?UTF-8?q?=20=EB=88=84=EC=A0=81=EA=B0=92=20=EC=8B=9C=EB=93=9C=20=EC=8B=9C?= =?UTF-8?q?=20=EB=B0=98=EC=98=81=20=EC=A4=91=EC=9D=B8=20=EC=A6=9D=EA=B0=80?= =?UTF-8?q?=EB=B6=84=EC=9D=84=20=ED=8F=AC=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 플러시가 pending을 processing으로 옮긴 뒤 MySQL에 반영하기 전에 누적값을 다시 채우면 그 증가분이 빠져 낮게 시드되던 문제 수정 --- .../domain/article/service/ArticleViewCounter.kt | 5 ++++- .../article/service/ArticleViewCounterTest.kt | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/ArticleViewCounter.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/ArticleViewCounter.kt index 8e7c6df..45a22fc 100644 --- a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/ArticleViewCounter.kt +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/ArticleViewCounter.kt @@ -61,8 +61,11 @@ class ArticleViewCounter( val field = articleId.toString() hash.get(TOTAL_KEY, field)?.let { return it.toLong() } + // 플러시가 pending을 processing으로 옮긴 뒤 MySQL에 반영하기 전이면 그 증가분은 processing에만 있다. 둘 다 더해야 + // 누적값이 일시적으로라도 낮게 시드되지 않는다. val pending = hash.get(PENDING_KEY, field)?.toLong() ?: 0 - val seeded = (articleRepository.findViewCountById(articleId) ?: 0) + pending + val processing = hash.get(PROCESSING_KEY, field)?.toLong() ?: 0 + val seeded = (articleRepository.findViewCountById(articleId) ?: 0) + pending + processing hash.putIfAbsent(TOTAL_KEY, field, seeded.toString()) return hash.get(TOTAL_KEY, field)?.toLong() ?: seeded } diff --git a/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/ArticleViewCounterTest.kt b/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/ArticleViewCounterTest.kt index 4757725..a1a8449 100644 --- a/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/ArticleViewCounterTest.kt +++ b/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/ArticleViewCounterTest.kt @@ -65,6 +65,20 @@ class ArticleViewCounterTest { verify(hash).putIfAbsent("article:views:total", "1", "107") } + @Test + @DisplayName("시드할 때 플러시 중(processing)인 증가분도 더한다") + fun seedIncludesProcessingDelta() { + whenever(hash.get("article:views:total", "1")).thenReturn(null, "110") + whenever(hash.get("article:views:pending", "1")).thenReturn("7") + whenever(hash.get("article:views:processing", "1")).thenReturn("3") + whenever(articleRepository.findViewCountById(1L)).thenReturn(100L) + whenever(values.setIfAbsent(any(), any(), any())).thenReturn(false) + + counter.recordView(1L, "u:10") + + verify(hash).putIfAbsent("article:views:total", "1", "110") + } + @Test @DisplayName("미반영 증가분이 없으면 비어 있는 결과를 돌려주고, 있으면 processing으로 옮겨 읽는다") fun drainsPending() {