https://www.notion.so/8-85ff9c1d45774f8e9ce7177a61d6fad9

 

 

애그리것과 트랜잭션

  • 한 주문 애그리것에 대해 운영자는 배송 상태로 변경할 때 사용자는 배송지 주소를 변경하면 어떻게 될까 ?

 

출처: https://incheol-jung.gitbook.io/docs/study/ddd-start/8

  • 한 애그리것을 두 사용자가 동시에 변경할 때 트랜잭션이 필요하다.

 

  • 메모리 캐시를 사용하지 않을 경우 운영자 스레드와 고객 스레드는 같은 주문 애그리것을 나타내는 다른 객체를 구하게 된다. ( 트랜잭션마다 리포지터리는 새로운 액릐것 객체를 생성한다. )

 

  • 운영자스레드와 고객스레드는 개념적으로 동일한 애그리것이지만 물리적으로는 서로 다른 애그리것 객체를 사용한다.
  • 떄문에 운영자 스레드가 주문 애그리것 객체를 배송 상태로 변경하더라도 고객 스레드가 사용하는 주문애그리것 객체에는 영향을 주지 않는다.
  • 고객스레드 입장에서 주문 애그리것 객체는 아직 배송 상태 전이므로 배송 정보를 변경할 수 있다.

 

  • 이 상황에서 두 스레드는 각각 트랜잭션을 커밋할 때 수정한 내용을 DBMS에 반영한다.
  • 즉 배송상태로 바뀌고 배송지 정보도 바뀌게 된다.
  • 이 순서의 문제점은 운영자는 기존 배송지 정보를 이용해서 배송상태로 변경했는데 그사이 고객은 배송지 정보를 변경했다는 점이다. 즉 애그리것의 일관성이 깨지는 것이다.

 

  • 이 문제를 방지하려면 두가지 중 하나를 해야 한다.
    1. 운영자가 배송지 정보를 조회하고 상태 변경하는 동안 고객이 애그리것을 수정하지 못하게 막는다.
    1. 운영자가 배송지 정보를 조회한 후에 고객이 정보를 변경하면 운영자가 애그리것을 다시 조회한 뒤 수정 하도록 한다.
  • 대표적인 트랜잭션 처리 기법에는 선점 잠금과 비선점 잠금의 두가지 방식이 있다.
  • 선점 잠금을 비관적 잠금, 비선점 잠금을 낙관적 잠금 이라고 표현하기도 한다.

 

선점 잠금

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

출처: https://incheol-jung.gitbook.io/docs/study/ddd-start/8

 

  • 쓰레드1이 선점 잠금 방식으로 애그리것을 구한 뒤 이어서 쓰레드2가 애그리것을 구하고 있는데 이경우 쓰레드2는 쓰레드1이 애그리것에 대한 잠금을 해제할때까지 블로킹 된다.

 

  • 쓰레드1이 애그리것을 수정하고 트랜잭션을 커밋하면 잠금을 해제 한다.
  • 이 순간 대기하고 있던 쓰레드2가 애그리것에 접근하게 된다.
  • 쓰레드2는 이미 쓰레드1이 트랜잭션을 커밋한 뒤에 애그리것을 구하게 되므로 쓰레드2는 쓰레드1이 수정한 애그리것의 내용을 보게 된다.

 

  • 쓰레드1이 수정하는 동안 다른 쓰레드가 애그리것을 구할수 없으므로 동시에 애그리것을 수정할때 발생하는 데이터 충돌 문제를 해소할수 있다.

 

  • 선점잠금은 보통 DBMS가 제공하는 행 단위의 잠금을 사용해서 구현한다.
  • 오라클을 비롯한 다수 DBMS가 for update와 같은 쿼리를 사용해서 특정 레코드에 한 사용자만 접근할 수 있는 잠금 장치를 제공한다.

 

  • JPA의 EntityManager는 LockModeType을 인자로 받는 find() 메서드를 제공하는데, LockModeType.PESSIMISTIC_WRITE를 값으로 전달하면 해당 엔티티와 매핑된 테이블을 이용해서 선점 잠금 방식을 적용할 수 있다

 

