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

애그리거트와 트랜잭션

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

이 경우 메모리 캐시를 사용하지 않을 경우 두 스레드는 개념적으로 동일한 애그리거트지만 물리적으로 서로 다른 애그리거트 객체를 구하게 된다. 이 상황에서 두 스레드는 각각 트랜잭션을 커밋할 때 수정한 내용을 DBMS에 반영한다. 따라서 애그리거트의 일관성이 깨지게 된다.

이런 문제를 발생하지 않게 위해서는 애그리거트를 위한 추가적인 트랜잭션 관리 기법이 필요하다. 애그리거트에 대해 사용할 수 있는 대표적인 트랜잭션 처리 방식은 다음과 같다.

  • 선점(Pessimistic, 비관적) 잠금

  • 비선점(Optimisitic, 낙관점) 잠금

선점 잠금

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

과정

  1. 스레드 1이 선점 잠금 방식으로 애그리거트를 구한 뒤 스레드 2가 같은 애그리거트를 구할 경우 스레드 1이 애그리거트에 대한 잠금을 해제할 때까지 블로킹된다.

  2. 스레드1이 애그리거트를 수정하고 트랜잭션을 커밋하면 잠금을 해제한다. 이 순간 대기하던 스레드2가 애그리거트에 접근한다.

  3. 스레드 1이 트랜잭션을 커밋한 후 스레드 2가 애그리거트를 구하게 되므로 스레드2는 스레드1이 수정한 애그리거트의 내용을 보게 된다.

한 스레드가 애그리거트를 구하고 수정하는 동안 다른 스레드가 수정할 수 없으므로 동시에 애그리거트를 수정할 때 발생하는 데이터 충돌 문제를 해소할 수 있다.

구현

선점 잠금은 보통 DBMS가 제공하는 행 단위 잠금을 사용해서 구현한다.

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

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

선점 잠금과 교착 상태

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

  1. 스레드1: A 애그리거트에 대한 선점 잠금 구함

  2. 스레드2: B 애그리거트에 대한 선점 잠금 구함

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

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

이 순서에 따르면 스레드1은 영원히 B 애그리거트에 대한 선점 잠금을 구할 수 없다. 두 스레드는 상대방 스레드가 먼저 선점한 잠금을 구할 수 없어 더 이상 다음 단계를 진행하지 못하게 된다. 즉, 스레드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' 힌트는 잠금을 구하는 대기 시간을 밀리초 단위로 지정한다. 지정한 시간 이내에 잠금을 구하지 못하면 익셉션을 발생시킨다.

선점 잠금으로 해결 못하는 문제

예를들어 A와 B가 동시에 제목이 같은 공지사항을 수정한다고 가정하자 결과적으로 먼저 완료한 사용자의 수정사항은 사라지고 나중에 완료한 수정사항만 남게된다. 이것을 두 번의 갱신 분실 문제 second lost updates problem라 한다. 두 번의 갱신 분실 문제는 데이터베이스 트랜잭션의 범위를 넘어선다. 따라서 트랜잭션으로만 문제를 해결 할 수 없다.

이 문제는 3가지 방법으로 해결할 수 있다.

  1. 마지막 커밋만 인정하기: 먼저 들어온 A의 내용을 무시하고 마지막에 커밋한 B의 내용만 인정한다.

  2. 최초 커밋만 인정하기: 사용자 A가 이미 수정을 완료했으므로 사용자 B가 수정을 완료할 때 오류가 발생한다.

  3. 총돌하는 갱신 내용 병합하기: 사용자 A와 사용자 B의 수정사항을 병합한다.

비선점 잠금이 최초의 커밋만 인정하는 방식이다.

비선점 잠금

선점 잠금으로 모든 트랜잭션 충돌 문제가 해결 되는 것은 아니다.

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

즉 배송 상태 변경 전에 배송지를 한 번 더 확인 하지 않으면 운영자는 다른 배송지로 물건을 발송하게 되고, 고객은 배송지를 변경했음에도 불구하고 엉뚱한 곳으로 주문한 물건을 받는 상황이 발생한다.

이 문제는 선점 잠금 방식으로 해결 할 수 없는데, 이 때 필요한 것이 비선점 잠금이다. 비선점 잠금 방식은 잠금 을 해서 동시에 접근하는 것을 막는 대신 변경한 데이터를 실제 DBMS에서 반영하는 시점에 변경 가능 여부를 확인하는 방식이다.

비선점 잠금을 구현하려면 애그리거트에 버전으로 사용할 숫자 타입의 프로퍼티를 추가해야한다. 애그리거트를 수정할 때마다 버전으로 사용할 프로피터 값이 1씩 증가하는데, 이때 다음과 같은 쿼리를 사용한다.

UPDATE aggrable SET version = version +1, colx = ?, coly =?
WHERE aggid =? and version = 현재 버전

이 쿼리는 수정할 애그리거트와 매핑되는 테이블의 버전 값이 현재 애그리거트의 버전과 동일한 경우에만 데이터를 수정한다. 그리고 수정에 성공하면 버전 값을 1증가 시킨다. 따라서, 다른 트랜잭션이 먼저 데이터를 수정해서 버전 값이 바뀌면 데이터 수정에 실패하게 된다.

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

  1. 스레드 1 애그리거트 조회

  2. 스레드 2 애그리거트 조회

  3. 스레드 1 애그리거트 수정 시도 (스레드 2 보다 먼저 시도한다), 수정에 성공하고 버전은 6이 된다.

  4. 스레드 2 애그리거트 수정 시도, 이미 애그리거트 버전이 6이므로 스레드2는 데이터 수정에 실패하게 된다.

