https://www.youtube.com/watch?v=LDi5muN2kgI 

목차

  • 1. Lock은 무엇일까?
  • 2. Lock과 Transaction ? 
  • 3. Lock과 전략
  • 4. JPA에서의 낙관적 & 비관적 락

 

1. Lock은 무엇일까?

  • 데이터가 존재한다. 이떄 여러 COnnection A, B, C, D 4개가 데이터 수정을 원할경우 데이터에 문제가 발생한다.
  • Connection이 오는 순서에 따라서 데이터 값이 어떻게 될지 모르기에 데이터 일관성에 오류가 생긴다.
  • 이떄 데이터의 일관성 해결방안 중 하나가 Lock이다.
  • Connection A가 데이터에 접근할떄 자물쇠를 잠구어서 다른곳에서 접근할 수 없다.
  • 이떄 이렇게 데이터에 접근할 수 없게 자물쇠를 잠근다는 개념을 Lock이라고한다.

 

2. Lock과 Transaction ? 

  •  Lock을 알아갈수록 Lock과 Transaction이 헷갈린다고 한다.
  • 차이점을 알아보자.
  • Lock은 동시성 제어
  • 트랜잭션 (All or Nothing, 작업의 원자성)
  • 왜 헷갈렸을까? 트랜잭션 격리 수준때문에 헷갈린다.
    • Lock
      • 동시에 발생하는 수정요청에 대한 데이터 일관성을 지키기 위한 메커니즘이다.
    • 트래잭션
      • 여러 트랜잭션에 대해 각 트랜잭션들을 어떻게 처리할지에 대한 전략
  • 즉, 이 각 트랜잭션들을 어떻게 처리할지에 대한 전략 중 구현방법 중 하나가 Lock이다.
  • 격리 수준을 구현하는 방법 중하나가 Lock인것이다.
  • Lock이 트랜잭션 격리 수준에 포함된다고 생각한다.

 

3. Lock과 전략

  • 낙관적 Lock
    • Transaction이 애초에 충돌이 발생하지 않는다고 생각하고 진행한다.
    • 애플리케이션 Lock이라고도 한다. 애플리케이션 내부에서 Version이라는 것을 통해서 해당 내부에서 구현할 수 있기에 애플리케이션 Lock이라고도 한다.
  • 비관적 Lock
    • 애초에 Transaction이 매번 충돌이 발생한다고 가정하고 사용한다.
    • 데이터베이스 트랜잭션 Lock 이라고한다.
    • SELECT FOR UPDATE 가 있다. 데이터베이스 update를 할떄 트랜잭션이 발생할 수 있기에 바로 Lock을 건다.

 

4. JPA에서의 낙관적 & 비관적 락

  • 테스트 상황 제시: 쿠폰이 5개가 존재한다. 총 20명의 사용자가 동시에 5개의 쿠폰을 발급하는 테스트를 해보자.
  • NormalCoupon.class
@Entity
@NoArgsConstructor
@Getter
public class NormalCoupon{
	@Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private int count;
    
    public NormalCoupon(final int count){
    	this.count = count;
    }
    
    public void issue(){
    	if(count <= 0){
        	throw new IllegalArgumentException("수량 부족");
        }
        count -= 1;
    }
}
  • 테스트코드..class
    • 일반적인 상황에서의 테스트이다.
    • Java에서의 동시성테스트는 ExecutorService 와 CountDownLatch 가 있다.
@Test
@DisplayName("동시에 쿠폰을 발급하게 되면 존재하 쿠폰의 개수 이상으로 쿠폰이 발급될 수 있다.")
void test_not_lock() throws InterruptedException{
	final int executeNumber = 20;
    
    final ExecutorService executorService = Executors.nexFixedThreadPool(10);
    final CountDownLatch countDownLatch = new CountDounLatch(executeNumber);
    
    final AtomicInteger successCount = new AtomicInteger();
    final AtomicInteger failCount = new AtomicInteger();
    
    for(int i=0;i<executeNumber; i++){
    	executorService.execute( () -> {
        	try{
            	normalCouponService.issueCoupon(1L);
                successCount.getAndIncrement();
                System.out.println("쿠폰 발급");
            } catch(Exception e){
            	failCount.getAndIncrement();
                System.out.println(e.getMessage());
            }
            countDownLatch.countDown();
        });
    }
    
    countDownLatch.await();
    
    System.out.println("발급된 쿠폰의 개수 = "+ successCount.get());
    System.out.println("실패한 횟수 = "+ failCount.get());
    
    assertEquals(failCount.get() + successCount.get(), executeNumber);
}

