본문 바로가기
동시성 처리

분산환경 동시성 처리 1 - db 사용

by pius712 2024. 5. 17.

동시성 이슈란 무엇인가?

멀티 스레딩 환경에서는 동시성 이슈로 인해서 공유자원이 경쟁상태(race condition)에 놓이는 상황이 발생할 수 있다. JVM 진영에서는 객체의 상태 변수도 race condition 이 발생할 수 있는데, 이번에는 데이터베이스를 중점적으로 다뤄볼 예정이다.

데이터베이스에서 발생하는 race condition 의 경우, JVM 언어가 아닌 nodejs 같은 싱글스레드 기반의 언어에서도 발생할 수 있다. 그 이유는 데이터베이스 자체가 멀티스레드로 동작하기 때문이다.

동시성이 발생하는 이유는 하나의 공유 자원에 대해 여러 개의 연산이 원자적(atomic) 으로 동작하지 않기 때문이다.

아래의 예시처럼 t1 스레드와 t2 스레드가 food 의 개수를 감소하는 로직을 다룬다고 했을 때,

두 개의 연산이 별개로 실행이 된다.

  • food 개수 가져오는 연산
  • 가져온 food 개수에서 하나를 감소시키는 연산

이로 인해서 lost update 현상이 발생하게 된다.

예를들어 하나의 버스에 예약 시스템이 동시성을 다루지 않게 되면, 실제 남은 좌석에 비해 더 많은 예약을 하게 된다거나, 재고 시스템에서 실제 재고와 다른 재고가 남게 될 것이다.

이번 포스팅에서는 데이터베이스의 락 기능을 통한 동시성 문제를 다루고, 다음 포스팅에서는 redis 를 활용하여 동시성 문제를 다루고자 한다.

동시성 해결의 기본 원리

동시성으로 인해서 race condition 이 발생하는 이유는 공유자원에 여러 개의 스레드가 동시에 공유자원의 상태를 변경하기 때문에 발생한다.

동시성 해결을 하기 위한 기본 아이디어는 공유 자원에 대한 수정을 원자적으로 실행 하는 것이다.

다른 말로 표현한다면, lock 을 획득하여 critical section 에 접근하고 작업을 마친 뒤, lock 을 해제하는 것이다.

 

예제

예제는 아래처럼 요즘 앱에서 많이 보이는 XX 키우기에서 밥 주기를 광클했을 때 처리하는 것으로 하려고한다.

펫(Pet)이 있고, 사료(Food) 를 주면 사료 개수만큼 파워가 오르는 것을 가정하고자 한다.

코드는 동시성 이슈를 다루기 위해 간단하게 구성하였다.

@Entity
@Table(name = "pet")
data class PetEntity(
    var power: Long,
) : BaseEntity() {}
@Entity
@Table(name = "food")
data class FoodEntity(
    val petId: Long,
    var count: Long,
) : BaseEntity() {}

 

테스트 코드

32 개의 고정 스레드 풀을 생성한다음, 1000번의 요청을 날리는 상황을 가정한다.

food 의 개수만큼 power 가 오르기 때문에, 요청이 끝난 후 펫의 파워는 최초 음식의 개수만큼 올라야한다.

var initialFood = 100L

// setup 생략
// ... 

@Test
fun feedTest() {
    val clickCount = 1000
    val executorService = Executors.newFixedThreadPool(32)
    val latch = CountDownLatch(clickCount)

    for (i in 1..clickCount) {
        executorService.execute {
            try {
                feedingService.feed(petId!!)
            } finally {
                latch.countDown()
            }
        }
    }
    latch.await()
    val pet = petRepository.findByIdOrNull(petId!!) ?: throw RuntimeException("Pet not found")
    val food = foodRepository.findByIdOrNull(petId!!) ?: throw RuntimeException("food not found")
    Assertions.assertThat(pet.power).isEqualTo(initialFood)
    Assertions.assertThat(food.count).isEqualTo(0)
}

 

락 없는 버전

@Service
class FeedingServiceV0(
    private val foodRepositoryV1: FoodRepositoryV0,
    private val petRepository: PetRepositoryV0,
) {

    // 락 없이 진행
    @Transactional
    fun feed(
        petId: Long,
    ) {
        val foodEntity = foodRepositoryV1.findByPetId(petId) ?: throw RuntimeException("Food not found")
        if (foodEntity.count <= 0) return;

        val petEntity = petRepository.findByIdOrNull(petId) ?: throw RuntimeException("Pet not found")
        foodEntity.count -= 1
        petEntity.power += 1;
    }

}

