본문 바로가기
JAVA/Spring & Java 학습 기록

동시성 이슈와 데드락 문제를 비관적 락를 이용해 해결하다.

by 구본식 2023. 6. 28.

문제 상황


봉사 매칭 서비스 사이드 프로젝트에서 다중 사용자가 봉사 일정 신청시 동시성 이슈가 발생했다.

즉, 봉사 일정에 참가 가능한 인원이 한명이고, 3명의 신청자가 동시에 참가 신청을 했을 때 동시성 이슈로 인해 모두 참가되는 문제가 발생한 것이다.

개발 중인 서비스 DB의 일부분이다.

앞서 문제를 다시 설명하자면, 일정 테이블:일정 참가자 테이블=1:N 관계이고 사용자 3명이 동시 신청시 일정 참가자 테이블에 3개의 데이터가 모두 insert 되는 것이다.

간단히 테스트를 통해 실제 동시성 이슈가 발생하는지 보도록 하자.

위는 코드는 일정 참가 신청시 호출되는 Service 메서드이다. 

간략히 설명하자면, 아래의 플로우로 일정 신청이 진행된다.

  1. 일정 검증(존재 여부, 참여 가능 일자 여부)
  2. 일정 참가 인원 검증 
    • 현재 Schedule Entity에는 현재 참가 인원을 파악하는 필드가 별도로 존재하지 않으므로 직접 쿼리를 통해 참가 인원을 파악하게 됨.
  3. 기존 일정 신청 내역존재 여부 검토(재신청) 및 신규 신청

테스트 코드를 통해 결과를 확인해보자!

일정에 참가 가능한 인원은 1명이고, 3명의 참여자가 동시에 신청하는 상황을 테스트해보았다.

정상적이라면, 1명의 신청자만 DB에 들어가야 할 것이다.

 

결과는?.....

3명의 신청자 모두 일정에 참가해버리는 상황에 발생했다....  그럼 왜 이런 현상이 일어날까?

시퀀스 사용자1(Tx1) 사용자2(Tx2) 사용자3(Tx3)
1 일정 A를 찾음    
2   일정 A를 찾음  
3     일정 A를 찾음
4 일정 참가 가능인원이 남아있는지 검증: o    
5   일정 참가 가능인원이 남아있는지 검증: o  
6     일정 참가 가능인원이 남아있는지 검증:o
7 일정 참여자 테이블에 저장    
8   일정 참여자 테이블에 저장  
9     일정 참여자 테이블에 저장
10 Tx1 commit    
11   Tx2 commit  
12     Tx3 commit

현재 프로젝트의 MySQL 버전은 8.0.x이고 기본 격리 레빌의 REPEATABLE_READ를 사용하고있다.

간략하게 설명하자면, 일정 A를 3명의 사용자가 동시에 조회하고, 참가 가능인원을 검증했을 때 모두 True가 나오기 때문에 3명의 참가자 모두 DB에 insert 되는 것이다.!

 

그럼 어떻게 해결해야 할까?

아래의 두가지 이유로 인해 먼저 어플리케이션 Level의 JPA Vesion 관리를 통한  낙관적 락을 사용하는 것을 고려해보았다.

  1. 충돌(트랜잭션 간)이 빈번하게 발생할 수 있는 상황이 아니다.
  2. DB Level인 비관적 락을 사용하면, 동시성 간 성능이 급격히 떨어질 수 있다

 

낙관적 락으로 해결해보자


조회 시에도 버전 체크가 이루어질 수 있도록 OPTIMISTIC 옵션을 사용했다.

또한, JPA를 통한 낙관적 락을 사용하기 위해 Version 필드도 추가했다.

 

commit 시 Version를 통한 동시성 유무를 파악하기 위해 Schedule Entity에 참가 인원 필드를 추가했다.

그럼, 수정된 Service의 메서드를 확인해보자.

