본문 바로가기
Spring/JPA

[Lock] 동시성 제어를 위한 JPA Lock 3. 비관적 락(Pessimistic Lock)

by 행운의나무 2022. 12. 8.
728x90
반응형

격리수준과 잠금

2022.12.06 - [Spring/JPA] - [Lock] 동시성 제어를 위한 JPA Lock 1. 격리수준과 잠금(락)

 

[Lock] 동시성 제어를 위한 JPA Lock 1. 격리수준과 잠금(락)

참고 https://hackernoon.com/optimistic-and-pessimistic-locking-in-jpa [Real MySQL 8.0] https://link.coupang.com/a/GW7Yc https://zzang9ha.tistory.com/381 https://www.baeldung.com/jpa-optimistic-locking https://www.baeldung.com/jpa-pessimistic-locking http

twer.tistory.com

낙관적 락(Optimistc Lock)

2022.12.07 - [Spring/JPA] - [Lock] 동시성 제어를 위한 JPA Lock 2. 낙관적 락(Optimistic Lock)

 

[Lock] 동시성 제어를 위한 JPA Lock 2. 낙관적 락(Optimistic Lock)

격리수준과 잠금 2022.12.06 - [Spring/JPA] - [Lock] 동시성 제어를 위한 JPA Lock 1. 격리수준과 잠금(락) [Lock] 동시성 제어를 위한 JPA Lock 1. 격리수준과 잠금(락) 참고 https://hackernoon.com/optimistic-and-pessimistic-

twer.tistory.com

참고
https://hackernoon.com/optimistic-and-pessimistic-locking-in-jpa
[Real MySQL 8.0] https://link.coupang.com/a/GW7Yc
https://zzang9ha.tistory.com/381
https://www.baeldung.com/jpa-optimistic-locking
https://www.baeldung.com/jpa-pessimistic-locking
https://www.baeldung.com/java-jpa-transaction-locks

 

Real MySQL 8.0 1

COUPANG

www.coupang.com

잠금(락)은 낙관적 락과 비관적 락 두 종류의 잠금 방식이 있습니다.

비관적 락(Pessimistic Lock)

비관적 락은 데이터베이스에서 제공하는 락입니다.
비관적 락은 공유락과 배타락으로 구분할 수 있습니다.
- 공유락 : 읽기는 가능하지만, 쓰기에 잠금을 겁니다.
- 배타락 : 읽기/쓰기 모두 금지합니다.
낙관적 락 보다 데이터 무결성을 보장할 수 있지만, 교착상태이 발생할 수 있습니다.
- 참고 : [데드락 해결방법] https://way-be-developer.tistory.com/291

JPA에서 제공하는 잠금 모드

- @Lock(LockModeType.PESSIMISTIC_READ) 
해당 리소스에 공유 락을 걸게 됩니다.
다른 트랜잭션에서 읽기는 가능하지만 쓰기는 불가능해집니다. 
LOCK IN SHARE MODE

- @Lock(LockModeType.PESSIMISTIC_WRITE)
해당 리소스에 배타 락을 걸게 됩니다.
다른 트랜잭션에서는 읽기와 쓰기 모두 불가능해집니다.  
FOR UPDATE

- @Lock(LockModeType.PESSIMISTIC_FORCE_INCREMENT)
PESSIMISTIC_WRITE와 유사하게 작동하지만 추가적으로 낙관적 락처럼 버저닝을 하게 됩니다.
따라서 버전에 대한 칼럼이 필요합니다. (@Version)
FOR UPDATE NOWAIT 

실습

DB

Goods Table

Entity

@Entity
@Table(name = "goods")
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EntityListeners(AuditingEntityListener.class)
public class Goods {

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

    private String name;

    // 재고
    private int stock;

    @CreatedDate
    private Timestamp createdAt;

    @LastModifiedDate
    private Timestamp updatedAt;

