Optimistic Concurrency Control VS Pessimistic Concurrency Control - What should i choose?

 Race Condition 즉 동시성 문제는 하나의 트랜잭션이 다른 트랜잭션에서 동시에 변경한 데이터를 읽거나 두 트랜잭션이 동시에 같은 데이터를 변경하고자 할 때 나타난다.

동시성 문제로 발생하는 버그는 타이밍에 운이 없을 때 발생하기 때문에 일반적인 테스트로 발견하기 어렵고, 타이밍 이슈는 간할적으로 발생할 수 있으며 해당 이슈를 재현하기 힘들고 추론도 힘들다.

어플리케이션 개발은 한 번에 한 사용자를 가정하고 개발하기도 어려운데 동시 사용자가 많다면 당연히 더 어렵다. 하지만 현실세계에서 동시성이 없는 어플리케이션이 없다고 봐도 무방할 만큼 동시성을 고려하지 않을 수 없다. 재고가 얼마 남지 않은 상품을 동시에 여러 구매자가 구매할 때, 하나의 회의실을 서로가 먼저 예약하려고 할 때, BTS 콘서트 티켓을 예매할 때.. 등등

Optimistic Concurrency Control

우리나라말로 해석하면 "낙관적 동시성 제어" 이다. 무엇이 낙관적일까? 위험한 상황이 발생할 가능성이 있을 때 Transaction 을 막는 대신 모든것이 괜찮아질 것이라고 희망을 갖고 계속 진행한다는 의미이다. 여기서 위험한 상황이란 다른 Transaction 이 현재 진행 중인 Transaction 이 변경하려고 하는 데이터와 동일한 데이터를 변경하고자 하는 상황을 의미한다. 그리고 막는다는 표현은 Database 관점에서 Exclusive Lock 을 통해서 Transaction 이 순서로 진행되는 것을 의미한다. 막지 않는 대신 각각의 Transaction 은 Commit 전에 Transaction 내에서 읽었던 Data 가 변경되지 않은것을 확인한다. 이는 이른바 MVCC(Multi Version Concurrency Control) 기법을 통해서 이루어진다. 만약 Data 의 변경이 감지된다면 해당 Transaction 은 Rollback 하고 재시작을 한다.

Pessimistic Concurrency Control

낙관적의 반대인 비관적이라는 말은 무엇일까? 위험한 상황에서 실행하기 전에 안전해질 때까지 기다리는 것이 낫다고 판단하는 원칙을 기반으로 한다. 다시 쉽게 풀자면, 동시에 Transaction 이 같은 Data 를 변경하고자 할 때 Transaction 들이 순서에 맞게 (Serializable) 진행할 수 있도록 잠금 (Lock) 을 사용한다. Data 를 변경하고자 할 때 해당 Transaction 은 변경하고자 하는 Data 에 대해서 배타적 잠금 (Exclusive Lock) 을 획득하여 진행하고 Transaction 이 종료될 때 해당 잠금을 푼다. 따라서 다른  Transaction 이 동시에 같은 Data 를 변경하는 것을 방지한다.


What should i choose?

정답은 없다. context 에 기반하여 선택해야 된다. 옳은 선택을 하기 위해서 어떠한 요건들을 확인해야 될까?
먼저, 각각의 기법에서 발생할 수 있는 이슈들을 보자. 낙관적 동시성 기법을 줄여서 낙관적 기법이라고 비관적 동시성 기법을 비관적 기법이라고 부르겠다.

- 낙관적 기법을 사용할 경우, 경쟁이 심할 경우에 다시 말해 Transaction 수가 증가할 수록 abort 시켜야할 Transaction 의 비율이 높아져서 성능이 떨어진다. abort 되는 Transaction 이 많을수록 재시도하는 Transaction 수가 많아진다. 시스템의 최대 처리량에 근접한 상태에서 재시도되는 Transaction 으로부터 발생되는 부하가 성능을 저하시킬 수 있다. 재시도되는 Transaction 을 처리할 충분한 용량이 받쳐주고 경쟁이 너무 심하지 않다면 낙관적 기법은 비관적 기법보다 성능이 좋은편이다.

- 비관적 기법은 낙관적 기법보다 기본적으로 트랜잭션의 처리량과 질의 응답 시간이 좋지 않다. 원인은 Lock 을 획득하고 해제하는 오버헤드이다. 경쟁 조건이 발생할 때 한 Transaction 은 다른 Transaction 이 완료될 때 까지 기다려야 한다. 동시성이 제한된다. 만약 한 Transaction 이 많은 데이터에 Lock 을 획득할 경우, 다른 Transaction 이 먼저 진행중인 Transaction 이 획득한 모든 Lock 이 다음 Transaction 에 영향을 많이 준다. 이는 전체적인 처리량의 감소로 이어진다. 또한 Dead Lock 이슈가 자주 발생할 수 있다. Transaction 이 Dead Lock 으로 인해서 abort 되면 역시 또한 다시 시도해야한다.

1. 동일한 자원에 대해서 읽기 작업보다 쓰기 작업에 대한 경쟁이 심할 경우 비관적 기법이 좋은 선택이 될 수 있다. 경쟁이 심할 경우 낙관 기법은 abort 된 Transaction 에 대해서 재시도 비용이 커지며 처리량도 낮아지기 때문이다.

2. Transaction 이 많은 데이터에 접근한다면 낙관적 기법이 좋은 선택이 될 수 있다. 비관적 기법을 사용하여 많은 데이터를 다룬다면 당연히 Transaction 은 많은 데이터에 대한 Lock 을 획득해야 한다. 이는 Dead Lock 의 발생위험을 증가시킨다. Dead Lock 이 발생하게 되면 Transaction 은 abort 된다. abort 된 Transaction 에 대해서 재시도 비용이 커지며 처리량이 낮아진다.

