게시글 좋아요 수 동시성 문제 해결 - ttasjwi/board-system GitHub Wiki

개요

  • 동시에 여러명의 사용자가 좋아요를 할 경우, 좋아요 수가 데이터베이스에 정상적으로 반영되지 않는 문제가 발생한다.
  • 게시글 좋아요수를 초기화하여 저장하는 과정에서 '중복키 예외'가 발생하여 좋아요수가 정상적으로 생성되지 않는 문제도 발생한다.
  • 왜 그런지 문제를 확인하고 해결해보도록 한다.

문제상황 확인

@Disabled // 수동테스트용(테스트 해보고 싶을 경우 주석처리)
@SpringBootTest
@DisplayName("[app] 게시글 좋아요 수 통합테스트")
class ArticleLikeCountIntegrationTest {

    @Autowired
    private lateinit var articlePersistencePort: ArticlePersistencePort

    @Autowired
    private lateinit var articleCategoryPersistencePort: ArticleCategoryPersistencePort

    @Autowired
    private lateinit var articleLikeUseCase: ArticleLikeCreateUseCase

    @Autowired
    private lateinit var articleLikeCountReadUseCase: ArticleLikeCountReadUseCase

    @Test
    @DisplayName("좋아요 수 동시성 테스트 : 동시 사용자가 많을 때, 좋아요 수")
    fun likeCountConcurrencyTest() {
        val executorService = Executors.newFixedThreadPool(100)
        likeCountTest(executorService, 111L, 111L, 111L)
        executorService.shutdown()
    }

    private fun likeCountTest(
        executorService: ExecutorService,
        boardId: Long,
        articleId: Long,
        articleCategoryId: Long
    ) {

        val userCount = 3000
        prepareArticleCategory(boardId, articleCategoryId)
        prepareArticle(boardId, articleCategoryId, articleId)

        val latch = CountDownLatch(userCount)
        println("--------------------------------------------------------------------------")
        println("start")
        val start = System.nanoTime()
        for (i in 1..userCount) {
            val userId = i.toLong()

            executorService.execute {
                try {
                    like(articleId, userId)
                } catch (e: Exception) {
                    println("Error for userId=$userId: ${e.message}")
                } finally {
                    latch.countDown()
                }
            }

        }
        latch.await()
        val end = System.nanoTime()
        println("time = ${(end - start) / 100_0000} ms")

        val response = articleLikeCountReadUseCase.readLikeCount(
            articleId = articleId,
        )

        println("end")
        println("count = ${response.likeCount}")
        println("--------------------------------------------------------------------------")

        assertThat(response.likeCount).isNotEqualTo(userCount)
    }

    private fun like(articleId: Long, userId: Long) {
        setAuthUser(userId)
        articleLikeUseCase.like(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
    }

    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,
                allowLike = true,
                allowDislike = true
            )
        )
    }
}
  • 3000명의 사용자가 동시에 같은 게시글에 좋아요를 하는 상황을 시뮬레이팅한다.
  • 총 100개의 스레드에서 동시 요청을 처리한다.