    // 재고 감소 메소드
    public Goods decreaseGoodsStock(int count){
        this.stock -= count;

        return this;
    }
}

@Entity
@Table(name = "orders")
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Builder
@EntityListeners(AuditingEntityListener.class)
public class Orders {

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

    private String orderNumber;

    @ManyToOne(fetch = FetchType.LAZY)
    private User user;


    @CreatedDate
    private Timestamp createdAt;

    @LastModifiedDate
    private Timestamp updatedAt;

}

Service

@Service
@RequiredArgsConstructor
@Slf4j
public class OrdersService {

    private final UserRepository userRepository;

    private final OrdersRepository ordersRepository;

    private final GoodsRepository goodsRepository;

    // 새로운 주문 요청 시 id 1번의 상품을 1씩 감소한다고 가정
    @Transactional
    public Runnable createOrder(Long userId) throws Exception{

        User user = userRepository.findById(userId).orElseThrow(() -> new RuntimeException("Not Found"));

        String name = user.getName();

        String orderNumber = this.orderNumber(name);


        Set<Goods> setGoods = new HashSet<>();
        Goods goodsA = goodsRepository.findByIdWithVersionLock(1L).orElseThrow(() -> new RuntimeException("Not Found"));

        if(goodsA.getStock() > 0) {
            setGoods.add(goodsA);
            Orders newOrder = Orders.builder()
                    .orderNumber(orderNumber)
                    .user(user)
                    .build();

            Goods goods = goodsA.decreaseGoodsStock(1);
            goodsRepository.save(goods);

            ordersRepository.save(newOrder);
        }
         else {
            log.info("재고 부족");
        }


        return null;
    }

    private String orderNumber(String userName){
        int randomNumber = (int) (Math.random() * 100000);

        StringBuilder sb = new StringBuilder();

        sb.append(userName.substring(1, 2));
        sb.append(randomNumber);

        return sb.toString();
    }

}

Test

- 상품(Goods) A의 재고는 총 2개

- 3개의 쓰레드를 생성하여 상품의 재고를 1씩 감소시키고 주문정보를 생성하도록 테스트 코드를 작성합니다.

@SpringBootTest
public class ConcurrencyTest {

    @Autowired
    private OrdersService ordersService;


    @Test
    void test() throws InterruptedException {
        ExecutorService executorService = Executors.newFixedThreadPool(3);

        CountDownLatch countDownLatch = new CountDownLatch(3);

        for (int i = 1; i < 4; i++) {
            int finalI = i;
            executorService.execute(() -> {
                Long userId = Long.valueOf(finalI);
                try {
                    System.out.println("userId: " + userId);
                    ordersService.createOrder(userId);
                } catch (Exception e) {
                    e.printStackTrace();
                }
                countDownLatch.countDown();
            });
        }

        countDownLatch.await();

    }
}

 

1. 락을 주지 않았을 경우

repository

public interface GoodsRepository extends JpaRepository<Goods, Long> {
 	@Query(value = "SELECT g FROM Goods g WHERE g.id = :id")
    Optional<Goods> findByIdWithVersionLock(@Param("id") Long id);
}

잠금 없이 실행 후 주문, 상품 테이블

- 락을 주지 않았을 경우 모든 쓰레드가 하나의 데이터를 이용하기 때문에 재고는 1만 감소합니다.

- 주문정보는 쓰레드 수가 3개이기 때문에 3개의 쓰레드가 생성됩니다. => 재고는 2개인데 3개의 주문이 생성되므로 동시성문제가 발생합니다.

2. 비관적 락 설정

- @Lock(LockModeType.PESSIMISTIC_READ) 
공유 락. 다른 트랜잭션에서 읽기는 가능하지만 쓰기는 불가능해집니다. 
LOCK IN SHARE MODE

repository

