상품 주문 동시성 문제 해결하기 - DeadLock, 낙관적 락(Optimistic Lock) & 비관적 락(Pessimistic Lock)

손효재·2022년 12월 6일
11
post-thumbnail

Intro

프로젝트를 진행하면서 사용자가 주문한 수량만큼 상품의 재고가 감소하는 주문 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)으로 인해 롤백된 것을 확인할 수 있다.

나는 아직 Lock을 사용하지 않았는데 데드락(Deadlock)이 발생했다!!

교착상태(Deadlock)가 발생한 이유는???

교착상태(Deadlock) 란??

둘 이상의 프로세스(여기서는 트랜잭션)들이 자원을 점유(Lock을 획득)한 상태에서
서로 다른 프로세스(트랜잭션)가 점유하고 있는 자원(Lock)을 요구하며 무한정 기다리는 상황을 의미한다.

Deadlock History 확인

show engine innodb status 명령어로 데드락 History를 확인할 수 있다.

  • HOLDS THE LOCK(S) : s-Lock(Shared Lock)을 획득
  • WAITING FOR THIS LOCK TO BE GRANTED : Lock 획득을 기다리고 있다.
    * 대기하는 Lock은 x-Lock(Exclusive Lock)임을 알 수 있다.

이처럼 동일한 레코드(데이터)에 대해 s-Lock과 x-Lock을 시도하고 있었다.
s-Lock 끼리는 동시에 설정할 수 있지만, x-Lock은 한 리소스에 하나의 x-Lock만 설정가능하기 때문에 데드락이 발생한 것이다.

아직 DB Lock을 사용하지 않았는데

하지만, DB Lock을 사용하지 않았는데 왜 s-Lcok과 x-Lock이 사용되는 것인가???
* DB Lock을 사용하지않고 @Version으로 변경사항을 감지하는 낙관적 Lock을 사용해도 결과는 같다.

외래키 잠금전파

“외래키는 변경시(INSERT, UPDATE) 부모 테이블이나 자식 테이블에 데이터가 존재하는지 체크하는 작업이 필요하다. 따라서, 잠금이 연관관계를 맺고 있는 여러 테이블로 전파되고, 해당 변경 작업을 위해 외래키 컬럼에 S-Lock이 걸리게 되면서 데드락이 발생할 수 있다.”

s-Lock이 사용된 이유

  • FK가 있는 테이블에서 FK를 포함한 데이터를 insert, update, delete 하는 쿼리는 제약조건을 확인하기 위해 s-Lock을 설정한다.
  • 여기서는 OrderItem 테이블에 데이터를 insert하면서 이때 FK로 가지고있는 Item 에 s-Lock이 걸린 것 같다.

x-Lock이 사용된 이유

  • 레코드를 update 할때 쿼리에 사용되는 모든 레코드에 x-Lock을 설정한다.
  • 여기서는 Item의 재고를 업데이트하면서 x-Lock이 설정된 것 같다.

출처 : MySQL 5.6 reference

데드락 발생 원인 정리

  1. 트랜잭션 A가 데이터를 insert하면서 FK가 걸려있는 레코드에 s-Lock 설정
  2. 트랜잭션 B가 데이터를 insert하면서 FK가 걸려있는 레코드에 s-Lock 설정
  3. 트랜잭션 B가 Item의 재고를 변경하면서 x-Lock 설정을 위해 대기
    하지만, 이미 해당 레코드에는 s-Lock이 설정되었고, 서로 호환되지 않기때문에 s-Lock이 풀릴때까지 대기
  4. 트랜잭션 A가 Item의 재고를 변경하면서 x-Lock 설정을 위해 대기
    하지만, 이미 해당 레코드에는 s-Lock이 설정되었고, 서로 호환되지 않기때문에 s-Lock이 풀릴때까지 대기

이로인해 서로 다른 트랜잭션이 같은 자원에 대해 Lock을 가지고 있으며, 서로 Lock을 해제할때까지 대기하면서 데드락이 발생하게 된다.

데드락 발생원인 결론