JPA는 버전을 이용한 비선점 잠금을 기능을 지원한다. 다음과 같이 버전으로 사용할 필드에 @Version 애노테이션을 붙이고 매핑되는 테이블 버전을 지정한 칼럼을 추가하기만 하면된다.

@Entity
@Table(name = "purchase_order")
public class Order {
    ...
    @Version
    private long version;
}

JPA는 엔티티가 변경되어 UPDATE 쿼리를 실행할 때 @Version에 명시한 필드를 이용해서 비선점 잠금 쿼리를 실행한다. 즉 애그리거트 객체의 버전 10이면 UPDATE 쿼리를 실행할 때 다음과 같은 쿼리를 사용해서 버전 일치하는 경우에만 데이터를 수정한다.

update purchase_order SET ..., version = version + 1
where number ? and version = 10;

응용 서비스는 버전에 대해 알 필요가 없다. 리포티터리에 필요한 애그리거트를 구현하고 알맞은 기능을 실행하면 된다. 기능을 실행하는 과정에서 애그리거트의 데이터가 변경되면 JPA트랜잭션 종료 시점에 비선점 잠금을 위한 쿼리를 실행 한다.

비선점 트랜잭션 충돌 문제 해결 Flow

위와 같이 비선점 잠금을 이용한 트랜잭션 충돌 방지를 여러 트랜잭션으로 확장하려면 애그리거트 정보를 뷰로 보여줄 때 버전 정보도 함께 사용자 화면에 전달해야 한다.

사용자 요청을 처리하는 응용 서비스를 위한 요청 데이터는 사용자가 전송한 버전 값을 포함한다. 응용 서비스는 전달받은 버전 값을 이용해서 애그리거트의 버전과 일치하는 경우에만 요청한 기능을 수행한다.

표현 계층은 버전 충돌 익셉션이 발생하면 버전 충돌을 사용자에게 알려주고 사용자가 알맞은 후속 처리를 할 수 있도록 한다.

강제 버전 증가

애그리거트의 애그리거트 루트가 아닌 다른 엔티티의 값만 변경할 경우 JPA는 루트 엔티티 버전을 증가하지 안흔다. 하지만 애그리거트 내 어떤 구성요소의 상태가 바뀌면 루트 애그리거틍의 버전 값을 증가해야 비선점 잠금이 올바르게 작동한다.

JJPA는 이런 문제를 처리할 수 있도록 EntityManager#find() 메서드로 엔티티를 구할 때 강제로 버전 값을 증가시키는 잠금 모드를 지원한다.

@Repository
public class JpaOrderRepository implements OrderRepository {
	@PersistenceContext
	private EntityManager entityManager;
	
	@Override
	public Order findByIdOptimisticLockMode(OrderNo id) {
		return entityManager.find(Order.class, id,
									LockModeType.OPTIMISTIC_FORCE_INCREMENT);
	}
}

LockModeType.OPTIISTIC_FORCE_INCREMENT을 사용하면 해당 엔티티의 상태가 변경되었는지 여부에 상관없이 트랜잭션 종료 시점에 버전 값 증가 처리를 한다.

오프라인 선점 잠금

단일 트랜잭션에서 동시 변경을 막는 선점 잠금 방식과 달리 오프라인 선점 잠금은 여러 트랜잭션에 걸쳐 동시 변경을 막는다. 첫 번째 트랜잭션을 시작할 때 오프라인 잠금을 선점하고, 마지막 트랜잭션에서 잠금을 해제한다. 잠금을 해제하기 전까지 다른 사용자는 잠금을 구할 수 없다.

예를 들어 수정 기능과 같이 두 개 이상의 트랜잭션으로 구성된 기능에서 오프라인 선점 잠금 방식을 적용한다. 한 사용자가 요청을 수행하지 않고 프로그램을 종료할 경우 잠금을 해제하지 않으므로 다른 사용자는 영원히 잠금을 구할 수 없다. 따라서 오프라인 선점 잠금 방식은 잠금의 유효 시간을 가져야 한다. 사용자가 잠금 유효 시간이 지난 후 요청 수행이 불가하므로 일정 주기로 유효 시간을 증가하는 방식이 필요하다.

구현

오프라인 선점 잠금은 크게 잠금 선점 시도, 잠금 확인, 잠금 해제, 락 유효 시간 연장의 네 가지 기능을 제공해야 한다. 이 기능을 위한 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 extendLockExpiration(LockId lockId, long inc) throws LockException;
}

Reference

트랜잭션 잠금 - Yun Blog | 기술 블로그해당 자료는 DDD Start 를 보고 정리한 자료입니다. 정말 추천드리는 책입니다. 한 주문 애그리거트에 대해 운영자는 배송 준비 상태로 변경할 때 사용자는 배송지 주소를 변경하면 어떻게 될까? 아래 그림은 운영자와 고객이 동시에 한 주문 애그리거트를 수정하는 과정을 보여준다. (배송 상태로 변경되면 더 이상 배송지 변경은 할 수 없다.)https://cheese10yun.github.io/transaction-lcok/

Last updated

Was this helpful?