당신의 동시성 테스트가 원하는 결과가 나오지 않는 이유
- 루퍼스/4주차
- 2025. 8. 7. 16:15
TL;DR: 낙관락과 비관락을 고르는 기준에 대해 설명합니다.
배경
그 전에도 낙관락, 비관락을 해봤기때문에 금방할 줄 알았다.
하지만 아니었다. 어디서 부터 문제 였을까?
생각하기에는 비관락을 사용하든, 낙관락을 사용하든 똑같은 결과가 나올거라 생각했다.
이론상으로 생각했을 때, 낙관락은 충돌이 적은 상황에서 사용하고 비관락은 충돌이 많은 경우에 사용한다고 한다.
또, 성능적으로는 낙관락이 더 우수하다는데 그게 사실인지도 궁금해졌다.
낙관락, 비관락이 뭘까?
인터넷에 낙관락, 비관락을 검색하면 무수히 많은 양의 낙관락과 비관락에 대해 알려주고 있다.
그런대도 잘 모르겠다.
내가 생각할때 영어를 한국어를 번역이 잘못했다고 생각한다.
그러면 이걸 영어로 보는것도 나쁘지 않다고 생각한다.
낙관락: Optimistic Lock
비관락: Pessimistic Lock
영어로 가져와도 크게 의미없는거 같다. Optimistic이 긍정적인, 낙관적이라는 뜻인데 다시 보니 직독직해를 하신거 같다.
아무튼 이게 중요한게 아니고
뭐가 낙관적인지 비관적인지 생각해봐야 할거 같다.
Optimistic
이건 낙관적이다. 일단 얘의 주체가 무엇인지 부터 생각을 해야 한다. 일반적으로 DB에서 데이터를 조회하게 되면
요렇게 데이터베이스로 접근되어진다. Controller는 입력이니 우리의 영역은 아니다. 데이터 베이스도 SQL, Nosql로 외부에서 들어온 자원들을 관리되어즈므로 역시나 우리의 영역이 아니다. 낙관적이라는건 일단 우리가 관리를 할 수 있어야 한다는 뜻이 된다고 생각한다.
그래야 우리의 미래를 우리가 직접 그릴수 있지 않을까? 암튼 낙관락이라는건
우리가 관리할 수 있는 환경에서 DB에 직접 락을 걸때와 비슷한 효과를 볼 수 있게끔 하는거라 생각한다.
수 많은 요청(쓰레드)이 동시에 들어왔다고 생각해보자. Application입장에서는 요청이 얼마나 많은지에 대해서는 딱히 중요하지 않다고 생각한다. 어찌되었든 데이터베이스에 어떻게 적재되는지가 중요하기 때문에 Application는 이 사태를 굉장히 낙관적으로 바라보지 않을까? DB는 Application의 요청에 의해 버전링을 추가하였다. 이제 Application은 이 버전을 보고 동시성을 관리를 하게 되어진다.
이것을
class Point {
// 생략
@Version
Long verison;
// 생략
}
이제 애플리케이션은 version이라는 값을 통해 락을 걸 수 있게 되었다. version도 DB의 칼럼이기 때문에 데이터의 주체는 DB이기 때문에 DB에서는 수많은 충돌이 발생활 거고 원하지 않는 결과를 리턴이 된다. 그래서 version이라는 칼럼으로 이 사태를 수습을 하려고 한 건데 문제는 version도 완벽하지는 않아서 버전이 존재한다고 해서 충돌이 안 생긴다는 보장을 할 수가 없다. 그렇기 때문에 낙관락을 사용하여도 충돌이 발생할 수밖에 없다. 그렇다는 건 충돌이 발생하여도 처리할 수 있는 방법을 생각해야 한다.
동일한 유저가 서로 다른 주문을 동시에 수행해도, 포인트가 정상적으로 차감되어야 한다.
예를 들어, 포인트를 10000포인트를 충전하여 1000개의 주문을 1포인트씩 사용했다고 가정해 보면
9000포인트가 남아야 한다. 하지만 결과는 충격적 이게도 두 개의 테스트가 모두 예상과 다른 결과가 나왔다.
첫 번째는 낙관락을 사용하지 않고 순수하게 쿼리를 날린 경우.
라는 결과가 나온다.
그렇다면 낙관적 락은 어떨까?
신기하게도 시간은 빨라졌지만, 최종포인트에서 8포인트의 차이를 보여줬다.
그렇다고 해서 정상적인 차감은 아니었다. 분명 락을 걸었는데... 이게 성능이 좋아진 건가... 애매하다.
속도는 빨라졌으니 성능이 좋아진 거 같긴 한데.. 포인트는 9000포인트에는 한참 못 미치는 결과가 나온 것을 확인할 수 있다.
이런 결과가 왜 나온 걸까?
위에서 말했듯이 충돌을 허용하기 때문에 이러한 결과가 나온다고 생각한다.
그렇다면 어떻게 하면 원하는 결과를 만들 수 있을까?
재시작을 사용하게 되면 될까?
재시작은 몇 회 사용해야 하는 걸까?
낙관락을 사용하게 되면 Opt뭐 에러가 발생한다고 한다. 일단 이거부터 확인을 해보는 것이 좋을 거 같다.
확인해 보니
이런 에러가 발생이 된다는 사실을 알게 되었다.
그리고 놀라운 사실은 시간 타이머를 강제로 들고 있으면 저 에러가 발생이 되었고
성공은 딱 하나만 존재한다는 사실을 알게 되었다. (실험 결과: 4-5초 정도만 되어도 성공은 1회만 발생하였다.)
이로써 우리가 알아낸 사실은 생각보다 빠르게 DB에 적재가 된다는 사실을 알게 되었다.
자 이제 이걸 처리하는 방법을 생각해 보자.
찾아보니 낙관락을 재시도를 하는 방법이 있다고 한다. 그래서 방법을 찾아보니
@Retryable
어노테션을 사용하면 된다고 한다. 재시도를 하는 방법은 많지만 나는 어노테이션을 하는 방법을 선택하였다.
// spring-retry
implementation("org.springframework.retry:spring-retry")
implementation("org.springframework:spring-aspects")
요렇게 하게 되면 낙관락에서 재시도를 손(?) 쉽게 작성이 가능하다.
@Retryable(
retryFor = {ObjectOptimisticLockingFailureException.class},
maxAttempts = 20,
backoff = @Backoff(value = 60, multiplier = 2)
)
@Transactional
이렇게 작성하게 되면 다음과 같은 결과를 리턴하게 된다.
문제는 시간이 굉장히 많이 걸린다. 4분 4초.. 그렇다면 낙관락 + 재시도를 사용할 이유가 없지 않을까?
maxAttempts: 최대 시도 횟수
delay: 대기시간
multiplier: 지수 증가 계수
이거에 대해 간략하게 말하면,
delay(1차 재시도) = 50ms
delay(2차 재시도) = 50ms * 2 = 100ms
delay(3차 재시도) = 100ms * 2 = 200ms (하지만 maxAttempts = 3이므로 여기서 끝)
라고 한다.
생각을 해야 할 것이 이렇게까지 재시도를 시도를 하는 것이 좋을까?
아니라고 생각한다. 이렇게 사용하면 성능이 굉장히 안 좋아진다는 것을 알 수 있다. 낙관락의 장점이 성능인데.. 이러면 의미가 없는 게 아닌가 생각이 든다.
그렇다면 이 문제를 어떻게 해결할 수 있을까?
제일 좋은 건 재시도를 하지 않는 거고,
그나마 권장하는 횟수는 2-3회 정도라고 한다.
낙관락에서 높은 성공률을 기대하는 건 좋은 방향이 아니라 생각이 든다.
낙관적으로? 충돌이 난다고 생각하는 것 같다.
결국 1000회를 낙관락으로 했을 때, 1000회 모두 성공할리가 거의 없다.
recover를 통해 보상 트랜잭션을 적용하는 것이 더 올바른 판단일 지도 모르겠다.
게다가 재시도 횟수를 3회로 하게 되면,
성공률은 올라갔지만 시간은 오히려 늘어났다.
그러면 성공 횟수가 아니라 손실로 방향성을 바꿔서 생각해 보자.
만약, 성공 횟수와 최종포인트를 합쳤을 때, 기존 포인트가 안 나온다면 이는 락이 제대로 걸리지 않음을 뜻한다.
재시도를 시도하지 않았을 때: 118 + 9882 = 10000포인트
재시도를 3회 하였을 때: 431 + 9569 = 10000포인트
전부 성공하게 하는 경우: 1000 + 9000 = 10000포인트
이걸로 알 수 있는 사실은 낙관락을 걸게 되면, 성공되었다고 인정이 된, 포인트는 손실이 전혀 없다는 사실을 알 수 있다.
참고로 낙관락을 걸지 않았을 때,
1000+9893 = 10893이 돼버린다. 합산 포인트가 기존 포인트보다 많아지는 현상이 발생하게 되었다.
그렇다면 손실액을 계산을 해야 하는데 107포인트가 소실이 되었다는 것을 알 수 있다.
낙관락은 소실 횟수에 관해서는 크게 신경을 쓰지 않는 방법이라고 여겨진다.
Pessimistic
얘는 비관락이다. 얘의 주체는 당연하게도 DB다. 낙관적이 락킹을 걸기 위해 칼럼을 이용했다면
얘는 쿼리를 통해 제어를 한다.
쿼리를 통해 제어를 하기 때문에 어느 정도 충돌을 제어할 수 있다. 즉, 쿼리 쪽에서 교통정리를 하게 된다고 생각하면 된다.
교통 정리를 잘하게 되면 업데이트를 하게 되면 곳에서는 확실하게 데이터를 받을 수 있게 된다.
그럼 당장 바꿔보자.
PointModel hasPoint = pointRepository.get(userId).orElseThrow(
() -> new CoreException(ErrorType.BAD_REQUEST, "사용할 수 있는 포인트가 존재하지 않습니다.")
);
락킹은 요 부분에 적용할 수 있다. 즉, 이 쿼리에서 트래픽을 교통 정리를 하게 된다.
Optional<PointModel> findByUserId(String userId);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select p from PointModel p where p.userId=:userId")
Optional<PointModel> findByUserIdWithLock(String userId);
주목해야 될 것은 @Lock을 통해 여러 타입을 정할 수 있게 된다.
일단 알아야 할 점은 PESSIMISTIC은 비관락이라는 뜻이다. 이게 붙여진 것들은 비관락의 한 종류라고 생각하면 된다.
그전에 한번 돌려보자.
놀랍게도 성공 1000 실패 0으로 되었으며 최종 포인트도 9000포인트로 소실이 되지 않았다는 사실을 알 수 있다.
게다가 3초 정도면 비 재시도 낙관락보다는 느리지만 재시도 낙관락 보다는 빠르거나 비슷한 속도로 여겨진다.
이렇게 비관락을 끝내버리면 뭔가 아쉽다는 생각이 든다.
비관락은 Lock타입을 설정하는 방법이 여러 가지다.
종류는 총 2가지로
LockModeType.PESSIMISTIC_WRITE: 베타락
LockModeType.PESSIMISTIC_READ: 공유락
요렇게 존재한다.
여기에서 사용한 것은 배타락이며 쓰기락이라고 한다.
기존에는 베타락으로 되어있어서 이걸 공유락으로 돌려 보면 다음과 같은 결과를 알 수 있다.
시간은 더 짧아졌고 실패 횟수는 더 늘어났다.
왜 이렇게 발생한 걸까?
찾아보니 공유락은 읽는 것만 허용하고 쓰기는 허용하지 않는 방법이라고 한다.
공유락은 S-Lock이라고 불리며 다음과 같은 특징이 있다고 한다.
- 다른 트랜잭션이 잠긴 객체를 읽고 다른 공유 락을 생성하는 것은 허용하지만, 쓰기나 베타 락을 생성하는 것을 허용하지 않는 잠금.
- 여러 트랜잭션이 동일한 행에 공유 락을 생성할 수 있음, 즉 다른 트랜잭션이 읽고 있는 행을 읽을 수 있음.
- 공유 락이 걸려있는 행에 베타 락을 걸 수는 없음, 즉 해당 공유 락이 해제될 때까지 쓰기 불가능.
공유락이 걸려있다는 건 무슨 의미 일까?
애석하게도 지금 코드에서는 공유락 테스트는 쉽지 않은 거 같다.
왜냐하면 트랜잭션이 2개가 필요한데 지금 존재하는 건 1개뿐이라 쉽지 않은 거 같다.. 찾은 것들이 전부 그래서...
그래서 코드 좀 만들고 오겠다.
@Transactional
public void use(String userId, BigInteger amount) {
PointModel hasPoint = pointRepository.get(userId).orElseThrow(
() -> new CoreException(ErrorType.BAD_REQUEST, "사용할 수 있는 포인트가 존재하지 않습니다.")
);
System.out.println("공유락 획득");
System.out.println("공유락 대기 종료");
}
@Transactional
public void tryUse(BigInteger point, String userId) {
int use = pointRepository.use(point, userId);
if (use == 0) {
throw new RuntimeException("포인트 차감 실패");
}
System.out.println("포인트 차감 성공");
}
요렇게 대충 코드를 만들고.. 내부적으로는 상상에 맡기겠다.
테스트를 돌릴 때 깜빡하고 충전을 안 시켰다.(머쓱;;)
Hibernate: select pm1_0.id,pm1_0.created_at,pm1_0.deleted_at,pm1_0.point,
pm1_0.updated_at,pm1_0.user_id from point pm1_0 where pm1_0.user_id=? for share
공유락 획득
Hibernate: update point pm1_0 set point=(pm1_0.point-?) where pm1_0.user_id=?
공유락 대기 종료
포인트 차감 성공
확인할 수 있듯이 공유락을 획득하고 -> t(1)
차감이 발생하고 -> t(2)
공유락 대기가 종료하고 -> t(1)
포인트 차감이 성공 메시지가 나오고 -> t(2)
요렇게 나온다.
이론에 따르면, 공유락이 걸려있으면 읽기는 얼마든지 읽을 수 있다는 걸로 나와있다. 그렇다는 얘기는 읽기 쪽에 쓰레드를 수 개가 들어가도 쓰기에는 영향이 없다는 뜻이 된다.
그럼 궁금한게 차감은 정상적으로 되는지가 궁금하다.
다행히도 차감은 잘 되는거 같다.
결론
지금까지 낙관락과 비관락에 대해 알아보았다.
낙관락을 테스트 하면서 데이터가 맞지 않는다는것을 알 수 있다.
이는 낙관락의 특성을 이해하지 못하였기 때문에 발생한 결과라고 생각한다.
Nosql를 고를때 CAP 이론 이라는것이 존재한다. 이를 간단하게 설명하자면 C는 일관성 A는 가용성 P 분할 허용성이라고 말한다.
이 이론에서 말하길 셋중 하나는 반드시 포기해야 한다고 한다. 하지만 분산 환경을 많이 사용이 되어지는 요즘 환경에서는 P는 필수로 여기진다. 즉, C 또는 A를 결정해서 nosql를 결정해야 하는 걸로 알고 있다.
이와같이 낙관락과 비관락도 이와 같은 조건을 가지고 결정해도 좋을거 같다.
두 개다 챙기면 좋겠지만, 위에서 언급 했 듯이 두 개를 동시에 챙기기는 생각보다 쉽지 않다.
내가 생각할때, 낙관락은 A가 중요되어지는 서비스에서 이용할거 같구 비관락은 C가 중요시 하는 서비스에서 이용이 되어질거 같다.
이걸 다시 말하자면 가용성이 좋다는 얘기는 어느정도 충돌을 허용이 되어진다는 뜻이고
일관성이 좋다는 얘기는 항상 같은 결과를 리턴이 되야 한다는 뜻이라 할 수 있다.
예를 들어, 좋아요를 동시에 1000명이 눌렀을때, 실질적으로 900명이 실패라고 100명이 성공하여도 딱히 문제 되지는 않는다.
내부적이든 외부적이든 다시 한 번더 요청하면 되기 때문이다. 하지만 사용자에게 빠르게 결과를 보여줘야 하기때문에 낙관락이 맞는 판단이라 여겨진다. (이 예시는 단순한 예제로 좋아요 해제 와 좋아요 갯수 증감은 없다는 가정이다.)
반대로 위 예제에서 활용한 포인트 같은 경우는 여러 주문이 동시에 주문한 경우 모든 주문이 완료가 되어야 할거다. 왜냐하면 사용자 입장에서 주문이 완료가 되어야할거고 100개를 주문한 경우 100개의 해당하는 주문 상품의 포인트가 차감이 되어야 한다. 만약 일부만 포인트(돈이)가 빠져나갔고, 모든 결제가 완료가 되었다면... 그 상품은 환불을 해야하고... (이하 생략)
그렇기때문에 비관락이 더 알 맞은 선택이라 할 수 있다.
실험으로 알게된 사실은 비관락은 생각보다 느리지 않았다.
그래서 왠만하면 비관락을 사용해도 크게 문제될거는 없을 수도 있을거 같다.
레퍼런스
https://velog.io/@paki1019/%EA%B3%B5%EC%9C%A0-%EB%9D%BDShared-Lock%EA%B3%BC-%EB%B2%A0%ED%83%80-%EB%9D%BDExclusive-Lock