--------------------------------------------------------------------------
start
2025-05-21T12:00:36.040+09:00  WARN 8424 --- [board-system] [pool-3-thread-3] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 1062, SQLState: 23000
2025-05-21T12:00:36.040+09:00  WARN 8424 --- [board-system] [ool-3-thread-33] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 1062, SQLState: 23000
2025-05-21T12:00:36.040+09:00  WARN 8424 --- [board-system] [ool-3-thread-16] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 1062, SQLState: 23000
2025-05-21T12:00:36.041+09:00 ERROR 8424 --- [board-system] [ool-3-thread-33] o.h.engine.jdbc.spi.SqlExceptionHelper   : Duplicate entry '111' for key 'article_like_counts.PRIMARY'
2025-05-21T12:00:36.040+09:00  WARN 8424 --- [board-system] [ool-3-thread-36] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 1062, SQLState: 23000
2025-05-21T12:00:36.040+09:00  WARN 8424 --- [board-system] [ool-3-thread-20] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 1062, SQLState: 23000
2025-05-21T12:00:36.041+09:00 ERROR 8424 --- [board-system] [ool-3-thread-20] o.h.engine.jdbc.spi.SqlExceptionHelper   : Duplicate entry '111' for key 'article_like_counts.PRIMARY'
2025-05-21T12:00:36.041+09:00 ERROR 8424 --- [board-system] [ool-3-thread-36] o.h.engine.jdbc.spi.SqlExceptionHelper   : Duplicate entry '111' for key 'article_like_counts.PRIMARY'
2025-05-21T12:00:36.040+09:00  WARN 8424 --- [board-system] [pool-3-thread-6] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 1062, SQLState: 23000
2025-05-21T12:00:36.040+09:00  WARN 8424 --- [board-system] [ool-3-thread-37] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 1062, SQLState: 23000
2025-05-21T12:00:36.042+09:00 ERROR 8424 --- [board-system] [pool-3-thread-6] o.h.engine.jdbc.spi.SqlExceptionHelper   : Duplicate entry '111' for key 'article_like_counts.PRIMARY'
2025-05-21T12:00:36.041+09:00  WARN 8424 --- [board-system] [ool-3-thread-25] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 1062, SQLState: 23000
2025-05-21T12:00:36.042+09:00 ERROR 8424 --- [board-system] [ool-3-thread-37] o.h.engine.jdbc.spi.SqlExceptionHelper   : Duplicate entry '111' for key 'article_like_counts.PRIMARY'
2025-05-21T12:00:36.042+09:00 ERROR 8424 --- [board-system] [ool-3-thread-25] o.h.engine.jdbc.spi.SqlExceptionHelper   : Duplicate entry '111' for key 'article_like_counts.PRIMARY'
2025-05-21T12:00:36.041+09:00  WARN 8424 --- [board-system] [ool-3-thread-34] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 1062, SQLState: 23000
2025-05-21T12:00:36.042+09:00 ERROR 8424 --- [board-system] [ool-3-thread-34] o.h.engine.jdbc.spi.SqlExceptionHelper   : Duplicate entry '111' for key 'article_like_counts.PRIMARY'
2025-05-21T12:00:36.041+09:00 ERROR 8424 --- [board-system] [pool-3-thread-3] o.h.engine.jdbc.spi.SqlExceptionHelper   : Duplicate entry '111' for key 'article_like_counts.PRIMARY'
2025-05-21T12:00:36.041+09:00 ERROR 8424 --- [board-system] [ool-3-thread-16] o.h.engine.jdbc.spi.SqlExceptionHelper   : Duplicate entry '111' for key 'article_like_counts.PRIMARY'
Error for userId=34: could not execute statement [Duplicate entry '111' for key 'article_like_counts.PRIMARY'] [insert into article_like_counts (like_count,article_id) values (?,?)]; SQL [insert into article_like_counts (like_count,article_id) values (?,?)]; constraint [article_like_counts.PRIMARY]
Error for userId=6: could not execute statement [Duplicate entry '111' for key 'article_like_counts.PRIMARY'] [insert into article_like_counts (like_count,article_id) values (?,?)]; SQL [insert into article_like_counts (like_count,article_id) values (?,?)]; constraint [article_like_counts.PRIMARY]
Error for userId=33: could not execute statement [Duplicate entry '111' for key 'article_like_counts.PRIMARY'] [insert into article_like_counts (like_count,article_id) values (?,?)]; SQL [insert into article_like_counts (like_count,article_id) values (?,?)]; constraint [article_like_counts.PRIMARY]
Error for userId=20: could not execute statement [Duplicate entry '111' for key 'article_like_counts.PRIMARY'] [insert into article_like_counts (like_count,article_id) values (?,?)]; SQL [insert into article_like_counts (like_count,article_id) values (?,?)]; constraint [article_like_counts.PRIMARY]
Error for userId=3: could not execute statement [Duplicate entry '111' for key 'article_like_counts.PRIMARY'] [insert into article_like_counts (like_count,article_id) values (?,?)]; SQL [insert into article_like_counts (like_count,article_id) values (?,?)]; constraint [article_like_counts.PRIMARY]
Error for userId=16: could not execute statement [Duplicate entry '111' for key 'article_like_counts.PRIMARY'] [insert into article_like_counts (like_count,article_id) values (?,?)]; SQL [insert into article_like_counts (like_count,article_id) values (?,?)]; constraint [article_like_counts.PRIMARY]
Error for userId=36: could not execute statement [Duplicate entry '111' for key 'article_like_counts.PRIMARY'] [insert into article_like_counts (like_count,article_id) values (?,?)]; SQL [insert into article_like_counts (like_count,article_id) values (?,?)]; constraint [article_like_counts.PRIMARY]
Error for userId=25: could not execute statement [Duplicate entry '111' for key 'article_like_counts.PRIMARY'] [insert into article_like_counts (like_count,article_id) values (?,?)]; SQL [insert into article_like_counts (like_count,article_id) values (?,?)]; constraint [article_like_counts.PRIMARY]
Error for userId=37: could not execute statement [Duplicate entry '111' for key 'article_like_counts.PRIMARY'] [insert into article_like_counts (like_count,article_id) values (?,?)]; SQL [insert into article_like_counts (like_count,article_id) values (?,?)]; constraint [article_like_counts.PRIMARY]
time = 30536 ms
end
count = 302
--------------------------------------------------------------------------
  • 3000개의 사용자가 100개 스레드를 통해 좋아요 동시 요청을 하였는데, 실제 최종 좋아요 수는 302개가 된다.
  • 그리고, 테스트 초반부에서는 '좋아요 수' 객체를 최초 삽입하는 과정에서도 중복키 예외가 발생하여, 좋아요가 정상적으로 생성되지 않는다.

