낙관적 락 vs 비관적

문정현·2024년 1월 16일
0

한정 수량이 정해진 “핫딜”이 등록되면 유저가 정해진 수량 이내에 구매가 가능하다

(아직 구매 개수의 제한에 대한 논의는 이루어지지 않았음)

심각한 그림판

동시 다발적으로 유저가 한 가지 핫딜 품목을 구매하려 했을 때 A유저의 구매 트랜잭션이 커밋 되지 않은 상태에서 B유저가 구매 행위를 했을 때 한정 수량을 넘겨서 판매하거나 한정 수량이 음수값이 되는 등 데이터 정합성이 깨지는 문제가 있다.

이런 문제를 레이스 컨디션 이라고 부른다. 위에 그림으로만 봐도 A의 처리 이후 잔고는 “5”로 업데이트를 하겠지만 B의 트랜잭션 이후에는 “3”으로 업데이트를 하려고 할것이다.

레이스 컨디션의 결과:

데이터 손상
Race Condition으로 인해 데이터 Condition이 일관되지 않거나 잘못될 수 있다.

예측할 수 없는 동작
Race Condition의 비결정적 특성으로 인해 시스템 동작을 예측하거나 재현하기 어려울 수 있다.

보안 취약성
Race Condition은 때때로 공격자가 무단 액세스 또는 권한을 얻기 위해 악용할 수 있다.

이런 치명적인 문제를 방지하기 위해 JPA에서 지원하는 Lock(낙관적 락 or 비관적 락) 그리고 ConcurrentQueue를 알아본다.

먼저 Lock에도 종류가 있는데
지금 상황이 비관적이므로 비관적 락 부터 알아본다

  • 비관적 락(Pessimistic Lock)
    • 트랜잭션이 충돌한다고 가정하고 락을 건다.
    • DBMS의 락 기능을 사용한다. (ex. SELECT FOR UPDATE)
    • 데이터 수정 시 즉시 트랜잭션 충돌여부를 확인할 수 있다.

지금 핫딜을 예시로 쉽게 설명하자면 “내가 지금 구매 할거니까 나 끝날 때까지 다들 기다리고 있어” 라고 보면 된다

JPA에서 제공하고 있기에 레포지토리 메소드에 @Lock 어노테이션을 사용하여 비관적 락을 걸 수 있다

public interface HotdealRepository extends JpaRepository<Hotdeal, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("sampel query")
		~~~~void afterCheckHotdealQuantityAndBuy(Long hotdealId);
    
}

LockModeType의 종류는 아래와 같다

  • PESSIMISTIC_READ
    • 다른 트랜잭션에서 읽기만 가능
  • PESSIMISTIC_WRITE
    • 다른 트랜잭션에서 읽기도 못하고 쓰기도 못함
  • PESSIMISTIC_FORCE_INCREMENT
    • 다른 트랜잭션에서 읽기도 못하고 쓰기도 못함 + 추가적으로 버저닝을 수행한다.

근데 비관적락의 단점은 뭘까 바로 성능적 이슈가 딱 봐도 보일것이다 구매요청이 극단적으로 10,000건이 동시에 몰렸는데 내가 10,000 번째 구매자다? 그럼 바로 9999가 쓰여져 있는 번호표를 들고 눈물을 훔쳐야 할 것…

  • 낙관적 락(Optimistic Lock)
    • 트랜잭션이 충돌하지 않는다고 가정한다.
    • 자원에 락을 걸어서 선점하지말고 커밋할 때 동시성 문제가 발생하면 그때 처리 하자는 방법론입니다.
    • JPA에서는 자체적으로 제공하는 버전 관리 기능을 사용한다. (hashcode나 timestamp를 이용할 수도 있다.)
    • 트랜잭션을 커밋하기 전까지는 충돌 여부를 확인할 수 없다.

기본적으로 충돌이 없을 것이라고 가정하고 수행하다 문제가 생겼을 떄 처리를 하는데

@Entity
// 어노테이션 생략
public class Hodeal {

    @Id
    @GeneratedValue(strategy생략)
    private Long id;

    @Version
    private Long version;

		// 아래 생략
}

@Version을 엔티티에 추가해서 사용하면 만약 Entity를 수정할 때마다 JPA가 자체적으로 Versioning을 지원하여 조회 시점의 버전과 수정 시점의 버전이 다르면 예외를 발생시킨다

ObjectOptimisticLockingFailureException 예외

허접한 그림판 재출격

@Lock(LockModeType.OPTIMISTIC)

JpaReository에 @Lock 어노테이션을 통해 비관적 락과 마찬가지로 LockModeType이 있으며 아래와 같다

  • OPTIMISTIC
    • 트랜잭션 시작 시 버전 점검이 수행되고, 트랜잭션 종료 시에도 버전 점검이 수행된다.
    • 버전이 다르면 트랜잭션이 롤백된다.
  • OPTIMISTIC_FORCE_INCREMENT
    • 낙관적 락을 사용하면서 추가로 버전을 강제로 증가시킨다.
    • 관계를 가진 다른 엔티티가 수정되면 버전이 변경된다. (ex. 댓글이 수정되면 게시글도 버전이 변경된다.)
  • READ
    • OPTIMISTIC과 동일하다.
  • WRITE
    • OPTIMSTIC_FORCE_INCREMENT와 동일하다.
  • NONE
    • 엔티티에 @Version이 적용된 필드가 있으면 낙관적 락을 적용한다.

낙관적 락과 비관적 락을 단순히 성능을 보자면 낙관적 락이 압도적으로 좋고 방식도 합리적인데 데이터의 일관성과 무결성을 보장해야 하는 경우나 충돌의 가능성이 매우 높은 상황에서는 비관적 락을 채택해야 할 것 같다.

+Redisson 분산락

@RequiredArgsConstructor
@Service
public class HotdealService {

    private final RedissonClient redissonClient;

    @Transactional
    public void changeDealQuantity throws(Status desiredStatus) {
        // 레디스 락 데이터 생성 후, 3초 락
        RLock lock = redissonClient.getLock("key 이름");
        
        try {
            boolean isLocked = lock.tryLock(2, 3, TimeUnit.SECONDS);
            if (!isLocked) {
                // 락 획득에 실패했으므로 예외 처리
                throw new Error( ... );
            }
        
            // 로직
            
        } catch (InterruptedException e) {
	    // 쓰레드가 인터럽트 될 경우의 예외 처리        
        } finally {
            // 락 해제
            lock.unlock();
        }
       	
    }
}

타임아웃을 설정하여 데드락 방지

tryLock의 첫번째 파라미터로 락 획득을 대기할 시간을, 두번째 파라미터로 락이 만료되는 시간을 설정한다

2초 동안 락을 획득하지 못하면 false를 반환하여 락이 실패됐음을 알려주고, 락을 획득하고 나서 3초가 지나면 자동으로 레디스에 저장된 key:value가 삭제되기 때문에 혹여나 락이 해제되지 않더라도 다른 스레드에서 락을 획득할 수 있는것..

profile
기록 == 성장

0개의 댓글