Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 = "문서 생성",
Expand Down Expand Up @@ -81,7 +87,8 @@ class ArticleController(
summary = "제목으로 문서 조회",
description =
"위키 링크(`[[문서명]]`)의 제목으로 문서를 조회한다. 제목은 생성 시와 같은 규칙(앞뒤 공백 제거, " +
"유니코드 정규화, 연속 공백 축소, 대소문자 무시)으로 정규화해 찾는다. 로그인 없이 호출할 수 있다.",
"유니코드 정규화, 연속 공백 축소, 대소문자 무시)으로 정규화해 찾는다. 로그인 없이 호출할 수 있고, " +
"문서 조회와 같이 조회수가 집계된다.",
)
@ApiResponses(
value = [
Expand All @@ -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<PopularArticleResDto> = queryPopularArticlesService.execute(size)

@Operation(
summary = "최근 변경 문서 목록 조회",
Expand Down Expand Up @@ -164,7 +192,9 @@ class ArticleController(

@Operation(
summary = "문서 조회",
description = "문서의 최신 내용을 조회한다. 로그인 없이 호출할 수 있다.",
description =
"문서의 최신 내용을 조회한다. 로그인 없이 호출할 수 있다. 조회하면 조회수가 늘어나며(같은 사용자의 30분 내 재조회는 " +
"집계하지 않는다), 응답의 `viewCount`에 반영된 누적 조회수가 담긴다.",
)
@ApiResponses(
value = [
Expand All @@ -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 = "문서 수정",
Expand Down Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -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 =
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -21,6 +22,25 @@ interface ArticleRepository : JpaRepository<ArticleJpaEntity, Long> {
@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<ArticlePopularView>

/** 분류에 속한 문서를 최근 수정 순으로. 본문은 읽지 않는다. */
@Query(
value =
Expand Down Expand Up @@ -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
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
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<String, String>().increment(PENDING_KEY, articleId.toString(), 1)
return redis.opsForHash<String, String>().increment(TOTAL_KEY, articleId.toString(), 1)
}

/** 증가분 반영을 위해 pending을 가져가고 비운다. 반환값은 문서 id → 증가분. */
fun drainPending(): Map<Long, Long> {
if (redis.hasKey(PROCESSING_KEY) != true) {
if (redis.hasKey(PENDING_KEY) != true) return emptyMap()
redis.rename(PENDING_KEY, PROCESSING_KEY)
}
return redis
.opsForHash<String, String>()
.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<String, String>()
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 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
}

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"
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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<PopularArticleResDto>
}
Original file line number Diff line number Diff line change
@@ -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?
}
Original file line number Diff line number Diff line change
@@ -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<PopularArticleResDto> =
articleRepository.findPopular(PageRequest.of(0, size)).map(PopularArticleResDto::from)
}
Loading
Loading