본문 바로가기
Backend/Spring

[예약 구매 프로젝트] 동시 주문 요청 처리

by DooDuZ 2024. 7. 24.

수요는 많고 공급은 적다

지금 진행 중인 예약구매 프로젝트의 메인 주제는 짧은 시간에 몰리는 대규모 트래픽을 처리하는 것입니다. 특정 브랜드들의 콜라보 한정 상품이나 온라인 티켓팅 등을 생각해 보면, 사려는 사람은 많고 제공되는 자원은 한정적인 것이죠. 그래서 대규모 트래픽이라고 하면 서버 부하나 성능에 대한 생각이 먼저 떠오르는 게 사실입니다. 그러나 막상 기능을 구현하다 보면 먼저 마주치게 되는 문제가 있습니다. 많은 요청이 하나의 자원을 바라보고 조작하려 하게 되는, 이른바 동시성 이슈입니다.

 

동시 주문 시나리오

편의상 하나의 아이템이 남아있고 아이템을 구매하기 위한 무수히 많은 요청이 들어오는 상황을 가정해 보겠습니다. 지난 포스팅에 올렸던 기본 주문 플로우를 사용한다고 가정해 보죠. 

아이템에 대한 주문 요청이 들어오면 서버는 DB에서 재고 값을 읽어오고, 주문 수량만큼 차감한 후 해당 값을 DB에 업데이트하게 됩니다. JPA를 사용해 DB 레코드를 조작한다면 자연스러운 흐름처럼 보입니다. 그러나 이 시퀀스는 여러 요청이 생기는 상황에서 문제가 됩니다. 2 -> 4의 과정에서 어느 정도의 지연이 발생할 수밖에 없기 때문에, 작업이 완료되기 전 다른 요청들 또한 같은 값을 읽어올 수 있기 때문입니다.

 

먼저 실행된 하나의 스레드가 DB에서 재고를 읽어옵니다. 이미지에선 한 번에 일어난 작업으로 보이지만 실제론 select와 데이터 응답 사이에 얼마간의 시간이 소요됩니다. 요청은 계속 쌓여가고 있고, 실행되는 요청 스레드도 점점 늘어납니다.

 

스레드 1이 로직을 처리하는 동안 스레드 2와 3이 DB에서 값을 읽어옵니다. 스레드 1, 2, 3은 모두 재고가 1개 남아있다는 응답을 받게 됩니다.

 

 

로직을 모두 처리한 스레드가 DB에 재고를 업데이트합니다. 1개 남아있던 수량을 구매했기 때문에 Update 쿼리는 재고를 0으로 변경하게 됩니다.

 

스레드 1은 모든 로직을 수행했으므로 클라이언트에게 주문 성공 코드로 응답합니다. 스레드 2와 3 또한 로직을 수행하고 Update 쿼리를 실행합니다. 현재 재고는 0이므로 스레드 2와 3의 주문은 실패처리 돼야 합니다.

 

그러나 두 요청은 기대값과 다르게 모두 성공합니다. 재고를 읽고, 요청 정보를 사용해서 읽은 데이터를 가공하고, 가공 데이터를 다시 DB에 반영했기 때문에 1개의 상품에서 3 건의 재고 차감이 일어난 게 아니라 1-1 연산만 3번 수행됐기 때문입니다. 결국 먼저 들어온 요청이 처리되기 전에, 변경 중인 데이터를 다른 스레드에서 읽을 수 있다는 게 문제가 됩니다.

 

Repeatable Read - MySQL의 기본 격리

위 시나리오에선 별다른 오류 없이 응답까지 처리됐지만, 이번에 진행한 프로젝트에선 시나리오 상황 외의 문제가 하나 더 발생했습니다. 서로 다른 트랜잭션이 Lock을 획득하려 무한히 대기하는 상태. DeadLock이슈입니다.

2024-07-06 02:44:15 - Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed:
org.springframework.dao.CannotAcquireLockException: could not execute statement [Deadlock found when trying to get lock; try restarting transaction]
[update product set amount=?,product_category_id=?,is_deleted=?,last_modify_date=?,price=?,product_detail=?,product_name=?,product_status=?,member_id=? where product_id=?];
SQL [update product set amount=?,product_category_id=?,is_deleted=?,last_modify_date=?,price=?,product_detail=?,product_name=?,product_status=?,member_id=? where product_id=?]] with root cause

 

로그를 보면 두 개의 update query가 Lock점유를 하려 충돌하는 걸 알 수 있습니다. 별도의 Lock을 걸지 않고 쿼리를 실행했기 때문에 전혀 예상하지 못한 이슈였는데, 원인을 DB 격리 수준에서 찾을 수 있었습니다.

 