문제 원인 파악

   private fun upsertArticleLikeCount(articleId: Long) {
        val articleLikeCount = articleLikeCountPersistencePort.findByIdOrNull(articleId = articleId)
            ?: ArticleLikeCount.init(articleId)
        articleLikeCount.increase()

        articleLikeCountPersistencePort.save(articleLikeCount)
    }
  • 문제의 원인은 좋아요수를 저장하는 부분에 있다.
  • 우선 데이터베이스에서 좋아요수를 조회하고
    • 없으면 '좋아요수' 객체를 초기화하고, 있으면 조회된 좋아요수 객체를 사용한다.
    • 이렇게 구해진 좋아요수 객체의 좋아요수를 1 증가시킨뒤 저장소에 save를 통해 저장한다.
  • 이 방식의 문제점은 조회된(초기화된) 좋아요수 객체의 상태를 기반으로 1 증가시키고 이를 데이터베이스에 변경된 값으로 업데이트하는 데에 있다.
    • A 트랜잭션이 좋아요수 조회
    • B 트랜잭션이 좋아요수 조회
    • A 트랜잭션이 조회한 좋아요수를 기반으로 좋아요수 증가 커밋
    • B 트랜잭션이 조회한 좋아요수를 기반으로 좋아요수 증가 커밋
  • 이렇게 동시 요청자수가 2명일 경우 좋아요수가 2 증가해야하지만, 실제로는 1 증가할 수 있음
  • 이렇게 될 경우, 좋아요수가 실제 좋아요 갯수와 일치하지 않게 된다.

문제 해결방법 고민

