[JPA] 비관적 락으로 동시성 이슈 해결하기

프로젝트 중 동일한 게시글을 여러 유저가 동시에 접근했을 때 조회수의 증가값이 유저의 수만큼 나오지 않는 문제가 생겼습니다. 해당 문제의 원인과 해결과정에 대해 공유합니다.

 

테스트 세팅

어떤 문제가 발생했는지 코드와 JMeter를 통해 보여드리겠습니다.

Post 엔티티

public class Post extends BaseEntity{
	//...
    private Long view;
    public void increaseView() {
        this.view++;
    }
}

PostService

@Transactional
public PostResponseDto getPost(Long postId) {
    Post post = postRepository.findById(postId)
            .orElseThrow(()-> new ApiException(PostResponseCode.POST_NOT_FOUNT));
    // view 증가
    post.increaseView();
    return PostResponseDto.From(post);
}

DB

JMeter 세팅

  • Number of Threads : 500
  • Ramp-up period : 1s

 

위와 같이 세팅하면 1초에 500개의 스레드가 생성이 되고 총 500번의 GET 요청이 발생합니다. 이를 통해 View가 500으로 나오는지 확인해 보겠습니다.

 

테스트 결과

총 500번의 요청 중에 서버가 초당 45.6개의 요청을 처리할 수 있어서 총 11초가 소요됐고 DB를 봤을 때는 View가 500이어야 하지만 56으로 출력이 됐습니다.

 

문제 발생 이유

view가 1일 때 트랜잭션 A에서 view를 증가시키는 중에 트랜잭션 B에서 view를 증가시키고 커밋을 진행하면 db에는 트랜잭션 B로 인해 view가 2로 반영이 됩니다. 하지만 트랜잭션 A는 1이라는 데이터를 가지고 있고 1을 증가시킨 다음에 커밋을 하면 DB에 2가 반영이 되고 이 과정에서 트랜잭션 B의 조회수 증가가 분실됩니다.

 

해결 방법

JPA에서는 해당 문제를 해결하기 위해 락을 제공해 준다. 락에는 낙관적 락, 비관적 락이 존재합니다. 낙관적 락은 버전을 통해 컨트롤하는데 버전이 다르면 rollback이 일어나기에 비관적 락을 이용해 보겠습니다.

 

비관적 락

비관적 락은 실제 데이터베이스의 락을 사용하여 동시성을 제어하는 방법입니다. 주로 쿼리에 select ... for update 구문을 사용합니다. 쿼리의 의미는 이 데이터는 내가 조회하여 수정 중이기 때문에 다른 사람은 건드릴 수 없다는 의미를 가지고 있습니다. 

비관적 락 타입에는 총 3가지가 존재합니다.

  • @Lock(LockModeType.PESSIMISTIC_READ) : 해당 리소스에 공유락을 걸어 다른 트랜잭션에서 읽기는 가능하지만 쓰기는 불가능해집니다. 
  • @Lock(LockModeType.PESSIMISTIC_WRITE) : 해당 리소스에 배타락을 걸어 다른 트랜잭션에서 쓰기는 불가능해집니다.
  • @Lock(LockModeType.PESSIMISTIC_FORCE_INCREMENT) : PESSIMISTIC_WRITE와 비슷하게 동작하지만 버전을 통해 관리되기에 버전에 대한 컬럼이 필요합니다.

JPA에서 다음과 같이 비관적 Lock을 사용할 수 있습니다.

@Query("select p from Post p where p.id = :postId")
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Post> findPost(Long postId);
@Transactional
public PostResponseDto getPost(Long postId) {
    Post post = postRepository.findPost(postId)
            .orElseThrow(()-> new ApiException(PostResponseCode.POST_NOT_FOUNT));
    // view 증가
    post.increaseView();
    return PostResponseDto.From(post);
}

해당 코드로 다시 JMeter를 이용해 부하를 걸어보면 결과는 다음과 같습니다.

DB에 조회수 500이 잘 적용되는 것을 확인할 수 있습니다.

기존 락을 걸지 않았을 때 데이터는 정확하지 않지만 초당 45.6개의 요청을 처리한 반면 비관적 락을 걸 때 초당 27.5개의 요청을 처리함으로써 성능적인 부분은 많이 떨어지는 것을 확인할 수 있습니다.

 

SERIALIZABLE은 왜 데드락이 발생하는가?

초기에 가장 엄격한 격리 레벨인 serializable을 적용하면 해결할 수 있지 않을까? 라는 생각을 했습니다. 그래서 실제로 아래와 같은 어노테이션을 사용하고 테스트를 해봤습니다.

@Transactional(isolation = Isolation.SERIALIZABLE)

테스트 결과 몇개의 트랜잭션만 제대로 실행이 되고 대부분은 데드락으로 인해 제대로 적용이 안됐습니다. 그래서 동일하게 락을 걸어서 다른 트랜잭션이 건들지 못하게 하는데 왜 데드락이 걸릴까? 라는 생각을 하며 검색해본 결과 동시성을 해결하기 위해 적용한 비관적 락른 베타락 하나만 사용되는 반면 SERIALIZABLE은 공유 락, 베타 락 두개가 사용되는 것을 알게되었습니다.

 

DB의 락 종류

  • Shared Lock(S lock) : 읽기 락, 공유 락으로 특정 Row를 읽을 때 사용되는 락. S lock 끼리는 동시에 접근이 가능하다. 또한, S lock가 걸린 레코드에 배타락을 사용할 수 없다.
  • Exclusive Lock(X lock) : 쓰기 락, 베타 락으로 특정 Row를 변경할 때 사용되는 락.

 

SERIALIZABLE의 방식

  1. 1번 트랜잭션이 게시글 테이블 1번 row를 수정하려고 읽었습니다(S Lock)
  2. 2번 트랜잭션이 게시글 테이블 1번 row를 수정하려고 읽었습니다.(S lock) S lock는 공유 되므로 두 트랜잭션 모두 락을 걸 수 있습니다.
  3. 1번 트랜잭션이 게시글의 1번 row를 수정하려고 시도합니다(X lock) 하지만 2번 트랜잭션에서 S lock을 걸고 있으므로 1번 트랜잭션은 X lock을 걸 수 없어 2번 트랜잭션이 S lock을 release할 때 까지 대기합니다.
  4. 2번 트랜잭션이 게시글 1번 row를 수정하려고 시도합니다(X lock) 하지만 1번 트랜잭션이 S lock을 release할 때 까지 대기합니다.

결국 베타락의 특징인 다른 트랜잭션에서 특정 자원을 읽고 있으면 계속 대기해야하고 이를 통해 1번과 2번 트랜잭션이 무한대기 상태에 있게됩니다. 그러다 DB가 2번은 롤백시키고 1번을 적용시킵니다.

 

결론

만약 동시성 문제에서 수정된 데이터가 롤백이 되면 안되고 데이터의 일관성을 보장해야 할 때 비관적 락을 이용하고, 동시성 문제가 발생할 때 특정 트랜잭션을 롤백시켜도 된다면 낙관적 락을 사용하자