public interface GoodsRepository extends JpaRepository<Goods, Long> {
    @Lock(LockModeType.PESSIMISTIC_READ)
    @Query(value = "SELECT g FROM Goods g WHERE g.id = :id")
    @QueryHints({@QueryHint(name = "javax.persistence.lock.timeout", value = "1000")})	
    Optional<Goods> findByIdWithVersionLock(@Param("id") Long id);
    
   /* natvie query 이용 
    @Query(value = "SELECT * FROM testdb.goods WHERE id = :id LOCK IN SHARE MODE", nativeQuery = true)
    @QueryHints({@QueryHint(name = "javax.persistence.lock.timeout", value = "1000")})	
    Optional<Goods> findByIdWithVersionLock(@Param("id") Long id);
    */
}

공유락 데드락
공유락 실행 후 상품, 주문 테이블

첫 번째, 두 번째 스레드(어떤 스레드인지 순서는 보장되지 않음)에서 데드락이 발생하여 CannotAcquireLockException 예외를 발생합니다. 
동시에 쓰레드 데이터를 업데이트(쓰기)를 할 때 데드락이 발생하게 됩니다. 
테스트 실행 후 상품의 재고는 1으로 감소하고, 주문 정보는 1개가 생성됩니다.

- @Lock(LockModeType.PESSIMISTIC_WRITE)
배타 락. 다른 트랜잭션에서는 읽기와 쓰기 모두 불가능해집니다.  
FOR UPDATE

repository

public interface GoodsRepository extends JpaRepository<Goods, Long> {

    // 배타락. 다른 트랜잭션에서는 읽기와 쓰기 모두 불가능해집니다
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @QueryHints({@QueryHint(name = "javax.persistence.lock.timeout", value = "1000")})
    @Query(value = "SELECT g FROM Goods g WHERE g.id = :id")
    Optional<Goods> findByIdWithVersionLock(@Param("id") Long id);
    
    /* natvie query FOR UPDATE
    @QueryHints({@QueryHint(name = "javax.persistence.lock.timeout", value = "1000")})
  	@Query(value = "SELECT * FROM testdb.goods WHERE id = :id FOR UPDATE", nativeQuery = true)
    Optional<Goods> findByIdWithVersionLock(@Param("id") Long id);
	*/
}

 

배타락 실행 후 상품, 주문 테이블
배타락 테스트 결과

실행결과 상품의 재고는 0까지 줄어들고 주문정보도 2개까지만 생성됩니다.

- @Lock(LockModeType.PESSIMISTIC_FORCE_INCREMENT)
PESSIMISTIC_WRITE와 유사하게 작동하지만 추가적으로 낙관적 락처럼 버저닝을 하게 됩니다.
따라서 버전에 대한 칼럼이 필요합니다. (@Version)
FOR UPDATE NOWAIT 

repository

public interface GoodsRepository extends JpaRepository<Goods, Long> {

    @Lock(LockModeType.PESSIMISTIC_FORCE_INCREMENT)
    @QueryHints({@QueryHint(name = "javax.persistence.lock.timeout", value = "1000")})
    @Query(value = "SELECT g FROM Goods g WHERE g.id = :id")
    Optional<Goods> findByIdWithVersionLock(@Param("id") Long id);
    

	/* native query 
	@Query(value = "SELECT * FROM testdb.goods WHERE id = :id FOR UPDATE NOWAIT", nativeQuery = true)
	@QueryHints({@QueryHint(name = "javax.persistence.lock.timeout", value = "1000")})
    Optional<Goods> findByIdWithVersionLock(@Param("id") Long id);
    */
}

실행 후 상품, 주문 테이블
테스트 실행

테스트를 실행하면 락이 걸려 있기 때문에 row 자체를 읽을 수 없다는 오류가 뜨며 상품에 변화가 없음을 알 수 있습니다.

쿠팡으로 연결 클릭

 

제주삼다수 그린 무라벨

COUPANG

www.coupang.com

파트너스 활동을 통해 일정액의 수수료를 제공받을 수 있음

반응형