본문 바로가기
프로젝트

JPA에 락(Lock) 걸어서 동시성 이슈 해결하기

by eunyoung 2023. 3. 19.
JPA에 락(Lock) 걸어서 동시성 이슈 해결

 

 

프로젝트를 진행하다가 발생한 동시성 이슈 관련해서 트러블 슈팅한 과정을 포스팅하고자 한다.

 

도서문장 공유 서비스인데 여기에는 피드에 좋아요를 누르는 기능이 있다.

 

 

위의 그림을 보면, 유저가 피드에 좋아요를 하고 싶으면 위의 그림의 하트 버튼을 눌러서 하면 된다.

 

하지만 이 기능에서 동시성 문제가 발생했다.

 

피드 좋아요를 아주 짧은 시간 안에 여러 번 누를때 중복된 데이터가 쌓이는 이슈가 발생했다.

 

 

 


피드 좋아요 API의 프로세스


피드 좋아요 API의 프로세스는 다음과 같다.

 

1. 좋아요 API가 서버에 요청된다.

2. 서버는 유저 Id와 피드 Id를 알아내서 동일한 유저 Id - 피드 Id 쌍이 존재하는지 확인한다.

3. 중복으로 확인되면 예외 메세지를 보낸다.

4. 중복이 아니면 유저 Id와 피드 Id를 피드 좋아요 테이블에 Insert한다.

 


좋아요 테이블에 유저 Id와 피드 Id는 Insert되기 이전에 중복 체크를 하기 때문에 유저 Id와 피드 Id가 중복인 컬럼이 존재하면 안된다.

 

 


동시성 이슈가 발생한 원인


원인을 그림으로 보면 다음과 같다.

 

 

 

트랜잭션 1이 커밋되기 이전에 트랜잭션 2가 시작되므로 발생한다.

 

트랜잭션 1이 커밋되기 전인 시점에 트랜잭션 2가 시작되고 이때 트랜잭션 2의 피드 좋아요가 존재하는지 확인하는 메서드(위의 그림에서 빨간 박스부분)에서 false를 반환하는 것이다.


그래서 결과적으로 트랜잭션1과 트랜잭션2가 둘다 커밋이 되면서 동일한 피드 좋아요가 2개가 생기는 문제가 발생하는 것이다.

 

 


해결 방법 고려하기


해결 방법으로 고려한 것이 대표적으로 3가지가 있다.

 

데이터베이스의 트랜잭션 격리 수준 변경하기, 낙관적 락 적용하기, 비관적 락 적용하기이다.

 

 

 


트랜잭션 격리 수준 변경하기


먼저 고려한 해결 방법은 데이터베이스의 기본 트랜잭션 격리 수준 변경하기이다.


데이터베이스의 기본 트랜잭션 격리 수준이란 여러 트랜잭션이 동시에 처리될때 특정 트랜잭션이 다른 트랜잭션에서 변경하거나 조회하는 데이터를 볼 수 있게 허용할지 말지를 결정하는 것이다.

 

오라클 DB의 기본 트랜잭션 격리수준은 Read-Commited이다.  

MySQL의 InnoDB의 기본 트랜잭션 격리수준은 Repeatable Read이다.  

 

각 트랜잭션 격리수준에 대해서 간략하게 정리하면 다음과 같다.

 

  • READ-UNCOMMITTED : 트랜잭션의 변경 내용이 commit이나 rollback 여부에 관계없이 다른 트랜잭션에서 보임.  

  • READ-COMMITED : commit이 완료된 데이터만 다른 트랜잭션에서 조회할 수 있음.  

  • REPEATABLE-READ : 트랜잭션에 진입하기 이전에 커밋된 내용만 참조할 수 있음.  

  • SERIALIZABLE : 한 트랜잭션에서 읽고 쓰는 레코드를 다른 트랜잭션에서 절대 접근할 수 없음. 동시성 처리 성능 측면에서 4가지 트랜잭션 격리 수준 중에서 제일 안좋음.      

현재 이 프로젝트에서 사용하는 DB는 Mysql 8.0 InnoDB이므로 트랜잭션 격리수준은 Repeatable Read이다.

여기서 좋아요 기능 동시성 처리를 한다고 데이터베이스 기본 격리수준을 SERIALIZABLE로 바꾸는 것은 동시성 문제를 해결할 수는 있으나 전체 애플리케이션 성능을 너무 떨어지게 만든다고 생각했다. 또 DeadLock 문제로 발생할 가능성이 있었다.

 

따라서 이 방법은 적용하지 않기로 했다.

 

 

 


비관적 락 고려하기


다음으로 고려한 방법은 비관적 락이다.

 

비관적 락이란 트랜잭션 충돌이 발생한다고 가정하고, 우선적으로 락을 걸어보는 방법이다.

데이터베이스가 제공하는 락으로, 트랜잭션이 시작될때 Shared Lock 또는 Exclusive Lock을 걸고 시작하는 방법이다.

 

