글을 쓰게 된 이유
최근에 동시성에 관심이 생겨 DB lock을 공부했습니다. 그래서 자주 사용하는 Spring Data JPA에서는 lock 메커니즘을 어떻게 구현하고 있는지, Java는 동시성 테스트를 어떻게 하는지 궁금했습니다.
이번 글에서는 Spring Data JPA에서의 비관적 락, 낙관적 락을 어떻게 구현하는지 알아보도록 하겠습니다.
먼저 비관적 락, 낙관적 락을 잘 모르신다면 전에 공부했던 글을 참고하면 좋을 것 같습니다.
dependency
spring 2.7.x
java 11
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
runtimeOnly 'com.mysql:mysql-connector-j'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
JPA에서의 낙관적 락
JPA에서는 @Version을 통해 버전 관리 기능을 제공하여 낙관적 락을 구현합니다.
@Version이 적용 가능한 타입은 Long(long), Integer(int), Short(short), Timestamp가 있습니다.
동시에 접근하는 값에 대해 수정하면 version 값이 1 오르게 되고, version 값이 같아야만 수정할 수 있습니다.
5장의 쿠폰이 있을 때, 여러 명의 사람이 동시에 요청할 때의 상황입니다.
@Entity
@NoArgsConstructor
@Getter
public class Coupon {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private int count;
@Version
private Integer version;
public Coupon(final int count) {
this.count = count;
}
public void issueCoupon() {
if (count <= 0) {
throw new IllegalArgumentException("수량 부족");
}
count -= 1;
}
public int getCount() {
return count;
}
}
@Service
public class CouponOptimisticService {
@Autowired
private CouponOptimisticRepository couponOptimisticRepository;
@Transactional
public void issueCoupon(long couponId) {
final Coupon coupon = couponOptimisticRepository.findById(couponId).get();
coupon.issueCoupon();
}
public Coupon getCoupon() {
return couponOptimisticRepository.findById(1L).get();
}
public void saveCoupon() {
couponOptimisticRepository.save(new Coupon(5));
}
}
lock을 사용하지 않을 때
먼저 Coupon 클래스에서 @Version을 사용하지 않고(lock 사용 X) 테스트를 해보겠습니다.
쿠폰의 개수는 5개이고 15번의 요청을 보내겠습니다.
@Test
void test_lock() throws InterruptedException {
int executeNumber = 15; //요청 횟수
//스레드 풀 개수 설정
final ExecutorService executorService = Executors.newFixedThreadPool(10);
final CountDownLatch countDownLatch = new CountDownLatch(executeNumber);
AtomicInteger successCount = new AtomicInteger();
AtomicInteger failCount = new AtomicInteger();
for (int i = 0; i < executeNumber; i++) {
executorService.execute(() -> {
try {
couponOptimisticService.issueCoupon(1L);
successCount.getAndIncrement();
System.out.println("성공");
} catch (ObjectOptimisticLockingFailureException oolfe) {
System.out.println("낙관적 락 발생");
failCount.getAndIncrement();
} catch (Exception e) {
failCount.getAndIncrement();
}
countDownLatch.countDown();
});
}
countDownLatch.await();
System.out.println("성공한 횟수 = " + successCount.get());
System.out.println("실패한 횟수 = " + failCount.get());
assertEquals(failCount.get() + successCount.get(), executeNumber);
}
쿠폰의 개수가 5개인데 성공적으로 발급한 횟수는 15번이 되었습니다. 즉, 동시성 이슈가 발생했다는 뜻이죠.
이를 해결하기 위해 @Version을 사용해보겠습니다.
@Version을 사용한 낙관적 락
Coupon에서 @Version 주석을 해제하고 테스트를 실행해 보겠습니다.
2번의 성공과 13번의 실패를 하게 됩니다. 그리고 version의 값은 정확히 2가 됩니다.
그런데 왜 2번을 성공하게 될까요?
@Version을 사용하게 되면 여러 개의 요청 중에 최초 요청만 커밋이 됩니다.
저는 커넥션 풀에 10개의 스레드를 저장하였고, 총 15번의 요청을 보내게 됩니다.
처음 요청 때 동시에 10개의 스레드가 coupon을 발급받으러 갑니다.
처음 스레드가 count를 1 빼고, version을 1 증가시킵니다. 그 이후 스레드들은 version이 0이기 때문에 같은 version의 entity를 찾을 수없어서 ObjectOptimisticLockingFailureException 이 발생하게 됩니다.
이제 다음 나머지 5개의 스레드들이 coupon을 발급하러 가면서 위의 상황과 똑같이 최초 스레드가 쿠폰을 발급받으면서 count를 1빼고, version 1 증가시킵니다. 그 이후 스레드들은 version 값이 1이기 때문에 같은 version(version = 2가 돼야 함)의 entity가 없어서 예외가 발생하게 됩니다.
JPA에서의 비관적 락
JPA에서 제공하는 비관적 락은 SQL 쿼리에 select ... for update를 통해 X Lock을 걸기 때문에 앞서 낙관적 락에서 사용하는 @Version을 사용하지 않습니다.
낙관적 락과 coupon, service는 똑같지만 repository 부분이 다릅니다.
public interface CouponPessimisticRepository extends JpaRepository<Coupon, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Coupon> findById(Long id);
}
낙관적 락을 사용할 때는 따로 LockModeType을 설정하지 않는다면 @Version 만으로도 낙관적 락을 사용할 수 있습니다.
하지만 비관적 락을 사용하기 위해서는 LockModeType을 PESSIMISTIC_WRITE를 사용해야 합니다.
(다양한 LockModeType은 ‘자바 ORM 표준 JPA 프로그래밍 pg 701을 참고)
@Test
void test_lock() throws InterruptedException {
int executeNumber = 15;
final ExecutorService executorService = Executors.newFixedThreadPool(10);
final CountDownLatch countDownLatch = new CountDownLatch(executeNumber);
AtomicInteger successCount = new AtomicInteger();
AtomicInteger failCount = new AtomicInteger();
for (int i = 0; i < executeNumber; i++) {
executorService.execute(() -> {
try {
couponPessimisticService.issueCoupon(1L);
successCount.getAndIncrement();
System.out.println("성공");
} catch (PessimisticLockingFailureException iae) {
System.out.println("iae.getMessage() = " + iae.getMessage());
failCount.getAndIncrement();
} catch (Exception e) {
System.out.println("비관적 락 발생");
System.out.println("e.getCause() = " + e.getCause());
System.out.println("e = " + e);
failCount.getAndIncrement();
}
countDownLatch.countDown();
});
}
countDownLatch.await();
System.out.println("성공한 횟수 = " + successCount.get());
System.out.println("실패한 횟수 = " + failCount.get());
assertEquals(failCount.get() + successCount.get(), executeNumber);
}
5개의 쿠폰에 총 15번의 요청을 보내게 됩니다.
비관적 락과 다르게 select 쿼리에 for update를 날리고, 순차적으로 select -> update -> lock 반환 후 다른 스레드 수행이 되고 있습니다.
또한, 성공 횟수는 정확히 티켓의 개수이고, 15번의 요청 중에 5개가 성공했으니 10개가 실패하게 됩니다.
결론
JPA에서는 동시성 이슈를 해결하기 위해 @Version 을 통한 낙관적 락, LockModeType.PESSIMISTIC_WRITE 옵션을 통해 비관적 락을 제공하고 있습니다.
코드를 보면 동시성 이슈가 둘 다 해결이 되는데 무조건 낙관적 락이 좋은 거 아니야?라고 생각하실 수도 있습니다. 그러나 현재 코드에서는 -1만 하는 상황이고, 만약에 두 트랜잭션이 동시에 동일한 데이터를 업데이트할 때, 업데이트 순서에 따라 데이터가 손실될 수 있습니다. 만약 데이터 경합이 빈번하다면 손실되는 데이터의 개수가 더욱 많아지기 때문에 낙관적 락이 적합하지 않을 수 있습니다.
그리고 실제 테스트하는 코드가 어렵지 락을 구현하는 코드는 막 복잡하지는 않았습니다.
그래서 상황, 애플리케이션에 따라서 낙관적 락, 비관적 락을 적절히 사용할 줄 알아야 하는 것을 배울 수 있었습니다.
REFERENCES
자바 ORM 표준 JPA 프로그래밍
'Spring Data' 카테고리의 다른 글
@NotNull vs @Column(nullable = false) (0) | 2022.10.22 |
---|---|
Find vs Get (0) | 2022.09.25 |
JpaRepository vs CrudRepository (0) | 2022.08.23 |
Spring Data JPA에서의 벌크 삭제 정리 (0) | 2022.07.12 |
[YAPP] 검색 성능 최적화 일지(Feat. Elasticsearch) (0) | 2022.07.08 |