JPA의 낙관적 락과 비관적 락

도비·2023년 12월 17일
0

Spring Boot

목록 보기
9/13
post-thumbnail

멀티 스레드 환경의 서버를 만들다보면, 동시성 이슈가 발생하는 상황을 만나볼 때가 있다.
실제로 동시성 이슈를 만났던 적이 있는데, 그에 대해서는 여기 정리되어있다.

이런 동시 요청이 동일 테이블의 동일 row에 대해 발생했을 때 데드락이 발생하는 경우가 있다.

데드락이란 교착 상태를 의미하며 무한히 다음 자원을 기다리게 되는 상태를 말한다.

실제로 좋아요 개수 수정 기능을 구현하고자 했을 때 다음과 같은 상황으로 데드락이 발생했다.

데드락 발생 상황


이렇게 각 트랜잭션이 서로 배타 락을 획득하려고 하므로 서로를 기다리며 데드락이 발생한다.

이 때 배타 락이란, 읽기, 쓰기 모두 불가능한 것을 뜻한다. 공유 락은 읽기는 가능하지만 쓰기는 불가능한 것을 뜻함

이러한 상황을 어떻게 해결할 수 있을까?
공유되고 있는 자원에 접근 제한을 두는 기능인 락을 통해 이를 해결할 수 있다.
락에 대해 알아보기 전에, 동시에 들어온 요청에서 어떤 문제가 발생할 수 있는지 살펴보자.

두 번의 갱신 분실 문제

트랜잭션 1이 수정하는 중에 트랜잭션 2가 수정을 시작했을 때, 1이 커밋하고 다음에 2가 커밋하면 1의 수정사항과 관련 없이 마지막 커밋내용인 2의 내용만 반영되는 문제이다. (트랜잭션 1이 분실되었다.)

이 문제의 해결 방법으로는 다음의 3가지 방법이 존재한다.

  • 마지막 커밋만 인정하기
    트랜잭션 1의 수정사항은 무시하고 마지막에 커밋한 2의 내용만 인정하는 방법, 기본적인 방법이다.
  • 최초 커밋 인정하기
    먼저 커밋한 1의 수정사항을 인정하고 2의 수정 요청은 예외를 발생시키킄 방법이다. JPA의 낙관적 락을 사용하여 구현할 수 있다.
  • 커밋된 갱신 내용 병합하기
    1과 2의 수정사항을 병합하여 적용하는 방법이다.

JPA에서의 락에 관하여

JPA는 데이터베이스에 대한 동시 접근으로부터 무결성을 유지할 수 있게 해주는 동시성 제어 매커니즘을 지원한다. 이 매커니즘에는 낙관적 락과 비관적 락이 존재한다.

락에 대해서 이해하려면 트랜잭션 격리 수준을 알아야하는데, 격리 수준에 관해서는 여기서 확인해볼 수 있다. JPA에서는 기본적으로 격리 레벨을 READ_UNCOMMITTED 정도로 가정한다.

낙관적 락

  • 트랜잭션이 충돌이 발생하지 않을 것이라고 낙관적으로 가정한다.
  • 데이터베이스가 제공하는 락 기능을 사용하지 않고, 엔티티의 버전을 통해 동시성을 제어한다. JPA가 자체적으로 제공하는 버전 관리 기능을 사용한다.
  • 트랜잭션을 커밋하기 전까지는 충돌 여부를 확인할 수 없다.

@Version

JPA에서는 @Version 어노케이션을 통해 낙관적 락을 처리할 수 있다. 버전 관리용 필드를 통해 트랜잭션 내에서 처음 조회되었을 때의 버전과 이후 커밋될 때의 버전을 비교한다.

@Version 이 적용 가능한 타입은 Long, Integer, Short, Timestamp 이다.

아래와 같이 엔티티에 버전 관리용 필드를 만들어 적용한다.

@Getter
@Entity
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

/*
생략
*/

    @Version
    private int version;
}

예외 상황


트랜잭션 2의 입장에서 처음 조회했던 1이 아니라 2로 변경되어 기존에 조회한 버전과 다르므로 업데이트가 실패한다.
이 때 update 쿼리는 다음과 같다.

update product
set
    # 수정할 필드
    version = 2 (이전 버전에서 +1)
where
    id = ?
    and version = 1

이렇게 쿼리가 발생하고, 업데이트할 대상을 찾지 못해 예외가 발생한다.

낙관적 락의 LockModeType

NONE

락 옵션을 지정하지 않고 엔티티에 @Version을 적용하면 기본으로 적용되는 락 옵션이다.

  • 동작: 엔티티를 수정하는 시점에 엔티티의 버전을 증가시킨다. 이때 엔티티의 버전이 조회 시점과 다르다면 예외가 발생한다.

OPTIMISTIC

NONE 옵션에서는 엔티티를 조회할 때 버전 체크를 하지 않는다. Optimistic 옵션에서는 조회 시에 버전을 체크한다.

조회 시 발생하는 쿼리
==================Product Optimistic===================
Hibernate: 
    select
        p1_0.id,
        p1_0.brand,
        p1_0.details,
        p1_0.heart_amount,
        p1_0.image_url,
        p1_0.discount,
        p1_0.price,
        p1_0.title,
        p1_0.version 
    from
        product p1_0 
    where
        p1_0.id=?
Hibernate: 
    select
        version as version_ 
    from
        product 
    where
        id=?
