주문 API를 통해 장바구니에 대한 상품들과 로그인 한 유저에 대한 정보를 받아옵니다.
@PostMapping("/add/{productId}")
public ResponseEntity<ReturnObject> AddStock(
@LoginUser User user,
@RequestBody OrderRegisterRequest orderRegisterRequest
) {
registerOrderUseCase.registerOrder(user, orderRegisterRequest);
ReturnObject returnObject = ReturnObject.builder()
.success(true)
.data("상품의 재고를 추가하였습니다.")
.build();
return ResponseEntity.status(HttpStatus.OK).body(returnObject);
}
주문을 처리하는 비즈니스 로직입니다.
@Override
public void registerOrder(User user, OrderRegisterRequest orderRegisterRequest) {
List<OrderItem> orderItems = new ArrayList<>();
for (OrderItemRegisterRequest orderItemRegisterRequest : orderRegisterRequest.getOrderItemRegisterRequests()) {
OrderItem orderItem = orderItemRegisterRequest.toEntity();
orderItems.add(orderItem);
}
Order order = orderRegisterRequest.toEntity(orderItems, user);
validateOrder(order);
for (OrderItem orderItem : orderItems) {
reduceStockUseCase.reduceStock(orderItem);
}
save(order, orderItems);
}
재고수량 감소 비즈니스 로직은 밑과 같습니다.
@Override
public void reduceStock(OrderItem orderItem) {
Stock stock = loadStockPort.loadStock(orderItem.getProductId());
if (stock == null) {
throw new EntityNotFoundException("상품이 없습니다.");
}
if (stock.getQuantity() < orderItem.getCount()) {
throw new RuntimeException("수량이 없습니다.");
} else {
stock.reduceStock(orderItem.getCount());
saveStockPort.saveStock(stock);
}
}
테스트 코드 : 100명의 유저가 1개의 상품에 대해 1개씩 주문을 한다
@SpringBootTest
@AutoConfigureMockMvc
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class StockControllerTest {
@Autowired
MockMvc mockMvc;
@Autowired
private StockJpaRepo stockJpaRepo;
@Autowired
private ReduceStockUseCase reduceStockUseCase;
@Autowired
private PlatformTransactionManager transactionManager;
OrderItem orderItem;
List<OrderItem> orderList = new ArrayList<>();
@BeforeEach
public void init() {
stockJpaRepo.save(new StockJpaEntity(1L, 100, 1L));
orderItem = new OrderItem(1L, 1L, 1, 1L);
}
@AfterEach
public void clear() {
// 테스트용 데이터 삭제
stockJpaRepo.deleteAll();
}
@Test
@DisplayName("100명이 동시에 1개씩 재고 감소시키기")
public void AtTheSameTime_100Requests() throws InterruptedException {
//given
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(2);
CountDownLatch latch = new CountDownLatch(threadCount);
// when
IntStream.range(0,100).forEach(e -> executorService.submit(() -> {
try {
TransactionStatus status = transactionManager.getTransaction(null);
reduceStockUseCase.reduceStock(orderItem);
transactionManager.commit(status);
} finally {
latch.countDown();
}
}
));
latch.await();
// then
StockJpaEntity stock = stockJpaRepo.findByProductId(1L);
assertEquals(0L, stock.getQuantity());
}
}
int threadCount = 100; CountDownLatch latch = new CountDownLatch(threadCount);
→ 이 코드가 없다면 IntStream.range(0,100).forEach
해당 foreach문이 순차적으로 하나씩 실행이 될텐데 위 코드가 있다면 100번의 실행이 직렬로 실행되는게 아닌 병렬로 동시에 처리가 됨.ExecutorService executorService = Executors.newFixedThreadPool(2);
→ 동시에 작업 가능한 스레드 개수를 설정한다.TransactionStatus status = transactionManager.getTransaction(null);
→ 트랜잭션의 상태를 관리하는데 null이라면 시작을 의미한다.transactionManager.commit(status);
→ 트랙잭션의 상태가 끝(성공)을 의미한다.latch.await();
→ 모든 작업이 끝났음을 의미함.결과는 처참히 실패하였다.
Thread-1 | Stock | Thread-2 |
---|---|---|
select * form Stock where productId =1 | {id : 1 quantity : 100} | |
update set quantity = quantity -1 form Stock where productId =1 | {id : 1 quantity : 99} | |
{id : 1 quantity : 99} | select * form Stock where productId =1 | |
{id : 1 quantity : 98} | update set quantity = quantity -1 form Stock where productId =1 |
Thread-1 | Stock | Thread-2 |
---|---|---|
select * form Stock where productId =1 | {id : 1 quantity : 100} | |
{id : 1 quantity : 100} | select * form Stock where productId =1 | |
update set quantity = quantity -1 form Stock where productId =1 | {id : 1 quantity : 99} | |
{id : 1 quantity : 99} | update set quantity = quantity -1 form Stock where productId =1 |
레이스 컨디션 해결방법 밑과 같은 방법들이 있다,
synchronized를 메소드에 명시해주면 하나의 스레드만 접근이 가능하게 만들어준다.
멀티스레드 환경에서 스레드간 데이터 동기화를 시켜주기 위해서 자바에서 제공하는 키워드이다.
밑과 같은 코드로 재고수량(비즈니스 로직을 처리할)메소드에 추가하여 테스트 코드를 돌려보았더니 성공하였다.
@Override
public **synchronized** void reduceStock(OrderItem orderItem) {
Stock stock = loadStockPort.loadStock(orderItem.getProductId());
if (stock == null) {
throw new EntityNotFoundException("상품이 없습니다.");
}
if (stock.getQuantity() < orderItem.getCount()) {
throw new RuntimeException("수량이 없습니다.");
} else {
stock.reduceStock(orderItem.getCount());
saveStockPort.saveStock(stock);
}
}
TransactionStatus status = transactionManager.getTransaction(null);
, transactionManager.commit(status);
이 두 부분도 주석처리 후 테스트를 돌려주면 성공으로 표기가 된다. synchronized의 단점
비관락은 데이터 갱신시 충돌이 발생할 걸 염려(비관)하여 미리 잠금을 거는 방식이다.
Thread-1 | Stock | Thread-2 |
---|---|---|
select * form Stock where productId =1 | {id : 1 quantity : 100} | ← 재고 획득 시도 |
update set quantity = quantity -1 form Stock where productId =1 | {id : 1 quantity : 100} | → 재고 획득 실패 |
{id : 1 quantity : 99} | ← 재고 획득 시도 select * form Stock where productId =1 | |
{id : 1 quantity : 99} | → 재고 획득 성공 update set quantity = quantity -1 form Stock where productId =1 |
낙관락은 트랜젝션 대부분이 충돌이 발생하지 않는다고 가정합니다.
Thread-1 | Stock | Thread-2 |
---|---|---|
select * form Stock where productId =1 | {id : 1 quantity : 100 version : 1} | select * form Stock where productId =1 |
update set quantity = quantity -1 form Stock where productId =1 | {id : 1 quantity : 99 version : 2} | |
{id : 1 quantity : 99 version : 2} | update set quantity = quantity -1 form Stock where productId =1 ← 재고 업데이트 실패 |
JPA의 LockModeType 기능을 활용하여, PESSIMISTIC_WRITE 쓰기 락을 걸었습니다.
@Lock(value = LockModeType.PESSIMISTIC_WRITE)
@Query("select s from StockJpaEntity s where s.productId = :productId")
StockJpaEntity findByProductIdWithPessimisticLock(Long productId);
객체에 배타락을 획득함으로써, 다른 트랜젝션에서 READ, UPDATE, DELETE 를 수행하는 것을 막아줍니다.
낙관락은 @Version을 통해 데이터의 변경 여부를 감지합니다. 따라서 Entity에 @Version을 붙인 변수를 추가해줘야합니다.
@Entity
@Table(name = "product_stock")
@AllArgsConstructor
@NoArgsConstructor
public class StockJpaEntity extends BaseTimeEntity implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private int quantity;
@Column(name = "product_id")
private Long productId;
@Version
private Long version; // 낙관락을 사용하기 위함
JPA의 LockModeType 기능을 활용하여, OPTIMISTIC 락을 걸었습니다.
@Lock(value = LockModeType.OPTIMISTIC)
@Query("select s from StockJpaEntity s where s.productId = :productId")
StockJpaEntity findByProductIdWithOptimisticLock(Long productId);
먼저 데이터를 읽은 후에 update를 수행할 떄 현재 내가 읽은 버전이 맞는지 확인하며 업데이트 합니다.
Pub-sub 기반으로 Lock 을 구현한다.
dependencies {
implementation 'org.redisson:redisson-spring-boot-starter:3.17.6'
}
@Component
@RequiredArgsConstructor
public class RedissonLockStockFacade {
private final RedissonClient redissonClient;
private final ReduceStockUseCase reduceStockUseCase;
private final PlatformTransactionManager platformTransactionManager;
public void reduceStock(OrderItem orderItem) throws InterruptedException {
RLock lock = redissonClient.getLock(orderItem.getProductId().toString());
try {
boolean available = lock.tryLock(10, 1, TimeUnit.SECONDS);
if (!available) {
System.out.println("lock 획득에 실패하였습니다");
return;
}
TransactionStatus status = platformTransactionManager.getTransaction(null);
reduceStockUseCase.reduceStock(orderItem);
platformTransactionManager.commit(status);
} finally {
lock.unlock();
}
}
}
RLock lock = redissonClient.getLock(orderItem.getProductId().toString());
→ 해당 주문한 상품의 Id를 키로 락 객체를 가져온다.lock.tryLock(10, 1, TimeUnit.SECONDS)
→ 락 획득 최대 시간은 10초이고 락 점유 시간은 1초이다.여러가지 기술들이 있었지만 각 회사 또는 각 프로젝트에 따라 각기 다른 기술스택을 선택해야한다고 생각합니다.
예를들어 정답은 없지만 서버의 개수, 비즈니스 로직, 트래픽 량 등 고려할 사항들을 고려해보고 선택해야 될 것 같습니다.
저는 추후에 해당 서비스에 MSA를 넣기 위해 Redisson을 이용한 방법을 선택하였습니다.