https://www.notion.so/8-85ff9c1d45774f8e9ce7177a61d6fad9
애그리것과 트랜잭션
- 한 주문 애그리것에 대해 운영자는 배송 상태로 변경할 때 사용자는 배송지 주소를 변경하면 어떻게 될까 ?
출처: https://incheol-jung.gitbook.io/docs/study/ddd-start/8
- 한 애그리것을 두 사용자가 동시에 변경할 때 트랜잭션이 필요하다.
- 메모리 캐시를 사용하지 않을 경우 운영자 스레드와 고객 스레드는 같은 주문 애그리것을 나타내는 다른 객체를 구하게 된다. ( 트랜잭션마다 리포지터리는 새로운 액릐것 객체를 생성한다. )
- 운영자스레드와 고객스레드는 개념적으로 동일한 애그리것이지만 물리적으로는 서로 다른 애그리것 객체를 사용한다.
- 떄문에 운영자 스레드가 주문 애그리것 객체를 배송 상태로 변경하더라도 고객 스레드가 사용하는 주문애그리것 객체에는 영향을 주지 않는다.
- 고객스레드 입장에서 주문 애그리것 객체는 아직 배송 상태 전이므로 배송 정보를 변경할 수 있다.
- 이 상황에서 두 스레드는 각각 트랜잭션을 커밋할 때 수정한 내용을 DBMS에 반영한다.
- 즉 배송상태로 바뀌고 배송지 정보도 바뀌게 된다.
- 이 순서의 문제점은 운영자는 기존 배송지 정보를 이용해서 배송상태로 변경했는데 그사이 고객은 배송지 정보를 변경했다는 점이다. 즉 애그리것의 일관성이 깨지는 것이다.
- 이 문제를 방지하려면 두가지 중 하나를 해야 한다.
- 운영자가 배송지 정보를 조회하고 상태 변경하는 동안 고객이 애그리것을 수정하지 못하게 막는다.
- 운영자가 배송지 정보를 조회한 후에 고객이 정보를 변경하면 운영자가 애그리것을 다시 조회한 뒤 수정 하도록 한다.
- 대표적인 트랜잭션 처리 기법에는 선점 잠금과 비선점 잠금의 두가지 방식이 있다.
- 선점 잠금을 비관적 잠금, 비선점 잠금을 낙관적 잠금 이라고 표현하기도 한다.
선점 잠금
- 선점 잠금은 먼저 애그리것을 구한 스레드가 애그리것 사용이 끝날 때 까지 다른 스레드가 해당 애그리것을 수정하는 것을 막는 방식이다.
출처: 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: A 애그리것에 대한 선점 잠금 구함
- 스레드2: B 애그리것에 대한 선점 잠금 구함
- 스레드1: B 애그리것에 대한 선점 잠금 시도
- 스레드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번에서 조회한 주문 정보를 기준으로 배송지를 정하고 배송 상태 변경을 요청한다.
- 여기서 문제는 운영자가 배송지 정보를 조회하고 배송 상태로 변경하는 사이에 고객이 배송지를 변경한다는 것이다.
- 운영자는 고객이 변경하기 전의 배송지 정보를 이용해서 배송 준비를 한 뒤에 배송 상태로 변경하게 된다.
- 즉 배송상태 변경전에 배송지를 한번 더 확인하지 않으면 운영자는 다른배송지로 물건을 발송하게 되고 고객은 배송지를 변경했음에도 불구하고 엉뚱한 곳으로 주문한 물건을 받는 상황이 발생한다.
- 이 문제는 선점 잠금 방식으로는 해결할 수 없다.
- 이때 필요한것이 비선점 잠금이다. ( 낙관적 잠금 )
- 비선점 잠금 방식은 잠금을 해서 동시에 접근하는것을 막는 대신 변경한 데이터를 실제 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 는 스프링에서 발생시킨다.
- 버전 충돌상황에 대한 구분이 명시적으로 필요 없다면 응용 서비스에서 프레임워크용 익셉션을 발생시켜도 된다.
'book > DDD start' 카테고리의 다른 글
10장 이벤트 (0) | 2021.07.28 |
---|---|
8장 애그리것 트랜잭션 관리 2 (0) | 2021.07.07 |
9장 도메인 모델과 바운디드 컨텍스트 2부 (0) | 2021.06.30 |
9장 도메인 모델과 바운디드 컨텍스트 1부 (0) | 2021.06.30 |
7장 도메인 서비스 (0) | 2021.06.22 |
Uploaded by Notion2Tistory v1.1.0