동시성이 많이 떨어지므로 특히 읽기가 많이 이루어지는 데이터베이스의 경우에는 손해가 더 많이 발생한다.

 

또한 비관적 락도 데드락이 발생할 가능성도 많다.

한 트랜잭션 1이 먼저 시작하고 S-Lock을 획득하고 그 이후 트랜잭션 2가 시작할때 S-Lock을 획득한다. 하지만 먼저 시작한 트랜잭션 1이 이후 동일한 컬럼에 대해서 X-Lock을 얻으려고 하면 트랜잭션 2에 걸려있는 S-Lock으로 인해서 획득하지 못한다. 트랜잭션 2도 동일한 상황이 발생한다.

따라서 데드락이 발생하는 것이다.

 

좋아요 기능은 거의 동시성 이슈가 발생하지 않는다고 생각하고 거의 예외적인 상황에서만 이 문제가 발생한다고 생각했다.

 

따라서 트랜잭션이 시작할때 무조건 락을 걸어주는 것은 매우 비효율적이고 전체 애플리케이션 성능을 떨어지게 하는 방법이라고 생각했다.

 

비관적 락은 적용하지 않기로 하였다.



 


낙관적 락 고려하기


마자막으로 고려한 방법은 낙관적 락이다.

 

낙관적 락은 트랜잭션 대부분은 충돌이 발생하지 않는다고 낙관적으로 가정하는 방법이다.

먼저 자원에 락을 걸지 말고, 동시성 문제가 발생하면 나중에 처리하자는 방법이다.

 

DB단에서 동시성을 처리하는 것이 아닌, 애플리케이션이 제공하는 락이다.

 

일반적으로 Version의 상태를 보고 충돌을 확인하며, 충돌이 확인된 경우 롤백시킨다.

 

많은 경우가 아닌 아주 예외적인 상황에서 좋아요 기능에서 동시성 이슈가 발생할 것이라고 생각했고, 이런 경우는 낙관적 락이 딱 적합하다는 생각이 들었다. 또 낙관적 락으로 해당 동시성 문제를 해결할 수 있었다.

 

따라서, 좋아요 기능에 낙관적 락을 걸어주기로 결론 지었다.

 

 

 


JPA에서 낙관적 락 적용하기


JPA에서는 @Version어노테이션을 사용하면 낙관적 락을 적용할 수 있다.

 

낙관적 락은 다음과 같은 순서로 진행된다.
  • 엔티티를 조회

  • 데이터를 수정 또는 저장

  • 마지막에 커밋할때 version을 비교한다

    이때 이전에 조회할때의 버전과 일치한다면 트랜잭션 커밋하고, 일치하지 않는다면 트랜잭션 롤백을 한다.

 

1. 엔티티에 버전 관리용 필드 추가

 

먼저, 엔티티에 버전 관리용 필드를 하나 추가하고 @Version을 붙이면 된다.

feedLike라는 피드 좋아요와 관련된 엔티티에  Version 필드를 아래 그림과 같이 추가해주었다.

 

2. 낙관적 락 옵션 적용

 

다음으로, 낙관적 락 옵션을 선택한다.

옵션으로는 None과 Optimistic, Optimistic_Force_Increment 등이 있다.

 

이중 제일 많이 쓰이는 Optimisitc과 None의 두개의 차이점은 다음과 같다.

  • None : 별도의 락 옵션을 지정하지 않아도 기본으로 적용되는 락 옵션, 엔티티를 수정하는 시점에 엔티티의 버전을 증가시킨다.

  • Optimistic : 엔티티를 조회만 해도 버전을 체크한다. 

 

피드를 조회한 시점부터 버전을 체크하는 것이 좋다고 판단하여 낙관적 락 옵션은 Optimistic으로 선택했다.

 

적용한 코드는 다음과 같다.

 

낙관적 락을 적용해서 동작되는 방식은 다음 그림과 같다.

 

 

트랜잭션 1이 트랜잭션을 시작하고 피드 조회할시 version은 0이다
이후 트랜잭션 2가 트랜잭션을 시작하고 피드를 조회하고 version은 0이다.

 

그후, 먼저 시작한 트랜잭션 1이 작업을 다 완료하고 트랜잭션을 커밋할때 버전 비교를 한다. 버전이 0으로 동일하므로 커밋에 성공하고 버전은 0 -> 1로 변한다.

이후, 트랜잭션 2가 작업을 다 완료하고 커밋하려고 할때 버전을 확인하는데 이때는 1과 0으로 다르므로 롤백시킨다.

 

이로써, 피드 좋아요 엔티티에 동일한 유저Id - 피드 Id 쌍이 Insert되는 것을 방지할 수 있다.

 

결과적으로 좋아요 기능 동시성 이슈 문제를 낙관적 락으로 해결할 수 있었다.

 

 

 


참고