MySQL의 기본 격리 수준은 Repeatable Read입니다. 이 격리 수준에선 별도의 Lock 설정 없이 데이터를 조회할 때 Next Key Lock이 걸리게 됩니다. 데이터를 조회하는 동안 데이터가 삽입되어 Read결과가 달라지는, 이른바 Phantom Read를 방지하기 위한 조치입니다. Next Key Lock이 적용된 레코드는 조회는 가능하지만 update나 delete 같은 변경이 제한되게 됩니다.

 

위 이미지를 기준으로 말해보자면

 

1. Thread 1이 주문 로직을 실행합니다 -> 트랜잭션 1 시작
2. Thread 2, 3이 주문 로직을 실행합니다 -> 트랜잭션 2, 3 시작
3. Thread 1이 재고 정보를 Select 합니다 -> Next Key Lock 적용
4. Thread 2, 3이 재고 정보를 Select 합니다 -> Next Key Lock 적용
5. Thread 1에서 재고 Update를 시도합니다 -> 트랜잭션 2, 3의 락으로 인해 update 불가 - 락 점유 대기
6. Thread 2, 3에서 재고 Update를 시도합니다 -> 트랜잭션 1의 락으로 인해 update 불가 - 락 점유 대기

 

결국 명시적으로 Lock을 사용하지 않아도 DB 격리 수준에 따라 Lock이 적용되고 동시성 문제가 다른 방식으로 발생할 수 있음을 알 수 있습니다.

 

해결 방안

1. 수정 중인 데이터에 접근을 제한 - Pessimistic Lock

시나리오의 재고보다 많은 수의 주문 성공도, 데드락도 동시에 같은 값을 읽는 게 문제라면 이를 막아주면 될 일입니다. 가장 간단한 방법인 비관적 락(Pessimistic Lock)을 적용해 하나의 트랜잭션이 값을 읽고 있는 중에는 다른 트랜잭션의 요청이 대기하도록 만들 수 있습니다.

 

 이 경우 Thread 2와 3은 Thread 1의 트랜잭션이 종료되기 전까진 작업 중인 레코드를 읽어올 수 없기 때문에 재고 불일치 이슈가 해소되게 됩니다. 요청이 많아질수록 대기 스레드가 많아지므로 데이터 정합성을 얻는 만큼 성능 저하가 클 것으로 예상할 수 있습니다. JPA를 사용한다면 repository에 메서드를 추가하는 것으로 간단하게 구현할 수 있습니다.

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT s FROM stock s WHERE s.productEntity = :productEntity")
Optional<StockEntity> findByProductEntityToOrder(@Param("productEntity") ProductEntity productEntity);

 

2. Get/Set 방식 Update → Atomic Query

낙관적 락, 네임드 락, 분산 락 등 동시성 이슈를 해결하기 위한 여러 Lock 메커니즘이 있지만, 사실 Lock을 사용하지 않고 처리할 수 있다면 가장 좋겠다는 생각이 들었습니다. 재고를 읽어오고 가공데이터를 반영하는데 시간이 걸리는 게 문제라면, 처음부터 작업을 분기별로 나누지 않고 한 번에 처리할 수 있겠다는 생각이었습니다. 재고 컬럼은 0보다 작을 수 없기 때문에 Unsigned 값으로 설정되어 있고, 그렇다면 여러 요청에서 재고 확인 절차를 생략하고 바로 -연산을 진행하더라도 초과 주문이 일어나지 않습니다.

 

// service
@Transactional
public void updateStockAfterOrder(Long productId, Long amount){
    stockRepository.updateStockAfterOrder(productId, amount);
}

// repository
@Modifying
@Transactional
@Query(value = "UPDATE `stock` SET amount = amount - :amount WHERE product_id = :productId", nativeQuery = true)
void updateStockAfterOrder(@Param("productId") Long productId, @Param("amount") Long amount);

 

 

두 방식은 10% 전후의 성능 차이를 보였습니다. 그러나 Pessimistic Lock 또한 여러 레코드를 수정하는 요청이 들어올 경우 데드락에서 완전히 자유로울 수 없고, Lock을 통한 제어는 DB부하를 증가시킬 수 있다는 생각이 들어서 최종적으론 Atomic 연산을 사용하는 방식을 채택했습니다.

 

 

후에 리팩토링을 거치면서 재고를 Redis에 캐싱해서 사용하게 되고, Redis에서도 Redisson을 사용한 Lock과  Redis Increment를 사용한 Atomic연산을 적용해 봤는데 이 경우에도 Atomic 연산을 사용한 경우가 최대 응답 속도에서 20% 정도 높은 퍼포먼스를 보여주었습니다. 항상 이 방식이 옳다고 할 순 없지만, Lock을 사용하지 않을 수 있는 부분에서는 이처럼 사용하는 것도 좋을 것 같습니다.