위와 같은 코드는, 여러 개의 스레드가 동시에 food 를 감소시키기 때문에 위에서 언급했듯이 food 의 개수가 덮어쓰게 될 수 있다.

Thread - 1                      Thread - 2
foodEntity.count - 1(100 - 1)
																foodEntity.count - 1 (100 - 1)
foodEntity.count = 99
																foodEntity.count = 99

그렇기 때문에 실제 food 감소에 성공한 요청횟수 보다 더 적은 food 가 감소하게 될 수 있다.

뿐만 아니라, pet 의 power 에도 같은 동시성문제가 발생할 수 있다.

 

유니크 테이블 사용한 락

lock 을 위해 유니크 키를 갖는 entity 를 생성한다.

@Entity
@Table(name = "food_lock", indexes = [Index(columnList = "lockKey", unique = true)])
data class FoodLockEntity(
    val lockKey: String
) : BaseEntity()

해당 테이블을 사용하여 락을 구현할 수 있다.

@Component
class FoodLock(
    private val foodLockRepository: FoodLockRepository
) {
    fun lock(lockKey: String) {
        try {
            foodLockRepository.save(FoodLockEntity(lockKey))
        } catch (e: Exception) {
            throw RuntimeException("Failed to lock food", e)
        }
    }

    @Transactional
    fun release(lockKey: String) {
        try {
            foodLockRepository.deleteByLockKey(lockKey)
        } catch (e: Exception) {
            throw RuntimeException("Failed to release food lockKey: $lockKey", e)
        }
    }
}

로직 구현

  • 락 획득
    • 락 획득 실패시 예외
  • 로직 수행
  • 락 해제
@Component
class FeedingExecutorV3(
    private val foodRepositoryV3: FoodRepositoryV3,
    private val petRepositoryV3: PetRepositoryV3,
    private val foodLock: FoodLock,
) {

    @Transactional
    fun feed(petId: Long) {
        try {
            foodLock.lock(petId.toString())
        } catch (e: Exception) {
            throw RuntimeException("Failed to lock food", e)
        }

        try {
            val foodEntity = foodRepositoryV3.findByPetId(petId) ?: throw RuntimeException("Food not found")
            if (foodEntity.count <= 0) return;

            val petEntity = petRepositoryV3.findByIdOrNull(petId) ?: throw RuntimeException("Pet not found")
            foodEntity.count -= 1
            petEntity.power += 1;
        } finally {
            foodLock.release(petId.toString())
        }

    }
}

만약, 실패시 예외 처리가 아니라 모든 요청을 처리해야한다면 이를 감싸서 아래와 같은 로직을 취할 수 있다.

다른 객체로 처리하는 이유는, transactional 처리시 예외 발생으로 인해 모두 실패할 수 있기 때문에, 별개로 처리하였다.

fun feed(
        petId: Long,
    ) {
        while (true) {
            try {
                feedingExecutor.feed(petId)
                return
            } catch (e: Exception) {
                logger.error("Failed to feed petId: $petId", e)
                Thread.sleep(500)
            }
        }
    }

이러한 방식은 참고로 스핀락 방식이기 때문에, 순차 처리를 보장하지 않는다. 만약에 선착순과 같은 로직을 수행한다면, 이러한 방식이 아닌 순차처리가 가능하도록 해야한다.

비관적 락

비관적 락은 트랜잭션이 충돌한다는 가정으로, 수정하려는 테이블에 레코드 락을 걸어서 다른 스레드가 해당 레코드에 접근하지 못하도록 하는 것이다.

아래의 코드를 실행하게 되면, 해당 레코드에 select for update 구문으로 락을 걸게 된다.

그러면 트랜잭션이 끝날때까지, 다른 트랜잭션은 해당 레코드를 획득할 수 없다.

interface FoodRepositoryV1 : JpaRepository<FoodEntityV1, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    fun findByPetId(petId: Long): FoodEntityV1?
}

따라서, foodRepositoryV1.findByPetId(petId) 구문으로 락을 획득하게 되면