이 동시성 문제를 해결하는 방법은 이미 잘 알려져있고, 그 중 몇 가지를 다뤄본다.

  • 방법1: 낙관적 락 -'좋아요수' 테이블에 'Version` 칼럼을 추가하고, 매 순간 좋아요수 레코드의 버전을 관리한다.
    • 별도의 락을 사용하지 않고, 매번 로직을 실행하여, 좋아요수 수정시도를 한다.
    • 저장시점의 Version 이 기존 Version + 1 이 아닐 경우, 예외가 발생한다.
    • 이런 예외를 애플리케이션에서 catch 하고, 성공할 때까지 무한반복을 하는 식으로 문제를 해결할 수 있다.
    • 그러나 동시요청자수가 매우 많아지면 재시도를 자주하게 되기때문에, 오히려 실제 락을 갖는 '비관적 락'방식에 비해 느려질 수 있다.
  • 방법2: 비관적 락
    • '좋아요수' 조회 시점에, select 쿼리 끝에 for update 를 붙입니다. 이렇게 하면 좋아요수 행에 대한 exclusive lock (배타락)을 획득한다.
    • 배타락(exclusive lock) 을 획득하면, 다른 트랜잭션에서 해당 행 데이터에 대한 수정이 불가능하고 배타락 획득을 할 수 없다.
    • 락은 트랜잭션이 종료(commit/rollback)될 때까지 유지된다.
    • 어느 한쪽에서 조회수를 증가시킨 뒤, 커밋할 때까지 다른 업데이트를 하는 스레드에서는 데이터를 조회해오지 못 한다.
    • 이 방식을 통해서 기능을 구현할 경우 데이터베이스의 락이 물리적으로 존재하게 되고, 성능의 저하가 발생할 수 있지만, 동시성 문제를 방어할 수 있다.

방법1, 방법2의 문제점

그런데 문제점이 있다.

  • 우리 애플리케이션 로직에서는 '좋아요수'가 최초 초기화되는 지점에서, 최초 좋아요를 한 사람이 좋아요수 객체를 생성하고, 좋아요수 행을 실제 insert 한다.
  • 이 좋아요수를 최초에 생성하는 것도 동시사용자가 여럿 있다면 '중복키 예외'가 발생할 수 있고, 락 개념을 통틀어서 생각해도 좋아요수가 항상 올바르다고 할 수 없다.
  • 락 개념이란 것도, 좋아요수 데이터가 실제 있어야 가능한 개념인데 실제 대상이 되는 좋아요수가 없을 수도 있기 때문에 위 방식에도 한계가 있을 수 있다.
  • '좋아요수'를 게시글이 생성된 시점에 초기화하는 방법도 고려해볼 수 있다.
    • 이렇게 하면 게시글이 생성된 시점에 '좋아요수'객체가 존재하는 것을 보장할 수 있다.
    • 다만 이렇게 하면 게시글 컨텍스트에서, '좋아요수' 개념을 알게되는 문제가 생긴다. 우리 서비스에서는 모듈간 기능의 의존성을 최소화하려고 하는데, 게시글 맥락에서 파생된 '좋아요' 기능을 게시글이 의존하게 하는, 개념 순환참조는 지양하고 싶다.
  • 또는 별도의 락 저장소를 통해, 게시글수 락을 관리하는 방법도 있다. 게시글이 실제 있든 없든, 좋아요를 하려는 사용자가 '좋아요 수 락'을 명시적으로 획득해서 관리하는 방법이다.
    • 이렇게 하면, 좋아요를 할 때마다 별도의 저장소(예: 레디스) 에서 좋아요수 락 획득을 시도한다.
    • 좋아요수를 초기화하고, 저장하는 작업을 하기 전에 락 획득을 시도하며 락을 획득하지 않으면 좋아요 로직을 실행할 수 없다.
    • 최초에 좋아요수가 객체가 없더라도, 좋아요수락 저장소에서 락을 획득할 수 있기 때문에 모든 경우를 통틀어 동시성 문제를 해결 할 수 있다.
    • 다만 별도의 저장소를 사용해야하는 비용이 발생한다.

