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 라는 필드에 여러 트랜잭션이 값을 감소시키는 시나리오이다.
역시 counter 라는 필드에 여러 트랜잭션이 값을 감소시키는 시나리오이다.
OptimisticTest Repository
Pessimistic Repository 는 @Lock 어노테이션을 통해서 Exclusive Lock 을 획득하여 트랜잭션을 순차적으로 처리하도록 했다.
@EnableRetry 를 통해서 Spring Retry 를 활성화했다.
Controller Layer 이다. Optimistic 의 경우 Transaction 내에서 읽기가 Stale 되었다면 abort 되고 5번의 재시도 끝에도 abort 된다면 로그를 찍고 422 status code 를 반환하도록 하였다.
optimistic_test, pessimistic_test table 및 초기 데이터를 설정해주었다.
Optimistic, Pessimistic 동시성 제어 기법을 각각 Transaction 안에서 실행되도록 Service Layer 를 작성했다. Optimistic 의 경우 Spring Retry 를 통해서 5 번 까지 재시도를 할 수 있도록 하였다.
RetryListenerSupport 를 통해서 Retry 횟수를 로깅한다.
이제 해당 코드로 Jmeter 를 통해서 간단한 성능 테스트를 진행해보겠다.
brew 를 통해서 플러그인들과 같이 설치하고 실행한다.
각각의 Thread Group 에 Http Request Sampler 를 추가해준다.
또한 각각의 Thread Group 에 결과 Listener 들을 추가해준다.
사용자 당 counter 를 5 씩 감소시키는 시나리오이다.
세팅이 완료된 모습
먼저 사용자 수 10명일 때
Pessimistic 결과
평균 37 ms 10건 모두 정상적으로 처리되었다.
사용자 50명 시나리오
Optimistic 결과
평균 52 ms 로 50건 모두 정상처리되었다. Max 로 1067 ms 가 발생한 것이 눈여겨볼 만하다. 이는 동시성으로 인해서 Transaction 이 abort 되고 재시도되었다.
평균 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번으로 제한
참고
댓글
댓글 쓰기