transaction이 끝날 때 까지 락을 보유하고 있어서, 동시성을 해결할 수 있는 것이다.

@Service
class FeedingServiceV1(
    private val foodRepositoryV1: FoodRepositoryV1,
    private val petRepositoryV1: PetRepositoryV1,
) {

    // 비관적 락
    @Transactional
    fun feed(
        petId: Long,
    ) {
		    // lock 
        val foodEntity = foodRepositoryV1.findByPetId(petId) ?: throw RuntimeException("Food not found")
        if (foodEntity.count <= 0) return;

        val petEntity = petRepositoryV1.findByIdOrNull(petId) ?: throw RuntimeException("Pet not found")
        foodEntity.count -= 1
        petEntity.power += 1;
    }
    // lock 해제

}

낙관적 락

낙관적락은 비관적 락과는 다르게 일반적으로는 동시성 문제가 발생하지 않는다는 전제로 수행된다.

데이터베이스의 락 기능이 아닌, ORM 의 기능으로 실제로 락을 수행하지 않고 Version 칼럼을 통해서,

현재 스레드가 수정하려고하는 버전이 수정되지 않았는지 확인하는 방식이다.

즉, update 쿼리시 아래처럼, version 정보를 활용한다.

  • affected 가 없으면 예외가 발생
  • 수정시 version + 1
update food_v2 
set
    count=?,
    created_at=?,
    pet_id=?,
    updated_at=?,
    version=? 
where
    id=? 
    and version=?

JPA 에서는 기본적으로 @Version 이 명시된 칼럼만 추가해도 낙관적 락이 동작한다.

이 외에도 낙관적 락과 관련된 여러 옵션을 명시해줄 수 있다.

@Entity
@Table(name = "food_v2")
data class FoodEntityV2(
    val petId: Long,
    var count: Long,
    @Version
    var version: Long = 0
) : BaseEntity() {
}
@Component
class FeedingExecutor(
    private val foodRepository: FoodRepositoryV2,
    private val petRepository: PetRepositoryV2,
) {

    @Transactional
    fun execute(petId: Long) {
        val foodEntity = foodRepository.findByPetId(petId) ?: throw RuntimeException("Food not found")
        if (foodEntity.count <= 0) return;

        val petEntity = petRepository.findByIdOrNull(petId) ?: throw RuntimeException("Pet not found")
        foodEntity.count -= 1
        petEntity.power += 1;
        return
    }
}

위 경우에도, 업데이트가 실패하는 경우에는 낙관적 락과 관련된 예외가 발생하는데,

요청을 모두 재시도 하고자한다면, 아래와 같이 스핀락 형태로 재시도를 할 수 있다.

@Service
class FeedingServiceV2(
    private val feedingExecutor: FeedingExecutor
) {

    val logger = LoggerFactory.getLogger(javaClass)

    // 낙관적 락
    fun feed(
        petId: Long,
    ) {
        while (true) {
            try {
                feedingExecutor.execute(petId)
                return;
            } catch (e: ObjectOptimisticLockingFailureException) {
                logger.info("Failed to feed petId: $petId", e)
                Thread.sleep(500)
            }
        }
    }
}

비관적 락 vs 낙관적 락

비관적 락은 실행됨을 보장해야하는 경우 사용하는 것이 좋고,

낙관적 락은 실패처리를 하는 경우 사용하는 편이 좋다.

 

위의 예시에서는 테스트 성공을 하고자 스핀락을 통해서 재시도하였으나, 낙관적 락을 통해 실패하는 경우에는

이미 처리 중이라는 식으로 예외 처리를 하는 것이 일반적이다.

 

동시성 테스트 삽질기..

동시성 테스트를 하다가 테스트에 Transactional 어노테이션으로 인한 삽질기도 있으니 참고바랍니다.

https://pius712.tistory.com/23

 

나는 테스트에서 @Transactional 붙이지 않겠다.. (feat. 동시성 삽질기)

재민님 유튜브: https://www.youtube.com/watch?v=PDhN6aiF7QQ 향로님 블로그: https://jojoldu.tistory.com/761토비님 페이스북 게시글: https://www.facebook.com/tobyilee/posts/pfbid037KmQz4TbwBfgkAXc8JjMjipMesF9iuTTWvMtUKirr3742cGfvVrq4Af

pius712.tistory.com