게시글 좋아요 수 동시성 문제 해결 - 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이 되는 것을 확인할 수 있다.
- 좋아요, 좋아요 취소 기능 모두를 활용했을 때 동시성 문제가 사라졌다.