이전에 카프카 동시성 이슈에 대해 작성하였습니다.
글을 작성하면서 찾았던 낙관적 락, 비관적 락, 분산락에 대해 설명하고, 기록하도록 하겠습니다.
동시성 테스트
아래 코드는 동시성 테스트를 위한 코드입니다.
큰 특징은
- newFixedThreadPool
- 해당하는 함수로 Java의 쓰레드를 생성하는 함수로써, 10개 스레드를 생성하였습니다.
- CountDownLatch
- new CountDownLatch(THREAD_COUNT) : 스레드 작업 카운트를 적시하여, 차후에 함수에 사용 합니다.
- latch.await(10, TimeUnit.SECONDS) : 위에 적시한 스레드 카운트가 0 이 될 때까지 기다리고 이후에 작업을진행 합니다.
package com.webtoonrank.demo.core.domain.product.service;
@SpringBootTest
public class ProductServiceTest {
@Autowired
private ProductService productService;
@Autowired
private ProductRepository productRepository;
private static final int THREAD_COUNT = 10;
@BeforeEach
public void setup() {
ProductEntity product = new ProductEntity("Test Product", 20);
productRepository.save(product);
}
@Test
public void testConcurrentStockDecrease() throws InterruptedException {
ExecutorService executorService = Executors.newFixedThreadPool(THREAD_COUNT);
CountDownLatch latch = new CountDownLatch(THREAD_COUNT);
ProductEntity productEntity = productRepository.findAll().get(0);
Long productId = productEntity.getId();
for (int i = 0; i < THREAD_COUNT; i++) {
executorService.submit(() -> {
try {
boolean success = productService.decreaseStock(productId, 1);
System.out.println("Stock decreased: " + success);
} catch (RuntimeException runtimeException) {
System.out.println(runtimeException.getMessage());
} finally {
latch.countDown();
}
});
}
latch.await(10, TimeUnit.SECONDS);
ProductEntity product = productRepository.findById(productId).orElseThrow();
System.out.println("Final stock: " + product.getStock());
assertThat(product.getStock()).isEqualTo(20 - THREAD_COUNT);
}
}
상품 ProductEntity로 상품의 재고를 세팅 한 후 decreaseStock을 통하여 재고를 줄이는 로직입니다.
package com.webtoonrank.demo.storage;
import jakarta.persistence.*;
import lombok.Getter;
@Entity
@Getter
public class ProductEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private int stock;
public ProductEntity(String name, int stock) {
this.name = name;
this.stock = stock;
}
public ProductEntity() {
}
public boolean decreaseStock(int quantity) {
if (this.stock >= quantity) {
this.stock -= quantity;
return true;
} else {
return false;
}
}
}
일반적으로 생각했을 때 스레드 개수만큼 productService.decreaseStock(productId, 1); 로 차감하여 [초기 재고 수량 20 - 스레드의 카운트] 만큼 나올 것이라고 기대합니다. 하지만 결과는 아래와 같습니다.

이렇게 나오는 이유는 아래와 같습니다.

- 즉 데이터가 커밋 되기 전에 조회되고, 재고의 카운트가 20으로 인지하여 동시성 이슈가 발생합니다.
낙관적 락을 사용한 동시성 문제 해결
- 낙관적 락(Optimistic Lock)
- 데이터 충돌 가능성이 낮다고 생각하고, 트랜잭션이 완료될 때까지 잠금을 걸지 않고 데이터를 처리하는 방법입니다.

- 위 그림을 보면 사용자 1은 update 시점에 version = 1을 찾고 업데이트를 통해서 version = 2로 올린 것을 확인할 수 있습니다. 나머지 유저들은 version = 1을 찾고 2로 업데이트를 하려고 하지만, 사용자 1에서 version을 2로 업데이트했기 때문에 Exception이 발생 되어 롤백이 진행이됩니다.
- 데이터 충돌 가능성이 낮다고 생각하고, 트랜잭션이 완료될 때까지 잠금을 걸지 않고 데이터를 처리하는 방법입니다.
[정리하자면]
- 사용자 1 : update 성공 version = 2로 업데이트
- 사용자 2 : update 실패 version =1을 사용자 1에서 업데이트했기 때문에 찾을 수 없어서 Exception 발생
- 사용자 3 : update 실패 version =1을 사용자 1에서 업데이트했기 때문에 찾을 수 없어서Exception 발생
- 사용자 4 : update 실패 version =1을 사용자 1에서 업데이트했기 때문에 찾을 수 없어서Exception 발생
이제 사용자 1인 원하는 주문을 성공하였고, 나머지유저들은 다시 시도하면서 데이터의 일관성을 유지하면서 주문을 진행할 수있습니다.
그렇다면 낙관적 락은 왜 사용하는 것일까?
- 버전을 통해서
동시성을 해결하고, 트랜잭션이 완료될 때까지 락을 걸지 않기 때문에, 데이터베이스의락 경합이 줄어들어 전체 시스템의 성능이 향상됩니다.
낙관적 락은 언제 사용하면 좋은가?
읽기 로직이 많고 쓰기 로직이 적을 때 사용하는 게 좋습니다.이유는 위에 언급한 것처럼 애플리케이션 레벨에서 version을 통해서 관리하고, update하는 시점에 version을 갱신하여 일치하는 트랜잭션만 처리되고 나머지는 Exception을 통해 롤백이 되기 때문입니다.
[실습]
- JPA에서 낙관적 락 사용법
- @Version
private Integer version; 만 선언하면 JPA에 자동으로 업데이트 시 값을 수정해 줍니다.
- @Version
@Entity
@Getter
public class ProductEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private int stock;
@Version
private Integer version;
....
public boolean decreaseStock(int quantity) {
if (this.stock >= quantity) {
this.stock -= quantity;
return true;
} else {
return false;
}
}
}
아래 테스트 코드를 보면
- org.springframework.orm.ObjectOptimisticLockingFailureException: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect) : [com.webtoonrank.demo.storage.ProductEntity#1] 를 통해서 트랜잭션이 실패되고, 롤백되는 것을 확인할 수 있습니다.

오늘은 낙관적 락에 대해 설명하였습니다.
직접 테스트 코드를 작성하면서 확인하였고, 낙관적 락을 언제 사용하면 좋을지 알 수 있었습니다.
한 가지 주의해될 점은 데드락 상황이 다음과 같이 발생할 수 있습니다.
- 트랜잭션 A가
Table1의 행을 수정하면서Table2의 참조 무결성을 확인하기 위해Table2의 잠금을 요청합니다. - 트랜잭션 B가 동시에
Table2의 행을 수정하면서Table1의 참조 무결성을 확인하기 위해Table1의 잠금을 요청합니다. - 이때 트랜잭션 A와 트랜잭션 B는 각각 상대방이 보유한 락을 기다리게 되어 데드락 상태에 빠질 수 있습니다.
오늘은 낙관적 락에대해 설명하게 되었습니다.
- 언제 사용하면 좋을지?
- 왜 사용하는지?
- 주의해야 될 점
- 사용방법
에 대해 알아보았으며, 추후에 비관적락, 분산락에 대해서도 설명하도록 하겠습니다.
감사합니다.
'Spring' 카테고리의 다른 글
| 상반기 회고 및 Spring신규 프로젝트 패키지 구조 (0) | 2024.08.10 |
|---|