8. 애그리거트 트랜잭션 관리

안성은·2023년 1월 2일
0

DDD

목록 보기
2/2
post-thumbnail

1. 애그리거트와 트랜잭션

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

한 애거리거트를 두 사용자가 거의 동시에 변경할 때 트랜잭션이 필요한 상황

문제 상황
운영자는 기존 배송지 정보를 이용해서 배송 상태로 변경했는데 그 사이에 고객은 배송지 정보를 변경하는 상황. 즉, 애그리거트의 일관성이 깨지는 상황

문제 해결 방법
1. (선점 잠금 방식)
운영자가 배송지 정보를 조회하고 상태를 변경하는 동안 고객이 애그리거트를 수정하지 못하게 막는다.
2. (비선점 잠금 방식)
운영자가 배송지 정보를 조회한 이후에 고객이 정보를 변경하면 운영자가 애그리거트를 다시 조회한 뒤 수정하도록 한다.
=> 애그리거트에 대해 사용할 수 있는 트랜잭션 처리 방식에 선점 잠금과 비선점 잠금의 방식 존재

2. 선점 잠금

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

스레드1이 선점 잠금 방식으로 애그리거트를 구한 뒤 이어서 스레드2가 같은 애그리거트를 구하고 있는데, 이 경우 스레드2는 스레드1이 애그리거트에 대한 잠금을 해제할 때까지 블로킹된다.
=> 한 스레드가 수정하는 동안 다른 스레드가 수정할 수 없어서 데이터 충돌 문제 해소

Order order = entityManager.find(Order.class, orderNo, LockModeType.PESSIMISTIC_WRITE);

하이버네이트의 경우 PESSIMISTIC_WRITE를 잠금 모드로 사용하면 for update 쿼리를 사용해서 선점 잠금을 구현한다. "for update"와 같은 쿼리를 사용해서 특정 레코드에 한 사용자만 접근할 수 있는 잠금 장치를 제공한다.

선점 잠금과 교착상태

선점 잠금 기능을 사용할 때는 잠금 순서에 따른 교착 상태(deadlock)가 발생하지 않도록 주의해야한다.

1. 스레드1 : A 애그리거트에 대한 선점 잠금 구함
2. 스레드2 : B 애그리거트에 대한 선점 잠금 구함
3. 스레드1 : B 애그리거트에 대한 선점 잠금 시도
4. 스레드2 : A 애그리거트에 대한 선점 잠금 시도

스레드1이 A의 Lock을 가지고 있고, 스레드2가 B의 Lock을 가지고 있는 상태에서 스레드1이 B의 Lock을 원하고 스레드2가 A의 Lock을 원하는 상태
=> Deadlock

선점 잠금에 따른 교착 상태는 상대적으로 사용자 수가 많을 때 발생할 가능성이 높고, 사용자 수가 많아지면 교착 상태에 빠지는 스레드가 더 빠르게 증가하게 된다. 때문에 잠금을 구할 때 최대 대기 시간을 지정해야 하며, JPA에서 선점 잠금을 시도할 때 최대 대기 시간을 지정하려면 힌트를 사용해서 지정할 수 있다.

Map<String, Object> hints = new HashMap<>();
hints.put("javax.persistence.lock.timeout", 2000);
Order order = entityManager.find(Order.class, orderNo, LockModeType.PESSIMISTIC_WRITE, hint)

주의사항 : DBMS에 따라 힌트가 적용되지 않을 수 있다.

3. 비선점 잠금

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

여기서 문제는 운영자가 배송지 정보를 조회하고 배송 상태로 변경하는 사이에 고객이 배송지를 변경한다는 것이다. 운영자는 고객이 변경하기 전의 배송지 정보를 이용해서 배송 준비를 한 뒤에 배송 상태로 변경하게 된다.

=> 선점 잠금 방식으로 해결하려면 운영자가 주문 정보 조회를 할 때 선점을 해서 고객이 주문 정보 변경 폼 요청 자체를 못하게 막아야한다.

=> 비선점 잠금 방식은 잠금을 해서 동시에 접근하는 것을 막는 대신 변경한 데이터를 실제 DBMS에 반영하는 시점에 변경 가능 여부를 확인하는 방식

UPDATE aggtable SET version = version + 1, colx = ?, coly = ?
WHERE aggid = ? and version = 현재 버전
이 쿼리는 수정할 애그리거트와 매핑되는 테이블의 버전 값이 현재 애그리거트의 버전과 
동일한 경우에만 데이터를 수정한다. 그리고 성공하면 버전 값을 1 증가시킨다.

[비선점 잠금을 이용한 트랜잭션 충돌방지]

@Entity
@Table(name = "purchase_order")
@Access(AccessType.FIELD)
public class Order {
	@EmbeddedId
    private OrderNo number;
	@Version
    private long version;
}
JPA는 버전을 이용한 비선점 잠금 기능을 지원하고 버전으로 사용할 필드에 @Version 어노테이션을 붙이고 
매핑되는 테이블에 버전을 저장할 칼럼을 추가하기만 하면된다.

