서론
낙관적 락은 데이터 충돌이 드물 것이라고 가정하고, 데이터를 수정할 때만 충돌을 검사합니다. 트랜잭션이 데이터를 수정하기 전까지는 락을 걸지 않고, 수정 시점에 데이터가 변경되지 않았는지 확인합니다.
본 글에서는 JPA의 @Version 어노테이션을 사용하여 낙관적 락을 구현하고, 낙관적 락을 사용하여 동시성 문제를 해결하는 동작을 확인해보겠습니다.
@Version 어노테이션이란 ❓
@Version 어노테이션은 주로 JPA를 사용할 때 낙관적 락(Optimistic Locking)을 구현하기 위해 사용됩니다.
이 어노테이션은 엔티티(Entity)의 버전 관리에 사용되며, 데이터베이스 테이블의 특정 열에 매핑되어 엔티티의 수정 이력을 추적합니다.
주요 기능
- 낙관적 락(Optimistic Locking)
- @Version 어노테이션이 적용된 필드는 엔티티가 저장될 때마다 자동으로 증가합니다.
- 이 필드는 주로 int, long, Timestamp와 같은 타입으로 선언됩니다.
- 동시성 제어
- 여러 트랜잭션이 동시에 같은 엔티티를 수정할 때, @Version 필드의 값이 변경되었는지 확인하여 동시성 문제를 방지합니다.
- 만약 트랜잭션이 데이터를 수정하려고 할 때, 이 필드의 값이 기존과 다르다면, JPA는 OptimisticLockException 예외를 발생시켜 변경을 막습니다.
Entity 클래스
Product Entity
Product 엔티티 클래스는 상품 정보를 담고 있는 간단한 JPA 엔티티입니다.
- @Version 어노테이션이 붙은 version 필드는 낙관적 락을 구현하기 위해 사용됩니다.
- 이 version 필드는 JPA가 엔티티의 상태를 관리하고, 업데이트 시 현재 버전과 데이터베이스의 버전을 비교하여 충돌을 감지합니다. 만약 다른 트랜잭션이 데이터를 변경했다면, 버전 불일치로 인해 OptimisticLockException 예외이 발생합니다.
Repository Interface
ProductRepository
public interface ProductRepository extends JpaRepository<Product, Long> {
}
Service
ProductService
ProductServic 클래스는 비즈니스 로직을 처리하는 서비스 클래스입니다.
- updateProductPrice 메서드는 특정 productId에 해당하는 상품의 가격을 새 값으로 업데이트합니다.
- 이 메서드는 @Transactional 어노테이션으로 트랜잭션 내에서 실행되며, 동시성 문제를 처리하기 위해 ObjectOptimisticLockingFailureException을 캐치(catch)합니다.
- 만약 두 개의 트랜잭션이 동일한 상품을 수정하려고 할 때, 첫 번째 트랜잭션이 완료된 후 두 번째 트랜잭션이 수행되면 version 불일치로 인해 예외가 발생하게 됩니다.
낙관적 락 동작 확인하기
@SpringBootTest
public class ProductServiceTests {
@Autowired
private ProductService productService;
@Autowired
private ProductRepository productRepository;
@Test
public void testOptimisticLocking() throws InterruptedException {
// 초기 데이터 생성
Product product = new Product();
product.setName("Product 1");
product.setPrice(100.0);
productRepository.save(product);
Thread thread1 = new Thread(() -> {
productService.updateProductPrice(product.getId(), 200.0);
});
Thread thread2 = new Thread(() -> {
assertThrows(ObjectOptimisticLockingFailureException.class, () -> {
productService.updateProductPrice(product.getId(), 300.0);
});
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
}
}
테스트 목표 🏁
두 번째 스레드가 동작할 때 ObjectOptimisticLockingFailureException이 발생하는지를 assertThrows를 통해 확인하는 것입니다.
스레드 2번이 Product의 가격을 300으로 수정하려고 할 때, 이미 버전이 변경되었으므로 예외가 발생할 것으로 예상합니다.
테스트를 위해 Hibernate가 product 테이블을 생성합니다. 이 테이블에는 price, version, id, name 필드가 있습니다. version 필드는 낙관적 락을 관리하는 데 사용됩니다.
테스트를 시작하기 전에 Product 1이라는 이름으로 초기 데이터를 삽입합니다. version 필드의 초기값은 0입니다.
위 로그를 보면 스레드 1과 스레드 2가 동시에 실행되었습니다. 스레드 1과 스레드 2가 거의 동시에 product 데이터를 조회합니다. 두 스레드 모두 동일한 version 값(version = 0)을 가져옵니다.
이 단계에서는 아직 데이터베이스에 아무런 변화도 없습니다.
업데이트 시도
위 로그를 보면 스레드 1은 Product 엔티티의 가격을 200으로 업데이트합니다. 이때 version 값이 0에서 1로 증가합니다.
- (binding parameter (3:INTEGER) <- [1]).
where id=? and version=? 조건을 통해 현재 버전이 0인지 확인한 후에 업데이트가 수행됩니다.
즉, 두 스레드가 동시에 UPDATE문을 실행합니다. 로그에 따르면, 두 스레드 모두 0인 버전을 사용하여 업데이트를 시도합니다. 하지만 스레드 1번이 먼저 성공적으로 업데이트를 완료하고, 버전이 1로 증가합니다.
위 로그를 보면 스레드 2의 업데이트 시도를 하다가 예외가 발생했습니다.
- 스레드 2도 Product 엔티티의 가격을 300으로 업데이트하려고 시도하지만, where id=? and version=? 조건에서 version이 이미 1로 변경된 것을 확인합니다.
이후 스레드 2번이 수정된 엔티티를 다시 수정하려고 할 때, 버전이 맞지 않아 충돌이 발생하게 되고, 이로 인해 스레드 2는 업데이트에 실패하고, ObjectOptimisticLockingFailureException이 발생합니다. 이 예외는 assertThrows로 예상한 대로 발생하여 테스트가 정상적으로 통과합니다.
결과적으로, 예상한 예외가 발생했으므로 테스트가 정상적으로 종료되고 "Tests passed"가 출력된 것입니다.
이는 낙관적 락이 성공적으로 동작했음을 보여줍니다.
"ObjectOptimisticLockingFailureException" 은 Spring Framework에서 제공하는 예외 중 하나로, JPA에서 낙관적 락(Optimistic Locking) 사용 시 발생할 수 있는 예외입니다.
이 예외는 주로 데이터베이스의 동시성 문제를 처리하는 과정에서 발생합니다.
'Framework > JPA' 카테고리의 다른 글
[JPA] Spring Data JPA의 페이징과 정렬을 쉽게 구현하는 방법: Pageable과 PageRequest (0) | 2024.09.02 |
---|---|
[JPA] JPA에서 단방향 및 양방향 관계 이해 (@ManyToOne, @OneToMany, @OneToOne, @ManyToMany) (1) | 2024.08.23 |
[JPA] 비관적 락(Pessimistic Lock)을 통한 동시성 제어 및 업데이트 손실(Lost Update)확인하기 (0) | 2024.08.21 |
[JPA] @Lock 어노테이션이란 무엇일까❓: 다양한 LockModeType 잠금 모드 (0) | 2024.08.21 |
[JPA] Query Method란 무엇일까 ❓ (0) | 2024.07.27 |