=======================================================
==================Product None=========================
Hibernate: 
    select
        p1_0.id,
        p1_0.brand,
        p1_0.details,
        p1_0.heart_amount,
        p1_0.image_url,
        p1_0.discount,
        p1_0.price,
        p1_0.title,
        p1_0.version 
    from
        product p1_0 
    where
        p1_0.id=?
=======================================================

None은 수정 시점까지의 일관성을 보장한다면, Optimistic은 트랜잭션이 끝날 때까지 일관성을 보장한다.

  • 동작: 트랜잭션을 커밋하는 시점에 버전정보를 체크한다.

OPTIMISTIC_FORCE_INCREMENT

  • 동작: 엔티티를 수정하지 않아도 트랜잭션을 커밋할 때 UPDATE 쿼리를 사용해서 버전 정보를 강제로 증가시킨다. 추가로 엔티티를 수정하면 수정 시 버전 UPDATE가 발생한다. 총 2번의 버전 증가가 나타날 수 있다.

비관적 락

  • 트랜잭션의 충돌이 발생한다고 가정하고, 락을 거는 방법이다.
  • 실제로 데이터베이스의 락을 사용하여 동시성을 제어하는 방법이다.

비관적 락의 LockModeType

PESSIMISTIC_WRITE

비관적 락이라 하면 일반적으로 이 옵션을 뜻한다. 데이터베이스에 쓰기 락을 걸 때 사용한다. 락이 걸린 로우는 다른 트랜잭션이 수정할 수 없다.

  • 동작: 데이터베이스 select for update를 사용해서 락을 건다.

    select for update 란, 데이터 수정하려고 SELECT 하는 중이니 다른 트랜잭션은 데이터에 대한 수정할 수 없음을 나타낸다. 좀 더 딱딱한 표현으로는 동시성 제어를 위하여 특정 데이터(ROW)에 대해 배타 락을 거는 기능이다.

PESSIMISTIC_READ

데이터를 반복 읽기만 하고 수정하지 않는 용도로 락을 걸 때 사용한다.

  • 동작: 데이터베이스 select for share을 사용해서 락을 건다. (공유 락)

PESSIMISTIC_FORCE_INCREMENT

버전 정보를 사용해 버전 정보를 강제로 증가시키는 비관적 락이다. 하이버네이트는 nowait를 지원하는 데이터베이스에 대해서 for update nowait옵션을 적용한다.
지원하지 않는다면 for update 사용.

for update nowait [= WAIT 0], LOCK을 획득하지 못하면 ORA-00054와 함께 바로 실패한다. (FOR UPDATE WAIT 0 도 같이 동작한다)

데드락 해결

앞서 발생한 동시성 이슈을 비관적 락을 통해 트랜잭션 쿼리 순서를 조정해 해결할 수 있었다.

ProductJpaRepository.java

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    Optional<Product> findById(final Long productId);

@Lock 어노테이션을 통해 메서드를 오버라이딩한다.

ProductService.java

public Product getProductById(final Long productId) {
    return productJpaRepository.findById(productId)
                .orElseThrow(() -> new NotFoundException(ErrorType.PRODUCT_NOT_FOUND_EXCEPTION));
}
public void decreaseHeart(final Long productId) {
    getProductById(productId).decreaseHeart();
}

public void increaseHeart(final Long productId) {
    getProductById(productId).increaseHeart();
}

비관적 락을 걸어준 트랜잭션을 사용해 decreaseHeartincreaseHeart를 구현한다.

HeartService.java

public HeartCreateResponse createHeart(final Long memberId, final Long productId) {
    try {
        Heart newHeart = create(memberId, productId);
        productService.increaseHeart(productId);
        return HeartCreateResponse.of(newHeart);
    } catch (DataIntegrityViolationException e) {
    	throw new ConflictException(ErrorMessage.HEART_EXISTED_EXCEPTION);
    }
}
public HeartDeleteResponse deleteHeart(final Long memberId, final Long productId) {
	productService.decreaseHeart(productId);
    delete(memberId, productId);
    return HeartDeleteResponse.of(null);
}

위와 같이 구현한 후 API에 동시 요청을 보내면 다음과 같은 순서로 쿼리가 날라가고, 앞서 발견했던 데드락 상황을 해결할 수 있다.

마무리

낙관적 락과 비관적 락에 대해 알아보고, 겪었던 데드락에 대해 해결할 수 있었다.
하지만 락을 적용할 경우 동시성을 제어할 수 있지만 트랜잭션이 길어지면 기다리는 시간이 늘어나기 때문에 성능 저하가 발생할 수도 있다.
락을 통해 트랜잭션을 제어하고자 할 때, 성능에 대한 오버헤드도 고려하며 사용해야 한다.

참고

  • 자바 ORM 표준 JPA 프로그래밍, 김영한
profile
하루에 한 걸음씩

1개의 댓글

comment-user-thumbnail
2024년 5월 20일

자세히 설명해주신글 잘봤습니다!
@Transactional 어노테이션 붙이면 서비스에서 처리하는 요청에 대해 만능인줄 알았는데 이러한 트레이드오프에 대해서 생각해보지는 못했네요!
트랜잭션을 레포범위로 줄이고 Lock을 걸어서 해결하는 방식과 격리수준에 대해서도 한번 알아보겠습니다~!

답글 달기