앞서와 큰 차이점은 없지만, 변경된 사항은 참가 인원 수의 필드가 추가되었기 때문에

해당 필드를 통한 참여 인원 수 검증이 이루어지고, 정상 흐름 시 참가 인원 수를 증가 시키는 부분이 추가 되었다.

 

그럼 과연 동시성 이슈가 해결됬는지 테스트해보자....두둥!!

상황은 앞서와 마찬가지이다.(일정 남은 자리1, 3명 동시 신청)

예상대로면, 동시성 문제가 발생해야하고 해당 예외를 catch 했기 때문에 "동시성 문제 발견"이 로그로 남을 것이다.

네?...Deadlock 이요?....저는 DB Lock 기능을 사용한 적이 없는데요?....

자세한 상황을 위해서 history를 살펴보았다.

history에 따르면,

Tx1이 Schedule 리소스에 S lock을 걸고 X lock을 얻기 위해 waiting 한다.

Tx2도 Schedule 리소스에 S lock을 걸고 X lock을 얻기 위해 waiting 한다.

 

S lock과 X lock이 무엇인지를 먼저 MySQL 레퍼런스와 검색을 통해 알아보았다.

S lock 과 X lock 이란?
공용 lock(S lock)
- read 연산 실행 가능, write 및 update 연산 실행 불가능
- 리소스에 대해 여러 트랜잭션이 함께 가질 수 있다. 즉 하나의 리소스에 대해 서로 다른 트랜잭션이 S Lock을 걸 수 있다.

베타 lock(X lock)
- read ,write 연산 모두 실행 가능
- 리소스에 대해 하나의 트랜잭션만 X lock을 걸 수 있다. 즉, 하나의 트랜잭션이 리소스의 독점권을 가진다.
- 다른 Lock들과 호환이 되지 않는다. 즉, X lock-X lock 안됨!, X lock-S-lock 안됨!

 

결론적으로, 데드락이 발생한 이유를 정리해보자면 아래와 같다.

  1. Tx1이 일정 참여자 테이블에 참여자 정보를 insert 한다.
    • 이때❗ FK로 있는 일정 레코드에 S Lock을 건다.
  2. 마찬가지로, Tx2이 일정 참여자 테이블에 참여자 정보를 insert 한다.
    • 이때❗ FK로 있는 일정 레코드에 S Lock을 건다.
    • 같은 리소스에 S Lock 끼리는 호환이 되므로, 문제가 되지 않는다.
  3. Tx1가 일정 엔티티의 참여자 수 필드값을 update 하기 위해서, 일정 레코드에 X Lock를 시도한다.
    • Tx2 가 해당 일정 레코드에 S Lock을 걸었고, S Lock 과 X Lock은 호환이 되지 않기 때문에, S Lock이 풀릴때까지 대기한다.
  4. 마찬가지로, Tx2도 일정 엔티티의 참여자 수 필드 값을 update하기 위해, 일정 레코드에 X Lock를 시도한다.
    • Tx1 가 해당 일정 레코드에 S Lock을 걸었고, S Lock 과 X Lock은 호환이 되지 않기 때문에, S Lock이 풀릴때까지 대기한다

두 트랜잭션 모두 X Lock를 획득하기 위해서 서로가 걸어준 S Lock를 해제할 때까지 기다리기 때문에, 데드락 현상이 발생한 것이다.

 

FK 제약 조건이 있는 테이블이 있는 경우 낙관적 락을 활용하여 동시성 문제를 해결할 수 없다. 개발자가 Lock을 걸지 않아도 DB 내에서 데이터 일관성을 지키기 위해서 자체적으로 사용하기 때문이다.

 

그럼 어떻게 해결해야 할까?

동시에 요청하는 상황이 발생하긴 하겠지만, 서비스의 규모가 크지 않아 이러한 상황이 많지 않을거라 생각했다.

그러므로, 성능 이슈의 영향도 적을 거라 판단하고 비관적 락을 사용해 해결해보고자 한다.

 