선점 잠금과 교착 상태

  • 선점 잠금 기능을 사용할 떄는 잠금 순서에 따른 교착 상태 ( Dead Lock )이 발생하지 않도록 주의 해야 한다.
  • 예를 들어 다음과 같은 순서로 두 스레드가 잠금 시도를 한다고 해보자.
    1. 스레드1: A 애그리것에 대한 선점 잠금 구함
    1. 스레드2: B 애그리것에 대한 선점 잠금 구함
    1. 스레드1: B 애그리것에 대한 선점 잠금 시도
    1. 스레드2: A 애그리것에 대한 선점 잠금 시도
  • 이 순서에 따르면 스레드1은 영원히 B 애그리것에 대한 선점 잠금을 구할 수 없다.
  • 왜냐면 스레드2가 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.PRESSIMISTIC_WRITE, hints);

 

  • 지정한 시간 이내에 잠금을 구하지 못하면 익셉션을 발생 시킨다.
  • 이 힌트를 사용할 떄 주의할 점은 DBMS에 따라 힌트가 적용되지 않는다는 것이다.
  • 이 힌트를 이용할 떄에는 사용 중인 DBMS가 관련 기능을 지원하는지 확인해야 한다.

 

  • DBMS에 따라 교체 상태에 빠진 커넥션을 처리하는 방식이 다르다.
  • 쿼리별로 대기 시간을 지정할수 있는 DBMS가 있고 커넥션 단위로만 대기 시간을 지정할 수 있는 DBMS도 있다.
  • 따라서 선점 잠금을 사용하려면 사용하고 있는 DBMS에 대해 JPA가 어떤 식으로 대기 시간을 처리하는지 반드시 확인해야 한다.

 

비선점 잠금

  • 선점잠금이 강력해 보이지만 선점 잠금으로 모든 트랜잭션 충돌문제가 해결되는것은 아니다.

출처: https://incheol-jung.gitbook.io/docs/study/ddd-start/8

감사합니다 인철이형님 [그림 8.4]

 

  • 실행 순서는 다음과 같다.
    1. 운영자는 배송을 위해 주문 정보를 조회 한다. 시스템은 정보를 제공한다.
    1. 고객이 배송지 변경을 위해 변경 폼을 요청한다. 시스템은 변경 폼을 제공한다.
    1. 고객이 새로운 배송지를 입력하고 폼을 전송해서 배송지를 변경한다.
    1. 운영자가 1번에서 조회한 주문 정보를 기준으로 배송지를 정하고 배송 상태 변경을 요청한다.
  • 여기서 문제는 운영자가 배송지 정보를 조회하고 배송 상태로 변경하는 사이에 고객이 배송지를 변경한다는 것이다.
  • 운영자는 고객이 변경하기 전의 배송지 정보를 이용해서 배송 준비를 한 뒤에 배송 상태로 변경하게 된다.
  • 즉 배송상태 변경전에 배송지를 한번 더 확인하지 않으면 운영자는 다른배송지로 물건을 발송하게 되고 고객은 배송지를 변경했음에도 불구하고 엉뚱한 곳으로 주문한 물건을 받는 상황이 발생한다.
  • 이 문제는 선점 잠금 방식으로는 해결할 수 없다.
  • 이때 필요한것이 비선점 잠금이다. ( 낙관적 잠금 )
  • 비선점 잠금 방식은 잠금을 해서 동시에 접근하는것을 막는 대신 변경한 데이터를 실제 DBMS에 반영하는 시점에 변경 가능 여부를 확인하는 방식이다.
  • 비선점 잠금을 구현하려면 애그리것에 버전으로 사용할 숫자 타입의 프로퍼티를 추가해야 한다.
  • 애그리것을 수정할 때마다 버전으로 사용할 프로퍼티의 값이 1씩 증가하는데 이때 다음과 같은 쿼리를 사용한다.
UPDATE aggtable SET version = version + 1, colx = ?, coly = ?
WHERE aggid = ? and version = 현재버전
  • 이 쿼리는 수정할 애그리것과 매핑되는 테이블의 버전값이 현재 애그리것의 버전과 동일한 경우에만 데이터를 수정한다.
  • 그리고 수정에 성공하면 버전값을 1 증가시킨다.
  • 따라서 다른 트랜잭션이 먼저 데이터를 수정해서 버전값이 바뀌면 데이터 수정에 실패하게 된다.

 

 

