비관적 락으로 동시성 문제 해결하기 (Spring Data Jpa, Kotlin)
환경
언어
: Kotlin프레임워크
: Spring Boot 3.0.xDB
: Oracle 19c트랜잭션 격리수준
: READ COMMITTED
문제 개요
이번에 리뷰 신고 기능을 개발하게 되었습니다.
세부적인 요구사항은 리뷰 신고가 5개 쌓였을 때 리뷰가 삭제되도록 하는 것입니다.
이 기능을 애플리케이션 계층에서 구현하게 되었을 때 대략 다음 흐름을 가집니다.
- 리뷰 신고 개수를 조회
- 신고 개수로 판단
2-1. 개수가 임계값을 넘어서면 리뷰 제거
2-2. 개수가 임계값 이하라면 리뷰 신고 저장
동시성 문제 발생 상황
조회와 판단을 할 때 다른 트랜잭션끼리 동시성 문제가 발생할 수 있습니다.
A, B 트랜잭션 두 개가 동신에 리뷰 신고를 요청합니다.
A, B가 동시에 리뷰 신고 개수를 조회하는데
이 때 A, B 트랜잭션에서 캡쳐한 리뷰 신고 개수가 3으로 동일합니다.
그리고 A, B 트랜잭션 둘 다 리뷰 신고 개수가 임계값 이하라 판단해 리뷰 신고를 저장합니다.
하지만 DB에 쌓인 리뷰 신고 개수는 5개이므로 정합성을 깨버리게 됩니다. 즉, 동시성 문제가 발생한다고 판단합니다.
데이터 정합성
데이터가 저장, 처리, 전송되는 과정에서 규칙이나 제약조건을 잘 지켜 불일치가 없는 상태를 말합니다.
바라던 결과
문제 해결
1. 방법 선택
다양한 해결방법이 있습니다.
- 비관적 락, 낙관적 락 같은 DB 락킹 메커니즘을 통해 해결하기
- Redis 같은 외부 요소를 통해 분산락 사용하기
- 리뷰 신고를 저장하고 스케쥴링 작업으로 리뷰 신고 수를 조회해 리뷰 제거 이벤트 발생
이렇게 고려하였습니다.
현재 DB가 하나인 점으로 생각해 2는 낭비적인 부분이 있어 고려하지 않았습니다.
3의 경우 아직 서비스가 크지 않아 스케쥴링으로 작업하기 보다 락킹 메커니즘으로 해결하는 것이
복잡도도 크지 않고 DB에서 제공해주니 적합할 것이라 생각했습니다.
남은 옵션은 비관적 락이냐 낙관적 락이냐인데 뭘 선택하든 데이터의 정합성은 잘 지켜집니다.
같은 리뷰를 대상으로 동시 트랜잭션이 그리 빈번히 나타날 패턴은 아니라 생각합니다.
그런점을 고려하면 낙관적 락이 좋아 보입니다. 실제 팀장님의 피드백도 그랬습니다.
그럼에도 비관적 락을 쓴건 동시 트랜잭션 중 하나를 탈락시키는 것보다 조금 기다려서라도
기대하는 결과를 돌려받는게 사용자에게 더 좋은 경험을 줄 수 있지 않을까하는 생각에 있었습니다.
(비관적 락을 써본적이 없어 한 번 써보고 싶었다는게 진심입니다. 사이드 프로젝트이기도 하니..)
실제 환경에서 쓰라면 낙관적 락을 고려해봤을 것 같네요. (이것도 동시 트랜잭션 빈번도를 따져서 판단해야 합니다.)
2. 방법 적용
어떤 방식을 사용할껀지 정했으니 실전으로 옮기겠습니다.
락으로 조회하는 방법은 Spring Data Jpa 를 이용했습니다. 실제 코드에선 코틀린 확장함수를 통해 예외를 null 을 한 번 벗겼네요.
핵심적인 내용은 위에 적은 내용을 그대로 따릅니다.
대신 대상 Entity 에 락을 걸면서 시작하는데 이 대상에 삭제하고자하는 리뷰입니다.
이 Entity 에 걸린 락은 트랜잭션 내내 유지되며 이 Entity 의 락을 가지지 못하는 트랜잭션은 작업을 수행할 수 없고 대기합니다.
- 신고 대상 리뷰 비관적 락으로 조회 (락을 얻지 못한 트랜잭션은 전원 대기)
- 리뷰 신고 개수를 조회
- 신고 개수로 판단
3-1. 개수가 임계값을 넘어서면 리뷰 제거
3-2. 개수가 임계값 이하라면 리뷰 신고 저장 - 락 해제 후 대기 중인 트랜잭션 하나가 락을 획득하고 이 과정을 반복합니다.
소스 코드
// FoodSpotsReviewRepository.kt
fun FoodSpotsReviewRepository.getByIdWithPessimisticLock(reviewId: Long): FoodSpotsReview =
findReviewById(reviewId) ?: throw FoodSpotsReviewNotFoundException()
interface FoodSpotsReviewRepository :
JpaRepository<FoodSpotsReview, Long>,
CustomFoodSpotsReviewRepository {
fun findByUser(user: User): List<FoodSpotsReview>
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(value = [QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")])
fun findReviewById(id: Long): FoodSpotsReview?
}
// ReviewFlagCommandService.kt
@Service
class ReviewFlagCommandService(
private val reviewRepository: FoodSpotsReviewRepository,
private val reviewFlagRepository: ReviewFlagRepository,
private val reviewCommandService: ReviewCommandService,
private val badgeCommandService: BadgeCommandService,
) {
companion object {
private const val FLAG_THRESHOLD = 4
}
@DistributedLock(lockName = USER_ENTITY_LOCK_KEY, identifier = "user")
@Transactional
fun flagReview(
user: User,
reviewId: Long,
) {
val review = reviewRepository.getByIdWithPessimisticLock(reviewId)
if (reviewFlagRepository.existsByReviewIdAndUserId(reviewId, user.id)) {
throw ReviewFlagAlreadyExistsException()
}
if (reviewFlagRepository.countByReviewId(review.id) >= FLAG_THRESHOLD) {
reviewCommandService.deleteReviewCascade(review.id)
} else {
reviewFlagRepository.save(FoodSpotsReviewFlag(review = review, user = user))
}
}
}
통합 테스트 코드
// ReviewFlagCommandServiceIntegrationTest.kt
@ServiceIntegrateTest
class ReviewFlagCommandServiceIntegrationTest(
private val userRepository: UserRepository,
private val foodSpotsRepository: FoodSpotsRepository,
private val reviewRepository: FoodSpotsReviewRepository,
private val repo: ReviewFlagRepository,
private val sut: ReviewFlagCommandService,
) : BehaviorSpec(
{
given("flagReview 동시성 테스트") {
val users = userRepository.saveAll(createTestUsers(5))
val foodSpots = foodSpotsRepository.save(createTestFoodSpots())
val review = reviewRepository.save(createTestFoodSpotsReview(foodSpots = foodSpots, user = users[0]))
`when`("5명이 동시에 신고를 한 경우") {
runBlocking {
val deferreds =
users.map { user ->
async(Dispatchers.Default) {
sut.flagReview(user, review.id)
}
}
deferreds.awaitAll()
}
then("신고 내역의 수는 0이다.") {
repo.countByReviewId(review.id).shouldBeZero()
}
}
}
},
)
모든 피드백 감사히 잘 받겠습니다.