[데이터베이스/JPA] 낙관적 락, 비관적 락이란? 예시를 통해 쉽게 알아보자

먼저 낙관적 락과 비관적 락에 대해 설명하기 앞서 락(Lock)이 무엇인지에 대해 알 필요가 있다.

락이 무엇인지 궁금하다면 아래 포스팅을 참고하자

2023.04.13 - [◼ CS 기초 지식/[데이터베이스]] - DB 락(Lock)과 Lock의 종류에 대해 알아보자.

 

DB 락(Lock)과 Lock의 종류에 대해 알아보자.

우선 Lock에 대해 알아보기 전에 트랜잭션에 대한 이해가 필요하므로 트랜잭션에 대해 모른다면 아래 포스팅을 참고하시길 바랍니다. 트랜잭션이란? 특징과 사용법에 대해 쉽게 알아보자트랜잭

hstory0208.tistory.com


동시성 문제 상황

낙관적 락과 비관적 락을 설명하기 앞서 동시성 문제가 발생한 코드를 한번 살펴보자.

 

위 코드는 동시성 테스트를 가능케 해주는 ExecutorService와 CountDownLatch를 사용해

재고가 50개인 Gift의 재고를 100명의 요청자가 동시에 감소시키는 테스트이다.

동시성이 발생하는 상황에서 정확한 요청 성공, 실패 횟수를 파악하기 위해 AtomicIntger를 사용했다.

 

위 테스트 코드를 실행하면 다음과 같은 결과가 나오게 된다.

100번의 요청이 성공했고 재고는 14개 밖에 감소하지 않았다.

요청자가 재고 수 보다 많은데 왜 14개 밖에 감소하지 않은 걸까

 

위와 같은 문제가 발생한 시나리오를 살펴보자.

다음과 같은 시나리오가 발생한 이유는 MySQL의 기본 격리 수준인 REPEATABLE_READ와도 관련 있다.

 

  1. 100개의 트랜잭션이 거의 동시에 시작되어 각자 재고를 1 감소시키는 요청을 한다.
  2. 트랜잭션들이 순차적으로 커밋된다.
  3. 첫 번째 트랜잭션은 성공적으로 재고를 49로 갱신한다.
  4. 이후의 트랜잭션들도 각자 재고를 1 감소시키지만, 초기 값 50에서 1을 뺀 49를 다시 쓰게 된다.
  5. 결과적으로 많은 트랜잭션이 성공했음에도 재고는 예상보다 적게 감소한다.

 

이 동시성 문제를 해결하기 위해선 어떻게 해야할까?

바로 Lock을 통해 동시성 문제를 해결 할 수 있다.


낙관적 락(Optimistic Lock)

말 그대로 "보통은 동시성이 문제가 발생하지 않을 것이니 문제가 발생하면 그 때 해결하자 ~"와 같이

"낙관적"으로 락을 거는 방식이다.

 

데이터에 접근할 때 트랜잭션에 Lock을 걸어 선점하지 않고 커밋할 때 동시성 문제가 발생하면 Lock을 걸어 처리하는 방법으로

DB 자체에 Lock을 거는 것이 아닌 애플리케이션 단에 Lock을 거는 방식이다.

 

그럼 낙관적 락은 어떻게 적용하는 걸까 ? 위 동시성 문제가 발생한 코드에 낙관적 락을 적용해보자.

낙관적 락 적용 방법

JPA에서 낙관적 락을 적용할 수 있는 @Version 어노테이션을 제공한다.

@Entity
@Getter
@NoArgsConstructor
public class Gift {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Version // 적용이 가능한 타입 : Long(long), Integer(int), Short(short), Timestamp
    private Integer version;

    private int stock;

    public Gift(int stock) {
        this(null, null, stock);
    }

    public void reduceStock() {
        stock--;
    }
}

 

엔티티에 @Version 어노테이션을 갖는 필드를 추가하면 된다.

이제 엔티티가 변경될 때마다 버전이 변경되고 버전 관리를 통해 애플리케이션 단에서 동시성을 관리한다.

 

낙관적 락을 적용하고 위 동시성 테스트를 다시 실행보자.

원하던 결과와 다른 23개의 재고만 감소했다.

원인이 뭘까 ? @Version 어노테이션을 사용해 낙관적 락을 적용해서 위 상황이 발생할 수 있는 시나리오를 살펴보자.

@Version 어노테이션을 사용한 낙관적 락 충돌 시나리오

 

재고 변경 요청마다 재고가 -1씩 차감되는 상황이다.

  1. 트랜잭션 1이 id가 1인 Gift 데이터를 조회한다.
  2. 트랜잭션 2도 id가 1인 Gift 데이터를 조회한다.
  3. 트랜잭션 1이 id가 1이고 version이 1인 Gift의 재고를 49로 변경시킨다. (조건문에 version을 추가하지않아도 자동으로 들어간다.)
  4. 이때 데이터의 변경으로 version도 하나 올라간다.
  5. 트랜잭션 2가 id가 1이고 version이 1인 Gift의 재고를 49로 변경시키지만 해당 조건에 부합하는 데이터가 없어 수정하지 못한다.
  6. 이때 JPA가 ObjectOptimisticLockingFailureException 예외를 던지고 해당 트랜잭션은 롤백된다.

 