출처: https://incheol-jung.gitbook.io/docs/study/ddd-start/8

 

  • 스레드1과 스레드2는 같은 버전을 갖는 애그리것을 읽어와 수정하고 있다.
  • 스레드1이 먼저 커밋시도, 이 시점에 애그리것의 버전은 5지만 스레드1이 수정에 성공하고 버전은 6이 된다.
  • 스레드1이 트랜잭션을 커밋한 후에 스레드2가 커밋을 시도하는데 이미 애그리것 버전이 6이므로 스레드2는 데이터 수정에 실패하게 된다.

 

  • JPA는 버전을 이용한 비선점 잠금 기능을 지원한다.
@Entity
@Table(name = "purchage_order")
@Access(AccessType.FIELD)
public class Order {
	@EmbeddedId
	private OrderNo number;

	@Version
	private long version;
	
	...
}

 

  • JPA는 엔티티가 변경되어 UPDATE 쿼리를 실행할때 @Version에 명시한 필드를 이용해서 비선점 잠금 쿼리를 실행한다.
  • 즉 애그리것 객체의 버전이 10이면 UPDATE쿼리를 실행할때 아래와 같은 쿼리를 실행한다
UPDATE purchage_order SET ..., version = version + 1
	WHERE number = ? and version = 10

 

  • 응용서비스는 버전에 대해 알 필요가 없다.
  • 리포지토리에서 필요한 애그리것을 구하고 알맞은 기능만 실행하면 된다.
  • 기능을 실행하는 과정에서 애그리것의 데이터가 변경되면 JPA는 트랜잭션 종료 시점에 비선점 잠금을 위한 쿼리를 실행한다.
public class ChangeShippingService {
    private OrderRepository orderRepository;

    @Transactional
    public void changeShipping(ChangeShippingRequest changeReq) {
        Order order = orderRepository.findById(new OrderNo(changeReq.getNumber()));
        checkNoOrder(order);
        order.changeShippingInfo(changeReq.getShippingInfo());
    }
		...
}

 

  • 비선점 잠금을 위한 쿼리를 실행할 때 쿼리 실행 결과로 수정된 행의 개수가 0이면 이미 누군가 앞서 데이터를 수정한 것이다.
  • 이는 트랜잭션이 충돌한 것이므로 트랜잭션 종료 시점에 익셉션이 발생한다.
  • 위 코드의 경우 스프링의 @Transactional을 이용해서 트랜잭션 범위를 정했으므로 changeShipping() 메서드가 리턴될때 트랜잭션이 종료되고 이시점에 트랜잭션 충돌이 발생하면 OptimisticLockingFailureException을 발생시킨다.

 

  • 표현 영역의 코드는 이 익셉션의 발생 여부에 따라 트랜잭션 충돌이 일어났는지 확인할 수 있다.
@Controller
public class OrderController {
	...
	@RequestMapping(value = "/changeShipping", method = RequestMethod.POST)
	public String changeShipping(ChangeShippingRequest changeReq) {
		try {
			changeShippingService.changeShipping(changeReq);
			return "changeShippingSuccess";
		} catch(optimisticLockingFailureException ex) {
				// 누군가 먼저 같은 주문 애그리거트를 수정했으므로, 
				// 트랜잭션 충돌이 일어났다는 메시지를 보여준다. 
				return "changeShippingExConflic";
		}
}

 

  • 비선점 잠금을 확장해서 적용할 수 있다.
  • 시스템은 사용자에게 수정 폼을 제공할 때 애그리것 버전을 함꼐 전송하고 사용자가 폼을 전송할 때와 폼을 생성할 때 사용한 애그리것 버전을 함께 전송하도록 할 수 있다.
  • 시스템을 애그리것을 수정할 때 사용자가 전송한 버전과 애그리것 버전이 동일한 경우에만 수정 기능을 수행하도로 함으로써 트랜잭션 충돌문제를 해소할 수 있다.

 

출처: https://incheol-jung.gitbook.io/docs/study/ddd-start/8

 

