게시글 조회수 기능
- 사용자 게시글을 조회하는 것은 GET 요청을 통해 조회한다.
- 그러나 조회하는 과정에서 조회수 증가가 필요한데, 데이터베이스에 대한 쓰기작업이 별도로 필요하다.
- 이 쓰기 작업 처리과정에서 문제가 발생하거나, 그 과정에서 장애가 발생한다면? 사용자는 게시글을 조회하는데 실패할 수 있다.
- 따라서 게시글 조회수 증가는 별도의 요청 및 로직을 통해 처리되어야한다.
- 예를 들어, 게시글 페이지에서 일정 시간 경과하면(조회수 증가 시간 통제 가능) API 서버에 '조회수 증가' 요청을 보내는 식으로 조회수 증가를 처리하는 것이다.
- 조회 작업에서 조회수 증가요청도 함께하는 것은 Restful API 철학에도 위배된다.
- GET 요청은 단순 데이터 조회를 위해 사용 호출. 그러나 실제 조회수가 증가하는 Side-effect가 발생한다. 멱등성에 위배됨
- 우리 서비스는 따라서, 게시글 조회 API 를 별도로 구현할 것이다.
모듈

- 우리 서비스는 게시글 조회수 증가 기능을 별도 모듈로 분리하고, 서비스 규모가 커질 경우 이 기능 역시 별도의 서비스로 분리하는 것을 가정한다.
게시글 조회수 저장방식
- 조회수 증가는 매우 빈번하게 호출되는 기능인만큼, 매 요청마다 조회수를 관계형 데이터베이스에 저장하는 것은 TPS 둔화의 원인이 된다.
- 인메모리 데이터베이스인 Redis 에 조회수를 보관한다.
- 조회속도가 빠르다.
- Redis는 싱글스레드 기반으로 동작하므로, 조회수 증가만 단순하게 처리한다면 동시다발적으로 요청이 들어와도 동시성 문제에서 안전하다.
- 다만 Redis 에 장애가 발생할 경우, 데이터 유실이 발생할 가능성이 존재한다.
- 우리서비스의 조회수는 100% 항상 정확한 값을 꼭 유지할 필요는 없다. 어느 정도의 근사한 정확도를 유지하는 것 정도면 충분하다.
- (기능 확장 가능성) 조회수를 어느정도 백업해두고 싶다면 별도의 배치 프로세스를 통해 10분 간격으로 조회수 변경 이력이 있는 게시글들의 조회수를 관계형 데이터베이스(MySQL)에 백업해두는 방법을 고려해볼 수 있을 듯 하다.
기능 흐름
fun increase(command: ArticleViewCountIncreaseCommand) {
// 게시글 존재 여부 확인
checkArticleExistence(command.articleId)
// 게시글 조회수 증가
articleViewCountPersistencePort.increase(command.articleId)
}
- 사용자로부터 게시글 조회수 증가 요청이 들어오면, 우선 게시글이 존재하는 지 확인하고 조회수를 증가시키기만 하면된다.
@Component
class ArticleViewCountPersistenceAdapter(
private val redisTemplate: StringRedisTemplate
) : ArticleViewCountPersistencePort {
companion object {
private const val KEY_PATTERN = "board-system::article-view::article::%s::view-count"
}
override fun increase(articleId: Long) {
val key = generateKey(articleId)
redisTemplate.opsForValue().increment(key)
}
- 게시글 증가 로직은 redis 의 INCR 기능을 활용하면 된다. (초기 값이 없으면 초기값을 0으로 하고 1 증가시킨다.)
- 저장방식은 잘 작동한다.
- 그러나 전체 흐름상 치명적인 문제가 존재한다.
- 동시에 특정 사용자가 악용 목적으로 게시글 조회수 증가를 동시에 매우 많이 요청할 경우, 게시글 조회수가 순식간에 폭증할 수 있다.(악용, 어뷰징)
어뷰징 방지
- 같은 사용자에게서 조회수 증가가 여러번 들어오더라도, 일정 시간동안은 한번의 증가만 허용하도록 하고 싶다.
- 10분 이내에 같은 사용자가 게시글 조회수 증가 요청을 하면 증가 처리를 하지 않도록 하고 싶다.
companion object {
private val TTL = Duration.ofMinutes(10)
}
fun increase(command: ArticleViewCountIncreaseCommand) {
// 게시글 존재 여부 확인
checkArticleExistence(command.articleId)
// 게시글 조회수 락 획득, 획득 실패시 그냥 반환
if (!articleViewCountLockPersistencePort.lock(command.articleId, command.user.userId, TTL)) {
return
}
// 게시글 조회수 증가
articleViewCountPersistencePort.increase(command.articleId)
}
- 기존 로직에, '게시글 조회수 락' 획득 로직을 추가했다.
- 사용자는 요청할 때마다 게시글 조회수 락 획득을 시도한다. 락 획득에 성공하면 게시글 조회수 증가 로직을 수행하고, 락 획득에 실패하면 조회수 증가를 하지 않는다.
- 그렇다면 이 락 획득에 대한 로직은 어떤 방식으로 기술적으로 구현할 수 있을까?
Redis 의 SetIfAbsent 를 활용한 분산락 구현
@Component
class ArticleViewCountLockPersistenceAdapter(
private val redisTemplate: StringRedisTemplate
) : ArticleViewCountLockPersistencePort {
companion object {
private const val KEY_PATTERN = "board-system::article-view::article::%s::user::%s::lock"
private const val LOCK_VALUE = ""
}
override fun lock(articleId: Long, userId: Long, ttl: Duration): Boolean {
val key = generateKey(articleId, userId)
return redisTemplate.opsForValue().setIfAbsent(key, LOCK_VALUE, ttl)!!
}
private fun generateKey(articleId: Long, userId: Long): String {
return KEY_PATTERN.format(articleId, userId)
}
}
- Redis 에서는 setIfAbsent 라는 기능을 제공한다.
- key 가 존재하지 않으면 value 를 저장할 수 있다. 저장에 성공하면 true 가 반환된다.(ttl 지정 가능)
- key 가 존재하면 value를 저장할 수 없다. 이 경우 false가 반환된다.
- 최초 락 획득 시도시, 10분간 유효하도록 setIfAbsent 를 호출하여, 10분간 value 를 저장할 수 없게 하는 것이다.
- 최초 요청 시 set에 성공하면 true 가 반환되는데(10분간 저장됨) 이를 락 획득 성공으로 간주한다.
- 이후 10분간 요청 시 false 를 반환받는데, 이를 락 회득 실패로 간주하게 하면 된다.
- 이 기능을 활용하여 분산락을 구현할 수 있다.
통합 테스트
@Disabled // 수동테스트용(테스트 해보고 싶을 경우 주석처리)
@SpringBootTest
@DisplayName("[app] 게시글 조회수 통합테스트")
class ArticleViewCountIntegrationTest {
@Autowired
private lateinit var articleViewCountIncreaseUseCase: ArticleViewCountIncreaseUseCase
@Autowired
private lateinit var articlePersistencePort: ArticlePersistencePort
@Autowired
private lateinit var articleCategoryPersistencePort: ArticleCategoryPersistencePort
@Autowired
private lateinit var articleViewCountReadUseCase: ArticleViewCountReadUseCase
@Test
@DisplayName("댓글 수 동시성 테스트 : 같은 사용자가 동시 조회수 어뷰징 요청하더라도, 조회수는 1 증가한다")
fun viewCountConcurrencyTest() {
val threadCount = 100
val tryCount = 10000
val boardArticleArticleCategoryUserId = 38314133141345L
val executorService = Executors.newFixedThreadPool(threadCount)
increaseViewCounts(
executorService = executorService,
tryCount = tryCount,
boardId = boardArticleArticleCategoryUserId,
articleId = boardArticleArticleCategoryUserId,
articleCategoryId = boardArticleArticleCategoryUserId,
userId = boardArticleArticleCategoryUserId
)
executorService.shutdown()
}
private fun increaseViewCounts(
executorService: ExecutorService,
tryCount: Int,
boardId: Long,
articleId: Long,
articleCategoryId: Long,
userId: Long,
) {
prepareArticleCategory(boardId, articleCategoryId)
prepareArticle(boardId, articleCategoryId, articleId)
val latch = CountDownLatch(tryCount)
println("--------------------------------------------------------------------------")
println("start increase viewCounts : articleId = $articleId")
val start = System.nanoTime()
for (i in 1..tryCount) {
executorService.execute {
try {
increaseViewCount(articleId, userId)
} catch (e: Exception) {
e.printStackTrace()
} finally {
latch.countDown()
}
}
}
latch.await()
val end = System.nanoTime()
println("time = ${(end - start) / 100_0000} ms")
val viewCount = articleViewCountReadUseCase.readViewCount(articleId).viewCount
println("end increase viewCounts : articleId = $articleId")
println("viewCount = $viewCount")
println("--------------------------------------------------------------------------")
assertThat(viewCount).isEqualTo(1L)
}
private fun prepareArticle(boardId: Long, articleCategoryId: Long, articleId: Long) {
articlePersistencePort.save(
articleFixture(
articleId = articleId,
title = "article-title-$articleId",
content = "article-content-$articleId",
boardId = boardId,
articleCategoryId = articleCategoryId,
writerId = 1L
)
)
}
private fun prepareArticleCategory(boardId: Long, articleCategoryId: Long) {
articleCategoryPersistencePort.save(
articleCategoryFixture(
articleCategoryId = articleCategoryId,
name = "테스트",
slug = "test",
boardId = boardId,
allowWrite = true,
allowSelfEditDelete = true,
allowComment = true,
allowLike = true,
allowDislike = true,
)
)
}
private fun increaseViewCount(articleId: Long, userId: Long) {
setAuthUser(userId)
articleViewCountIncreaseUseCase.increase(articleId)
}
private fun setAuthUser(userId: Long) {
val authentication = AuthUserAuthentication.from(
authUser = authUserFixture(
userId = userId,
role = Role.USER
)
)
val securityContext = SecurityContextHolder.getContextHolderStrategy().createEmptyContext()
securityContext.authentication = authentication
SecurityContextHolder.getContextHolderStrategy().context = securityContext
}
}
- 조회수 증가 동시성 테스트를 작성해봤다.
- 아주 짧은 시간동안 같은 사용자가 1만회의 조회수 증가 요청을 취한다. 이 요청은 100개 스레드가 받아 동시에 처리한다.
- 그 결과 조회수가 1 증가하는 지 확인하면 된다.
실행 결과
--------------------------------------------------------------------------
start increase viewCounts : articleId = 38314133141345
time = 21549 ms
end increase viewCounts : articleId = 38314133141345
viewCount = 1
--------------------------------------------------------------------------
- 동시에 한 사용자가 1만번 조회수 증가 요청을 하게 해봤고(100개 스레드), 조회수는 1 정도만 증가하는 것을 확인했다.
- 사용자의 동시 조회수 증가 어뷰징 문제를 해결했다.