📌 고민사항
재고 증가/감소 로직에서 동시성 문제가 발생하였고, 해당문제를 해결하기 위해서 뮤텍스, 세마포어, 원자적연산등의 방법들을 고려하였다.
자바에서 뮤텍스 방식을 활용하기 위해 synchronized를 지원하는데, 동시성 문제가 발생할 수 있는 임계영역에 락을 걸어서 다른 스레드가 접근하지 못하도록 막는 방식이다.
락 방식을 사용할 때 가장 생각해봐야 할 점은 하나의 스레드에서 락을 걸어버리는 순간 다른 스레드는 해당 자원에 접근하지 못하기 때문에 대기를 하게되고, 병목현상이 발생될 수 있다는 점이다.
그래서 synchronized를 적용시킬 때에는 적용시킬 영역을 최소화 하는것이 중요하다.
📌 기존의 재고 감소 로직
아래는 동시성 문제가 발생할 수 있는 재고 수량 감소 메서드이다.
@Transactional
public synchronized void subtract(final Long productId, final Stock productStock) {
Stock preStock = stockRepository.findByProductId(productId)
.map(stock -> stock.subtract(productStock.getStockCount()))
.map(stockRepository::save)
.orElseThrow(() -> new GlobalException(HttpStatus.NOT_FOUND, "[ERROR] product stock not found"));
}
위의 코드는 MySQL Database에서 데이터를 가져오고, 수정하고, 저장하는 로직을 수행하기 때문에 락을 걸어줄 때 @Transactional 바깥에 걸어줘야 동시성 문제가 발생하지 않는다.(현재는 synchronized가 @Transactinal 안에 있기 때문에 문제가 발생할 가능성이 존재)
Database 조회와 synchronized최소화
위의 로직은 데이터베이스에서 데이터를 조회해오기 때문에 조회, 수정, 저장 전체를 락으로 걸어줘야 의미있게 락킹 처리가 된다. 그렇다면 Database조회를 하지 않고 synchronized를 최소화할 수 있는 방법은 없을까?
나는 synchronized를 최소화 하고 싶기 때문에 예약상품의 오픈시간 전에 일괄적으로 MySQL이 아닌 로컬서버에 공통 Stock클래스를 올려놓는다고 가정하였고, synchronized를 최소화를 해보려고 한다.
📌 synchronized 최소화 시키기
실제 임계영역은 stock.subtract(productStock.getStockCount())부분 이므로 synchronized 영역을 조금 더 줄여보자.
@Transactional
public void subtract(final Long productId, final Stock productStock) {
Stock preStock = stockRepository.findByProductId(productId)
.orElseThrow(() -> new GlobalException(HttpStatus.NOT_FOUND, "[ERROR] product stock not found"));
synchronized (this) {
productStock.subtract(productStock.getStockCount());
}
stockRepository.save(preStock);
}
preStock은 여러 스레드에서 접근할 수 있는 공통 클래스 이므로 더 개선해보면 임계영역을 Stock클래스의 subtract메서드로 줄여볼 수 있을 것 같다.
현재 Stock클래스의 substract메서드는 아래와 같다.
public Stock subtract(int quantity) {
if (stockCount < quantity) {
throw new GlobalException(HttpStatus.CONFLICT, "재고수량이 부족합니다.");
}
stockCount = stockCount - quantity;
return this;
}
위의 substract메서드의 임계영역에 synchronized를 적용시키면 아래와 같다.
public Stock subtract(int quantity) {
if (stockCount < quantity) {
throw new GlobalException(HttpStatus.CONFLICT, "재고수량이 부족합니다.");
}
synchronized(this){
stockCount = stockCount - quantity;
}
return this;
}
📌 synchronized적용 후에도 동시성 문제?
이렇게 synchronized키워드 영역을 최소화 시켰는데, 더 이상 발생할 수 있는 문제는 없는지 고민해봐야 한다.
만약 if(stockCount < quantity)에 걸리지 않아서 synchronized(this)를 타고 들어갔는데, 다른 스레드에서 재고수량을 감소시켜버리면 stockCount가 음수가 될 수 있지 않을까?
이 문제는 더블 체크드 락킹방식과 유사하다고 생각되는데, 해당문제가 발생되는지 직접 테스트를 해보았다.
아래와 같이 2개의 쓰레드로 subtractTest를 호출한다.
@DisplayName("감소로직의 synchronized 동시성 문제 테스트")
@Test
void synchronized_test() throws InterruptedException {
ExecutorService executorService = Executors.newFixedThreadPool(2);
CountDownLatch countDownLatch = new CountDownLatch(2);
Stock stock = Stock.builder()
.productId(1L)
.stockCount(1)
.build();
// thread 1
executorService.submit(() -> {
try {
stock.subtractTest(1, 99);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
countDownLatch.countDown();
}
});
// thread 2
executorService.submit(() -> {
try {
stock.subtractTest(1, 100);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
countDownLatch.countDown();
}
});
countDownLatch.await();
assertThat(stock.getStockCount()).isEqualTo(-1);
System.out.println("재고수량 출력결과");
System.out.println(stock.getStockCount());
}
이때 subtractTest메서드의 내용은 아래와 같고, if 조건문을 통과하고 이후에 temp로 99가 들어오면 10초동안 멈춰있는 로직이다. 이렇게 되면 temp가 99, 100인 동시요청 모두 if문을 통과하게 될 것이고, stockCount가 음수가 나올것으로 예상된다.
public Stock subtractTest(int quantity, int temp) throws Exception {
if (stockCount < quantity) {
throw new GlobalException(HttpStatus.CONFLICT, "재고수량이 부족합니다.");
}
if (temp == 99) {
Thread.sleep(10000);
}
synchronized (this) {
stockCount = stockCount - quantity;
}
return this;
}
실제로 테스트결과 아래와같이 -1이 출력된다.(재고 수량이 음수가 되면 절대 안된다)
📌 더블 체크 방식을 활용하여 해결
이를 해결하기 위하여 synchronized 키워드 안에도 if조건문을 걸어서 해결해준다.
public Stock subtractTest(int quantity) throws Exception {
if (stockCount < quantity) {
throw new GlobalException(HttpStatus.CONFLICT, "재고수량이 부족합니다.");
}
synchronized (this) {
if (stockCount < quantity) {
throw new GlobalException(HttpStatus.CONFLICT, "재고수량이 부족합니다.");
}
stockCount = stockCount - quantity;
}
return this;
}
해결되는지 테스트를 수행해보자.
아래와 같이 재고수량은 0개가 되는 것을 확인할 수 있다.
📌 reordering문제와 volatile
하지만 위와같이 코드를 작성하면, if문 로직이 중복이 되고 컴파일시점에 코드가 재정렬되어 원하지 않는 synchronized가 호출될 수 있다.
재정렬을 방지하기 위하여 volatile키워드를 적용해보면 아래와 같다.
(고민해보기 : reordering 문제 발생하는지 어떻게 테스트해볼 수 있을까?)
public class Stock {
private Long productId;
private volatile int stockCount;
public Stock subtract(int quantity) throws Exception {
if (stockCount < quantity) {
throw new GlobalException(HttpStatus.CONFLICT, "재고수량이 부족합니다.");
}
synchronized (this) {
if (stockCount < quantity) {
throw new GlobalException(HttpStatus.CONFLICT, "재고수량이 부족합니다.");
}
stockCount = stockCount - quantity;
}
return this;
}
}
📌 고민해볼 사항들
현재는 특정 시간에 오픈예정인 상품에 대해 미리 재고수량 클래스가 캐시와같은 저장소에 올라와 있다고 가정하고 synchronized 영역을 최소화 시켰다. Database에서 집접 데이터를 조회하고 수정하는 경우에는 Database자체이 락기능을 활용해볼 수 있을 것 같다.
현재는 이론적으로 적용해볼 수 있는 부분인 synchornized영역 최소화와, synchornized영역 밖에서의 if조건문 처리, 재정렬기능을 방지하기 위하여 volatile키워드를 활용해보았는데, 실제 100만개의 요청에 대해서 테스트해본 결과 성능적으로 큰 차이는 없었다.
테스트 환경이 나의 로컬 PC라는 점과, 복잡한 비즈니스 로직이 들어가 있지 않다는 점, 실제 운영환경과 다르다는 점 등 여러가지 이유가 있을 것이라고 생각된다.
실제 운영환경이라고 한다면 이론적인 부분을 적용시켜보고, 테스트(정확성, 성능)결과를 확인해보는 습관이 잘 갖춰져야 할 것 같다.
마지막으로 synchornized의 경우 단이 서버에서 유용한 락 방식이므로, 다중서버로 확장 시 다른 방법을 고려해보아야 한다.
생각해본 방안은 아래와 같다.
1. redis를 통해 캐시 기능을 사용하면서, redis에서 제공해주는 락기능을 이용해서 동시성 문제를 해결
2. 요청시간과 요청자정보를 redis에 기록하고, 추가 요청이 들어왔을 때에 요청시간에 따라 주문할 수 있는지 없는지를 판단하는 방식
다음 학습은 위의 복잡한 로직을 단순화할 수 있는 아토믹 클래스를 활용에 대해 학습해볼 예정이다.
'프로젝트 > 예약상품' 카테고리의 다른 글
[회고] 예약 상품 프로젝트 (0) | 2024.03.04 |
---|---|
[프로젝트] 회복탄력성(CircuitBreaker와 Retry) (0) | 2024.03.02 |
[프로젝트] 상품 오픈시간에 같은 사용자의 여러 요청을 대비하자 (0) | 2024.02.26 |
[프로젝트] redis로 동시성 문제를 접근해보자 (0) | 2024.02.26 |
[프로젝트] 재고정보를 판매오픈전 캐싱할 때 고려사항 (0) | 2024.02.26 |