📌 1. 문제상황
주문API에 대해 검토하던 중, 주문 상태 변경에 대해 데이터 경쟁 문제가 발생할 수 있다고 판단하였다.
마치 멀티쓰레드 환경에서 동시성 문제가 발생하는 것 처럼, 주문수락과 주문취소가 동시에 발생했을 때 운이 좋지 않으면 사용자에게는 주문 취소 요청이 되었다고 응답될 것이고, 음식점은 주문 취소된지 모르고 수락하여 처리해버리는 사태가 발생할 수 있다.
📌 2. 분석
실제로 이러한 데이터 경쟁문제가 발생하는지 확인하는 과정이 필요했다.
그래서 아래와 같이 주문수락 요청 후 10초정도 대기하면서, 주문 취소요청을 수행하도록 하였다.
1. 판매점 계정으로 주문 수락 하기 -> 2. 주문 수락 요청 후 10초안에 주문 요청자 계정으로 주문 취소하기
@Transactional
public OrderStatus approveOrder(final Long orderId) {
final Order order = findExistingOrder(orderId);
try {
Thread.sleep(10000);
}catch (Exception e) {
}
order.approve();
orderRepository.save(order);
return order.getOrderStatus();
}
@Transactional
public OrderStatus cancelOrder(final Long orderId, Long requesterId) {
final Order order = findExistingOrder(orderId);
order.checkSameMember(requesterId);
order.cancel();
orderRepository.save(order);
return order.getOrderStatus();
}
주문 취소 요청은 성공적으로 응답받았다. 하지만 데이터베이스에서 해당 주문에 대한 레코드를 확인한 결과, 수문은 취소가 아닌 수락으로 변경되어있었다.
우선 해당 문제가 실제로 발생할 가능성이 있다는 것은 확인을 하였다.
주문 수락, 취소요청 같은 경우에는 수많은 사람들이 동시에 접근할 수 있는 데이터가 아니고, 해당 주문을 한 사람과 판매점 사이에서만 일어날 수 있는 경쟁상태이다.
그렇기 때문에 실제로 이러한 상황이 발생할 확률은 아주 낮겠지만, 배달 서비스에서 주문 수락과 취소와 같은 동작은 잘못 동작하면 안되는 중요한 사항일 것이므로 해당 문제가 발생하지 않도록 해결해야 한다.
📌 3. 문제 해결 접근
💡 접근1) Java 멀티쓰레드 환경에서 Lock메커니즘을 활용한 동시성제어
- 주문을 수락하는 메서드와 주문을 취소하는 메서드에 synchronized 키워드를 사용한다.
- 주문 수락과 주문 취소요청은 동시에 많은 요청이 들어오는 기능은 아니라고 판단하였다.
- 현재는 하나의 판매점(상점)을 기준으로 프로젝트를 진행 하였지만, 만약 수많은 판매점을 관리한다면 동시에 수많은 주문 수락 요청이 들어올 수 있기 때문에 이러한 상황에는 synchronized보다 더 효율적인 방법을 생각해야 할 것 같다.
고민사항1
트랜잭션의 커밋 시점과, 메서드가 끝나는 시점의 미세한 시간간격에서 동시성 문제가 발생할 수 있다.
예를들어 synchronized가 끝나는 시점과 트랜잭션 커밋이 일어나는 시점 사이에 취소요청이 처리가 되어버린다면 동시성 이슈가 발생할 가능성이 아직은 남아 있는 상태이다.
실제로 수락요청에서 스레드 sleep()을 통해 10초동안 기다리는 동안, 주문 취소요청이 들어오면 synchronized키워드를 사용해도 동시성 이슈가 발생하였다. (수락 요청이 Lock을 반환하고 나서, 취소요청이 수행되고, 수락요청의 트랜잭션이 커밋 되어버렸다)
해결
이를 해결하기 위하여 synchronized와 트랜잭션 커밋시점 사이에서의 동시성 이슈를 방지하기 위해서, 아래와 같이 메서드 시작전에 작은 시간간격을 두고 시작한다.
@Transactional
public synchronized OrderStatus cancelOrder(final Long orderId, Long requesterId) {
try {
Thread.sleep(100);
}catch (Exception e) {
}
final Order order = findExistingOrder(orderId);
order.checkSameMember(requesterId);
order.cancel();
orderRepository.save(order);
return order.getOrderStatus();
}
한계
하지만 실제 운영에서 sleep()을 사용하면서까지 데이터 정합성을 보장하는 것은 성능상 비효율적이라고 생각한다.
고민사항2
현재 프로젝트에서는 단일 서버에서 운영하지만, 만약 여러대의 서버로 구성되어있는 환경이라면 여러 프로세스에서 동작하기 때문에 synchronized 키워드로는 모두 해결할 수 없다.
💡 접근2) Lock()기능을 활용
잠금 기능에는 크게 비관적(Pessimistic = 선점)잠금과 낙관적(Optimistic=비선점)잠금이 있다.
고민사항1
비관적 락 적용해보기
@Lock(LockModeType.PESSIMISTIC_WRITE)을 아래와 같이 특정 주문을 조회하는 쿼리메서드에 적용하였다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT o FROM orders o JOIN FETCH o.memberEntity WHERE o.id = :orderId")
Optional<OrderEntity> findByIdWithMember(@Param("orderId") Long orderId);
하지만 데이터를 변경할 때 특정 행에 잠금이 걸려서, 특정 행에 대해 조회할때에도 잠금이 걸리는 것을 확인하였다.
나의 목적은 쓰기와 같은 동작에서만 잠금기능을 활성화하고, 조회할 때에는 잠금에 관계없이 데이터를 가져오고 싶었다.
해결
현재 서비스로직에서 특정 id를 조회하는 get메서드와, 특정 id의 주문을 수락, 취소하는 메서드 모두 같은 레포지토리의 같은 조회메서드를 사용하고 있다.
이것을 조회하는 쿼리메서드와 상태변경을 위한 쿼리메서드를 분리해주는 방식을 사용하였다.
@Query("SELECT o FROM orders o JOIN FETCH o.memberEntity WHERE o.id = :orderId")
Optional<OrderEntity> findByIdWithMember(@Param("orderId") Long orderId);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT o FROM orders o JOIN FETCH o.memberEntity WHERE o.id = :orderId")
Optional<OrderEntity> findByIdWithMemberForUpdate(@Param("orderId") Long orderId);
위와 같이 작성하여, 상태를 변경하는 서비스로직에서는 findByIdWithMemberForUpdate()를 호출하고, 조회만하는 기능의 서비스 로직에서는 findByIdWithMember()메서드를 호출한다.
@Lock을사용할 때에는 쿼리문에 for update가 붙는다.
주의사항
1. 비관적 잠금방식을 사용할 때에는 주의해야할 점이 있다. 바로 데드락에 빠지지 않도록 하는 것이다.
현재 프로젝트의 코드에서는 하나의 자원을 선점하면서 다른 자원을 기다리는 일 자체가 없어서 데드락이 발생하지 않을것이라고 예측할 수 있다.
- 데드락이 발생할 수 있는 경우에는, 최대 대기 시간을 지정하여 해결할 수 도 있다.(Spring Data Jpa의 @QuerytHints 키워드 참고 -> DBMS에 따라 대기시간을 지정할 수 있고 없고의 차이가 있음.)
2. 두번째로 주의해야할 사항이 있는데, 하나의 스레드에서 주문을 주회하고, 다른 스레드에서 주문 상태를 변경한다. 그리고 나서 다시 처음 스레드에서 아까전에 주문조회한 정보를 토대로 작업을 수행하는 경우에 문제가 발생할 수 있다. 즉, 데이터를 조회하고 작업중일 때, 데이터가 변경되어버리는 경우 문제가 생길 수 있다.
- 현재 프로젝트에서는 이러한 상황이 발생할 가능성이 있는 로직은 존재하지 않는다. 하지만 잠재적으로 에러가 발생할 수 있기 때문에 주의가 필요하다.
- 이러한 문제를 해결하기위해서는 낙관적 잠금 방식을 사용할 수 있다.
낙관적 잠금방식은 실제 데이터가 DBMS에 반영하는 시점에 변경 가능 여부를 확인하는 방법이다. 데이터에 버전필드를 추가하여 데이터 수정시 버전값도 함께 1씩올라가도록 하여 변경을 감지하는 방식이다.
- Jpa에서 @Version과 @Lock을 이용하여 낙관적 잠금방식을 적용할 수 있다.
- OPTIMISTIC옵션 : 트랜잭션 커밋시점에 버전정보를 확인한다. 즉, 트랜잭션 작업중에 데이터 변경이 일어나지 않음을 보장한다.
- 버전 충돌 발생 시, 트랜잭션 커밋시점에 OptimisticLockingFailureException이 발생한다.
// 엔티티에 버전필드를 추가해준다.
@Version
private Long version;
@Lock(LockModeType.OPTIMISTIC)
@Query("SELECT o FROM orders o JOIN FETCH o.memberEntity WHERE o.id = :orderId")
Optional<OrderEntity> findByIdWithMember(@Param("orderId") Long orderId);
만약 버전 충돌이 발생하여 예외가 발생한다면 아래처럼 구현해줄 수 있을 것 같다.
try {
orderService.changeOrderStatus(orderId);
} catch(OptimisticLockingFailureException ex) {
log("누군가 먼저 수정하였습니다")
}
낙관적 잠금 방식의 경우 주의할 점은 동시성 이슈가 자주 일어나는 로직에 적용하면 트랜잭션 롤백이 자주 일어나기 때문에 성능상 비관적 잠금 방식보다 성능이 안좋을 수 있다. 그러므로 동시성 이슈가 발생할 확률이 높을때에는 비관적 잠금방식을, 거의 발생하지 않는 경우네는 낙관적 방식을 많이 사용한다.
이제 주문 수락/취소 요청에서 수락 후 10초안에 취소요청을 했을 때, 이미 수락이 되어 취소 요청이 실패된 모습을 확인할 수 있다.
참고 : 프로젝트는 MySQL8.0기준으로 진행하였고, 사용하는 DBMS마다 조금씩 지원하는 방식이 다를 수 있다.
[참고자료]
도메인 주도 개발 시작하기(최범균) - https://product.kyobobook.co.kr/detail/S000001810495
'프로젝트 > 배달 REST API' 카테고리의 다른 글
[프로젝트] 비밀번호 암호화 적용하기 (0) | 2023.11.20 |
---|---|
[프로젝트] 예외처리하고 응답하기 (0) | 2023.11.16 |