본문 바로가기
Spring/JPA

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

by 행운의나무 2022. 12. 7.
728x90
반응형
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

참고
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

 

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

낙관적 락(Optimistic Lock)

낙관적 락은 어플리케이션에서 제공하는 락입니다.
트랜잭션 대부분이 충돌이 일어나지 않을 것이라고 가정하고 수행합니다.
JPA에서는 @Version 어노테이션을 이용하여 낙관적 락을 거는 엔티티의 값을 설정합니다.
- 엔티티 객체의 업데이트 수행 시 @Version 어노테이션 설정된 값의 버전을 체크하고 버전이 맞지 않으면 OptimisticLockException을 던집니다.
- 버전이 맞다면 트랜잭션은 업데이트를 커밋하고, @Version의 값을 증가시킵니다.
- @Version 어노테이션이 설정된 값은 증가할 수 있는 값이 필요하므로 'updated_at' 등을 설정하면 좋습니다.
- 적용 가능 타입 : Long, Integer, Short, java.sql.Timestamp
- 하나의 엔티티에는 하나의 @Version만 설정 가능합니다. 
- 연관관계의 테이블인 경우 기본(주된) 테이블에 선언되어야 합니다.

특징
- 읽기를 많이 수행하는 어플리케이션 동작에 유용하게 동작할 수 있습니다.
- 일정 시간 동안 잠금을 유지할 수 없는 상황에서도 사용 가능합니다. (비관적락을 걸었을 때 많은 지연시간이 필요한 경우)
- 단점으로는 충돌이 발생한 경우 예외가 발생하는데, 예외에 대한 처리를 해주어야 합니다.

JPA에서 제공하는 잠금 모드

- @Version이 설정된 값을 기준으로 읽기/쓰기 잠금 옵션을 추가할 수 있습니다.
- @Lock 어노테이션은 쿼리에 설정하며, 트랜잭션에서 읽기동작만 있는 경우에도 트랜잭션이 종료될때  버전을 체크합니다.
- @Lock(LockModeType.OPTIMISTIC) (읽기) : @Version 값이 포함된 모든 엔티티의 읽기 잠금
- @Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT) (쓰기): 모든 엔티티의 읽기를 잠그지만, 버전의 값은 증가 시킴

반응형

실습

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. 낙관적 락 설정

@Version 설정
Goods 엔티티의 updated_at 값을 @Version으로 설정해 줍니다. 

@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;

    @Version
    @LastModifiedDate
    private Timestamp updatedAt;

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

        return this;
    }
}


업데이트 실행 시 Hibernate의 쿼리문 where 절에 @Version으로 설정된 값(updated_at)이 추가됩니다.

Hibernate: update goods set created_at=?, name=?, stock=?, updated_at=? where id=? and updated_at=?

@Version 설정 실행 후 상품, 주문 테이블
테스트 코드를 실행 후 예외발생

@Version 처리를 실행했을 경우, 모든 쓰레드에서 동일한 상품을 읽으려고 시도하지만 이미 다른 트랜잭션에서 해당 값을 읽고있음을 알 수 있게되어 ObjectOptimisticLockingFailureException 예외를 발생합니다.
업데이트 수행 시 @Version 어노테이션 설정된 값의 버전을 체크하고 업데이트를 진행하기 때문에 동시성을 제어할 수 있습니다.
- 상품의 재고는 1만큼 감소하고 주문 정보도 1개 생성됨을 알 수 있습니다.
- @LOCK(LockModeType.NONE)

 

락 모드 타입
@Version 설정이 된 엔티티의 값을 기준으로 락의 모드를 설정하는 옵션을 가질 수 있습니다.

- @Lock(LockModeType.NONE)
@Version만 설정한 경우 기본적으로 NONE으로 옵션이 설정됩니다.

- @Lock(LockModeType.OPTIMISTIC)
읽기 모드로 @Version 값이 포함된 모든 엔티티의 읽기 실행에 락을 겁니다.

@OPTIMISTC 모드 설정 후 상품, 주문 테이블
테스트 코드 실행 후 예외 발생

읽기자체에 락을 걸기 때문에 다른 쓰레드에서 읽기 시도를 했을 때 락이 걸려 아무런 동작을 취하지 못하고 예외를 발생시킵니다.

- @LOCK(LockModeType.OPTIMISTIC_FORCE_INCREMENT)
쓰기 모드로 모든 엔티티의 읽기에 락을 걸지만, 버전의 값은 강제로 증가시킵니다.
@Version의 값은 강제적으로 올리는 역할이 추가되며, 현재 엔티티가 업데이트 하는 동안 다른 엔티티를 잠그려는 동작에서 사용될 수 있습니다.
참고 :
https://www.logicbig.com/tutorials/java-ee-tutorial/jpa/optimistic-lock-force-increment-use-case.htmlhttps://vladmihalcea.com/hibernate-locking-patterns-how-does-optimistic_force_increment-lock-mode-work/

쿠팡으로 연결 클릭

 

제주삼다수 그린 무라벨

COUPANG

www.coupang.com

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

반응형