현재 프로젝트의 재고 도메인 코드에 문제가 있다는 것을 알아차렸다.
문제는 여러 명의 사용자가 동시에 재고 감소 로직을 실행한다면 동시성 이슈가 발생하는 것이다.
마침 동시성 관련 강의를 인프런에서 발견했고 이를 적용해보았다.
프로젝트 테이블 이해
- 재고 모델과 옵션 모델은 N:M 관계이다. 따라서 중간 테이블이 존재한다.
- 중간 테이블의 옵션 id 외래키를 in 절로 사용해 재고 엔티티를 찾는다.
- 테스트할 데이터는 사전에 DB에 미리 넣어놓았다. (id가 9L인 재고 테이블 Row)
먼저 문제 상황을 테스트 코드로 작성해보았다.
@Test
void 재고_감소() throws InterruptedException {
//given
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(threadCount);
//when
//여러 사용자가 접근해서 재고를 감소시킨다고 가정
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
stockService.decreaseStock(1, List.of(1L, 2L)); //감소할 재고의 갯수, 재고를 찾을 옵션 id 리스트
} finally {
latch.countDown();
}
});
}
latch.await(); //위 코드가 마무리될 때까지 기다린다.
//then
Assertions.assertThat(stockRepository.findById(9L).get().getQuantity()).isEqualTo(0);
}
stockService의 decreaseStock 메서드 코드는 아래와 같다.
@Transactional(readOnly = false)
public void decreaseStock(Integer quantity, List<Long> optionIds){
Stock stock = stockQueryRepository.findStock(optionIds);
stock.decrease(quantity);
}
stockQueryRepository의 findStock 메서드 코드는 다음과 같다.
@Repository
public class StockQueryRepositoryImpl implements StockQueryRepository {
private final JPAQueryFactory query;
public StockQueryRepositoryImpl(EntityManager em) {
this.query = new JPAQueryFactory(em);
}
@Override
public Stock findStock(List<Long> optionIds) {
return query
.select(stock)
.from(stockOption)
.join(stock)
.on(stockOption.stockId.eq(stock.id))
.where(
stockOption.optionId.in(optionIds)
)
.groupBy(stockOption.stockId)
.fetchFirst();
}
}
테스트 코드를 돌려보면 아래와 같은 결과가 나온다.
문제가 발생하는 이유
문제 상황을 가정해보겠다.
재고 테이블에 하나의 Row가 있다. 재고량이 100으로 등록되어 있다.
동시에 2명의 사용자가 가져간다고 생각해보자. 그리고 1개만 주문할 것이므로 각자 재고를 1개씩 감소시킬 것이다.
이제 진행 시나리오를 살펴보자.
- 사용자가 동시에 Row를 변경하려고 시도한다. 이때 2명의 사용자가 가져가는 재고량은 100이다.
- 각자 1개씩 재고를 감소시키므로 둘 다 100의 재고량을 99로 감소시키고 DB에 변경한 로우를 커밋한다.
- 그렇다면 어떤 값으로 커밋이 되었을까? 사용자 1도 99로 변경하고 커밋을 했고, 사용자 2도 99로 변경하고 커밋을 한다.
- 우리는 98의 결괏값을 원했지만, 99라는 결과가 나와버린다.
이 같은 상황은 경쟁조건이 발생했기 때문에 일어났다.
이제 문제를 해결하는 방법을 알아보자.
문제 해결
현재 프로젝트에서는 MySQL과 JPA를 사용하고 있다.
또한 이메일과 캐시 도입을 위해 Redis를 사용하고 있다.
MySQL과 Redis를 사용한 해결 방법을 각각 사용해보았다.
비관적 락 적용
먼저 MySQL과 JPA를 사용하는 비관적 락을 사용했다.
낙관적 락도 고민했지만 코드를 변경해야한다는 점에서 감점이었다.
또한 재고의 경우에는 많이 주문하는 상품에서는 충돌이 많을 수도 있다고 생각해 비관적 락을 선택했다.
비관적 락을 적용한 코드는 다음과 같다. QueryDsl에서 비관적락을 걸어주었다.
비관적 락의 예시로는 select update 구문이 있다.
재고 서비스 코드와 Querydsl 코드를 다음과 같이 수정했다.
//서비스 코드
@Transactional(readOnly = false)
public void decreaseStock(Integer quantity, List<Long> optionIds){
Stock stock = stockQueryRepository.findStockWithLock(optionIds);
stock.decrease(quantity);
}
//QueryDsl 변경 코드
public Stock findStockWithLock(List<Long> optionIdList) {
return query
.select(stock)
.from(stockOption)
.join(stock).on(stockOption.stockId.eq(stock.id))
.where(
stockOption.optionId.in(optionIdList)
)
.groupBy(stockOption.stockId)
.setLockMode(LockModeType.PESSIMISTIC_WRITE) //이 부분이 추가됨
.fetchFirst();
}
그리고 이제 테스트 코드를 돌려보았다.
테스트가 성공적으로 통과한 것을 확인할 수 있다.
Redis 적용
Redis에도 Lettce를 확인하는 방법과 Redisson을 사용하는 방법이 있다.
Lettuce는 스핀락을 사용하므로 Redis에 부하가 걸릴 수 있다. 또한 재시도를 하지 않는 경우에 사용한다.
Redisson은 채널을 사용한 pub-sub 기반으로 Redis에 부하가 적게 걸린다.
대신 라이브러리이므로 코드를 작성해주어야 한다.
결론은 Redisson을 적용하기로 했다. 부하도 Lettuce에 비해 덜 걸리고 락 획득 재시도를 진행할 수 있고,
라이브러리 공부도 진행하려고 Redisson을 선택했다.
데코레이터 패턴을 적용했으며 코드는 다음과 같다.
@Slf4j
public class StockServiceDecorator implements StockService {
private final StockService stockService;
private final RedissonClient redissonClient;
public StockServiceDecorator(StockService stockService, RedissonClient redissonClient) {
this.stockService = stockService;
this.redissonClient = redissonClient;
}
@Override
public void decreaseStock(Integer quantity, List<Long> optionIds) {
RLock lock = redissonClient.getLock(optionIds.toString());
try {
//몇초를 기다리고, 몇초를 점유할건지 정한다. lock 획득을 시도
boolean available = lock.tryLock(10, 1, TimeUnit.SECONDS);
if (!available) {
log.error("락을 획득활 수 없습니다.");
return;
}
stockService.decreaseStock(quantity, optionIds);
} catch (InterruptedException e) {
throw new StockDecrementException(e.getCause());
} finally {
lock.unlock();
}
}
}
결과는 아래와 같이 성공이다.
결론적으로 비관적 락과 Redisson 라이브러리 두 방식중에는 Redisson 라이브러리를 선택했다.
기존 프로젝트가 이미 레디스를 사용하고 있고, 이 방식이 성능이 더 좋다고 하므로 해당 방식을 택했다.
이렇게 동시성 문제를 해결할 수 있었다.
최종 테스트 코드
현재는 테스트 데이터를 미리 데이터베이스에 넣고 테스트 코드를 실행중이다.
그러나 이를 테스트 코드에 포함하고 싶어서 트랜잭션 전파를 REQUIRES_NEW를 사용해 트랜잭션 템플릿을 사용했다.
깔끔하지는 않지만 일단 만족!!
@Test
void 재고_감소() throws InterruptedException {
//given
TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager);
transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
transactionTemplate.execute(status -> {
Stock stock = stockRepository.save(new Stock(100));
stockOptionRepository.save(new StockOption(stock.getId(), 1L));
return null;
});
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(threadCount);
//when
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try{
stockService.decreaseStock(1, List.of(1L));
}
finally {
latch.countDown();
}
});
}
latch.await(); //대기
//then
Assertions.assertThat(stockQueryRepository.findStock(List.of(1L)).getQuantity()).isEqualTo(0);
transactionTemplate.execute(status -> {
stockRepository.deleteAll();
stockOptionRepository.deleteAll();
return null;
});
}
댓글