기록하며 성장한다 - 개발, 회고

개발 회고록

[ 개발 회고록 ] 낙관적 Lock 에서 @Transacional 을 사용하면 안되는 이유 + Lock 을 통한 라이더 배차 서비스 개발

전대홍 2024. 1. 23. 19:57

배차 이력 테이블의 라이더 아이디와, 주문 테이블에 잡힌 라이더 아이디가 왜 다르지...?

 


서론

현재 배달과 관련 된 토이프로젝트를 진행하고 있다.

토이 프로젝트 링크 <<<

필자가 진행하는 프로젝트에는 라이더들이, 근처 10KM 이내의 배차대기중 상태의 주문 정보들을 확인 할 수 있고, 그 중에서 라이더가 원하는 주문 정보를 잡아, 본인이 배달을 하겠다고 신청하는 로직이 있다.

이 부분을 테스트 할 때 동시에 여러 스레드에서 접근을 하면, 한 가지 문제가 발생하는 것을 알 수 있었다.

 


본론

1. 문제 원인

생기는 문제는, 주문 테이블의 배달 신청 라이더 컬럼이 있는데, 이 부분과 실제 배달을 진행하려는 라이더의 ID가 일치하지 않는 문제였다.

필자는 여러 라이더가 동시에 접근하여도, Insert 문으로 배차를 기록하기 때문에, 한 번에 2명 이상의 라이더가 배차가 되는 문제는 없었다.

그러나 주문 테이블에 담기는 라이더의 ID 는 처음 잡힌 라이더가 아닌, 마지막에 잡힌 엉뚱한 라이더의 ID가 들어가는 문제가 발생한 것이다.

이 문제는 라이더가 배차를 잡는 로직이 단일 연산이 아니므로, 아주 적은 시간차이로 2개 이상의 스레드가 접근하였을 때, 데이터의 atomic이 보장되지 않는 문제가 발생한 것이며, 일종의 레이스 컨디션 문제라고 생각하였다.

 

 

2. 낙관적 Lock

필자는 설계를 할 때, 애플리케이션 서버는 여러대를 두어야겠다고 설계를 하였지만, DB는 한 대만 두는 것을 생각하였다. 그래서 이 부분은 낙관적 Lock 으로 접근을 해야겠다고 생각했다.

무엇보다, 항상 충돌을 하는 문제도 아니고, 가끔가다 한 번씩 그리고 작업들 중 하나만 성공시키게끔 해야하는 문제이니 낙관적 Lock 으로 해결하는 것이 좋다고 판단하였다.

이전에 마주한 포인트 전환 문제에서는 Redisson Lock 을 활용하였는데, 이번에는 다른 Lock 을 통해 문제를 해결해보고 싶은 생각도 있었다.

낙관적 락은 Application 단에서 로직으로 해결하지만, JPA @Version 과 함께 DB의 version 컬럼을 통해 락을 거는 방식이다.

쉽게 설명하면, 동시에 2개의 요청이 들어왔을 때,

1명이 접근하여 값을 변경하였을 때 Version 이 갱신되고, 다음 접근에서는 그 Version 이 업데이트 되었으므로 본인이 찾고자하는 버전과 달라 갱신을 할 수 없게된다.

해당 내용에 대해서는 필자가 TIL 블로그에 정리한 글이 있으니 참고하면 좋다. ( TIL 블로그 포스팅 링크 <<< )

 

 

3. 문제 해결

필자의 깃허브에 들어가면, 더 자세하게 확인 할 수 있다.

지금은 간단하게 설명하면, OptimisticLockFacade 라는 클래스를 만들어 위와 같은 로직을 작성하였고,

서비스단과 리파지토리에서는 JPA 에서 제공해주는 @Lock(LockModeType.OPTIMISTIC) 을 활용하여 낙관적 Lock 을 걸었다.

또한, 컨트롤러에서 불러올 때는 해당 서비스를 직접 호출 하는 것이 아니라, Redisson Lock 때 했던 것처럼, Facade 를 호출하면 된다.

해당 방법으로 수정한 후 테스트를 진행하였고,

필자가 원하는 생각한 것처럼,

주문 테이블과 배달 이력 테이블에 같은 RIDER_ID 가 들어가게 되었다.

 

 

4. 새로운 문제

이 부분을 해결하면서 재밌는 문제를 만나게 되었다.

바로 테스트 코드에서 Lock 은 획득하는 거 같은데.. while 문을 탈출하지 못하고 코드가 계속 반복되는 문제였다.

처음에는 이 문제가 뭔지를 몰랐지만, 여러 테스트와 그리고 블로그를 찾아보며 문제를 해결할 수 있었다.

바로 Facade 에 Transactional 어노테이션을 붙인게 문제였다.

필자는 Facade 에서 배차 서비스 로직을 불러오는데, 해당 배차 서비스 로직에만 Transactional 이 걸려있어야하며, 그 바깥에 있는 로직인 Facade 에는 Transactional 이 걸려있으면 안된다.

그 이유는 필자가 사용하는 DB가 MySQL이기 때문이다.

MySQL 은 기본 Isolation Level 이 REPEATABLE READ 이다. 그렇기 때문에 처음 SELECT 한 값은 트랜잭션이 끝나기 전까지 몇 번을 SELECT 해도 동일한 값을 읽게 된다.

그런데 낙관적 LOCK 은 Version 을 활용한다고 위에서 설명하였다.

그래서 첫 Version 이 1이라고 가정한다면, 모든 스레드들이 트랜잭션이 끝날때까지는 Version 을 1로만 읽는다는 것이므로, 무한히 실패하며, 무한히 로직이 실행되는 문제가 발생한 것이다.

따라서 한 트랜잭션 안에서 업데이트와 재시도 로직이 동시에 진행되지 않도록, @Transactional 을 제거해주어야 한다.

정말로 격리 수준 때문인지 궁금하다면, Oracle 에서 진행해보거나, 격리 수준을 READ COMMITTED 로 바꾸고 테스트를 진행해보기를 바란다.

 


결론

Lock 도 상황에 따라서 사용할 수 있는 방법이 여러가지가 있다.

어떠한 상황에서 어떠한 방식의 Lock 을 걸면 좋은지를 염두해두고 있으면 좋다.

또한 본론 4번의 문제는 필자도 처음 봉착한 문제였는데, 새로운 경험을 하고 문제를 해결 할 수 있어서 좋았다.