저번 포스트 동시성문제(3) 에서는 자바의 비관적 락으로 어떻게 동시성 문제를 해결할 수 있는지, 어떻게 적용하는지 알아보았다. 이번에는 낙관적 락을 통해 문제를 어떻게 해결할 수 있는지 알아보겠다.
현실적으로 데이터 갱신 시 경합이 발생하지 않을 것이라고 보고 잠금을 거는 기법. 예를 들어 회원정보에 대한 갱신은 보통 회원에 의해서 이루어지므로 동시에 여러 요청이 발생할 가능성이 적다. 따라서 동시에 수정이 이루어진 경우를 감지해서 예외를 발생시켜도 실제로 예외가 발생할 가능성이 낮다고 낙관적으로 보는 것이다. 이는 엄밀한 의미에서 보면 잠금이라기 보다는 일종의 "충돌방지" 에 가깝다.
낙관적 락은 트랜잭션이 데이터를 읽을 때 락을 걸지 않는다. 대신, 데이터를 업데이트할 때 데이터가 변경되지 않았는지 확인하는 방식이다. 이렇게 충돌이 빈번하지 않은 환경에서, 충돌이 일어날 것인지 확인하는 메커니즘을 살펴볼 때 "충돌방지" 라는 말이 더 쉽게 와닿았다.
1. kyungmin 과 Tom 서버가 id 가 1 인 데이터를 가져간다.
2. kyungmin 이 업데이트를 하면서 버전 1을 2로 올려준다.
3. 이 후 Tom 은 아까 읽은 버전 1로 업데이트를 시도하지만, 버전은 2로 바뀌었기 때문에 업데이트에 실패한다.
이처럼, 내가 읽은 버전에서 수정사항이 생겼을 경우에는 애플리케이션에서 다시 읽은 후, 작업을 수행해야 한다.
코드로 낙관적 락을 어떻게 적용할 수 있는지 알아보자.
.
.
Stock 엔티티
@Entity
@Getter
public class Stock {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long productId;
private Long quantity;
@Version
private Long version;
(생략)
.
.
OptimisticLockStockService
@Service
@RequiredArgsConstructor
public class OptimisticLockStockService {
private final StockRepository stockRepository;
@Transactional
public void decrease(Long id, Long quantity) {
Stock stock = stockRepository.findByIdWithOptimisticLock(id);
stock.decrease(quantity);
stockRepository.saveAndFlush(stock);
}
}
OptimisticLockStockFacade
@Service
@RequiredArgsConstructor
public class OptimisticLockStockFacade {
private final OptimisticLockStockService optimisticLockStockService;
public void decrease(Long id, Long quantity) throws InterruptedException {
// 업데이트때 실패했을 경우 재시도를 위한 while
while (true) {
try {
optimisticLockStockService.decrease(id, quantity);
break;
} catch (Exception e) {
// 재고 수량 감소 실패 -> 50ms 후 재시도
Thread.sleep(50);
}
}
}
}
OptimisticLockStockFacadeTest
@Test
@DisplayName("낙관적 락을 걸었을 경우")
public void sameRequest() throws InterruptedException {
int threadCount = 100;
// ExecutorService : 비동기로 실행하는 작업을 단순화하여 사용할 수 있도록 도와주는 자바의 API
ExecutorService executorService = Executors.newFixedThreadPool(32);
// CountDownLatch : 다른 스레드에서 수행중인 작업이 완료될 때 까지 대기할 수 있도록 도와주는 클래스
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i=0; i < threadCount; i++) {
executorService.submit( () -> {
try {
optimisticLockStockFacade.decrease(1L, 1L);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
latch.countDown();
}
});
}
latch.await();
Stock stock = stockRepository.findById(1L).orElseThrow();
assertEquals(0, stock.getQuantity(),"테스트 미통과");
System.out.println("테스트 통과");
}
비관적 락은 미리 락을 걸어놓고 업데이트가 끝날 때까지 접근이 불가하다.
하지만 낙관적 락은 이와 다르게 충돌이 애초에 거의 없을 것이라 판단하기 때문에, 충돌이 발생한다면 이를 감지하고 해결하는 방식으로 진행된다.
특징 | 비관적 락 | 낙관적 락 |
---|---|---|
충돌 가정 | 충돌이 빈번하게 발생할 것으로 가정 | 충돌이 드물게 발생할 것으로 가정 |
락 방식 | 데이터를 읽을 때 락을 걸어 다른 트랜잭션을 대기시킴 | 데이터를 읽을 때 락을 걸지 않고 업데이트 시 충돌 검증 |
락 유지 기간 | 트랜잭션 완료 시 까지 락 유지 | 데이터 업데이트 시에만 충돌 검증 |
충돌 처리 | 락을 통해 충돌 자체를 방지 | 충돌 발생 시 이를 감지하고 처리 |
성능 영향 | 락 유지로 인해 대기 시간이 증가, 성능 저하 가능성 있음 | 락 없이 동작하여 일반적으로 성능이 더 좋음 |
사용 예시 | 금융 거래와 같이 충돌이 자주 발생하는 경우 | 충돌이 드물고 성능이 중요한 웹 애플리케이션 |