Skip to content

선점 잠금과 비선점 잠금

image

운영자 스레드와 고객 스레드는 같은 주문 애그리거트를 나타내는 다른 객체를 구하게 된다. 운영자 스레드와 고객 스레드는 개념적으로 동일한 애그리거트이지만 물리적으로 서로 다른 애그리거트 객체를 사용한다.

위 그림과 같은 상황에서 두 스레드는 각각 트랜잭션을 커밋할 때 수정한 내용을 DB에 반영하는데, 상태가 서로 충돌되기 때문에 애그리거트의 일관성이 깨진다. 이 순서의 문제점은 운영자는 기존 배송지 정보를 이용해서 배송 상태로 변경했는데 그 사이 고객은 배송지 정보를 변경했다는 점이다. 즉 애그리거트의 일관성이 깨지는 것이다. 이런 문제가 발생하지 않도록 하려면 다음 두 가지 중 하나를 해야한다.

  • 운영자 배송지 정보를 조회하고 상태를 변경하는 동안 고객이 애그리거트를 수정하지 못하게 막는다.
  • 운영자가 배송지 정보를 조회한 이후 고객이 배송지 정보를 변경하면 운영자가 애그리거트를 다시 조회한 뒤 수정하도록 한다.

이 두 가지는 애그리거트 자체의 트랜잭션과 관련이 있는데, 이를 구현하기 위해선 DBMS가 지원하는 트랜잭션과 함께 애그리거트를 위한 추가적인 트랜잭션 처리 기법이 필요하다. 애그리거트에 대해 사용할 수 있는 대표적인 트랜잭션 처리 방식에는 선점 잠금비선점 잠금의 두 가지 방식이 있다.


선점 잠금

선점 잠금은 먼저 애그리거트를 구한 스레드가 애그리거트 사용이 끝날 때까지 다른 스레드가 해당 애그리거트를 수정하는 것을 막는 방식이다. (Pessimistic Lock)

image

선점 잠금을 사용하면 스레드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 옵션을 사용할 수 있다.


참고

https://cheese10yun.github.io/transaction-lcok/

https://github.com/softpeanut/dul-dul-dul/blob/main/도메인%20주도%20개발%20시작하기/Chapter08.%20애그리거트%20트랜잭션%20관리/01.%20애그리거트와%20트랜잭션.md