본문 바로가기
프로젝트

JPA에서 동시성 이슈 해결하기

by eunyoung 2023. 4. 5.
트랜잭션 동시성 이슈 해결하기

 

이번 프로젝트에서 특정 컨텐츠를 클릭하면 조회수를 +1 해주는 기능이 있었다.

 

아래 그림에서 보는 것과 같이 유저가 특정 컨텐츠를 클릭하면 해당 컨텐츠의 조회수가 +1이 된다.

 

해당 프로세스에서 동시성 이슈가 발생할 수 있고 이에 대해서 포스팅 해보고자 한다.

 

 

 

 


조회수 증가 API의 전체 프로세스


조회수 증가 API의 전체 프로세스는 다음과 같다.

 

조회수 증가 API의 전체 프로세스

1. 사용자가 특정 컨텐츠를 클릭함.

2. 조회수 증가 API가 애플리케이션 서버에 요청됨

3. 동일한 IP에서 특정 URL을 조회한 기록이 있는지 검사(특정 IP에서 여러번 같은 컨텐츠를 클릭해도 조회수는 1이어야 하므로)

4. 있으면 조회수 +1 로직 처리를 안함
5. 없으면 조회수 +1 로 업데이트

 

 

아래는 조회수 증가 API의 코드이다.

 

하지만 해당 프로세스에서 동시성 문제가 발생할 가능성이 있었다.

 

 


어떤 동시성 문제가 발생할까?


해당 컨텐츠 엔티티의 조회수를 업데이트 하는 로직은 JPA의 더티체킹을 통해서 이루어진다.

 

JPA의 더티체킹이란? 상태 변경 검사라는 것이고, JPA에서 트랜잭션이 끝나는 시점에 엔티티 최초 조회 상태와 비교해서 변경된 부분을 데이터베이스에 자동으로 반영해주는 것을 말한다.

 

즉, 해당 조회수 업데이트 트랜잭션이 커밋되는 시점에 엔티티의 스냅샷(엔티티 조회 상태시 상태)과 비교하여 변경된 컬럼이 있는지 확인하고, 있다면 변경된 엔티티에 update쿼리를 실행하는 방식이다.

 

더티체킹을 사용하면 직접 update 쿼리를 실행시키지 않는다는 장점이 있지만 이 부분 때문에 조회수 로직에서 동시성 문제가 발생하였다.

 

문제상황을 그림으로 그려보면 다음과 같다.

 

유저 A가 트랜잭션 1이고, 유저 B가 트랜잭션 2라고 가정하면,

 

유저 A가 트랜잭션 1에 진입을 한 이후, 해당 엔티티를 조회해오고 조회수는 0이다.

이후, 유저 B가 트랜잭션2에 진입을 한 후, 해당 엔티티를 조회해온다. 이때 트랜잭션 1이 커밋되기 이전이므로 조회수는 0이다.

 

이어서 유저 A가 조회수 증가 로직을 실행시키고 트랜잭션 커밋을 하게 되면 조회수는 0->1 즉, 1로 변한다.

이후 또 유저 B가 조회수 증가 로직을 실행시키고 트랜잭션 커밋을 하게 되므로 이때도 조회수 0->1 즉, 1로 변한다.

 

트랜잭션 1,2가 모두 커밋된 후, 실제 해당 엔티티의 조회수는 +2가 되어야 하지만, 결과적으로는 +1이 된다.

 

 


해결 방법 찾아내기


이 문제를 해결하기 위해서 생각해낸 해결 방법은 크게 4가지가 있었다.

 

트랜잭션 격리레벨 조정하기, 비관적 락 적용, 낙관적 락 적용, JPA의 더티체킹 적용하지 않기였다.

 

 


트랜잭션 격리레벨 조정하기


첫번째로 고민해본 해결 방안은 트랜잭션 격리레벨 조정하기이다.

 

이 프로젝트에서 사용하고 있는 DB는 Mysql 8.0 InnoDB이다.

기본적으로 트랜잭션 격리레벨은 REPEATABLE-READ였다.

 

이보다 한단계 위의 격리수준인 Serializable을 한번 적용해보면 어떨까 생각을 했다.

Seriablizable은 한 트랜잭션을 다른 트랜잭션으로부터 완전히 분리하는 격리수준이다.

다른 트랜잭션 격리레벨의 모든 부정합 문제를 해결하지만, 동시 처리 성능도 매우 떨어진다. 따라서 애플리케이션 성능이 떨어질 수 있다.

 

처리 성능도 매우 떨어지지만 이 경우 DeadLock문제도 발생한다.

그림으로 보면 다음과 같다.

 

 

트랜잭션 1이 먼저 해당 엔티티의 조회수 읽기 작업을 하기 위해서 S-LOCK을 획득한다.

이후, 트랜잭션 2도 해당 엔티티의 조회수 읽기 작업을 하기 위해서 S-LOCK을 획득한다.

(S-LOCK은 한 트랜잭션이 데이터를 읽기 위해서 걸어도 그 이후 다른 트랜잭션이 S-LOCK을 거는 것이 가능하다.)

 

읽기 작업 이후 트랜잭션1이 조회수 증가를 위해 Update 쿼리를 실행하기 위해서 X-LOCK을 획득한다.

하지만 트랜잭션 2가 S-LOCK을 가지고 있으므로 트랜잭션1은 X-LOCK을 획득을 하지 못하고 대기상태에 빠진다.

 

트랜잭션2도 마찬가지 상황이 벌어진다.

