이번엔 동시성 문제를 해결해보자 한다
동시성 문제란
여러 사용자가 동일한 데이터를 처리하려할때 발생하는 문제이다
재고가 10개 남아있는 상황에서 사용자 두명이 동시에 주문을 진행하는 상황일때
사용자 1 -> 재고 조회 10개
사용자 2 -> 재고 조회 10개
사용자 1 -> 상품 1개 주문 (재고 1 감소)
사용자 2 -> 상품 1개 주문 (재고 1 감소)
총 재고 9
이렇게 원래대로라면 재고가 8개 남아있어야 정상이지만 덮어씌여져서 재고가 9개가 남게된다
왜 이런 문제가 발생하냐
DB 트랜잭션 격리수준이 낮을경우 그렇다
DB 격리 수준
- READ UNCOMMITTED: 다른 트랜잭션이 아직 완료되지 않은 데이터를 읽음
- READ COMMITTED: 다른 트랜잭션의 완료된 데이터만 읽음.
- REPEATABLE READ: 같은 데이터를 읽을 때 항상 동일한 값을 보장.
- SERIALIZABLE: 트랜잭션을 직렬화하여 순차적으로 처리 (성능 저하).
아래로 갈수록 격리수준이 높아진다 Serializable은 가장 높은 격리 수준을 제공하고 동시성 문제가 발생하지 않지만
성능이 저하된다는 문제가있다 현재 프로젝트에서 사용하는 Mysql은 기본적으로 REPEATABLE_READ 격리수준을 기본으로 사용한다
repeatable_read는 같은 트랜잭션내에서의 동일한 값을 보장한다는 점
그래서 위 상품 주문을 예로든것과같은 문제가 발생한다
격리 수준에 대한 이해가 아직 부족하여 좀 더 학습해야 할 것같다
동시성 문제 테스트
실제로 동시성 문제를 테스트 해보았다
현재 프로젝트에서 테스트하기엔 좀 그래서 테스트용 새로운 프로젝트를 생성하고 간단하게 구성했다
@Entity
@Data
public class Product {
@Id
private Long id;
private int stock;
public void reduceStock() {
this.stock -= 1;
}
}
간단하게 상품 엔티티를 만들어주고
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
@Transactional
public void reduceStock() {
Product product = productRepository.findById(1L)
.orElseThrow(IllegalArgumentException::new);
product.reduceStock();
productRepository.save(product);
}
}
해당 쓰레드마다 각각의 트랜잭션을 사용해야하기때문에
서비스에서 재고 감소 로직을 작성했다
@SpringBootTest
public class ConcurrencyTest {
@Autowired
private ProductRepository productRepository;
@Autowired
private ProductService productService;
@Test
@DisplayName("동시성 문제 발생")
void concurrency_problem() throws InterruptedException {
int threadCount = 10;
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
Runnable task = () -> {
try {
productService.reduceStock();
} finally {
latch.countDown();
}
};
for (int i=0; i<threadCount; i++)
executorService.execute(task);
latch.await();
Product product = productRepository.findById(1L)
.orElseThrow(IllegalArgumentException::new);
System.out.println("product.getStock() = " + product.getStock());
}
}
일단 미리 DB에 재고가 10개인 상품을 등록해 놓은 상태로 테스트를 진행하였다
테스트는 10명의 사용자가 동시에 상품 1개씩 주문하는 환경이다
모든 쓰레드가 끝날때까지 기다린 후 마지막에 상품의 재고를 확인해보았다
동시성 문제가 발생하지 않았다면 상품의 재고는 0개가 남아야한다
하지만 실제로 남은 재고는 9개가 남아있다
실제 서비스시 이런 문제가 발생하면 안되기에 해결해보고자 한다
동시성 문제를 해결하는 방법
동시성 문제를 해결하는 방법엔 크게 두가지 방법이 있는것같다
첫번쨰 DB 트랜잭션 격리수준을 높인다
두번째 락을 이용한다
첫번째 방법은 트랜잭션 격리 수준을 SERIALIZABLE로 높이는 방법이다
하지만 이 방법은 선택한다면 동일한 레코드에 다른 트랜잭션의 접근이 되지않아 성능적으로 크게 떨어지는 단점이있다
따라서 극단적인 상황이 아니라면 추천하지않는다고 한다
두번째 방법은 락을 이용하는 방법인데
락을 사용하는 방법엔 비관적 락과 낙관적 락으로 나뉜다
비관적 락이란?
트랜잭션에서 데이터에 접근할때 다른 트랜잭션의 접근을 막아 대기상태에 있도록 하는 방법이다
비관적 락에는 또 두가지 개념이있는데
공유 락
공유 락은 데이터를 읽을 순 있지만 수정은 불가능하다
베타 락
베타 락은 데이터를 읽거나 수정이 다 불가능하다
장점
데이터의 무결성을 보장한다
단점
성능 저하 가능성이있다
왜냐하면 다른 트랜잭션이 접근할때 기존 트랜잭션이 끝날때까지 기다려야하기 때문에 데드락이 발생할 수 있다
적합한 상황
충돌 가능성이 높은 경우와 데이터의 무결성이 가장 중요한 경우에 적합하다
낙관적 락이란?
데이터를 수정할 때 충돌이 발생하지 않을 것이라는 가정하에 처리하는 방식이다
직접적으로 락을 걸지않고 Version을 설정하고 비교하여 충돌 여부를 확인한다
버전이 맞지 않을경우 예외를 발생하고 롤백한다
낙관적 락 예시
예를들어 두명의 사용자가 동시에 재고를 감소시키는 상황이다
사용자 1 -> 재고 확인 (버전 1)
사용자 2 -> 재고 확인 (버전 1)
사용자 1 -> 상품 주문 - 재고 감소 성공 (버전 1 -> 버전 2 증가)
사용자 2 -> 상품 주문 - 재고 감소 요청 (버전 1 상태)
여기서 사용자 2의 트랜잭션은 실패한다
사용자 1의 트랜잭션은 성공하여 버전이 2로 증가한 상태이고
사용자 2의 버전(버전 1)과 현재 버전(버전 2)가 틀리기 때문에 실패하고 롤백을 진행하고 재시도를 한다(선택)
장점
락을 거는것이 아니기 때문에 데이터를 읽을때 대기가 발생하지않는다
데이터 무결성을 보장한다
단점
데이터 수정시에 충돌 여부를 확인하여 사전에 방지할 수 없다
적합한 상황
충돌 가능성이 낮은 경우에 사용한다
비관적 락과 낙관적 락 적용 테스트
먼저 비관적 락을 적용해본다
비관적 락
@Lock(LockModeType.PESSIMISTIC_WRITE) // 베타 락
@Query("select p from Product p where p.id = :id")
Optional<Product> findByIdWithPessimisticLock(Long id);
먼저 ProductVariantRepository에
@Lock 어노테이션과 함께 LockModeType.PESSIMISTIC_WRITE를 적용하여 락을 적용한다
이렇게 되면 조회 시점에 락을 적용하여 다른 트랜잭션의 접근 막는다
테스트 코드도 작성해본다
@Transactional
public void reduceStockWithPessimisticLock() {
Product product = productRepository.findByIdWithPessimisticLock(1L)
.orElseThrow(IllegalArgumentException::new);
product.reduceStock();
productRepository.save(product);
}
서비스에 비관락을 적용한 로직을 작성하고
@Test
@DisplayName("비관적 락 적용")
void concurrency_problem_pessimistic_lock() throws InterruptedException {
int threadCount = 10;
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
Runnable task = () -> {
try {
productService.reduceStockWithPessimisticLock(); // 락 작용
} finally {
latch.countDown();
}
};
for (int i=0; i<threadCount; i++) {
executorService.execute(task);
}
latch.await();
Product product = productRepository.findById(1L)
.orElseThrow(IllegalArgumentException::new);
System.out.println("product.getStock() = " + product.getStock());
}
테스트 코드도 작성했다 이전에 작성한 코드와 로직은 똑같지만 락을 적용했냐 안했냐의 차이다
이제 락을 적용했으니 예상대로 마지막 상품의 재고는 0개가 출력되어야한다
정상적으로 락이 적용되어 동시성 문제가 해결되었다
낙관적 락
낙관적 락도 한번 적용하여 테스트 해보겠다
@Entity
@Data
public class Product {
@Id
private Long id;
private int stock;
@Version
private Integer version; // version 추가
public void reduceStock() {
this.stock -= 1;
}
}
상품 엔티티에 먼저 @Version 어노테이션을 이용하여 버전 필드를 추가해준다
@Transactional
public void reduceStockWithOptimisticLock() {
Product product = productRepository.findById(1L)
.orElseThrow(IllegalArgumentException::new);
product.reduceStock();
productRepository.save(product);
}
낙관적 락을 적용할 메서드를 추가해준다
@Test
@DisplayName("낙관적 락 적용")
void concurrency_problem_optimistic_lock() throws InterruptedException {
int threadCount = 10;
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
Runnable task = () -> {
try {
productService.reduceStock();
} catch (Exception e) {
System.out.println(e);
}
finally {
latch.countDown();
}
};
for (int i=0; i<threadCount; i++) {
executorService.execute(task);
}
latch.await();
Product product = productRepository.findById(1L)
.orElseThrow(IllegalArgumentException::new);
System.out.println("product.getStock() = " + product.getStock());
}
테스트를 추가하였지만 처음 작성한것과 똑같다 차이가 있다면 발생하는 예외를 출력하는것만 다르다
실행해보면
재고가 9개가 남아있다 하지만
중간중간 ObjectOptimisticLockingFailureException이 발생하는것을 볼 수 있다
낙관전 락이 충돌하였을 경우(이전에 설명한 버전차이) 발생하는 예외이다 아직 재시도하는 로직을 작성하지 않았기 때문이다
version 컬럼의 값이 2로 증가되어있다
첫번째 트랜잭션이 성공하고 version값을 2로 올려놓은뒤 그 다음 트랜잭션들은 버전차이 때문에 실패한 것이다
이제 재시도하는 로직을 추가로 작성해본다
public void reduceStockWithOptimisticLock() {
boolean success = false;
while(!success) {
try {
reduceStockInNewTransaction();
success = true;
} catch (ObjectOptimisticLockingFailureException e) {
System.out.println("재시도");
}
}
}
@Transactional
public void reduceStockInNewTransaction() {
Product product = productRepository.findById(1L)
.orElseThrow(IllegalArgumentException::new);
product.reduceStock();
productRepository.save(product);
}
재고 감소 요청마다 새로운 트랜잭션이 생성되어야하기 때문에 상품을 조회하고 감소시키는 로직을 트랜잭션으로 묶고
성공할때까지 무한으로 재시도해본다
정상적으로 재고 10개가 감소되어 남은 재고가 0개로 출력된다
Version도 11까지 올라가있는것을 볼수있다
하지만 이렇게 사용하면 상품을 조회하고 재고를 감소시킬때 셀수도없이 많은 쿼리가 반복해서 나가는것을 볼수있다
이러면 많은 동시 요청이 들어왔을때 성능이 확 나빠질 수 있기 때문에
실제로 사용할땐 재시도하는 횟수를 적절히 설정하고 동시성 문제가 빈번하지 않을 경우에만 사용해야 할 것 같다