최초 생성 중복문제와 통틀어 저렴하게 동시성 문제를 해결하는 방법

  • 별도의 저장소를 사용하지 않고, MySQL 만을 이용해 좋아요수 동시성 문제를 해결할 방법은 없을까?
  • 답은 생각보다 간단했다. 좋아요수를 조회해서 증가시켜서 데이터베이스에 반영시키지 않고, 그냥 바로 데이터베이스에서 현재 데이터베이스 상태에 기반하여 생성/증가를 하도록 하는 것이다.
    @Modifying
    @Query(
        """
            INSERT INTO article_like_counts (article_id, like_count)
            VALUES (:articleId, 1)
            ON DUPLICATE KEY UPDATE like_count  = like_count + 1
        """, nativeQuery = true
    )
    fun increase(articleId: Long)
  • mysql native query의 'on duplicate key' 구문을 활용하면, insert 쿼리 실행시 중복키 문제가 발생할 경우 어떻게 갱신시킬지를 설정할 수 있다.
  • 최초에 좋아요수를 초기화하는 시점에 중복키 문제가 발생하면 update 구문이 실행됨으서 최초 초기화 시의 동시성 문제를 해결할 수 있다.
  • 매번 실행될 때마다 현재 데이터베이스의 상태를 기반으로 값을 업데이트하므로 동시성 문제를 해결할 수 있다.
    private fun upsertArticleLikeCount(articleId: Long) {
        articleLikeCountPersistencePort.increase(articleId)
    }
  • 이제, 좋아요수 증가하는 부분을 수정해보자.
    • 기존 : '조회' -> 객체 가져오기(없으면 생성) -> '객체 상태 변경' -> '데이터베이스에 반영'
    • 변경후 : 바로 데이터베이스에 반영
  • 동시성 문제가 해결된 것 같다.
  • 굳이 단점을 따져보면 좋아요 수 증가 로직이 도메인 규칙을 기반으로 증가하는 것이 아니고 데이터베이스 중심 설계처럼 보인다는 점...?

테스트 다시 실행

--------------------------------------------------------------------------
start
time = 35604 ms
end
count = 3000
--------------------------------------------------------------------------
  • 다시 테스트를 실행해보면, 좋아요수가 3000만큼 딱 증가된 것을 볼 수 있다.
  • 실제 데이터베이스에서도 잘 되어있는지 확인해보자.
mysql> select Count(*) from article_likes where article_id = 12345;
+----------+
| Count(*) |
+----------+
|     3000 |
+----------+
1 row in set (0.00 sec)
mysql> select * from article_like_counts where article_id = 12345;
+------------+------------+
| article_id | like_count |
+------------+------------+
|      12345 |       3000 |
+------------+------------+
1 row in set (0.00 sec)
  • 실제 좋아요 갯수도 3000개 생성됐고, 좋아요수 테이블의 좋아요수 상태도 3000으로 잘 저장된 것을 볼 수 있다.

좋아요수 취소 로직 수정

    @Modifying
    @Query(
        """
            UPDATE article_like_counts
            SET like_count = like_count - 1
            WHERE article_id = :articleId
        """, nativeQuery = true
    )
    fun decrease(articleId: Long)
    /**
     * 게시글 좋아요 수 감소
     */
    private fun decreaseArticleLikeCount(articleId: Long) {
        articleLikeCountPersistencePort.decrease(articleId)
    }
  • 마찬가지로, 좋아요 취소시 좋아요수 감소가 되어야하는데 이 역시 동시성 이슈가 있다.
  • 위와 같은 방식으로 문제를 해결해보자.
--------------------------------------------------------------------------
start create Likes : articleId = 13413413413
time = 36193 ms
end create Likes : articleId = 13413413413
count = 3000
--------------------------------------------------------------------------
--------------------------------------------------------------------------
start cancel Likes : articleId = 13413413413
time = 27069 ms
end cancel Likes : articleId = 13413413413
count = 0
--------------------------------------------------------------------------
  • 좋아요, 좋아요 취소 기능을 각각 3000번(사용자 3000명) 호출시, 좋아요 수가 3000으로 증가했다가 0이 되는 것을 확인할 수 있다.
  • 좋아요, 좋아요 취소 기능 모두를 활용했을 때 동시성 문제가 사라졌다.