이로인해 FK 제약 조건이 있는 테이블에는 낙관적 락(Optimistic Lock)을 활용해도 데드락을 피할 수 없다.
DB에서 Lock이 없으면 데이터의 일관성을 지키기 어렵기 때문에 Lock을 걸지 않도록 만들 방법이 없다.

이를 해결하기 위한 비관적 락 (Pessimistic Lock)에 대해 알아보기 전에 s-Lock, x-Lock, 낙관적 락의 개념을 다시 확인하고 넘어가자

Lock의 종류

Shared Lock(공유, 읽기 잠금, s-lock)

  • s-Lock 이라고 하며, 데이터를 읽을 때 사용하는 Lock이다.
  • 다른 s-Lock과 한 리소스에 두개 이상의 Lock을 동시에 설정할 수 있지만, x-Lock은 불가하다.
  • 즉 여러 트랜잭션에서 동시에 하나의 데이터를 읽을 수 있다. 그러나 변경중인 리소스를 동시에 읽을 수는 없다.

Exclusive Lock(베타, 쓰기 잠금, x-lock)

  • x-Lock 이라고 하며, 데이터를 변경할 때 사용한다.
  • 다른 Lock들과 호환되지 않기 때문에, 한 리소스에 하나의 x-Lock만 설정 가능하다.
  • 즉, x-Lock은 동시에 여러 트랜잭션이 한 리소스에 엑세스할 수 없게 된다. 읽기도 안된다.
    오직 하나의 트랜잭션만 해당 리소스를 점유할 수 있다.

Optimistic Lock (낙관적 락) 이란?

실제로 Lock 을 이용하지 않고 Version을 이용함으로써 정합성을 맞추는 방법이다.
먼저 데이터를 읽은 후에 update 를 수행할 때 현재 내가 읽은 Version이 맞는지 확인하며 업데이트 한다.

자원에 락을 걸어서 선점하지 않고, 동시성 문제가 발생하면 그때가서 처리하는 방식이다.

내가 읽은 버전에서 수정사항이 생겼을 경우에는 application에서 다시 읽은 후에 작업을 수행하는 롤백 작업을 수행해야 한다.

낙관적 락 장단점

낙관적 락은 트랜잭션을 필요로하지 않고, 별도의 락을 사용하지 않으므로 비관적 락보다 성능적으로 더 좋다.

하지만, 동시성 문제가 빈번하게 일어나면 계속 롤백처리를 해줘야하며, 업데이트가 실패했을 때 재시도 로직을 개발자가 직접 작성해야 한다.

적용하기

@Lock 어노테이션을 이용한다.

NONE : 락을 적용하지 않아도 엔티티에 @Version이 적용된 필드가 있다면 낙관적 락이 적용된다.
OPTIMISTIC(READ) : 읽기시에도 락이 걸린다.
버전을 체크하고, 트랜잭션이 종료될 때까지 다른 트랜잭션에서 변경하지 않음을 보장한다.
OPTIMISTIC_FORCE_INCREMENT(WRITE) : 낙관적 락을 사용하면서 버전 정보를 강제로 증가
논리적인 단위의 엔티티 묶음을 관리할 수 있다. 예시로, 양방향 연관관계에서 주인 엔티티만 변경했을 때, 매핑된 엔티티는 변경되지 않았지만 논리적으로 변경되었으므로 버전을 증가시킨다.

@Lock(value = LockModeType.OPTIMISTIC)
Item findWithOptimisticLockById(Long id);

낙관적 락의 흐름

  1. A가 Item 엔티티를 조회 ( version = 1 )
  2. B가 Item 엔티티를 조회 ( version = 1 )
  3. B가 version 1인 Item의 값 변경 → 성공, 데이터 변경과 함께 version 업데이트 ( version = 2 )
  4. A가 version 1인 Item 엔티티의 값 변경 → 실패, version = 2
    * 이미 version이 2로 업데이트 되었기 때문에 A는 해당 row를 갱신하지 못하고 예외를 반환

이처럼 같은 데이터에 대해서 다른 2개의 수정 요청이 있었지만 먼저 요청한 데이터로 변경되면서 version이 변경되었기 때문에 뒤의 수정 요청은 반영되지 않게 된다. 

이런식으로 낙관적 락은 version 같은 별도의 컬럼을 이용해서 충돌을 예방할 수 있다.

