JoyMall의 주문 후 상품에 대한 재고 감소 로직에 비관적 락을 적용했었는데요. 네이버 클라우드의 Cloud DB for Redis를 생성하여 코드레벨에선 Redisson을 이용한 분산락을 적용해보겠습니다.
비관적 락은 데이터베이스 수준으로 사용(특정 테이블 또는 행 단위로 락을 적용)되기 때문에 다중 서버에 대한 데이터 정합성을 보장해주지 못합니다. 따라서 단일 서버로 이루어진 Redis 서버를 통한 분산락을 적용하였습니다.
비관적 락을 적용한 코드
https://github.com/f-lab-edu/joy-mall/pull/77/commits/51fd1dfd69d01953d976b871d32a9a6897a99fcc
JoyMall은 SpringBoot 3, Java 17, Spring Data JDBC 로 이루어져 있습니다. 또한 인프라는 네이버 클라우드의 VPC, Subnet, 쿠버네티스 클러스터로 이루어져 있습니다.
Redis 서버 생성은 간단하게 설명하겠습니다.
단일 Redis 서버 구성을 위해 Redis Simple 으로 생성하였고 Redis Version에 맞춘 Config Group을 생성하여 적용하면 됩니다.
그렇다면 위와 같이 생성되었을 것입니다.
application.yml
spring:
data:
redis:
host: ${JOY_MALL_REDIS_URL}
port: 6379
Redis 서버 연결을 위한 application.yml 파일을 수정해주었습니다.
build.gradle
implementation 'org.redisson:redisson-spring-boot-starter:3.31.0'
또한 redisson 사용을 위한 의존성을 추가해주었습니다.
@Component
@RequiredArgsConstructor
public class SalesProductService {
private final SalesProductRepository salesProductRepository;
@Override
@Transactional
public void decreaseStock(Set<OrderItem> orderItems) {
orderItems.forEach(orderItem -> {
SalesProduct salesProduct = salesProductRepository.findById(orderItem.getSalesProductId())
.orElseThrow(NoSuchElementException::new);
salesProduct.decreaseStock(orderItem.getQuantity());
salesProductRepository.save(salesProduct);
});
}
}
public interface SalesProductRepository extends CrudRepository<SalesProduct, Long> {
@Lock(LockMode.PESSIMISTIC_WRITE)
Optional<SalesProduct> findById(Long id);
}
위 코드는 @Lock(LockMode.PESSIMISTIC_WRITE)
를 선언하여 비관적 락을 적용했던 코드입니다.
이 코드를 적용한다면 로드 밸런서를 통해 요청이 여러 서버로 분산되는 경우, 데드락이 발생할 수 있고 락 획득과 해제를 하는 과정에서 경합이 발생할 것입니다.
SalesProductFacade
@Component
@RequiredArgsConstructor
public class SalesProductFacadeImpl implements SalesProductFacade {
private final RedissonClient redissonClient;
private final SalesProductService salesProductService;
private static final String LOCK_KEY_PREFIX = "salesProduct:";
@Override
public void decreaseStock(Set<OrderItem> orderItems) {
for (OrderItem orderItem : orderItems) {
String lockKey = LOCK_KEY_PREFIX + orderItem.getSalesProductId();
RLock lock = redissonClient.getLock(lockKey);
try {
boolean acquireLock = lock.tryLock(10, 1, TimeUnit.SECONDS);
if (!acquireLock) {
throw new RuntimeException("SalesProduct Lock 획득 실패");
}
salesProductService.decreaseStock(orderItem);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("Lock 획득 중 인터럽트 발생");
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
}
기존 재고감소 로직은 SalesProductService 에 구현되어 있으므로 그대로 사용을 하고 SalesProductFacade를 구현하여 여러 가지 락을 구현할 수 있게 작성했습니다.
또한 RedissonClient를 통해 redis 락을 생성하고 락을 획득했다면 재고 감소를 진행하고 재고가 감소되었다면 락을 해제하는 로직입니다.
아래 코드는 1000개의 스레드를 사용하여 동시에 재고 감소 작업을 수행하고, 최종적으로 재고가 0으로 감소되었는지 확인합니다.
@SpringBootTest
class SalesProductFacadeTest {
@Autowired
private SalesProductFacade salesProductFacade;
@Autowired
private SalesGroupRepository salesGroupRepository;
@Autowired
private SalesProductRepository salesProductRepository;
@Test
public void 판매_상품_동시성_재고_감소_테스트() throws InterruptedException {
// given
SalesProduct salesProduct = new SalesProduct(1L, 1000, 1000, SalesStatus.ON_SALES);
Set<SalesProduct> salesProducts = new HashSet<>();
salesProducts.add(salesProduct);
SalesGroup salesGroup = new SalesGroup(salesProducts);
SalesGroup savedSalesGroup = salesGroupRepository.save(salesGroup);
List<SalesProduct> savedSalesProducts = savedSalesGroup.getSalesProducts().stream().toList();
Long savedSalesProductId = savedSalesProducts.get(0).getId();
OrderItem orderItem = new OrderItem(savedSalesProductId, 1, 1000);
Set<OrderItem> orderItems = new HashSet<>();
orderItems.add(orderItem);
int threadCount = 1000;
ExecutorService executorService = Executors.newFixedThreadPool(700);
CountDownLatch latch = new CountDownLatch(threadCount);
// when
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
salesProductFacade.decreaseStock(orderItems);
} finally {
latch.countDown();
}
});
}
latch.await();
// then
SalesProduct findSalesProduct = salesProductRepository.findById(savedSalesProductId).orElseThrow(NoSuchElementException::new);
assertThat(findSalesProduct.getSalesStock()).isEqualTo(0);
}
}
로컬 Redis 서버와 nGrinder로 진행하였습니다.
10만건의 재고가 있다고 가정한 후 nGrinder를 통해 100개의 쓰레드를 10초간 재고 감소에 대한 테스트를 진행하였습니다.
로그 파일을 확인하면 총 3047번의 API 통신이 이루어졌다는걸 확인할 수 있습니다.
nGrinder web에 나타난 테스트 개수는 Log와 일치하지 않을 수 있습니다.
https://github.com/naver/ngrinder/discussions/939
실제 데이터베이스를 확인해보면
10만건에서 3047건을 뺀 96953건의 재고가 남아있는 것을 확인할 수 있습니다.
이처럼 JoyMall 프로젝트에서 Redisson을 활용한 분산락 적용 과정을 살펴보았습니다. 실제 부하 테스트를 통해 효과를 검증하며 상품 재고 감소 로직의 데이터 정합성을 보장할 수 있게 되었습니다.
소스 코드
https://github.com/f-lab-edu/joy-mallSpring 동시성 처리에 대한 다른 방법들
[Spring] 스프링에서 동시성 문제 해결 방법 (1) - synchronized, Lock 사용