운영자 스레드와 고객 스레드는 같은 주문 애그리거트를 나타내는 다른 객체를 구하게 된다. 운영자 스레드와 고객 스레드는 개념적으로 동일한 애그리거트이지만 물리적으로 서로 다른 애그리거트 객체를 사용한다.
위 그림과 같은 상황에서 두 스레드는 각각 트랜잭션을 커밋할 때 수정한 내용을 DB에 반영하는데, 상태가 서로 충돌되기 때문에 애그리거트의 일관성이 깨진다. 이 순서의 문제점은 운영자는 기존 배송지 정보를 이용해서 배송 상태로 변경했는데 그 사이 고객은 배송지 정보를 변경했다는 점이다. 즉 애그리거트의 일관성이 깨지는 것이다. 이런 문제가 발생하지 않도록 하려면 다음 두 가지 중 하나를 해야한다.
- 운영자 배송지 정보를 조회하고 상태를 변경하는 동안 고객이 애그리거트를 수정하지 못하게 막는다.
- 운영자가 배송지 정보를 조회한 이후 고객이 배송지 정보를 변경하면 운영자가 애그리거트를 다시 조회한 뒤 수정하도록 한다.
이 두 가지는 애그리거트 자체의 트랜잭션과 관련이 있는데, 이를 구현하기 위해선 DBMS가 지원하는 트랜잭션과 함께 애그리거트를 위한 추가적인 트랜잭션 처리 기법이 필요하다. 애그리거트에 대해 사용할 수 있는 대표적인 트랜잭션 처리 방식에는 선점 잠금과 비선점 잠금의 두 가지 방식이 있다.
선점 잠금은 먼저 애그리거트를 구한 스레드가 애그리거트 사용이 끝날 때까지 다른 스레드가 해당 애그리거트를 수정하는 것을 막는 방식이다. (Pessimistic Lock)
선점 잠금을 사용하면 스레드1이 애그리거트를 구한 뒤 이에서 스레드2가 같은 애그리거트를 구하고 있는데, 이 경우 스레드2는 스레드1이 애그리거트에대한 잠금을 해제할 때 까지 블로킹된다.
스레드1이 애그리거트를 수정하고 트랜잭션을 커밋하면 잠금을 해제한다. 이 순간 대기하고 있던 스레드2가 애그리거트에 접근하게 된다. 스레드1이 트랜잭션을 커밋 뒤에 스레드2가 애그리거트를 구하게 되므로 스레드2는 스레드1이 수정한 애그리거트의 내용을 보게된다.
한 스레드가 애그리거트를 구하고 수정하는 동안 다른 스레드가 수정할 수 없음므로 동시에 애그리거트를 수정할 때 발생하는 데이터 충돌 문제를 해소할 수 있다.
선점 잠금은 보통 DBMS가 제공하는 행 단위 잠금을 사용해서 구현한다. 오라클을 비롯한 다수 DBMS가 for update와 같은 쿼리를 사용해서 특정 레코드에 한 사용자만 접근할 수 있는 잠금 장치를 제공한다.
JPA의 EntityManager는 LockModeType을 인자로 받는 find() 메서드를 제공하는데, LockModeType.PESSIMISTIC_WRITE
를 값으로 전달하면 해당 엔티티와 매핑된 테이블을 이용해서 선점 잠금 방식을 적용할 수 있다.
Order order = entityManager.find(Order.class, orderNo, LockModeType.PESSIMISTIC_WRITE)
JPA 프로바이더와 DBMS에 따라 잠금 모드의 구현이 다른데, 하이버네티으의 경우 PESSIMISTIC_WRITE를 잠금 모드로 사용하면 for update 쿼리를 사용해서 선점 잠금을 구현한다.
선점 잠금 기능을 사용할 떄는 잠금 순서에 따른 교착 상태가 발생하지 않도록 주의해야 한다. 예를 들어, 다음과 같은 순서로 두 스레드가 선점 잠금을 시도를 한다고 해보자.
스레드 1 : A 애그리거트에 대한 선점 잠금 구함
스레드 2 : B 애그리거트에 대한 선점 잠금 구함
스레드 1 : B 애그리거트에 대한 선점 잠금 시도
스레드 2 : A 애그리거트에 대한 선점 잠금 시도
이 두 스레드는 상대방 스레드가 먼저 선점한 잠금을 구할수 없어 더 이상 다음 단계를 진행하지 못하게 된다. 즉 스레드 1과 스레드 2는 교착상태에 빠지게 된다.
선점 잠금에 따른 교착 상태는 상대적으로 사용자 수가 많을 때 발생할 가능성이 높고, 사용자 수가 많아지면 교착 상태에 빠지는 스레드가 더 빠르게 증가하게 된다. 더 많은 스레드가 교착 상태에 빠질수록 시스템은 점점 아무것도 할 수 없는 상황에 이르게 된다.
이런 문제가 발생하지 않도록 하려면 잠금을 구할 때 최대 대기 시간을 지정해야한다. JPA에서 선점 잠금을 시도할 때 최대 대기 시간을 지정하려면 다음과 같이 힌트를 사용하면 된다.
Map<String, Object> hints = new HashMap<>();
hints.put("javax.persistence.lock.timeout", 2000);
Order order = entityManager.find(Order.class, orderNo, LockModeType.PESSIMISTIC_WRITE, hints);
JPA의 javax.persistence.lock.timeout 힌트는 잠금을 구하는 대기 시간을 밀리초 단위로 지정한다. 지정한 시간이내에 잠금을 구하지 못하면 익셉션을 발생 시킨다.
DBMS에 따라 힌트가 적용되지 않을 수 있기 때문에 관련 기능을 지원하는지 확인해야 한다.
스프링 데이터 JPA는 @QueryHints 어노테이션을 사용해 쿼리 힌트를 지정할 수 있다.
비선점 잠금은 직접적으로 잠금하지 않고 버전을 통해 트랜잭션 결과의 정합성을 지키는 것이다. (Optimistic Lock)
두 요청이 동시에 들어와서 정보를 수정했다면, 선점잠금은 마지막에 끝나는 트랜잭션의 결과가 남는 반면에 비선점 잠금은 먼저 끝난 트랜잭션의 결과가 유지된다.
JPA는 @Version 어노테이션을 사용해 비선점 잠금 기능을 구현할 수 있다.
@Controller
public class OrderAdminController {
private StartShippingService startShippingService;
@RequestMapping(value = "/startShipping", method = RequestMethod.POST)
public String startShipping(StartShippingRequest startReq) {
try {
startShippingService.startShipping(startReq);
return "shippingStarted";
} catch(OptimisticLockingFailureException | VersionConflicException ex) {
// 트랜잭션 충돌
return "startShippingTxConflict";
}
}
...
다음 코드는 스프링 프레임워크가 발생시키는 OptimisticLockingFailureException과 응용 서비스에서 발생시키는 VersionConflicException을 처리하고 있다.
VersionConflicException
은 이미 누군가가 애그리거트를 수정했다는 것을 의미하고, OptimisticLockingFailureException
은 누군가가 거의 동시에 수정했다는 것을 의미한다.
JPA는 애그리거트 루트가 아닌 다른 엔티티가 변경되었을 때 루트 엔티티 자체의 값은 바뀌지 않으므로 버전 값을 갱신하지 않는다.
하지만 애그리거트 관점에서 보았을 때 애그리거트의 구성요소가 바뀌면 논리적으로 애그리거트도 바뀐 것이다.
이러한 경우에 버전을 올려주고 싶다면 LockModeType.OPTIMISTIC_FORCE_INCREMENT
옵션을 사용할 수 있다.
참고