기타/문제 해결

낙관적 락을 사용해 동시성 문제 해결

펭귄힝 2024. 5. 31. 15:41

 

 

오늘은 애플리케이션에서 발생한 동시성 문제를 해결해보겠다.

 

 

 

 

 

가장 먼저 이해를 돕기 위해 프로젝트의 간략한 구조에 대해 설명한다. 

 

하나의 플랫폼에는 멤버들이 여러 개의 리뷰를 작성할 수 있고 각각의 플랫폼들은 리뷰의 별점들을 토대로 평점을 가지고 있게 된다. (배달의 민족 리뷰 시스템과 비슷함)

 

 

이제 동시성 문제가 발생하는 코드를 살펴보자.

 

 

 

 

 

 

리뷰를 삭제하는 메서드이다. 삭제하고 난 뒤 refreshPlatformStar() 메서드를 호출하여 플랫폼의 평점을 업데이트한다.

 

 

 

리뷰를 작성하는 메서드이다. 이것도 refreshPlatformStar() 를 호출하여 플랫폼의 평점을 업데이트한다.

 

 

 

 

 

 

refreshPlatformStar() 메서드의 내부 코드인데, reviewRepository.findByStar() 메서드를 통해 작성된 리뷰들의 별점 합계와 갯수를 ReviewCountDto 로 매핑해온다. (이때, JPQL 을 사용한다.)

 

그리고 updateStar() 메서드를 통해 최종적으로 평점을 업데이트 시킨다.

(updateStar() 메서드는 플랫폼에 달린 리뷰 갯수와 별점 총합을 기준으로 평점을 구하고 엔티티에 반영시킨다.)

 

 

 

 

 

 

동시성 문제를 발생시키는 테스트 코드를 작성해준다. 

 

3점짜리 리뷰를 한 개 작성시킨 상태에서 삭제시키고 10점 짜리 리뷰를 추가한다.

 

우리가 기대하는 결과대로라면 플랫폼 평점이 10점이어야 한다.

 

 

 

 

 

하지만 예상과 다르게 플랫폼 평점이 10점이 아닌, 6점으로 나온다.

 

왜 이런 결과가 나온 것일까? 출력 로그를 보면 다음과 같다.

 

 

 

[삭제 쓰레드] 플랫폼 평점 업데이트 시도
[작성 쓰레드] 플랫폼 평점 업데이트 시도
[삭제 쓰레드] 플랫폼 평점 업데이트 완료 - 실제 DB 반영
[작성 쓰레드] 플랫폼 평점 업데이트 완료 - 실제 DB 반영

 

1. 삭제 쓰레드가 먼저 플랫폼 평점 업데이트를 시도한다. 이때 이미 reviewRepository.findByStar() 메서드를 통해 리뷰 0개를 가져온 상태다. (커밋되지 않음)

 

2. 작성 쓰레드가 플랫폼 평점 업데이트를 시도한다. reviewRepository.findByStar() 메서드를 통해 리뷰 2개를 가져온다.

 

3. 삭제 쓰레드가 리뷰 0개를 기준으로 평점을 반영하여 커밋한다. (플랫폼 평점 0점으로 반영된 상태)

└커밋된 시점이 되어서야 리뷰 갯수가 0개로 반영된다.

 

4. 작성 쓰레드가 리뷰 2개를 기준으로 평점을 반영하여 커밋한다. (10 + 3 / 2 = 6)

 

 

위와 같은 프로세스를 통해 진행되었기 때문에 당연히 플랫폼 평점은 6점으로 저장이 된다.

 

(여기서 왜 삭제 쓰레드와 작성 쓰레드가 가져온 리뷰 갯수가 다른지 궁금할 수 있는데, 이는 스프링에서 제공하는 @Transactional 과 JPA 에 대한 이해가 필요하다. 본 게시물 주제와 벗어나는 내용이므로 다루진 않는다.)

 

 

 

 

그럼 이러한 문제를 어떻게 해결할 수 있을까?

 

 

해결책 1. 동기화(Synchronized) 메서드 사용

 

위와 같이 메서드마다 synchronized 키워드를 붙여서 동기화 처리해주는 방법이다.

 

