이번 포스팅에서는 이커머스 프로젝트에서 상품의 재고 추가를 위해 제가 구현한 메서드에 대해 간단히 살펴보겠습니다!
혹시 비관적 락(Pessimistic Lock)과 낙관적 락(Optimistic Lock)에 대해 처음 들으신다면 아래에 포스팅을 참고해 주세요 :)
https://wookjongbackend.tistory.com/39
HTTP 메서드의 멱등성과 재고 추가 API 설계
아래 코드는 제가 Controller단에 구현한 메서드입니다!
@RequestMapping("/sellers")
@RestController
@RequiredArgsConstructor
public class SellerController {
private final SellerService sellerService;
...
// PUT을 사용할수도 있지만 이 경우엔 멱등성을 가지지 않기에 POST가 어울린다고 판단.
@PostMapping("/{sellerId}/items/{itemId}/stock")
public ResponseEntity<SuccessResponse> addItemStock(@PathVariable Long sellerId,
@PathVariable Long itemId, int addNum){
SellerItemResponseDto item = sellerService.addStock(sellerId, itemId, addNum);
return new ResponseEntity<>(new SuccessResponse(200, "재고 추가가 완료되었습니다.",item),
HttpStatus.OK);
}
}
저는 처음에 "재고를 수정하는 것이니 @PutMapping을 사용해야겠다."라고 생각했습니다. 하지만 특정 블로그에서 재고 추가 기능을 @PostMapping으로 설계한 코드를 보고 그 이유를 찾아보게 되었습니다.
혹시 HTTP의 멱등성(Idempotent)에 대해 들어보셨나요???
멱등성이란 요청(Request)을 한번 호출하든, 여러 번을 호출하든 그 결과가 같음을 의미합니다.
예를 들어 상품 정보(이름, 설명, 가격 등)를 수정하는 기능이 있다고 해보겠습니다. 이때 보통 클라이언트는 수정 정보를 Form에 담은 형식으로 요청을 보낼 것입니다. 이때 서버에서 응답으로 바뀐 아이템의 정보를 내려준다고 했을 때 이러한 요청은 멱등하다고 볼 수 있을까요???
상품의 이름이 "상품 1", 설명이 "상품 1입니다", 가격 1000원일 때, 클라이언트가 이름을 "상품 2", 설명을 "상품 2입니다.", 가격 2000원으로 수정하는 요청을 여러 번 보낸다면 항상 똑같은 결과가 내려오기에 멱등하다고 볼 수 있습니다.
하지만 재고를 추가하는 경우는 어떨까요???
상품의 현재 재고가 100개이고, 클라이언트가 재고 3개를 추가하는 요청을 보내는 경우를 상상해 봅시다. 만약 클라이언트가 재고 추가 요청을 여러 번 보낸다면? 103 -> 106 -> 109 ... 와 같이 재고는 등차수열 형식으로 증가하게 됩니다.
즉 재고 추가의 경우에는 요청을 여러 번 보내면 그 결과가 항상 달라집니다. 따라서 항상 다른 결과를 나타내기에 멱등하지 않다고 볼 수 있는 것이죠
HTTP 표준에 따르면, HTTP 메서드 중 PUT은 멱등성을 가지지만, POST는 멱등성을 가지지 않습니다.
따라서 상품 정보 수정과 같은 경우에는 @PutMapping을, 재고 추가와 같은 경우에는 @PostMapping을 사용하여 API를 설계하는 것이 바람직합니다 :)
재고 추가 및 락
@Override
@Transactional
public SellerItemResponseDto addStock(Long sellerId, Long itemId, int addNum) {
Item item = itemRepository.findByIdAndSellerId(sellerId, itemId)
.orElseThrow(() -> new BusinessException(ErrorCode.ITEM_NOT_MATCH));
Stock stock = stockRepository.findByIdForUpdate(item.getStock().getId())
.orElseThrow(() -> new BusinessException(ErrorCode.STOCK_NOT_FOUND));
stock.setQuantity(stock.getQuantity() + addNum);
return SellerItemResponseDto.of(item);
}
public interface StockRepository extends JpaRepository<Stock, Long> {
Optional<Stock> findById(Long id);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Stock s where s.id = :id")
@QueryHints({@QueryHint(name = "javax.persistence.lock.timeout", value="10000")})
Optional<Stock> findByIdForUpdate(@Param("id") Long stockId);
}
저는 재고 데이터의 정합성을 보장하기 위해 락을 사용하였습니다.
간단히 정리하자면 락이란, 여러 트랜잭션이 동시에 데이터에 접근하는 것을 방지하기 위해 사용하는 기술입니다.
낙관적 락이란 데이터가 거의 충돌하지 않을 것이라고 낙관적으로 가정하는 방식입니다. 이때 각 트랜잭션은 충돌 없이 데이터를 수정할 수 있으리라 가정하고, 실제로 데이터를 수정할 때 충돌이 발생하면 트랜잭션을 롤백합니다.
반면 비관적 락은 데이터 충돌이 발생할 수 있다고 비관적으로 가정하는 방식입니다. 트랜잭션이 데이터에 접근할 때 락을 걸어 다른 트랜잭션이 해당 데이터에 접근하는 것을 방지합니다.
그렇다면 이러한 락을 왜 사용했는지 이해하기 위해 간단한 상황에 대해 살펴보겠습니다!
- 판매자 A가 특정 상품의 재고를 추가하려고 합니다.
- 동시에, 구매자 B가 같은 상품을 구매하려고 합니다.
- 판매자 A는 해당 상품의 현재 재고를 조회하고, 추가하려는 수량을 더하려고 합니다.
- 그런데 동시에 구매자 B는 같은 상품의 재고를 조회하고, 구매하려는 수량만큼 재고를 감소시키려 합니다.
- 판매자 A의 트랜잭션이 먼저 완료되어 재고가 증가되었지만, 구매자 B는 판매자 A가 재고를 추가하기 전의 재고 상태를 기준으로 계산을 하고 업데이트를 시도합니다.
- 이런 경우 판매자 A가 추가한 재고가 구매자 B의 업데이트에 의해 덮어씌워질 수 있습니다.
위와 같은 상황을 막기 위해 락을 사용하는 것입니다.
저는 해당 프로젝트에서 총 3개(구매자의 잔고, 판매자의 판매 수익, 재고)의 데이터에 락을 사용하였습니다.
해당 데이터들의 경우엔 돈과 상당히 밀접한 관련이 있습니다. 이 경우 동시성은 떨어질 수 있음에도 불구하고, 데이터의 정합성이 상당히 중요하다고 생각되어 락을 적용하였습니다.
그렇다면 낙관적 락과 비관적 락 중 어떤 것을 사용하는 것이 좋을지 고민해 보았습니다.
특히 선착순 구매 이벤트 같은 상황을 생각해 보며, 두 사용자가 거의 동시에 수량이 한 개 남은 상품을 주문하려고 하는 경우를 상상해 보았습니다.
예를 들어, 상품이 1개만 남은 상황에서 사용자 A와 사용자 B가 거의 동시에 주문을 시도한다고 가정해 보겠습니다. 사용자 A가 조금 더 빨리 주문을 시도하였습니다. 이때, 사용자 A의 요청을 처리하는 쓰레드 A와, 사용자 B의 요청을 처리하는 쓰레드 B가 거의 동시에 상품의 재고 정보(1개)를 읽어옵니다. 이 과정은 낙관적 락 기준으로 이루어지며, 주문을 진행하는 메서드에 포함되어 있습니다.
낙관적 락은 레코드를 읽을 때 락을 걸지 않고, 업데이트를 수행할 때 이전에 읽은 값과 현재 데이터베이스의 값이 같은지 확인합니다. 만약 값이 다르다면 다른 트랜잭션에서 변경이 발생했음을 의미하므로 롤백을 수행합니다.
이 상황에서 쓰레드 B가 더 빠르게 주문을 처리하고 성공한다면, 사용자 A가 먼저 주문을 시작했으나, 처리 속도가 느려 쓰레드 B에 의해 재고가 먼저 변경됩니다. 따라서 사용자 A의 주문 과정에서 재고를 줄이려 할 때, 쓰레드 B에 의해 이미 재고가 변경되었으므로 충돌이 발생하게 됩니다. 낙관적 락의 원칙에 따라 이로 인해 사용자 A의 주문은 롤백 처리되어 실패하게 됩니다.
반면, 비관적 락을 사용하게 되면 상황은 달라집니다. 비관적 락은 데이터를 처음 읽을 때 락을 걸어 다른 트랜잭션이 해당 데이터를 수정하거나 삭제할 수 없도록 합니다. 따라서 사용자 A가 주문 과정에서 재고 정보를 읽어오면서 락을 걸었다면, 쓰레드 B는 사용자 A의 트랜잭션이 완료될 때까지 대기하게 됩니다. 이로 인해 동시성 문제가 해결되며, 동시에 여러 요청이 발생해도 데이터의 일관성을 유지할 수 있습니다.
그리고 저는 락을 사용함으로써 발생하는 성능 측면 문제에 대비해 Stock 테이블을 따로 설계하였습니다.
예를 들어, 두 트랜잭션이 거의 동시에 실행된다고 가정해 보겠습니다. 하나는 상품의 재고를 추가하는 작업이고, 다른 하나는 상품의 정보(이름, 가격, 상품 설명 등)를 조회하는 작업입니다.
만약 상품의 재고 정보가 상품 테이블에 포함되어 있고 락이 적용된다면, 상품 테이블의 모든 정보(재고, 이름, 가격, 상품 설명 등)에 락이 걸릴 것입니다. 이 경우, 상품 정보를 조회하는 작업은 재고를 추가하는 작업이 완료될 때까지 기다려야 합니다. 이는 불필요한 대기 시간을 초래하며, 시스템의 전반적인 성능에 부정적인 영향을 미칠 수 있습니다.
따라서 저는 우선 재고 정보를 별도의 테이블로 분리하는 설계를 선택하였습니다! 그리고 추후 성능 향상을 위해 다른 개념들에 대해 익히고 적용해 볼 예정입니다.
우선 결론적으로, 어떤 유형의 락을 사용할 것인지는 해당 서비스의 요구사항과 상황에 따라 달라집니다. 따라서, 상황에 맞는 최적의 방법을 선택하는 것이 중요한 것 같습니다!(저는 우선 비관적 락을 사용하여 코드를 작성하였습니다)
참고
https://gnaseel.tistory.com/24
혹시 틀린 내용이 있거나 조언해 주실 부분 있으시다면 댓글 부탁드립니다!!! 언제나 환영합니다 :)
'이커머스 프로젝트' 카테고리의 다른 글
상품 주문 로직 구현해보기(with 동시성 제어 테스트 및 트러블 슈팅) (1) | 2023.08.05 |
---|---|
상품의 수정 및 삭제에 대해 정책적으로 다루어보자! (0) | 2023.08.03 |
판매자의 상품 검색 기능 - 동적 쿼리 생성을 위해 JPA Specification 적용 (0) | 2023.07.24 |
트러블 슈팅 - save the transient instance before flushing (0) | 2023.07.21 |