이번 글에서는 Java 기반 Spring Boot 와 MySQL 그리고 JPA 를 통해 간단한 예제로 동시성을 다루어보겠다.

- Spring Boot 2.4.4

- Java 11

- MySQL 5.7

MySQL 은 docker compose 를 이용했다.


OptimisticTest 라는 Entity 에 version 컬럼을 통해서 낙관적 동시성 제어 기법을 test 해볼 예정이다. counter 라는 필드에 여러 트랜잭션이 값을 감소시키는 시나리오이다.

PessimisticTest 라는 Entity 로 비관적 동시성 제어 기법을 test 해볼 예정이다.
역시 counter 라는 필드에 여러 트랜잭션이 값을 감소시키는 시나리오이다.

OptimisticTest Repository

Pessimistic Repository 는 @Lock 어노테이션을 통해서 Exclusive Lock 을 획득하여 트랜잭션을 순차적으로 처리하도록 했다. 

Optimistic, Pessimistic 동시성 제어 기법을 각각 Transaction 안에서 실행되도록 Service Layer 를 작성했다. Optimistic 의 경우 Spring Retry 를 통해서 5 번 까지 재시도를 할 수 있도록 하였다.

@EnableRetry 를 통해서 Spring Retry 를 활성화했다.

RetryListenerSupport 를 통해서 Retry 횟수를 로깅한다.


Controller Layer 이다. Optimistic 의 경우 Transaction 내에서 읽기가 Stale 되었다면 abort 되고 5번의 재시도 끝에도 abort 된다면 로그를 찍고 422 status code 를 반환하도록 하였다.

optimistic_test, pessimistic_test table 및 초기 데이터를 설정해주었다.



이제 해당 코드로 Jmeter 를 통해서 간단한 성능 테스트를 진행해보겠다.
brew 를 통해서 플러그인들과 같이 설치하고 실행한다.

2개의 Thread Group 을 추가해준다. 1개는 Optimistic Test 다른 하나는 Pessimistic Test 로 이름을 변경해준다.

각각의 Thread Group 에 Http Request Sampler 를 추가해준다.

또한 각각의 Thread Group 에  결과 Listener 들을 추가해준다.

사용자 당 counter 를 5 씩 감소시키는 시나리오이다.

세팅이 완료된 모습

사용자 수와 테이스 케이스는 10명, 50명, 100명, 1000명 으로 localhost 단일 노드 환경을 기준으로 Optimistic, Pessimistic 으로 진행하였다.

먼저 사용자 수 10명일 때





Optimistic 결과

평균 39 ms 10건 모두 정상적으로 처리되었다.



Pessimistic 결과

평균 37 ms 10건 모두 정상적으로 처리되었다.





사용자 50명 시나리오


Optimistic 결과

평균 52 ms 로 50건 모두 정상처리되었다. Max 로 1067 ms 가 발생한 것이 눈여겨볼 만하다. 이는 동시성으로 인해서 Transaction 이 abort 되고 재시도되었다.




Pessimistic 결과

평균 31 ms 로 역시 50건 모두 정상처리 되었다.



사용자 100명 시나리오

Optimistic 결과

평균 381 ms 로 100건 모두 성공하였지만, Max 값이 2113 ms 이다. 또한 1000 ms 이상 소요된 건수가 33건이다. 100건 중 33건 30% 가 abort 되었고 재시도로 인해서 성능이 저하되었다.

특정 Transaction 은 최대 2번 까지 abort 되었다. 3번 째 시도에 성공하였다. 이것이 2113 ms 에 처리된 건이다.




Pessimistic 결과

평균 68 ms 로 100건 전부 정상적으로 처리되었다.



사용자 1000명 시나리오

Optimistic 결과

1000건 중 131 건이 실패했다. retry 5번을 초과해서 더 이상 재시도 하지 않고 422 로 응답한 건수가 131건이다.
error rate 가 13.1 % 이다. 
평균 처리 속도는 9210 ms 으로 9.2 초이다. 굉장히 성능이 저하되었다.
max 는 18524 ms 로 18.5 초이다. 





Pessimistic 결과

1000건 모두 정상적으로 처리 되었다. 평균 4423 ms 로 4.4 초 가량 시간이 소요되었다. 최대값으로는 8721 ms 로 8.7 초 가량 소요되었다.



처리 되는 도중 아래 쿼리로 진행중인 lock 과 트랜잭션들의 상태를 확인할 수 있었다.




시나리오 테스트 정리






해당 시나리오에서는 비관적 동시성 제어 기법이 성공률도 높고 처리시간도 좋게 나왔다. 물론 정말 단순하게 특정 row 의 값을 감소시키는 시나리오이다. 실제 비즈니스에서는 이렇게 간단한 시나리오로 정의할 수 없다. 

해당 시나리오에서 단순히 간과하면 안되는 요소를 짚으면서 이 글을 마무리하겠다.

- 단일 row 만 변경시키는 단순한 Transaction

- read-and-modify 주기 사용

- 테스트 환경 단일 node 

- MySQL 5.7 버전 Repeatable Read Isolation Level 사용

- Transaction Abort 시 재시도 횟수는 5번으로 제한



참고








https://github.com/ndgndg91/concurrency-test



댓글

이 블로그의 인기 게시물

Spring Boot Actuator readiness, liveness probes on k8s

About Kafka Basic

sneak peek jitpack

About idempotent

About G1 GC

About ZGC

About JVM Warm up

I need to know a little JVM

HackerRank Java Between Two Sets

Java - HashMap (feat. LinkedList, Tree.. maybe Later)