일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
- redissonlock aop
- spring aop
- @transactional
- 낙관적 락 재시도
- kotest testcontainers
- java
- jpa
- ObjectOptimisticLockingFailureException
- 소수찾기 java
- AccessToken
- netty
- RefreshToken
- TestContainers
- 멀티모듈 테스트컨테이너
- aop
- interface
- 형상관리
- spring DI
- multimodule testcontainers
- ObjectOptimisticLockingFailureException 처리
- 알고리즘
- 우아한 테크러닝
- DI
- S3
- OptimisticLock
- Spring Cloud Gateway
- springsecurity
- Invalid property 'principal.username' of bean class
- 백준
- 낙관적 락 롤백
- Today
- Total
조급하면 모래성이 될뿐
동시성 처리하기 - 낙관적 락 + 재시도 본문
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번을 시도해도 성공하지 못할 수 있다.
'TroubleShooting > Etc' 카테고리의 다른 글
10명이 동시에 2GB의 파일을 업로드 한다면 ? (Multipart) (0) | 2023.10.17 |
---|