인프런에서 재고시스템으로 알아보는 동시성이슈 해결방법 강의를 들으면서 배운 내용과 추가로 공부한 내용을 기록하려고 한다.
강의를 들으면서, 코드는 아래 링크에 기록해 두었다.
https://github.com/minnseong/study-labs/tree/main/stock-system-concurrency-issue
Lock에 대해서 정리하기 전에 강의를 다 듣고 나서, 락을 사용하지 않고 트랜잭션 격리 수준을 통해 동시성 문제를 해결할 수 있지 않을까 라는 고민을 하게 되었다.
격리 수준을 SERIALIZABLE로 설정한 후 강의에서 제공한 테스트 코드를 돌려본 결과, 무수히 데드락이 발생하는 것을 확인할 수 있었다. 당연히 동시성은 잡지 못했다.
@Service
@RequiredArgsConstructor
public class StockService {
private final StockRepository stockRepository;
@Transactional(isolation = Isolation.SERIALIZABLE)
public void decrease(Long productId, Long quantity) {
Stock stock = stockRepository.findByProductId(productId).orElseThrow();
stock.decrease(quantity);
}
}
@Transaction(isolation = Isolation.SERIALIZABLE)을 통해 격리 수준을 SERIALIZABLE으로 설정
그 이유는 서로 다른 트랜잭션이 같은 데이터에서 대해서 s-lock을 걸고, 그 직후 서로 x-lock을 거는 상황이 연출되는데, 그 경우 데드락이 발생한다. s-lock과 x-lock은 양립할 수 없기 때문이다. 두 트랜잭션은 x-lock을 걸기 위해 서로가 s-lock을 해제하는 시점을 무한히 대기하며 타임아웃될 것이다.
* X-Lock : 특정 Row를 변경하고자 할 때 사용되며, 특정 Row에 Exclusive Lock이 해제될 때까지, 다른 트랜잭션은 Shared Lock과 Exclusive Lock을 걸 수 없다. (SELECT.. FOR UPDATE)
* S-Lock : 특정 Row를 읽을 때 사용되며, Shared Lock 끼리는 동시에 접근이 가능하다. 하지만 Shared Lock이 설정된 Row에 Exclusive Lock은 사용할 수 없다. (SELECT... FOR SHARE)
또한, 트랜잭션만으로 두 번의 갱신 분실 문제를 해결할 수 없다.
*두 번의 갱신 분실 문제 : A와 B가 동시에 같은 데이터를 수정한다고 했을 때, A가 먼저 수정을 완료하고 B가 이후에 완료버튼을 눌렀다면, B의 수정사항만 남게 되고 A의 수정사항은 사라지는 문제이다.
갱신 분실 문제는 데이터베이스 트랜잭션 범위를 넘어서는 문제로 트랜잭션으로만 해결할 수는 없고 3가지 선택 방법이 존재한다.
1. 마지막 커밋 인정
2. 최초 커밋 인정
3. 충돌하는 갱신 내용 병합
트랜잭션의 격리 수준으로는 '마지막 커밋만 인정하기' 외의 정책을 구현할 수 없다.
따라서 동시성을 제어하기 위해서는 Lock을 사용해야 한다.
추가로, 트랜잭션 격리 수준은 트랜잭션 동안의 일관된 데이터 읽기를 목표하고, 락은 특정 데이터에 대한 동시 접근을 막기 위함을 목표로 한다.
낙관적 락
- 대부분의 트랜잭션이 충돌하지 않을 것이라고 낙관적으로 가정하고 사용하는 락이다.
- 데이터베이스가 Lock 기능을 제공하지 않고, 애플리케이션 레벨에서 엔티티의 버전을 통해서 동시성을 제어한다.
- 비관적 락보다는 성능이 좋고, 데이터를 읽는 동안 다른 트랜잭션이 해당 데이터를 수정할 수 있기 때문에 데드락 발생 가능성은 낮다.
- 충돌이 발생했을 때, 데이터를 다시 읽고 업데이트해야 하는 추가 작업이 필요하다.
JPA에서는 @Version 어노테이션을 제공하여, 엔티티의 버전을 관리할 수 있다.
@Entity
public class Stock {
...
@Version
private Long version;
private Long quantity;
public void decrease(Long quantity) {
if (this.quantity - quantity < 0) {
throw new RuntimeException("재고는 0개 미만이 될 수 없습니다.");
}
this.quantity -= quantity;
}
}
엔티티를 수정할 때, JPA는 자동으로 version을 증가시킨다. 그리고 엔티티를 수정할 때, 엔티티를 조회한 시점의 버전의 값과 수정한 시점의 버전이 일치하지 않을 경우 아래와 같은 예외(ObjectOptimisticLockingFailureException)를 발생시킨다.
public interface StockRepository extends JpaRepository<Stock, Long> {
@Lock(LockModeType.OPTIMISTIC)
@Query(value = "select s from Stock s where s.productId = :productId")
Optional<Stock> findByProductIdWithOptimisticLock(@Param("productId") Long productId);
}
스프링에서 제공하는 @Lock 어노테이션을 통해 락을 걸 수 있으며, LockModeType을 통해 락의 속성을 지정할 수 있다.
LockModeType
None
- 조회한 엔티티를 수정하는 시점에 다른 트랜잭션으로부터 수정되지 않음을 보장한다.
- 엔티티를 수정하는 시점에 엔티티의 버전을 증가시켜, 조회 시점의 엔티티의 버전과 비교하여 다르다면 예외가 발생한다.
OPTIMISTIC
- None의 경우 엔티티를 수정해야 버전을 체크하지만, OPTIMISTIC 옵션은 엔티티를 조회만 해도 버전을 체크한다.
- 엔티티의 조회시점부터 트랜잭션이 끝날 때까지 다른 트랜잭션에 의해 변경되지 않음을 보장한다.
- 트랜잭션을 커밋하는 시점에 버전정보를 체크한다.
OPTIMISTIC_FORCE_INCREMENT
- 낙관적 락을 사용하면서 버전 정보를 강제로 증가한다. 엔티티가 물리적으로 변경되지 않았지만, 논리적으로는 변경되었을 경우 버전을 증가하고 싶을 때 사용한다.
- 예를 들어, 게시물에 첨부파일이 하나 추가된 상황은 게시물 엔티티의 물리적 변경은 일어나지 않았지만, 논리적인 변경은 일어났다. 이때 버전을 변경하고 싶다면 해당 락 옵션을 사용하면 된다.
- 엔티티가 직접적으로 수정되어 있지 않아도, 트랜잭션을 커밋할 때 UPDATE 쿼리를 사용해 버전 정보를 강제로 증가시킨다. 이때 엔티티의 버전을 체크하고 일치하지 않으면 예외가 발생한다. 이때 추가로 엔티티의 정보도 실제로 변경되었다면 2번의 버전 증가가 발생한다.
@Service
@Transactional
@RequiredArgsConstructor
public class OptimisticLockStockService {
private final StockRepository stockRepository;
public void decrease(Long productId, Long quantity) {
Stock stock = stockRepository.findByProductIdWithOptimisticLock(productId).orElseThrow();
stock.decrease(quantity);
}
}
* 예제 코드에서는 각 클래스와 메서드가 하나의 책임만 하도록 퍼사드 패턴을 적용하였다.
(OptimisticLockStockService.decrease 는 잔고를 줄이는 역할을 하며, OptimisticLockStockFacade.decrease 는 예외가 발생했을 때 재처리해주는 로직을 처리한다.)
@Component
@RequiredArgsConstructor
public class OptimisticLockStockFacade {
private final OptimisticLockStockService optimisticLockStockService;
public void decrease(Long productId, Long quantity) throws InterruptedException {
while (true) {
try {
optimisticLockStockService.decrease(productId, quantity);
break;
} catch (Exception e) {
Thread.sleep(50);
}
}
}
}
위에서 말한 언급한 것과 같이 낙관적 락을 사용했을 경우, 예외를 발생했을 때 개발자가 직접 예외를 잡고 재처리/롤백 로직을 작성해줘야 하는 단점이 있다.
비관적 락
- 공유 자원에 동시성 문제가 발생할 것이라고 예상하고, 데이터베이스의 락을 사용하여 동시성을 제어하는 방법이다.
- 데이터를 읽는 동안 다른 트랜잭션이 해당 데이터를 변경할 수 없어 데이터의 일관성이 보장된다.
- 반대로, 성능 저하와 데드락 발생 가능성이 높다.
public interface StockRepository extends JpaRepository<Stock, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query(value = "select s from Stock s where s.productId = :productId")
Optional<Stock> findByProductIdWithPessimisticLock(@Param("productId") Long productId);
}
LockModeType
PESSIMISTIC_WRITE
- 데이터베이스에 SELECT.. FOR UPDATE를 사용하여 배타락을 건다. (X-Lock 쿼리 수행)
- NON-REPEATABLE READ를 방지한다.
RESSIMISTIC_READ
- 데이터를 반복 읽기만 하고 수정하지 않을 때 사용한다. (S-Lock 쿼리 수행)
- SELECT... FOR SHARE
PESSIMISTIC_FORCE_INCREMENT
- 비관적 락 중에서 유일하게 버전을 사용한다.
- 하이버네이트의 경우 nowait를 지원하는 데이터베이스에 대해서 FOR UPDATE NOWAIT 옵션을 적용하고, 그렇지 않다면 FOR UPDATE를 적용한다.
@Service
@Transactional
@RequiredArgsConstructor
public class PessimisticLockStockService {
private final StockRepository stockRepository;
public void decrease(Long productId, Long quantity) {
Stock stock = stockRepository.findByProductIdWithPessimisticLock(productId).orElseThrow();
stock.decrease(quantity);
}
}
낙관적 락과 비관적 락을 선택하는 기준
"동시에 비번하게 수정되는 데이터인지?"
동시에 수정하는 경우가 빈번하지 않아 충돌이 적을 때는 낙관적 락을 사용하는 것이 좋다.
하지만, 충돌이 빈번하게 일어나는 상황에서 낙관적 락을 사용한다면, 모든 수정 요청이 완료될 때까지 재시도 로직이 수행될 것이다. 따라서 데이터베이스 부하가 생길 것이다.
하지만 비관적 락을 사용한다면, 데이터베이스의 락을 사용하기 때문에 현재 요청이 완료될 때까지 다른 요청은 대기했다가 순차적으로 진행될 것이다.
참고
'개발 > Spring' 카테고리의 다른 글
[Spring] 선착순 쿠폰 발급 요구사항 개발 (Redis, Kafka) (1) | 2024.05.16 |
---|---|
[Spring] AOP 기반 Redis 분산락 적용 (0) | 2024.05.11 |
[Spring] 커스텀 Annotation 대상 AOP 적용하기 (0) | 2024.04.13 |
[MSA] 서킷브레이커 적용 (Resilience4j) (4) | 2024.01.23 |
[Spring] MyBatis 사용법 및 동적 쿼리 정리 (1) | 2024.01.09 |