=> 비선점 잠금을 이용하면 버전이 달라서 롤백되었을 경우 다시 처음부터 정보를 입력해야 한다는 단점이 존재한다고 생각한다.

표현 영역의 코드는 이 익셉션의 발생 여부에 따라 트랜잭션 충돌이 일어났는지 확인할 수 있다.

@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";
		}
}

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

비선점 잠금 방식을 여러 트랜잭션으로 확장하려면 애그리거트 정보를 뷰로 보여줄 때 버전 정보도 함께 사용자 화면에 전달해야 한다. 사용자 요청을 처리하는 응용 서비스를 위한 요청 데이터는 사용자가 전송한 버전값을 포함한다. 응용 서비스는 전달받은 버전 값을 이용해서 애그리거트의 버전과 일치하는지 확인하고 일치하는 경우에만 요청한 기능을 수행한다. 표현 계층은 버전 충돌 익셉션이 발생하면 버전 충돌을 사용자에게 알려주고 사용자가 알맞은 후속 처리를 할 수 있도록 한다.

@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";
		}
}
버전 충돌 상황에 대한 구분이 명시적으로 필요 없다면 응용 서비스에서 예외 처리를 해도된다.   

강제 버전 증가

애그리거트에 애그리거트 루트 외에 다른 엔티티가 존재하는 기능 실행 도중 루트가 아닌 다른 엔티티의 값만 변경된다고 하면 JPA는 루트 엔티티의 버전 값을 증가하지 않는다.

=> 루트 엔티티의 값은 변경되지 않았지만 애그리거트의 구성 요소 중 일부 값이 바뀌면 논리적으로 그 애그리거트는 바뀐 것이다. 따라서, 애그리거트 내에 어떤 구성요소의 상태가 바뀌면 루트 애그리거트의 버전 값을 증가해야 비선점 잠금이 올바르게 동작한다.

@Repository
public class JpaOrderRepository implements OrderRepository {
	@PersistenceContext
	private EntityMangager entityManager;

	@Override
	public Order findbyIdOptimisticLockMode(OrderNo id) {
		return entityManager.find(Order.class, id
				LockModeType.OPTIMISTTIC_FORCE_INCREMENT);
	}
}
LockModeType.OPTIMISTIC_FORCE_INCREMENT을 사용하면 해당 엔티티의 상태가 변경되었는지
여부에 상관없이 트랜잭션 종료 시점에 버전 값 증가 처리를 한다., 애그리거트 루트 엔티티가
아닌 다른 엔티티나 밸류가 변경되더라도 버전 값을 증가시킬 수 있으므로 비선점 잠금 기능을 
안전하게 적용할 수 있다.

4. 오프라인 선점 잠금

더 엄격하게 데이터 충돌을 막고 싶다면 누군가 수정화면을 보고 있을 경우 수정 화면 자체를 실행하지 못하도록 해야 한다. 한 트랜잭션 범위에서만 적용되는 선점 잠금 방식이나 나중에 버전 충돌을 확인하는 비선점 잠금 방식으로는 이를 구현할 수 없다. 이때 필요한 것이 오프라인 선점 잠금 방식이다.

오프라인 선점 잠금은 여러 트랜잭션에 걸쳐 동시 변경을 막는다. 잠금을 해제하기 전까지 다른 사용자는 잠금을 구할 수 없다.

사용자 A가 과정 3의 수정 요청을 수행하지 않고 프로그램을 종료한다면 잠금을 해제하지 않았기 때문에 다른 사용자는 영원히 잠금을 구할 수 없는 상황이 발생한다. 이런 사태를 방지하기 위해 오프라인 선점 방식은 잠금의 유효 시간을 가져야 한다. 유효 시간이 지나면 자동으로 잠금을 해제해서 다른 사용자가 잠금을 일정 시간 후에 다시 구할 수 있도록 해야 한다.

오프라인 선점 잠금을 위한 LockManager 인터페이스와 클래스

오프라인 선점 잠금은 크게 잠금 선점 시도, 잠금 확인, 잠금 해제, 락 유효 시간 연장의 네가지 기능을 제공해야 한다.

public interface LockManager {
	LockId tryLock(String type, String id) throws LockException;
    void checkLock(LockId lockId) throws LockException;
    void releaseLock(LockId lockId) throws LockException;
    void extendLockLock(LockId lockId) throws LockException;
}

잠금 선점에 성공하면 tryLock()에서 LockId를 리턴한다. 이 LockId는 다음에 잠금을 해제할 때 상요하거나 잠금이 유효한지 검사하거나, 잠금의 유효 시간을 늘릴 때 LockId를 사용한다.

0개의 댓글