✔️ 문제 코드
@Entity
class PostLike(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0L,
@Column(nullable = false)
val postId: Long,
likeCount: Long,
) {
@Column(nullable = false)
var likeCount: Long = likeCount
private set
fun increase() {
this.likeCount += DEFAULT_INCREASED_LIKE_COUNT
}
}
테스트를 위한 도메인 구성으로 id 값과 postId 값 두 개를 생성했고, 편의성을 위해 postLikeId 값을 활용하여 접근해 좋아요 수 증가 및 조회할 생각이다. 추가적으로 해당 테스트에서는 좋아요 증가 기능만을 다룬다.
@Service
class PostLikeService(
private val postLikeRepository: PostLikeRepository,
) {
@Transactional
fun increase(postLikeId: Long) {
val postLike = postLikeRepository.findById(postLikeId).orElseThrow {
NoSuchElementException("PostLike not found")
}
postLike.increase()
}
}
좋아요 증가 로직을 JPA에서 제공하는 변경 감지를 사용하여 구현하였다.
@Test
fun `좋아요가 정상적으로 1 증가한다`() {
// given
val postLike = postLikeRepository.save(PostLike(postId = 1L, likeCount = 0L))
// when
postLikeService.increase(postLike.id)
val findPostLike = postLikeRepository.getReferenceById(postLike.id)
// then
assertThat(findPostLike.likeCount).isEqualTo(1)
}
실제로 increase() 메서드를 호출했을 때, 정상적으로 좋아요 수가 증가하는지 테스트를 진행하니 정상적으로 종료되었다.
🤔 해당 방법으로 좋아요 증가 로직을 작성했을 때, 별다른 문제점은 없는 것인가?
@Test
fun `멀티 스레드 환경에서 동시에 총 100개의 좋아요 증가 요청이 들어오면, 좋아요 수는 100이다`() {
// given
val postLike = postLikeRepository.save(PostLike(postId = 1L, likeCount = 0L))
val threadCount = 100
val executorService = Executors.newFixedThreadPool(20)
val countDownLatch = CountDownLatch(threadCount)
// when
repeat(threadCount) {
executorService.submit {
try {
postLikeService.increase(postLike.id)
} finally {
countDownLatch.countDown()
}
}
}
countDownLatch.await()
val findViewCount = postLikeRepository.getReferenceById(postLike.id)
// then
assertThat(findViewCount.likeCount).isEqualTo(100)
}
20개의 스레드를 활용할 수 있는 스레드 풀을 생성한 뒤, 해당 스레드들을 활용하여 동시에 총 100번 좋아요를 증가시키는 로직이 수행되도록 테스트 코드를 작성하였다.
테스트 결과, 기대했던 좋아요 수 100이 저장되어 있는 것이 아닌 21이 저장되어 테스트가 실패하는 것을 확인할 수 있다.
✔️ 원인
멀티 스레드에 의한 경쟁 조건(Race Condition)이 발생하여 좋아요 수에 100이 저장되는 것이 아닌 21이 저장되었다.
공유 데이터(likeCount)에 스레드들이 동시에 접근해서 수정하기 때문에 순차적으로 좋아요가 증가되지 않는다. 즉, 스레드 1이 증가시킨 좋아요가 저장되기 전에 스레드 2가 좋아요를 조회하고 해당 값을 토대로 증가 로직을 실행하기 때문에 순차적인 증가가 이루어지지 않아 문제가 발생한다.
🔗 동시성과 경쟁 조건(Race Condition)
동시성이란, 어떠한 두 사건이 동시에 일어나는 것을 의미한다.
경쟁 조건(Race Condition)이란, 여러 프로세스 및 스레드가 동시에 공유 데이터에 접근하여 조작할 때, 접근 순서나 타이밍에 따라 예상했던 결과와 달라질 수 있는 상황을 의미한다.
📋 동시성 문제 해결 방법
✔️ Synchronized 활용 - Application Level
@Transactional
@Synchronized fun increaseWithSynchronized(postLikeId: Long) {
val postLike = postLikeRepository.findById(postLikeId).orElseThrow {
NoSuchElementException("PostLike not found")
}
postLike.increase()
}
자바에서 지원하는 동기화 해결 방법인 Synchronized를 활용한다. 코틀린에서 Synchronized를 사용하기 위해서는 위와 같이 @Synchronized로 명시해서 사용해야 한다.
@Test
fun `멀티 스레드 환경에서 동시에 총 100개의 좋아요 증가 요청이 들어오면, 좋아요 수는 100이다`() {
// given
val postLike = postLikeRepository.save(PostLike(postId = 1L, likeCount = 0L))
val threadCount = 100
val executorService = Executors.newFixedThreadPool(20)
val countDownLatch = CountDownLatch(threadCount)
// when
repeat(threadCount) {
executorService.submit {
try {
postLikeService.increaseWithSynchronized(postLike.id)
} finally {
countDownLatch.countDown()
}
}
}
countDownLatch.await()
val findViewCount = postLikeRepository.getReferenceById(postLike.id)
// then
assertThat(findViewCount.likeCount).isEqualTo(100)
}
Synchronized 키워드를 사용해서 동시성을 보장하려 했지만, 결과는 마찬가지로 실패이다.
🤔 동시성을 보장하도록 Synchronized를 사용했는데 왜 실패할까?
그 이유는 @Transactional에 있다. Spring에서 @Transactional이 붙은 메서드는 AOP에 의해 다음과 같은 코드 형식을 가지게 된다.
class Transaction~~(
private val postLikeService: PostLikeService,
) {
...
fun increase(postLikeId: Long) {
startTransaction()
...
postLikeService.increase(postLikeId)
...
endTransaction()
}
...
}
postLikeService.increase(postId)가 동작할 때만 Synchronized 키워드에 의해 동시성이 보장되고, endTransaction() 과정에서 데이터가 저장될 때는 동시성이 보장되지 않아 좋아요 수가 100 임을 보장할 수 없다.
🛠️ Synchronized 키워드를 사용한 동시성 보장을 위한 해결 방법
@Synchronized fun increaseWithSynchronized(postLikeId: Long) {
val postLike = postLikeRepository.findById(postLikeId).orElseThrow {
NoSuchElementException("postLike not found")
}
postLike.increase()
postLikeRepository.saveAndFlush(postLike)
}
JPA의 변경 감지를 사용해서 Synchronized 블록 외부에 존재하는 트랜잭션 종료 시점에 데이터를 업데이트하는 것이 아니라, Synchronized 블록 내에서 데이터가 저장되도록 saveAndFlush() 메서드를 사용한다.
해당 방법으로 테스트 코드를 수행하면, 정상적으로 테스트가 성공해 동시성이 보장됨을 확인할 수 있다.
❌ Synchronized 키워드 사용의 더 큰 문제점
Synchronized 키워드를 사용해서 동시성을 보장할 때, 더 큰 문제점이 존재한다. 요즘 대다수의 서버는 스케일 아웃을 통해 여러 대의 서버를 실행하고, 로드밸런스를 통해 부하를 분산하여 서비스를 제공한다. Synchronized는 하나의 프로세스 내에서만 동시성을 보장하기 때문에 이러한 환경에서는 동시성을 보장할 수 없다.
📝 Synchronized 키워드 사용 시, 다중 서버에서의 동시성 보장 확인
해당 문제를 테스트하기 위해, 8080포트, 8081 포트 총 2개의 서버를 실행하고, mysql에 데이터를 1개 삽입했다.
@RestController
@RequestMapping("/api/v1/post-likes")
class PostLikeController(
private val postLikeService: PostLikeService,
) {
@PostMapping("/{postLikeId}/increase")
fun increasePostLike(
@PathVariable("postLikeId") postLikeId: Long,
): ResponseEntity<Void> {
postLikeService.increaseWithSynchronized(postLikeId)
return ResponseEntity.noContent().build()
}
}
@Synchronized fun increaseWithSynchronized(postLikeId: Long) {
val postLike = postLikeRepository.findById(postLikeId).orElseThrow {
NoSuchElementException("postLike not found")
}
postLike.increase()
postLikeRepository.saveAndFlush(postLike)
}
각 서버는 Synchronized를 활용하여 동시성을 보장하려하고 있다.
2개의 서버에 동시에 요청을 보내기 위해 Jmeter를 사용한다. Thread Group으로 20개의 스레드가 총 10번 실행되도록 설정해서 각 서버에 요청을 200번 보낼 것이다. (총 400번) 따라서, 실제 테스트를 진행했을 때의 기대 결과 값은 좋아요 수에 400이라는 값이 들어 있어야 한다.
그러나 실제 테스트를 진행하면, 좋아요 수에 203의 값이 들어 있는 것을 확인할 수 있다.
✏️ 해당 테스트의 결과로 다중 서버 환경에서는 Synchronized 키워드를 사용해 동시성을 보장할 수 없다는 것을 확인할 수 있다.
❌ Synchronized를 활용하면, 다중 서버 환경에서 동시성을 보장할 수 없다.
'Backend > Spring' 카테고리의 다른 글
동시성 문제와 해결 방법들 - 2편 (분산락 with. Database Lock, Redis) (0) | 2023.09.07 |
---|---|
Spring Event 사용하기 (0) | 2023.06.29 |
kotlin + HttpInterface 알아보기 (0) | 2023.06.22 |
Kotlin에서 Rest Docs 문서화 코드 개선하기 (0) | 2023.05.29 |
Kotest를 통한 DCI 패턴 적용 (0) | 2023.05.27 |