결과값

  • 발급된 쿠폰의 개수는 20개다. 우리는 5개의 쿠폰만 존재하는데 20개가 발급되었다.
발급된 쿠폰의 개수 = 20
실패한 횟수 = 0

 

 

  • 낙관적 락을 활용하여 해결해보자.
    • Version 은 어렵게 생각하지않고, 버전 1, 2 3 4 라고 생각한다.
    • Entity의 접근해서 값이 변경될때 이 버전이 같이 증가한다.
    • 현재 버전이 맞는지, 아닌지 검사/알기 위한 숫자다.
    • @Version을 붙여준 이유는 이 어노테이션을 통해 해당 변수 version에 +1 해주기 위한 어노테이션이다.
    •  
@Entity
@NoArgsConstructor
@Getter
public class NormalCoupon{
	@Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private int count;
    
    @Version
    private Integer version;
    
    public NormalCoupon(final int count){
    	this.count = count;
    }
    
    public void issue(){
    	if(count <= 0){
        	throw new IllegalArgumentException("수량 부족");
        }
        count -= 1;
    }
}
  • TEST를 진행해보면
    • 아래의 것이 나온다.
    • 현재 버전이 맞는지 확인한뒤 값을 변경하고 version도 변경하는 모습을 보인다.
update optimistic_coupon
set count=3, version=2
where id=1 and version=1;
  • 20명의 사용자가 5장의 카드를 발급했는데 왜 3개만 발급되었을까?
    • JPA에서 낙관적 락은 최초의 요청만 Commit하기 때문이다.
성공한 횟수 = 3
낙관적 락 획득 실패횟수 = 17
갯수 부족 횟수 = 0

 

  • 아까와 같이 count가 존재한다.
    • Transaction A가 count값을 수정하기 위해 Transaction이 시작한다. 이떄 Version = 1 이다.
    • 동시에 TransactionB도 count값을 수정하기 위해 Transaction이 시작한다. 이떄 Version = 1이다.
    • 동시에 TransactionC도 count값을 수정하기 위해 Transaction이 시작한다. 이떄 Version = 1이다.
    • 동시에 TransactionD도 count값을 수정하기 위해 Transaction이 시작한다. 이떄 Version = 1이다.
      • TransactionA update ...  version =2 where version = 1
      • TransactionB update ...  version =2 where version = 1 (버전이 맞지 않아 안됨)
      • TransactionC update ...  version =2 where version = 1 (버전이 맞지 않아 안됨)
      • TransactionD update ...  version =2 where version = 1 (버전이 맞지 않아 안됨)
    • 그렇기에 JPA에서 낙관적락은 최초의 요청이 발생하기 때문에 동시성테스트는 JVM이 어떻게 작동하느냐에 따라 쿠폰의 개수가 달라질 수 있다.
    • 가장 중요한점은 보통상황에서와는 다르게 그래도 쿠폰의 개수가 5개는 넘지않는것은 보장된다.

JPA에서의 비관적락

public interface PessimisticCouponRepository extends JpaRepository<PermisticCoupon, Long> {
	@Lock(LockModeType.PESSIMISTIC_WRITE)
    Optional<PessimisticCoupon> findById(Long id);
}
  •  JPA에서의 비관적락은 @Lock 어노테이션과 함께 PESSIMISTIC_WRITE를 사용한다.
  • 테스트 진행해보면 정확하게 5개의 쿠폰이 사용된다.
    • 왜 비관적락은 정확히 5개 쿠폰이 발급되고, 낙관적 락은 실패했을까?
    • 비관적 락은 데이터에 접근할때부터 바로 자물쇠를 잠그기 때문에 20명의 사용자가 동시에 쿠폰을 발급할 경우에는 트랜잭션 1개가 들어가는 순간 나머지 1개는 대기하게 된다.
    • 5개가 채운뒤 부족하면 바로 Exception이 발생한다.
사용된 쿠폰 횟수 = 5
갯수 부족 횟수 = 15
  • select for update가 바로 트랜잭션이 데이터에 접근할때부터 바로 Lock을 거는 쿼리이다.
select pessimisti0_.id as id
...
...
for update;
  • Transaction A가 select ... for Update를 통해서 접근을 했다. 그래서 아예 바로 Lock을 건다.
  • Transaction B, C, D는 애초에 접근하지 못한채 기다리고 대기한다.
  • Transaction A가 이 자물쇠가 끝난 후 접근가능하다.

결론

  • 낙관적락, 비관적 락을 각 상황에 맞게 적용해야한다.

+ Recent posts