게시글 조회수 기능 설계( 어뷰징 방지) - ttasjwi/board-system GitHub Wiki

게시글 조회수 기능

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

모듈

image

  • 우리 서비스는 게시글 조회수 증가 기능을 별도 모듈로 분리하고, 서비스 규모가 커질 경우 이 기능 역시 별도의 서비스로 분리하는 것을 가정한다.

게시글 조회수 저장방식

  • 조회수 증가는 매우 빈번하게 호출되는 기능인만큼, 매 요청마다 조회수를 관계형 데이터베이스에 저장하는 것은 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 정도만 증가하는 것을 확인했다.
  • 사용자의 동시 조회수 증가 어뷰징 문제를 해결했다.