[e-commerce] 동시성 제어 시나리오 분석
DB Lock
낙관적 락/비관적 락이 가장 기본으로 사용됩니다.
- 낙관적 락
DB에 Lock을 걸지 않고 읽기 시점/쓰기 시점의 데이터 변경 여부에 따라
동시성을 제어하는 방식이라 성능이 비관적 락보다 상대적으로 높습니다.
트랜잭션 간 충돌이 많아질 경우 retry 빈도 증가하게 되며 DB Connection, 스레드 점유 등의 단점이 존재합니다.
- 비관적 락
DB Lock(x-lock)을 사용해 하나의 트랜잭션만 작업이 가능하여 일관성을 확보하지만 해당 작업이 끝날때 까지 다른 트랜잭션은 대기 상태입니다.
트래픽이 몰리는 경우 락 대기시간으로 성능에 영향을 줄 수 있습니다.
Distributed Lock(Redis)
- Lettuce
- Netty 기반의 Redis Client로 넌블로킹 I/O로 구현되어 비동기 방식으로 처리, 고성능
- SETNX를 이용하여 Spin Lock 형태를 구현
- 경쟁 스레드들이 지속적으로 요청을 보내기 때문에 서버 부하가 심하다
- 개발자가 직접 retry, timeout 구현해야하며 지속적인 재시도로 네트워크 비용과 스레드 점유등의 문제가 발생
- Redisson
Redisson은 Redis 기반의 Java 클라이언트로 네트워크 트래픽 또는 CPU 사용량을 줄이기 위해 Lua 스크립트를 활용합니다. 재시도 로직을 내장하고 있어 락 흭득을 위한 별도의 재시도 로직을 작성하지 않아도 됩니다. 동시에 락 흭득 요청 시 FIFO 형태로 요청 순서를 보장합니다.
현재 시나리오에서 발생할 수 있는 동시성 문제는 재고 감소이며 코드는 아래와 같습니다.
동시성 제어 방식 비교해보기
DB Lock
상품정보 불러오기
@Lock(LockModeType.PESSIMISTIC_WRITE)
ProductStockEntity findByProductIdWithPessimisticLock(@Param("productId") Long productId);
배타락을 사용하여 재고를 감소 시키는 로직
@Component
@RequiredArgsConstructor
public class OrderUseCase {
public Long order(OrderRequest request) {
request.setPaymentPrice((long) getProductsPrice(request));
// 1. Order테이블, OrderItem 테이블 저장
OrderEntity order = orderService.serviceOrder(request.getUser_id(), request);
// 2. 재고 감소
productService.decreaseStock(request.getProducts());
// 3. 유저 포인트 차감
userService.payment(request.getUser_id(), request.getPaymentPrice());
return order.getId();
}
}
@Component
@RequiredArgsConstructor
public class ProductUpdater {
@Transactional
public List<ProductStockEntity> decreaseStock(List<OrderProductsRequest> req) {
List<ProductStockEntity> stockEntities = new ArrayList<>();
for (OrderProductsRequest orderRequest : req) {
ProductStockEntity productStock = productStockRepository.findByProductIdWithPessimisticLock(orderRequest.getProduct_id());
productStock.decreaseStock(orderRequest.getProduct_id(), (long) orderRequest.getProduct_quantity());
stockEntities.add(productStock);
productStockRepository.stockSave(productStock);
}
return stockEntities;
}
}
동시성 테스트 코드
@Test
void DB락_재고_100개_상품에_101번의_주문시도() throws InterruptedException {
int threadCount = 101;
Long userId = 1L;
Long productId = 1L;
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
List<OrderProductsRequest> orderProductsRequests = List.of(
new OrderProductsRequest(productId, 1)
);
OrderRequest orderRequest = new OrderRequest(userId, orderProductsRequests, 0L);
AtomicInteger successCount = new AtomicInteger();
AtomicInteger failCount = new AtomicInteger();
long startTime = System.currentTimeMillis();
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
orderUseCase.order(orderRequest);
successCount.getAndIncrement();
} catch (OutOfStockException e) {
failCount.getAndIncrement();
} finally {
latch.countDown();
}
});
}
latch.await();
long endTime = System.currentTimeMillis();
log.info("실행 시간 : {} milliseconds", endTime - startTime);
Product product = productService.readProductDetail(productId);
UserEntity userInfo = userService.getUserInfo(userId);
assertEquals(100, successCount.get());
assertEquals(1, failCount.get());
assertEquals(0, product.getStock());
assertEquals(800000, userInfo.getPoint());
}
성능
실행 시간 348 ms
복잡성
JPA 레포지토리에 @Lock 어노테이션으로 배타락을 설정할 수 있어 간편하다는 장점이 있습니다.
효율성
배타락은 하나의 트랜잭션만 작업이 가능하여 데이터의 일관성은 확보됩니다. 그러나, 다른 트랜잭션의 대기시간 발생해 트래픽이 순식간에 몰리는 상황에서는 latency 증가를 초래할 수 있습니다.
현재 stock 테이블만 락을 걸기때문에 문제가 없지만, 여러 테이블에 락을 설정해야되는 트랜잭션이라면 데드락 발생이 예상됩니다.
Distributed Lock
분산 락 구현시 왜 Redis인가?
분산락에 관한 Reference들을 보면 Redis를 활용하는 것을 볼 수 있습니다.
- 간단하게 Redis의 명령어를 사용하여 Lock 구현이 가능하다.
- retry, timeout과 같은 부가 기능들을 제공한다.
- Redis는 싱글 스레드로 병렬적으로 들어오는 요청들을 직렬화하여 원자성을 보장할 수 있다.
Lettuce 분산 락 구현
@RequiredArgsConstructor
@Component
public class RedisLockRepository {
private final RedisTemplate<String, String> redisTemplate;
public Boolean lock(Object key) {
return redisTemplate
.opsForValue()
.setIfAbsent(key.toString(), "lock", Duration.ofMillis(3000));
}
public Boolean unlock(Object key) {
return redisTemplate.delete(key.toString());
}
}
public class OrderUseCase {
private final RedisLockRepository redisLockRepository;
private final OrderService orderService;
private final ProductService productService;
public Long orderWithLettuce(OrderRequest request) {
request.setPaymentPrice((long) getProductsPrice(request));
// 1. Order테이블, OrderItem 테이블 저장
OrderEntity order = orderService.serviceOrder(request.getUser_id(), request);
// 2. 재고 감소
for (OrderProductsRequest prod : request.getProducts()) {
while(!redisLockRepository.lock(prod.getProduct_id())){
try{
Thread.sleep(500);
}catch (InterruptedException e){
throw new RuntimeException(e);
}
}
try{
productService.LettuceDecreaseStock(prod.getProduct_id(), (long) prod.getProduct_quantity());
}finally {
redisLockRepository.unlock(prod.getProduct_id());
}
}
// 3. 유저 포인트 차감
userService.payment(request.getUser_id(), request.getPaymentPrice());
return order.getId();
}
}
// 재고 감소 로직
@Component
@RequiredArgsConstructor
public class ProductUpdater {
private final ProductStockRepository productStockRepository;
@Transactional
public List<ProductStockEntity> LettuceUpdateStock(Long productId, Long quantity) {
List<ProductStockEntity> stockEntities = new ArrayList<>();
ProductStockEntity productStock = productStockRepository.findById(productId);
productStock.decreaseStock(productId, quantity);
stockEntities.add(productStock);
productStockRepository.stockSave(productStock);
return stockEntities;
}
}
@SpringBootTest
public class OrderConcurrentlyTest {
@Test
void Lettuce_재고_100개_상품에_101번의_주문시도() throws InterruptedException {
int threadCount = 101;
Long userId = 1L;
Long productId = 1L;
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
List<OrderProductsRequest> orderProductsRequests = List.of(
new OrderProductsRequest(productId, 1)
);
OrderRequest orderRequest = new OrderRequest(1L, orderProductsRequests, 0L);
AtomicInteger successCount = new AtomicInteger();
AtomicInteger failCount = new AtomicInteger();
long startTime = System.currentTimeMillis();
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
orderUseCase.orderWithLettuce(orderRequest);
successCount.getAndIncrement();
} catch (OutOfStockException e) {
failCount.getAndIncrement();
} finally {
latch.countDown();
}
});
}
latch.await();
long endTime = System.currentTimeMillis();
log.info("실행 시간 : {} milliseconds", endTime - startTime);
Product product = productService.readProductDetail(productId);
UserEntity userInfo = userService.getUserInfo(userId);
assertEquals(100, successCount.get());
assertEquals(1, failCount.get());
assertEquals(0, product.getStock());
assertEquals(800000, userInfo.getPoint());
}
}
성능
실행 시간 17660 ms
복잡성
1. 의존성 추가로 간단하게 구현 가능합니다.
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
효율성
경쟁 스레드들이 락 흭득을 위해 반복적으로 retry를 진행하면서 스레드 점유와 같은 부하를 가져오게 됩니다.
Redisson 분산 락 구현
@Configuration
public class RedissonConfig {
@Value("${spring.data.redis.host}")
private String redisHost;
@Value("${spring.data.redis.port}")
private int redisPort;
private static final String REDISSON_HOST_PREFIX = "redis://";
@Bean
public RedissonClient redissonClient() {
RedissonClient redisson = null;
Config config = new Config();
config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + redisHost + ":" + redisPort);
redisson = Redisson.create(config);
return redisson;
}
}
public class OrderUseCase {
private final RedisLockRepository redisLockRepository;
private final OrderService orderService;
private final ProductService productService;
public Long orderWithRedisson(OrderRequest request) {
request.setPaymentPrice((long) getProductsPrice(request));
// 1. Order테이블, OrderItem 테이블 저장
OrderEntity order = orderService.serviceOrder(request.getUser_id(), request);
// 2. 재고 감소
for (OrderProductsRequest prod : request.getProducts()) {
RLock rLock = redissonClient.getLock(String.format("LOCK:PROD-%d", prod.getProduct_id()));
try {
boolean available = rLock.tryLock(10, 1, TimeUnit.SECONDS);
if(!available) {
throw new IllegalArgumentException("Lock Not acquired");
}
productService.RedissonDecreaseStock(prod.getProduct_id(), (long) prod.getProduct_quantity());
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
rLock.unlock();
}
}
// 3. 유저 포인트 차감
userService.payment(request.getUser_id(), request.getPaymentPrice());
return order.getId();
}
}
// 재고 감소 로직
@Component
@RequiredArgsConstructor
public class ProductUpdater {
private final ProductStockRepository productStockRepository;
@Transactional
public List<ProductStockEntity> LettuceUpdateStock(Long productId, Long quantity) {
List<ProductStockEntity> stockEntities = new ArrayList<>();
ProductStockEntity productStock = productStockRepository.findById(productId);
productStock.decreaseStock(productId, quantity);
stockEntities.add(productStock);
productStockRepository.stockSave(productStock);
return stockEntities;
}
}
@Test
void Redisson_재고_100개_상품에_101번의_주문시도() throws InterruptedException {
int threadCount = 101;
Long userId = 1L;
Long productId = 1L;
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
List<OrderProductsRequest> orderProductsRequests = List.of(
new OrderProductsRequest(productId, 1)
);
OrderRequest orderRequest = new OrderRequest(1L, orderProductsRequests, 0L);
AtomicInteger successCount = new AtomicInteger();
AtomicInteger failCount = new AtomicInteger();
long startTime = System.currentTimeMillis();
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
orderUseCase.orderWithRedisson(orderRequest);
successCount.getAndIncrement();
} catch (OutOfStockException e) {
failCount.getAndIncrement();
} finally {
latch.countDown();
}
});
}
latch.await();
long endTime = System.currentTimeMillis();
log.info("실행 시간 : {} milliseconds", endTime - startTime);
Product product = productService.readProductDetail(productId);
UserEntity userInfo = userService.getUserInfo(userId);
assertEquals(0, product.getStock());
assertEquals(100, successCount.get());
assertEquals(1, failCount.get());
assertEquals(800000, userInfo.getPoint());
}
- redissonClient.getLock(name) : Lock 인스턴스 생성 로직, 파라미터 이름으로 Lock Key 설정
- rLock.tryLock(waitTime, leaseTime, TimeUnit) : Lock 흭득 시도
- waitTime : Lock 흭득 위한 대기 시간
- leaseTime : Lock 흭득 후 작업 처리 최대시간 (해당 시간 지나면 Lock 자동 반납)
- TimeUnit : 앞의 인자값들 시간 단위
성능
실행 시간 673 ms
복잡성
1. 의존성 추가
implementation 'org.redisson:redisson-spring-boot-starter:3.24.3'
2. RedissonConfig 파일 추가
- 호스트 및 포트 설정
효율성
개발자가 직접 retry와 timeout 을 구현할 필요없이 추상화 메서드를 사용할 수 있었습니다.
Pub-Sub 동작으로 인해 Lettuce에 비해 Redis에 부하가 덜 생기는 것을 확인했습니다.