프로젝트를 진행하면서 사용자가 주문한 수량만큼 상품의 재고가 감소하는 주문 API를 개발하였다.
현장판매를 위한 상품재고를 점주가 컨트롤할 수 있기위해, 흔히 사용하는 서비스와 동일하게 사용자가 주문하면, 점주가 이를 확인 후 수락 or 거절을 통해 주문이 완료되는 로직으로 서비스를 개발하였다.
이로인해 동시에 많은 주문이 들어오더라도 점주가 재고를 확인한 후 하나씩 승인하기때문에 문제는 없었다.
하지만, 이러한 점주의 승인과정없이 사용자가 주문함과 동시에 결제가 완료되는 서비스라면,
여러명의 사용자가 주문을 동시에 요청하게 된다면 어떻게 처리될까?
여러 스레드가 동시에 같은 인스턴스의 필드의 값을 변경하면서 발생하는 동시성 문제를 어떻게 해결해야 할까?
동시에 주문요청을 했을때 발생하는 동시성 문제와 DB Lock을 사용하지 않았는데 데드락(Deadlock)이 발생하는 이유에 대해 알아보고, 이를 DB 락(Lock)을 사용해 해결하는 과정을 알아보자!
아래는 상품 주문 메서드로, 사용자가 주문할 아이템 id와 개수를 가져와 주문한다.
이때 상품의 재고가 0이면 ItemStockZeroException 예외를 반환하고,
주문할 개수보다 재고가 적으면 ItemStockMinusException을 반환한다.
// 핵심 코드만 작성
@Transactional
public void orderItem(Long itemId, int orderCount) {
Long orderId = orderService.order(Long itemId);
Item item = itemRepository.findById(itemId).orElseThrow(ItemNotFoundException::new);
OrderItem orderItem = new OrderItem(item.getReference(Order.class, orderId),1);
int orderItemStock = item.updateStock(orderCount);
orderItemRepository.save(orderItem);
Order findOrder = orderRepository.findById(orderId).orElseThrow(OrderNotFoundException::new);
findOrder.updateOrderStatus(OrderStatus.ORDER);
}
현재 재고가 3개인 상품에 5번의 주문을 ‘동시에’ 넣기 위해 터미널에 curl 명령어를 이용해 API를 동시에 요청하였다.
curl 'http://localhost:8080/api/order' & curl 'http://localhost:8080/api/order' & curl 'http://localhost:8080/api/order' & curl 'http://localhost:8080/api/order' & curl 'http://localhost:8080/api/order'
총 5번 주문했지만 상품의 재고가 1 밖에 감소되지않았고,
주문은 1번만 반영된 것을 확인할 수 있었다.
로그를 살펴보니 4번의 데드락(Deadlock)으로 인해 롤백된 것을 확인할 수 있다.
교착상태(Deadlock) 란??
둘 이상의 프로세스(여기서는 트랜잭션)들이 자원을 점유(Lock을 획득)한 상태에서
서로 다른 프로세스(트랜잭션)가 점유하고 있는 자원(Lock)을 요구하며 무한정 기다리는 상황을 의미한다.
show engine innodb status
명령어로 데드락 History를 확인할 수 있다.
HOLDS THE LOCK(S)
: s-Lock(Shared Lock)을 획득WAITING FOR THIS LOCK TO BE GRANTED
: Lock 획득을 기다리고 있다.이처럼 동일한 레코드(데이터)에 대해 s-Lock과 x-Lock을 시도하고 있었다.
s-Lock 끼리는 동시에 설정할 수 있지만, x-Lock은 한 리소스에 하나의 x-Lock만 설정가능하기 때문에 데드락이 발생한 것이다.
하지만, DB Lock을 사용하지 않았는데 왜 s-Lcok과 x-Lock이 사용되는 것인가???
* DB Lock을 사용하지않고 @Version으로 변경사항을 감지하는 낙관적 Lock을 사용해도 결과는 같다.
“외래키는 변경시(INSERT, UPDATE) 부모 테이블이나 자식 테이블에 데이터가 존재하는지 체크하는 작업이 필요하다. 따라서, 잠금이 연관관계를 맺고 있는 여러 테이블로 전파되고, 해당 변경 작업을 위해 외래키 컬럼에 S-Lock이 걸리게 되면서 데드락이 발생할 수 있다.”
출처 : MySQL 5.6 reference
이로인해 서로 다른 트랜잭션이 같은 자원에 대해 Lock을 가지고 있으며, 서로 Lock을 해제할때까지 대기하면서 데드락이 발생하게 된다.
이로인해 FK 제약 조건이 있는 테이블에는 낙관적 락(Optimistic Lock)을 활용해도 데드락을 피할 수 없다.
DB에서 Lock이 없으면 데이터의 일관성을 지키기 어렵기 때문에 Lock을 걸지 않도록 만들 방법이 없다.
이를 해결하기 위한 비관적 락 (Pessimistic Lock)에 대해 알아보기 전에 s-Lock, x-Lock, 낙관적 락의 개념을 다시 확인하고 넘어가자
Shared Lock(공유, 읽기 잠금, s-lock)
Exclusive Lock(베타, 쓰기 잠금, x-lock)
실제로 Lock 을 이용하지 않고 Version을 이용함으로써 정합성을 맞추는 방법이다.
먼저 데이터를 읽은 후에 update 를 수행할 때 현재 내가 읽은 Version이 맞는지 확인하며 업데이트 한다.
자원에 락을 걸어서 선점하지 않고, 동시성 문제가 발생하면 그때가서 처리하는 방식이다.
내가 읽은 버전에서 수정사항이 생겼을 경우에는 application에서 다시 읽은 후에 작업을 수행하는 롤백 작업을 수행해야 한다.
낙관적 락은 트랜잭션을 필요로하지 않고, 별도의 락을 사용하지 않으므로 비관적 락보다 성능적으로 더 좋다.
하지만, 동시성 문제가 빈번하게 일어나면 계속 롤백처리를 해줘야하며, 업데이트가 실패했을 때 재시도 로직을 개발자가 직접 작성해야 한다.
@Lock
어노테이션을 이용한다.
NONE : 락을 적용하지 않아도 엔티티에 @Version
이 적용된 필드가 있다면 낙관적 락이 적용된다.
OPTIMISTIC(READ) : 읽기시에도 락이 걸린다.
버전을 체크하고, 트랜잭션이 종료될 때까지 다른 트랜잭션에서 변경하지 않음을 보장한다.
OPTIMISTIC_FORCE_INCREMENT(WRITE) : 낙관적 락을 사용하면서 버전 정보를 강제로 증가
논리적인 단위의 엔티티 묶음을 관리할 수 있다. 예시로, 양방향 연관관계에서 주인 엔티티만 변경했을 때, 매핑된 엔티티는 변경되지 않았지만 논리적으로 변경되었으므로 버전을 증가시킨다.
@Lock(value = LockModeType.OPTIMISTIC)
Item findWithOptimisticLockById(Long id);
이처럼 같은 데이터에 대해서 다른 2개의 수정 요청이 있었지만 먼저 요청한 데이터로 변경되면서 version이 변경되었기 때문에 뒤의 수정 요청은 반영되지 않게 된다.
이런식으로 낙관적 락은 version 같은 별도의 컬럼을 이용해서 충돌을 예방할 수 있다.
ex) 예로 선착순 100명에게 쿠폰을 발급하는 이벤트에서 동시에 100명이 요청했다면 100명은 모두 쿠폰을 발급받는걸 기대하겠지만, 낙관적 락(Optimistic Lock) 메커니즘상 최초의 커밋만 인정하기 때문에 100개의 쿠폰이 있어도 최초 요청한 1명에게만 쿠폰이 발급된다. 그럼 나머지 99명은 다시 발급 요청을 해야한다.
어플리케이션 서버는 재시도하는 요청만큼 부하를 받게 되고, 재시도에 대한 처리와 실패에 대한 처리도 구현해야 하므로 코드의 복잡성이 증가할 수 있다.
현재 구현하는 서비스의 성격 + 어플리케이션 부하 + 재시도에 대한 처리등을 고려해야 한다.
실제로 데이터에 Lock을 걸어서 정합성을 맞추는 방법으로, 자원 요청에 따른 동시성문제가 발생할 것이라고 예상하고 락을 걸어버리는 방식이다.
비관적 락은 트랜잭션이 시작될 때 Shared Lock 또는 Exclusive Lock을 걸고 시작한다.
Shared Lock(공유, 읽기 잠금, s-lock)
다른 트랜잭션에서 읽기만 가능하기 때문에 Exclusive Lock은 적용할 수 없다.
Exclusive Lock(베타, 쓰기 잠금, x-lock)
다른 트랜잭션에서 읽기,쓰기가 불가능하기 때문에, 한 트랜잭션에 하나의 리소스만 사용할 수 있다.
결국 Pessimistic Lock이란, 데이터에는 락을 가진 스레드만 접근이 가능하도록 제어하는 방법이다.
JPA에서는 @Lock
어노테이션을 이용해 비관적 락을 쉽게 구현할 수 있다.
PESSIMISTIC_READ : Shared Lock을 획득하고 데이터가 update, delete 되는 것을 방지한다.
PESSIMISTIC_WRITE : Exclusive Lock을 획득하고 데이터를 다른 트랜잭션에서 read,update,delete하는 것을 방지한다.
PESSIMISTIC_FORCE_INCREMENT : PESSIMISTIC_WRITE
와 유사하지만 @Version
이 지정된 Entity와 협력하기 위해 도입되어 PESSIMISTIC_FORCE_INCREMENT
락을 획득할 시 버전이 업데이트 된다.
@Lock(value = LockModeType.PESSIMISTIC_WRITE)
Item findWithPessimisticLockById(Long id);
테스트가 통과하면서 쿼리에 for update가 존재한다.
* SQL 문법으로 SELECT 절 뒤에 FOR UPDATE 를 추가하면, 특정 ROW 조회시에 X-Lock을 걸 수 있다. ex) SELECT * FROM ITEM FOR UPDATE
PESSIMISTIC_READ를 적용하면 테스트에 실패하며 쿼리에 for share가 존재한다.
트랜잭션이 시작될 때 Shared Lock 또는 Exclusive Lock을 걸고 시작한다.
이렇게 Transaction을 이용하여 내가 접근하고 하는 Database 리소스에 다른사람이 접근조차 하지못하도록 락을 걸고 작업을 진행하여 충돌을 예방하는 것이 바로 비관적 락(Pessimistic Lock)이다.
@Test
@DisplayName("상품 주문하기")
void orderItemWithLock() throws InterruptedException {
final int soldOut = 0;
final int orderCount = 5;
final int buyCount = 1;
OrderRequestDto orderRequestDto = new OrderRequestDto(itemId,buyCount);
ExecutorService executorService = Executors.newFixedThreadPool(orderCount);
CountDownLatch countDownLatch = new CountDownLatch(orderCount);
for(int i = 0; i < 5; i++) {
executorService.execute(() -> {
orderItemService.orderItem("test@gmail.com", orderRequestDto);
countDownLatch.countDown();
});
}
countDownLatch.await();
Item findItem = itemRepository.findById(itemId).orElseThrow(ItemNotFoundException::new);
assertThat(soldOut).isEqualTo(findItem.getItemStock());
}
5개의 상품재고에 5번의 주문을 요청하는 테스트를 확인한 결과
아래와 같이 5번의 주문이 비관적 락으로 인해 순차적으로 적용되어 재고가 0이 되는 것을 확인할 수 있다.
다음으로, 처음 진행했던 curl 명령어를 활용해 재고가 3개인 상품에 주문을 동시에 5번 넣어본 결과
순차적으로 재고가 차감되면서 재고가 0일때 발생하게 만든 StockZeroException 예외가 2번 발생하는 것을 확인할 수 있다.
DB에서 확인했을때, 3번의 주문이 완료되었고 재고는 0으로 잘 반영된 상태이다.
Lock Timeout을 설정하여 락을 잡고 있는 최대 시간을 지정할 수 있다.
LockModeType.PESSIMISTIC_READ
으로 진행했을 때 Lock Timeout이 걸려 있다면 Lock이 걸려있다고 바로 에러를 내지 않고, 지정한 시간은 대기하고 예외처리를 하게된다.
@Lock(value = LockModeType.PESSIMISTIC_WRITE)
@QueryHints( {@QueryHint(name = "javax.persistence.lock.timeout", value = "10000")})
Optional<Item> findForUpdateByItemId(Long itemId);
여러 테이블에 Lock을 걸면서 데드락이 발생하는 경우는 비관적 락으로 해결할 수 없다.
-> Kafka의 메시징 큐, Redis의 Sorted Set을 활용해 해결할 수도 있다.
예시로, 선착순 이벤트의 경우 Redis의 Sorted Set을 활용하는 사례를 우아한 데크톡 영상에서 확인할 수 있다.
[우아한테크토크] 선착순 이벤트 서버 생존기! 47만 RPM에서 살아남다?!
- 자바 ORM 표준 JPA 프로그래밍 - 김영한
- JPA 비관적 락
- 낙관적 락, 비관적 락
- CountDownLatch 동시성 테스트
- Lock 테스트 코드 참고