트랜잭션2가 업데이트 쿼리를 실행하려고 X-LOCK을 얻으려고 하면, 트랜잭션1이 S-LOCK을 가지고 있으므로 대기상태에 빠진다.

 

이로써, 두 트랜잭션이 대기상태에 빠지는 DeadLock 상태가 발생한다.

 

따라서, Serializable은 데드락 발생문제도 있고, 성능 상에도 영향을 많이 미친다고 판단해서 다른 해결방안을 고려하게 되었다.

 

위에서 나온 S-LOCK과 X-LOCK이란?

S-LOCK(Shared Lock)은 공유락으로 읽기 잠금이다.

어떤 트랜잭션에서 데이터를 읽고자 해당 데이터에 공유락을 걸면 다른 트랜잭션의 S-LOCK은 허용이 되지만, X-LOCK은 걸수가없다. 즉, 한 트랜잭션에서 S-LOCK을 걸어서 데이터를 읽으면 다른 트랜잭션에서는 그 해당 데이터를 읽을 수는 있지만 수정할 수는 없다.

 

X-LOCK(Exclusive Lock)은 배타락으로 쓰기 잠금이다.

어떤 트랜잭션에서 데이터를 변경하고자 배타락을 걸면 다른 트랜잭션의 S-LOCK과 X-LOCK 둘다 허용이 안된다.

즉, 한 트랜잭션에서 X-LOCK을 걸어서 데이터를 변경하고자 하면 다른 트랜잭션에서는 그 해당 데이터를 읽을 수도 없고 변경할 수도 없다는 것이다.

 

 

 


비관적 락 적용하기


두번째로 고려한 해결책은 비관적 락이다.

 

조회수 조회 메서드에 비관적 락을 걸어주면 트랜잭션 1이 트랜잭션을 시작할때 배타락을 얻게 되고, 이후 트랜잭션 2는 트랜잭션 1이 커밋될때까지를 기다려야 한다.

 

이 경우, 트랜잭션 2개가 실행이 되었는데도 조회수가 +2로 바뀌지 않는 데이터 정합성 문제가 해결된다.

그리고 위의 트랜잭션 격리 수준 Serializable로 인해서 발생한 데드락 문제 모두 다 해결이 된다.

 

하지만 이 경우 특정 트랜잭션이 시작되면 다른 트랜잭션들이 대기 시간이 너무 길어진다는 문제점이 발생한다.

 

다음 그림과 같다.

 

 

 

조회수 동시성 문제는 아주 자주 발생하는 문제는 아니라고 생각했는데 이를 위해서 비관적 락을 걸어주는 것은 애플리케이션 전체 성능을 너무 낮출 수 있다고 생각했다.

이 이유로 비관적 락은 사용하지 않고 다른 방안을 고려하게 되었다.

 

 

 


낙관적 락 적용하기


비관적 락이 적합하지 않다는 판단을 하고 이후 낙관적 락에 대해서 고려하게 되었다.

 

하지만 낙관적 락을 건다고 조회수 동시성 문제인 데이터 정합성 문제가 해결되지는 않았다.

낙관적 락은 버전 정보를 활용해서 버전이 일치하는 경우에만 커밋을 하고, 버전이 일치하지 않는 경우에는 커밋되지 않고 롤백된다.

 

낙관적 락을 걸고 해당 조회수 프로세스에 대한 그림은 아래와 같다.

 

트랜잭션1이 시작되고 시작 시에는 버전이 0이었다.

그후, 트랜잭션 2가 시작되고 시작 시에는 트랜잭션 2도 버전이 0이다.

 

트랜잭션1이 업데이트 쿼리를 마치고 커밋 시에 자신이 가지고 있던 버전과 DB 버전을 비교했을때 0으로 같아서 성공적으로 커밋하고 버전을 1로 바꾼다.

이후, 트랜잭션2가 업데이트 쿼리를 마치고 커밋 시에 자신이 가지고 있던 버전과 DB 버전을 비교하는데 이때는 0과 1로 달라서 트랜잭션2가 커밋되지 않고 롤백된다.

따라서 조회수는 +2가 되어야 하는데 +1이 되는 문제는 낙관적 락을 걸어도 여전히 일어난다.

 

따라서 위에서 말한 데이터 정합성 문제를 해결하지는 못한다.

 

이로써 낙관적 락으로도 해결하지 못하였다.

 

 


JPA의 더티체킹을 이용하지 말고 update 쿼리 직접 실행하기


트랜잭션 격리레벨 조절로도 해결이 안되고, 낙관적 락, 비관적 락도 적합하지 않다는 판단을 하고, 이후 해결방법으로 고려한 것은 JPA의 더티체킹을 사용하지 않는 것이었다.

 

즉,더티체킹을 이용하지 말고 update 쿼리를 이용해서 직접 데이터를 업데이트 시키는 방법이다.

 

앞에서 말한 JPA의 더티체킹으로 인한 장점은 포기해야하지만, 조회수 데이터 정합성 문제는 해결할 수 있었다.

데이터베이스에서 자체적으로 제공해주는 배타락(X-Lock)으로 인해서 정합성 보장도 가능하다.

 

JPA의 더티체킹을 사용하지 않으므로, 객체지향적인 관점에서는 좋은 방법이 아닐 수 있고 JPA 더티체킹을 장점도 포기해야 하지만 위에서 살펴본 다른 방법에 비해서 어느 정도 성능도 보장되고 데이터 정합성 문제도 해결이 되어서 이 방법을 채택하게 되었다.

 

 

 

 


참고