예외 처리 - 재시도 로직

위 처럼 트랜잭션 커밋 시 버전 정보가 다르면 예외가 발생한다.

그러나 이상한점이 있다.

id가 1인 Gift의 재고가 아직 충분한데 트랜잭션 2에 대한 재고 변경 요청이 Version 정보가 달라 실패하게 된다.

이 처럼 Version으로 인해 비즈니스 예외 상황이 아님에도 불구하고 요청이 실패할 수도 있다.

즉, 최초 커밋만 인정하는 상황이다.

이 문제를 예방하기 위해 다음과 같이 예외 발생 시 재시도 로직을 추가할 수 있다.

비즈니스 예외 상황이 아닌 트랜잭션 2의 요청도 정상적으로 수행될 수 있도록 위 처럼 재시도 로직을 추가했다.

 

결론

 

위 시나리오와 결과를 보면 낙관적 락에 대해 다음과 같은 결론을 지을 수 있다.

  • 읽을 때는 Lock을 걸지 않고 데이터를 업데이트 할 때만 데이터의 버전을 비교해 충돌 여부를 판단한다.
  • 트랜잭션이 Lock을 점유하고 있지 않아 동시성이 높다. -> DB 자체에 Lock을 거는 비관적 락 보다 읽기가 빠르다.
  • 위와 같이 극단적인 동시 데이터 업데이트 요청에서는 원하는 결과가 나오지 않을 위험이 있다.
  • 충돌이 많은 상황일 경우 재시도 로직으로 DB에 굉장히 많은 요청을 보낼 수 있어 오히려 성능이 떨어질 수 있다.

위 결론들을 바탕으로 낙관적 락이 적절한 상황은 변경보다 읽기의 비율이 높은 상황에 사용하기 적합해 보인다.


비관적 락(Pessimistic Lock)

낙관적 락과 반대인 뜻으로, 이것도 단어 그대로 해석하면 된다.

"ㅉㅉ 무조건 동시성 문제가 발생해 애초에 예방해야해"처럼 비관적으로 바라보고 락을 거는 방식이다.

 

비관적 락은 일반적으로 아는 Lock처럼 수정할 데이터를 조회할 때 부터 Lock을 걸어 해당 트랜잭션이 주도권을 갖는 방법이다.

 

그럼 비관적 락은 어떻게 적용하는 걸까 ? 위 동시성 문제가 발생한 코드에 비관적 락을 적용해보자.

낙관적 락 적용 방법

DB에 접근하는 계층인 Repository에서 데이터를 변경하기 전에 조회에 사용하는 쿼리에 

JPA가 제공해주는 @Lock 어노테이션을 적용하면 된다.

public interface GiftRepository extends JpaRepository<Gift, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select g from Gift g where g.id = :id")
    Optional<Gift> findByIdWithLock(Long id);
}

 

비관적 락을 적용하고 위 동시성 문제가 발생한 테스트를 실행해보면 이제 기대한 결과가 나오는 것을 볼 수 있다.

 

 

@Lock 어노테이션의 LockModeType 열거형 종류
열거 타입 설명
NONE Lock을 사용하지 않음.
OPTIMISTIC 낙관적 Lock 사용. @Version 어노테이션을 사용한 것과 같다.
OPTIMISTIC_FORCE_INCREMENT OPTIMISTIC과 동일하지만, 데이터가 읽히는 즉시 버전 정보가 업데이트 된다.
PESSIMISTIC_READ 공유 락(Shared Lock) : 다른 트랜잭션이 데이터를 읽은 순 있으나, 데이터 수정을 잠금
(select ... for share 구문을 사용해 Lock 적용)
PESSIMISTIC_WRITE 베타 락(Exclusive Lock) : 다른 트랜잭션이 데이터를 읽지도 수정도 못하도록 잠금.
(select ... for update 구문을 사용해 Lock 적용)
PESSIMISTIC_FORCE_INCREMENT PESSIMISTIC_WRITE와 유사하지만 버전 필드를 사용하며 잠금과 동시에 버전 정보를 업데이트한다.

 

비관적 락은 일반적으로 아는 Lock과 동일하기 때문에 시나리오 설명을 생략한다.

 

결론
  • 데이터를 읽을 때 부터 해당 데이터에 대한 Lock을 걸어 반환하기 까지 다른 트랜잭션이 접근하지 못한다. (낮은 동시성)
  • 데이터를 읽는 동안 다른 트랜잭션이 접근하지 못해 데이터의 일관성을 보장할 수 있다.
  • 블로킹으로 인한 성능 저하와 데드락을 조심해야 한다.

상황에 따른 락 선택

지금까지 알아본 낙관적 락과 비관적 락을 언제 어떤 상황에서 무엇을 적용해야 적합할지 간단하게 요약하면 다음과 같다.

 

낙관적 락이 적합한 상황

동시에 데이터를 변경해 문제가 발생할 확률이 낮고

수정보다는 읽기의 작업이 많아 충돌이 잘 발생하지 않고, 동시성이 중요할 경우에 적합

 

비관적 락이 적합한 상황

동시에 데이터를 변경해 문제가 발생할 확률이 높고 데이터의 일관성 보장이 중요한 경우에 적합