이렇게 하면 여러 개의 쓰레드가 한 번에 수정이나 삭제를 하려해도 어느 한 쪽이 끝나야 실행된다.

 

 

 

 

 

 

결과를 보면 이렇게 우리가 기대했던 바와 같이 평점이 10점으로 나온 것을 알 수 있다.

 

 

하지만 이 방법은 마치 해결된 것처럼 보이지만 좋은 해결책이 되진 못한다.

 

 

왜냐하면 이렇게 메서드 단위로 락을 걸어버린다는 것 자체가 여러 개의 요청이 들어왔을 때, 작성이나 삭제 작업 하나만 수행 가능하다는 말이 되고, 이는 결국 수행 속도를 느리게하며 성능 저하로 이어지게 된다.

 

 

그리고 또 다른 이유로는 분산 처리 환경에서 동기화되지 않는다.

 

 

 

 

 

해결책 2. 낙관적 락 사용하기

낙관적 락 방법을 통해 이 문제를 해결해보자.

 

여기서 말하는 낙관적 락이라는 것은 DB 에 버전을 기록해두고 실제 커밋되는 시점에 내가 가져온 버전과 DB에 있는 버전을 비교하여 다를 경우 예외를 발생시킨다. (애플리케이션에서 예외를 처리 한다)

 

 

 

적용을 위해 Platform 엔티티에 version 필드를 추가하고 JPA에서 제공하는 @Version 어노테이션을 달아준다.

이곳에 플랫폼 버전이 기록된다.

 

 

이제 기존 코드를 뜯어 고쳐야 한다.

 

 

 

 

 

 

실제 삭제 로직을 별도의 새로운 클래스를 만들어서 분리시킨다. (@Transactionl 을 붙여준다.)

 

 

 

 

 

 

서비스 계층에서 새로 만든 클래스의 삭제 로직을 호출시키고 예외 처리를 시켜준다.  (@Transactionl 을 붙이지 않음)

 

이렇게 구조를 변경한 이유는 낙관적 락을 사용하기 위해서인데,

커밋되는 시점에 대상의 버전(Platform의 version) 을 확인하게 되고 이 버전이 다른 경우 ObjectOptimisticLockingFailureException 예외를 터트리게 된다.

 

그리고 위 로직에서는 예외가 터지면 while 문으로 돌아가 다시 시도한다.

 

 

(리뷰 작성 기능도 이렇게 적용해주었다.)

 

 

 

 

 

 

테스트 코드 실행 결과를 보면 이렇게 평점이 잘 반영된 것을 볼 수 있다.

 

 

출력 로그는 다음과 같다.

[삭제 쓰레드] 플랫폼 평점 업데이트 시도
[작성 쓰레드] 플랫폼 평점 업데이트 시도
[삭제 쓰레드] 플랫폼 평점 업데이트 완료 - 실제 DB 반영
[작성 쓰레드] 예외 발생
[작성 쓰레드] 플랫폼 평점 업데이트 시도
[작성 쓰레드] 플랫폼 평점 업데이트 완료 - 실제 DB 반영

 

전체적인 흐름을 정리하면 다음과 같다.

 

1. 삭제 쓰레드가 플랫폼을 가져와서(버전 1) 평점 업데이트 시도

 

2. 작성 쓰레드가 플랫폼을 가져와서(버전 1) 평점 업데이트 시도

 

3. 삭제 쓰레드가 가져온 플랫폼(버전 1)에 대해 업데이트 완료, 플랫폼 버전이 올라감 (버전1 -> 버전2)

 

4. 작성 쓰레드가 가져온 플랫폼(버전 1) 에 대해 업데이트 하려했으나 실제 DB를 봤더니 버전 2 로 되어있음. 예외 발생

 

5. 작성 쓰레드가 플랫폼을 가져와서(버전 2) 평점 업데이트 시도

 

6. 작성 쓰레드가 가져온 플랫폼(버전 2)에 대해 업데이트 완료, 플랫폼 버전이 올라감 (버전2 -> 버전3)

 

 

 

 

 

 

 

 

 

 

 

참고 자료

더보기