조급하면 모래성이 될뿐

동시성 처리하기 - 낙관적 락 + 재시도 본문

TroubleShooting/Etc

동시성 처리하기 - 낙관적 락 + 재시도

Pawer0223 2023. 9. 25. 15:35

1. 낙관적 락 방식을 통해 동시성 문제를 제어한다.

2. 버전 충돌로 인한 문제가 발생했을 때, 다시 로직을 시도한다.

 

낙관적 락(Optimistic Lock) 이란?

- 코드 레벨에서 동시성 문제를 해결하는 방법이다. 간단하게 설명하면 버저닝을 통해서 문제를 해결한다.

- 읽을 때 버전과, 변경할 때 버전이 다르면 예외가 발생한다.

- DB 자체에 Lock을 걸게 되는 비관적 락(Pessimistic Lock) 방식보다 빠르다. 

- 그러나 Rollback 처리가 번거롭다. 스프링과 같이 트랜잭션 관리를 프레임워크가 처리한다면 비교적 신경 쓸 부분이 많이 줄어들지만, 그렇지 않은 경우에는 코드레벨에서 롤백에 대한 처리를 신경 써야 한다.

 

적용해 보기

- Jpa와 함께 낙관적 락을 사용해 보자

- 버저닝을 위한 필드를 추가해 주면 된다.

@Entity
class Point(
    @Id
    @Column(name = "point_id")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null,

    var amount: Long = 0,

    @Version
    var version: Long = 0
)

 

- 아래와 같은 서비스를 호출한다면

    @Transactional
    fun addPoint(pointId: Long, amount: Long) {
        val point =
            pointRepository.findByIdOrNull(pointId) ?: throw IllegalArgumentException("없는 아이디")
        point.amount += amount
        pointRepository.save(point)
    }

- 조회할 때 0번의 버전을 가져와서

- 변경할 때 버전을 +1 해주는 걸 확인할 수 있다.

 

동시성 문제 확인하기

- 동시에 포인트 적립 요청이 온 경우를 테스트하면 하나의 요청만 성공한다.

- 따라서 아래 테스트는 실패한다.

 

    fun <T> submitWithPerThread(threadPoolSize: Int, vararg functions: () -> T) {
        val executorService = Executors.newFixedThreadPool(threadPoolSize)
        val latch = CountDownLatch(threadPoolSize)

        for (function in functions) {
            executorService.submit {
                try {
                    function.invoke()
                } catch (e: Throwable) {
                    log.info("in executorService --> ", e)
                    throw e
                } finally {
                    latch.countDown()
                }
            }
        }
        latch.await()
    }


    test("[포인트 적립] 동시에 10원, 20원, 30원 적립 시도시 60원 적립.") {
        // given
        val point = point2Repository.save(Point2(amount = 0))

        // when
        submitWithPerThread(
            3,
            { pointService.addPoint(point.id!!, 10) },
            { pointService.addPoint(point.id!!, 20) },
            { pointService.addPoint(point.id!!, 30) }
        )
        // then
        val findPoint = point2Repository.findByIdOrNull(point.id!!)!!

        findPoint.amount shouldBe 60
    }

 

- ObjectOptimisticLockingFailureException 예외가 발생한다

- 즉 3개 중 2개는 실패 응답으로 처리할 수 있기 때문에, 데이터 정합성 문제는 예방할 수 있다. 사용자는 문제를 인지하고 다시 요청할 수 있다.

 

재시도하기

- RedissonLock 방식을 사용했을 때는, 공유자원에 동시 접근해도 상호배제를 통해 모든 요청이 성공할 수 있다.

- 낙관적 락 방식도 유사한 결과를 만들고 싶었다.

- 실패했을 때, 오류를 응답하는 게 아니고 성공할 때까지 재시도를 하고 싶었다.

- 결과부터 얘기하면 100% 보장하지는 않는다. 하지만 재시도를 통해 실패에 대한 응답을 어느 정도 줄일 수 있었다.

 

1. 의존성 추가

- retry를 위한 라이브러리를 추가한다.

    implementation("org.springframework.retry:spring-retry")

 

2. Enable 설정

- Retry가 동작할 수 있도록 enable 설정을 해준다. 별도의 Config 파일에 작성해도 상관없다.

@EnableRetry
class BoltaApplication

 

3. 재시도 설정

value: 지정된 예외가 발생하면 Retry를 수행한다.

maxAttempts: 최대 재시도 횟수

backOff: 재시도 간격을 지정한다. 다시 요청하기 전에 대기할 시간을 설정한다. 0.5초의 약간의 딜레이를 주어서 다른 요청이 진행 중인 경우 약간의 여유를 가질 수 있도록 했다.

    @Transactional
    @Retryable(
        value = [ObjectOptimisticLockingFailureException::class],
        maxAttempts = 3,
        backoff = Backoff(delay = 500)
    )
    fun addPoint(pointId: Long, amount: Long) {
        val point =
            pointRepository.findByIdOrNull(pointId) ?: throw IllegalArgumentException("없는 아이디")
        point.amount += amount
    }

 

결과 확인

예외가 발생했을 때, 재시도 수행하여 위 테스트(60원인지 확인하는 테스트)가 정상적으로 성공하는 것을 확인할 수 있다.

그러나 역시 네트워크 문제 등으로 3번을 시도해도 성공하지 못할 수 있다.

반응형