  • 위의 과정2에서 운영자는 배송 상태 변경을 요청할 때 앞서 과정 1을 통해 받은 애그리것의 버전값을 함께 전송한다.
  • 시스템은 애그리것을 읽는데 해당 시점의 버전 값도 함께 읽어온다. 만약 과정 1에서 받은 버전 A와 과정 2.1을 통해 읽은 애그리것의 버전 B가 다르면 과정 1과 과정 2 사이에 다른 사용자가 해당 애그리것을 수정한 것이다.
  • 이 경우 시스템은 운영자가 이전 데이터를 기준으로 작업을 요청한 것으로 간주하여 과정 2.1.2와 같이 수정할 수 없다는 에러를 응답으로 전송한다.
  • 만약 A와 B의 버전이 같다면 과정 1과 과정 2 사이에 아무도 애그리것을 수정하지 않은것이므로 이 경우 시스템은 과정 2.1.3과 같이 애그리것을 수정하고 과정 2.1.4를 이용해서 변경 내용을 DBMS에 반영한다.
  • 과정 2.1.1과 과정 2.1.4사이에 아무도 애그리것을 수정하지 않았다면 커밋에 성공하므로 성공 결과를 응답으로 전송한다.
  • 반면에 과정 2.1.1과 과정 2.1.4사이에 누군가 애그리것을 수정해서 커밋했다면 버전값이 증가한 상태가 되므로 트랜잭션 커밋에 실패하고 결과로 에러 응답을 전송한다.

 

  • 위와 같이 비선점 잠금 방식을 여러 트랜잭션으로 확장하려면 애그리것 정보를 뷰로 보여줄때 버전 정보도 함께 사용자 화면에 전달해야 한다.
  • HTML 폼을 생성하는 경우 버전 값을 갖는 hidden 타입 (input) 태그를 생성해서 폼 전송 시 버전 값이 서버에 함께 전달되도록 한다.

 

 

  • 사용자 요청을 처리하는 응용서비스를 위한 요청 데이터는 사용자가 전송한 버전값을 포함한다.
  • 예를 들어, 배송 상태 변경을 처리하는 응용 서비스가 전달받는 데이터는 다음과 같이 주문 번호와 함께 해당 주문을 조회한 시점의 버전 값을 포함해야 한다.
public class StartShippingRequest {
    private String orderNumber;
    private long version;

    protected StartShippingRequest() {}

    public StartShippingRequest(String orderNumber, long version) {
        this.orderNumber = orderNumber;
        this.version = version;
    }

    public String getOrderNumber() {
        return orderNumber;
    }

    public long getVersion() {
        return version;
    }
}

 

  • 응용서비스는 전달받은 버전 값을 이용해서 애그리것의 버전과 일치하는지 확인하고 일치하는 경우에만 요청한 기능을 수행한다.
@Service
public class StartShippingService {
    private OrderRepository orderRepository;

    @Transactional
    public void startShipping(StartShippingRequest req) {
        Order order = orderRepository.findById(new OrderNo(req.getOrderNumber()));
        checkNoOrder(order);
        if (!order.matchVersion(req.getVersion())) {
//            throw new VersionConflictException();
            throw new OptimisticLockingFailureException("version conflict");
        }
        order.startShipping();
    }

    @Autowired
    public void setOrderRepository(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }
}

 

  • 버전이 맞지않으면 표현 계층으로 exception 을 전달한다.

 

@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 | VersionConflictException ex) {
			// 트랜잭션 충돌
			return "startShippingTxConflict";
		}
	}
	...
  • 표현 계층에서 익셉션을 받아 처리한다.
  • VersionConflictException은 응용 서비스코드에서 발생시키고 OptimisticLockingFailureException 는 스프링에서 발생시킨다.
  • 버전 충돌상황에 대한 구분이 명시적으로 필요 없다면 응용 서비스에서 프레임워크용 익셉션을 발생시켜도 된다.

 

 

https://www.notion.so/8-85ff9c1d45774f8e9ce7177a61d6fad9

https://www.notion.so/8-85ff9c1d45774f8e9ce7177a61d6fad9

+ Recent posts