비관적 락으로 해결해보자


JPA의 @Version 기술이 아닌 DB의 락 기능을 사용하기 때문에, 추가한 Version 필드를 없애고 

PERSSIMISTIC_WRITE 옵션으로 수정했다.

락 옵션 변경 외에 변경된 사항은 없다. 그럼 테스트를 해보자..!!

비지니스 로직에서 인원 마감 시, BusinessException(ErrorCode.INSUFFICIENT_CAPACITY ~) 예외가 발생하도록 했으므로 테스트 결과 "인원 마감~" 의 로그가 출력되어야 할 것이다. 과연...결과는?.....

테스트 결과 2명의 사용자의 요청에 대해서 인원 마감 처리가 되었고, 실제 DB에도 한명의 참가자 정보만 들어온 것을 볼 수 있다.

ㅇㅇㅇㅇ

 

또한, 쿼리 결과를 보면 select for update 가 사용된 것을 볼 수 있다.

먼저 들어온 트랜잭션이 Schedul 레코드 조회 시 Lock를 했기 때문에, 

다른 트랜잭션는 개입할 수 없어 동시성 문제가 해결된 것을 볼 수 있다.

 

 

 

 

 

 

 

이처럼 비관적 락을 사용해서 해당 서비스의 동시성 문제는 해결할 수 있었다. 

 

하지만 비관적 락을 사용할 때 문제점은 아래와 같이 여전히 존재한다.

  1. 여전히 존재하는 데드락 문제
    • 비관적 락에서도 마찬가지로 데드락 이슈가 발생 할 수 있다.
      1. Tx1이 A 테이블 a 레코드에 Lock를 건다.
      2. Tx2가 B 테이블 b 레코드에 Lock을 건다.
      3. Tx1이 B 테이블 b 레코드에 접근, Tx2가 A 테이블 a 레코드에 접근하게 되면 마찬가지로 데드락이 발생
    • 현재는 하나의 테이블의 특정 레코드에만 Lock을 걸었기 때문에 발생할 수 있는 문제는 하니지만, 
      서비스의 규모가 커지고 트래픽이 많이 몰렸을 경우 충분히 발생할 수 있는 문제라고 생각이 든다.
  2. 성능 이슈
    • 비관적 락은 모든 트랜잭션에 대해서 Lock를 걸게 된다. 많은 트래픽이 발생할 경우 순차적으로 트랜잭션이 처리되기 때문에 성능 이슈가 발생할 수 있고, 뒤에 요청한 트랜잭션들이 모두 Blocking 되어 타임아웃이 발생할 수 있다.
Blocking이란?
트랜잭션 간 Lock 경합으로 인해 트랜잭션이 더 이상 작업을 진행하지 못하고 멈춰진 상태.
하나의 트랜잭션이 리소스에 베타 Lock을 걸게 되면, 다른 트랜잭션은 어떠한 Lock도 걸지 못하고 대기하게 된다.

예를 들어, 치킨 쿠폰 이벤트와 같이 동시에 많은 트래픽이 몰릴 경우, 성능 이슈와 여러 테이블간 Lock을 통한 데드락으로 인해 낙관적 락만으로는 해결할 수 없다. 아래는 해당 동시성 이슈에 대해 공부하면서 알게 된 방식들이다.

(나중에 나도 저런 경험을 꼭 해봤으면...🙏)

- Redis Sorted Set 활용

https://www.youtube.com/watch?v=MTSn93rNPPE&t=710s

- Kafak 를 이용한 메시징 큐 활용 등

 

또한, 낙관적 락과 비관적 락은 싱글 DB 환경에서 적용 가능한 기술이다. 만약 분산 DB 환경 일 경우에는 분산 락 방식도 이용할 수 있다고 한다.

프로젝트 진행 간 시간 여유가 생긴다면 이 방식도 사용해봐야겠다.!!

 

참고 자료