어플리케이션 단에서 Lock 개념으로 언급되는 것은 크게 낙관적 락(Optimistic Lock) 과 비관적 락(Pessimistic Lock)이 있다.
각각의 방식을 적용해서 동시성 문제를 해결해보도록 하자!
비관적 락
비관적 락은 말 그대로 트랜잭션 끼리의 충돌은 무조건 발생(비관적으로 생각) 한다고 보고 트렌잭션이 접근하는 데이터에 미리 락을 거는 방식을 뜻한다.
public interface CouponRepository extends JpaRepository<Coupon, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT count(c) FROM Coupon c")
long countWithPessimisticLock();
}
기존의 CouponRepository 인터페이스가 기본으로 제공하는 쿼리메서드인 count() 대신
@Query 어노테이션과 Lock 어노테이션을 활용하여 직접적으로 쿼리문과 락을 명시해주었다.
위 리스트는 LockModeType의 종류들이다. JPA는 이처럼 자체적으로 락 기능을 제공한다!
PESSIMISTIC_WRITE을 사용하면, 다른 트랜잭션에서는 쓰기 뿐만 아니라 읽는 것 까지 하지 못한다.
반면 PESSMISTIC_READ 같은 경우 다른 트랜잭션에서 읽기는 허용한다.
나 같은 경우 정확히 100개의 쿠폰만 발행하고 싶은 경우이기 때문에 읽기와 관련된 count 메서드 자체가
여러 스레드가 동시에 접근하면 안된다. (스레드 2개가 동시에 쿠폰을 99로 읽으면, 쿠폰을 2번 발행하게 된다! 그러면 총 갯수는 101개가 된다.)
따라서 읽기 잠금 까지 지원하는 PESSIMISTIC_WRITE 옵션을 사용하였다.
@Transactional
public String issueCoupon(Member member) {
long count = couponRepository.countWithPessimisticLock();
if (count >= MAX_COUNT) {
throw new CouponException("모든 쿠폰이 소진되었습니다.");
}
if (!member.getCoupons().isEmpty()) {
throw new CouponException("쿠폰 중복 발급은 불가능합니다.");
}
return makeCoupon(member);
}
Service 레이어에서도 해당 메서드를 사용하는 방식으로 변경한 후 테스트 코드를 돌리면
다음과 같이 정확히 100개의 쿠폰이 발급되는 것을 확인할 수 있다.
하지만 로그를 자세히 보면
데드락이 엄청나게 발생한다.
데드락은 스레드들이 서로가 요구하는 자원을 건네주지 않기 때문에 발생하는데
지금과 같은 경우 왜 데드락이 발생하는걸까?
show engine innodb status;
명령어로 데드락 관련 로그를 확인할 수 있는데, 이를 살펴보면 다음과 같다.
------------------------
LATEST DETECTED DEADLOCK
------------------------
2023-11-25 19:14:00 0x16dfb7000
*** (1) TRANSACTION:
TRANSACTION 65279, ACTIVE 0 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1128, 2 row lock(s)
MySQL thread id 1402, OS thread handle 6166491136, query id 1626579 localhost 127.0.0.1 jeongho update
insert into coupon (code, member_id) values ('69220ad3-9bec-4b23-b', 7)
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 443 page no 4 n bits 72 index PRIMARY of table `coupon`.`coupon` trx id 65279 lock_mode X
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
0: len 8; hex 73757072656d756d; asc supremum;;
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 443 page no 4 n bits 72 index PRIMARY of table `coupon`.`coupon` trx id 65279 lock_mode X insert intention waiting
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
0: len 8; hex 73757072656d756d; asc supremum;;
*** (2) TRANSACTION:
TRANSACTION 65280, ACTIVE 0 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1128, 2 row lock(s)
MySQL thread id 1403, OS thread handle 6164262912, query id 1626584 localhost 127.0.0.1 jeongho update
insert into coupon (code, member_id) values ('3f8d77d8-120c-44b7-b', 8)
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 443 page no 4 n bits 72 index PRIMARY of table `coupon`.`coupon` trx id 65280 lock_mode X
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
0: len 8; hex 73757072656d756d; asc supremum;;
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 443 page no 4 n bits 72 index PRIMARY of table `coupon`.`coupon` trx id 65280 lock_mode X insert intention waiting
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
0: len 8; hex 73757072656d756d; asc supremum;;
*** WE ROLL BACK TRANSACTION (2)
쿼리 로그를 간략하게 요약하자면, 동일한 PK 인덱스에 각각의 트랜잭션이 insert하기 때문에 데드락이 발생하는 상황이다.
비관적 락 모드를 걸어주면 쿼리가 다음과 같이 나가게 되는데
select
count(coupon0_.coupon_id) as col_0_0_
from
coupon coupon0_ for update
테이블에 데이터가 하나도 없으면 락을 걸 Row가 존재하지 않기 때문에,
여러 스레드가 동시에 밑에 빨간색 네모박스로 표시한 코드 블럭에 진입하게 된다.
이후에 실행되는 makeCoupon 로직에서는 여러 트랜잭션이 동일한 pk로 insert하기 떄문에 데드락이 발생한다.
그래서 데이터가 1개라도 Insert 처리가 된 이후에는, 정상적으로 락을 획득하기 때문에 데드락이 발생하지 않는다.
낙관적 락
낙관적 락은 트랜잭션 간에 충돌이 거의 발생하지 않는 다는 가정하에, 동시성 이슈를 대비하는 방식이다.
JPA에서는 Version 속성을 활용하여 이를 쉽게 구현할 수 있다.
다음과 같이 CouponInventory Entity를 만들고 @Version 어노테이션을 활용하여 버전 정보를 관리하도록 구현하였다.
해당 칼럼에 접근하여 수정을 할 때 마다 해당 Version 필드 값이 증가한다.
만일 어떤 트랜잭션이 수정한 Entity를 커밋하는 시점에 DB상의 버전정보와 다르면
OptimisticLockException을 발생시키고 롤백처리가 된다.
만일 트랜잭션을 여러 개 물고 있는 작업이 있으면, 예외가 발생했을 때 관련 된 변경사항들을 원래대로 돌려줘야 하기 때문에
비관적 락과 달리 개발자가 직접 관련된 코드를 작성해줘야 한다.
그런데 과연 저 말이 항상 맞는말일까?
상식적으로 생각해보면, 하나의 트랜잭션 내에 Insert나 Update 작업의 경우 예외가 발생하면 자동으로 롤백처리가 된다.
즉 항상 롤백 관련된 기능적인 코드를 개발자가 작성할 필요는 없다.
여러 트랜잭션을 물고 있는 작업을 할 경우, 미처 롤백되지 못한 이전의 트랜잭션 작업들을 원래대로 되돌릴 때 필요한 것이다.
아무튼, 정말 낙관적락을 사용하면 OptimisticLockException이 발생하는걸까? 궁금해져서 실험을 해봤다.
우선 다음과 같이 CouponInventory라는 Entity를 생성하고, Coupon을 발급할 때 마다 remainingCoupons라는 필드값을 증가시키도록 했다.
@Version 어노테이션을 사용하여 version을 명시해주면, 데이터에 수정이 가해질 때 마다 자동으로 버전 값이 증가한다.
해당 값은 개발자가 임의로 조작하거나 해서는 안된다.
처음에 작성한 로직을 이렇다.
쿠폰을 발급하고 couponInventory.plusCount() 메서드를 통해 remainingCoupons 갯수와 version 정보를 업데이트해주었다.
그리고 테스트코드를 다음과 같이 작성하였다.
30여개의 멀티스레드가 마련된 환경에서 200번 정도 동시에 쿠폰을 발급 받는다면, 동시에 발급 받는 트랜잭션 간에 Version 정보가 다르기 때문에 무조건 OptimisticLockException이 발생하지 않을까? 생각했다.
하지만 무슨 수를 써도 예외를 잡을 수 없었다. 테스트 결과는 다음과 같다.
만약에 모든 트랜잭션이 순차적으로 데이터에 접근하고 쿠폰을 발급받았다면, 정확히 100개가 발급되어야 한다. 즉 4건 혹은 그 이상의 정충돌이 발생하여 해당 트랜잭션 작업은 커밋되지 않았기 떄문에 96개의 쿠폰만이 발급된 것이다.
그렇다면 왜 예외가 발생하지 않은 것일까?
원인은 @Transaction 어노테이션에 있었다.
스프링은 AOP를 활용하여 트랜잭션 어노테이션이 붙어있는 메서드 위 아래에 트랜잭션 시작과 커밋 관련 로직이 추가된
프록시 클래스를 생성한다. 그리고 해당 프록시 클래스를 실제 서비스 클래스의 스프링 빈으로 주입해준다.
다음과 같이 기존의 issuCoupon 위 아래 빨간색 네모로 표시한 부분에 트랜잭션 로직이 들어가게 된다.
문제는 트랜잭션이 커밋되는 시점이 해당 메서드가 끝나는 시점이라는 것이다.
JPA는 기본적으로 더티체킹 가능을 제공하는데, update 쿼리가 나가는 시점은 트랜잭션이 커밋될 때 DB에 반영된다.
즉 이미 메서드가 정상적으로 실행되고 커밋되는 시점에 예외가 발생하기 때문에, 어플리케이션 로직에서 예외 관련 로그를 확인할 수 없는
것이었다.
따라서 Facade 패턴을 활용하여, 특정 메서드에서 issueCoupon메서드를 호출하는 식으로 다음과 같이 변경해봤다.
기존 예외 로직이 issueCoupon 메서드 안에 있는 것과 다르게, 해당 로직은 Facade 메서드에 try catch가 구현되어있다.
이렇게 되면, CouponServiceV4 프록시 클래스의 메서드가 실행되고 나서 발생되는 예외를 catch 할 수 있다.
테스트를 실행하보면 다음과 같이 OptimisticLock 관련 Exception이 로그로 찍히는 것을 확인할 수 있다.
두 락 방식 비교
그렇다면 각각의 락 방식에 따른 차이는 어떻게 될까?
100,000명이 접속하여 10,000개의 쿠폰을 발급 받는 상황을 가정해보도록 하자.
먼저 비관적 락 방식의 테스트 코드이다.
결과는 다음과 같다. 약 22초정도 소요가 되었고, 10,000건의 쿠폰이 정상적으로 발급되었다.
다음은 낙관적 락 방식의 테스트 코드이다.
결과는 다음과 같다. 약 22초 정도가 소요되었고 10,000건의 쿠폰이 정상적으로 발급되었다.
생각보다 성능적인 측면에서 두 락 방식이 차이가 안나서 당황스러웠다.
당연히 실제로 DB에 락을 획득하는 비관적 락 방식보다 어플리케이션 레벨에서 처리하는 낙관적 락 방식이 훨씬 빠를 것이라고 생각했는데, 성능 측면에서 비슷한 결과가 나왔다.
내가 놓치고 있는 부분이 있을 것 같다는 생각이 들었는데, 당장은 원인을 파악하지 못했다.
하지만 눈에 띄게 드러난 차이는, 요청에 대한 순차적인 처리 여부였다.
만일 100,000건의 요청이 아니라 10,000건의 요청이 동시에 들어온다면 둘의 차이는 어떻게 날까?
먼저 비관적 락의 테스트 결과이다.
6건 정도의 요청이 처리가 안된 것을 볼 수 있다.
다음은 낙관적 락의 테스트 결과이다. 거의 절반 정도의 요청이 처리가 되지 못한것을 확인할 수 있다.
성능적인 측면에서는 비록 내가 원했던 결과는 아니지만, 충돌 가능성이 많은 환경에서는 낙관적락 방식보다 비관적 락 방식을 쓰는게 바람직하다는 것을 해당 테스트를 통해 확인해 볼 수 있었다.
'Projects' 카테고리의 다른 글
쿠폰 발급 프로젝트 (1) (0) | 2023.11.12 |
---|