격리수준과 잠금
2022.12.06 - [Spring/JPA] - [Lock] 동시성 제어를 위한 JPA Lock 1. 격리수준과 잠금(락)
낙관적 락(Optimistc Lock)
2022.12.07 - [Spring/JPA] - [Lock] 동시성 제어를 위한 JPA Lock 2. 낙관적 락(Optimistic Lock)
참고
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
잠금(락)은 낙관적 락과 비관적 락 두 종류의 잠금 방식이 있습니다.
비관적 락(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
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 자체를 읽을 수 없다는 오류가 뜨며 상품에 변화가 없음을 알 수 있습니다.
파트너스 활동을 통해 일정액의 수수료를 제공받을 수 있음
'Spring > JPA' 카테고리의 다른 글
[Lock] 동시성 제어를 위한 JPA Lock 2. 낙관적 락(Optimistic Lock) (0) | 2022.12.07 |
---|---|
[Lock] 동시성 제어를 위한 JPA Lock 1. 격리수준과 잠금(락) (0) | 2022.12.06 |
식별 관계 비식별 관계 (0) | 2021.07.13 |
[JPA 연관관계매핑] 단방향, 양방향 연관 관계 매핑 (0) | 2021.04.11 |
[JPA] Hibernate의 ddl-auto (0) | 2021.04.09 |