주의사항

  • 버전은 JPA가 직접 관리하므로 개발자가 수정하면 안된다.
    단, 벌크연산시 JPA가 관리하지 않으므로 이 때는 직접 버전을 관리해줘야 한다.
  • 최초의 커밋만 인정한다.

ex) 예로 선착순 100명에게 쿠폰을 발급하는 이벤트에서 동시에 100명이 요청했다면 100명은 모두 쿠폰을 발급받는걸 기대하겠지만, 낙관적 락(Optimistic Lock) 메커니즘상 최초의 커밋만 인정하기 때문에 100개의 쿠폰이 있어도 최초 요청한 1명에게만 쿠폰이 발급된다. 그럼 나머지 99명은 다시 발급 요청을 해야한다.

어플리케이션 서버는 재시도하는 요청만큼 부하를 받게 되고, 재시도에 대한 처리와 실패에 대한 처리도 구현해야 하므로 코드의 복잡성이 증가할 수 있다.

현재 구현하는 서비스의 성격 + 어플리케이션 부하 + 재시도에 대한 처리등을 고려해야 한다.

Pessimistic Lock (비관적 락)이란??

실제로 데이터에 Lock을 걸어서 정합성을 맞추는 방법으로, 자원 요청에 따른 동시성문제가 발생할 것이라고 예상하고 락을 걸어버리는 방식이다.

비관적 락은 트랜잭션이 시작될 때 Shared Lock 또는 Exclusive Lock을 걸고 시작한다.

Shared Lock(공유, 읽기 잠금, s-lock)
다른 트랜잭션에서 읽기만 가능하기 때문에 Exclusive Lock은 적용할 수 없다.

Exclusive Lock(베타, 쓰기 잠금, x-lock)
다른 트랜잭션에서 읽기,쓰기가 불가능하기 때문에, 한 트랜잭션에 하나의 리소스만 사용할 수 있다.

결국 Pessimistic Lock이란, 데이터에는 락을 가진 스레드만 접근이 가능하도록 제어하는 방법이다.

비관적 락의 장단점

  • 동시성 문제가 빈번하게 일어난다면 롤백의 횟수를 줄일 수 있기 때문에, Optimistic Lock 보다 성능이 좋을 수 있다.
  • 하지만, 비관적 락은 모든 트랜잭션에 Lock을 사용하기 때문에, Lock이 필요하지 않은 상황이더라도 무조건 Lock을 걸어서 성능상 문제가 될 수 있다. (특히 읽기가 많이 이루어지는 경우)
  • 선착순 이벤트같이 많은 트래픽이 몰리는 상황이나, 여러 테이블에 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을 걸고 시작한다.

  1. Transaction_1 에서 table의 Id 2번을 읽음 ( name = Karol )
  2. Transaction_2 에서 table의 Id 2번을 읽음 ( name = Karol )
  3. Transaction_2 에서 table의 Id 2번의 name을 Karol2로 변경 요청 ( name = Karol )
    * 하지만 Transaction 1에서 이미 shared Lock을 잡고 있기 때문에 Blocking
  4. Transaction_1 에서 트랜잭션 해제 (commit)
  5. Blocking 되어있었던 Transaction_2의 update 요청 정상 처리

이렇게 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 설정

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을 걸면서 데드락이 발생하는 경우는 비관적 락으로 해결할 수 없다.

  • 트랜잭션 A가 테이블1의 1번 데이터에 lock을 획득
  • 트랜잭션 B가 테이블2의 1번 데이터에 lock을 획득
  • 트랜잭션 A가 테이블2의 1번 데이터에 lock 획득 시도(실패 - 대기)
  • 트랜잭션 B가 테이블1의 1번 데이터에 lock 획득 시도(실패 - 대기)

-> Kafka의 메시징 큐, Redis의 Sorted Set을 활용해 해결할 수도 있다.
예시로, 선착순 이벤트의 경우 Redis의 Sorted Set을 활용하는 사례를 우아한 데크톡 영상에서 확인할 수 있다.
[우아한테크토크] 선착순 이벤트 서버 생존기! 47만 RPM에서 살아남다?!

참고

0개의 댓글