8장: 애그리거트 트랜잭션 관리

애그리거트와 트랜잭션

  • 운영자와 고객이 동시에 한 애그리거트를 수정하는 상황이 발생할 수 있다.

  • 이 때 트랜잭션마다 리포지터리는 새로운 애그리거트 객체를 생성하므로 운영자 스레드와 고객 스레드는 같은 주문 애그리거트를 나타내는 다른 객체를 가져와 사용하게 된다.

  • 두 스레드는 각각 트랜잭션을 커밋할 때 수정한 내용을 DB에 반영하는데, 만약 판매자가 배송이 되었다는 버튼을 눌러 배송 상태를 변경하는 사이에 고객이 배송지를 변경했다면 문제가 발생할 것이다.

  • 이를 해결하기 위해서는 선점 락(Pessimisitic Lock) 혹은 비선점 락 기법(Optimistic Lock)을 사용할 수 있다.

애그리거트 락 기법

Pessimistic Lock

개념

  • 애그리거트를 먼저 조회한 스레드가 애그리거트 사용이 끝날 때까지 다른 스레드는 해당 애그리거트를 수정하지 못하게 막는 방식

  • 한 스레드가 먼저 애그리거트를 수정하고 있다면, 다른 스레드는 애그리거트에 대한 잠금이 해제될 때 까지 블로킹된다.

  • 이 방식은 동시에 애그리거트를 수정할 때 발생하는 데이터 충돌 문제를 원천적으로 방지한다.

  • 예를 들면 아래와 같이 운영자 스레드가 먼저 주문 애그리거트에 대해 수정을 하려고 했다면, 고객 스레드가 주문 애그리거트를 동시에 수정하지 못하도록 락을 건다. 고객 스레드는 주문 애그리거트를 구할 수 없기 때문에 기다리거나, 바로 에러를 반환할 수 있다.

사용 방법

  • DBMS는 행 단위 잠금을 제공하여 특정 레코드에 하나의 커넥션만 접근할 수 있는 잠금장치를 제공한다.

  • JPA의 EntityManager에서는 LockModeType을 인자로 받는 find() 메서드를 제공하기 때문에 아래와 같이 Pessimistic lock을 적용할 수 있다.

Order order = entityManager.find(Order.class, orderNo, LockModeType.PESSIMISTIC_WRITE);
  • Spring Data JPA에서는 @Lock 어노테이션을 제공하여 잠금 모드를 지정할 수 있다.

  • 하이버네이트의 경우 PESSIMISTIC_WRITE를 잠금 모드로 사용하면 for update 쿼리를 이용해 선점 잠금을 구현한다.

public interface MemberRepository extends Repository<Member, MemberId> {
	@Lock(LockModeType.PESSIMISTIC_WRITE)
	@Query(“select m from Member m where m.id = :id”)
	Optional<Member> findByIdForUpdate(@Param(“id”) MemberId memberId);
	// ...
}

데드락

  • 여러 스레드 간 락에 의한 데드락이 발생하지 않도록 주의해야 한다.

  • 만약 스레드1이 A 애그리거트 → B 애그리거트 순서로 수정하고 스레드2는 B 애그리거트 → A 애그리거트 순서로 수정하는 상황에서 스레드1은 A 애그리거트에 대한 락을 갖고 스레드2는 B 애그리거트에 대한 락을 가지고 있다면 두 스레드는 데드락에 걸려 영원히 멈춰있게 된다.

  • 이러한 문제를 방지하려면 잠금 시 최대 대기 시간을 지정해야 한다.

  • JPA에서는 find 메서드의 인자로 hints Map 객체를 입력해 최대 대기 시간을 지정할 수 있다.

Order order = entityManager.find(Order.class, orderNo, LockModeType.PESSIMISTIC_WRITE, Map.of(“javax.persistence.lock.timeout”, 2000));
  • Spring Data JPA에서는 @QueryHints 어노테이션을 사용해 최대 대기 시간을 지정할 수 있다.

public interface MemberRepository extends Repository<Member, MemberId> {
	@Lock(LockModeType.PESSIMISTIC_WRITE)
	@QueryHints({
	@QueryHint(name =javax.persistence.lock.timeout, value =2000”)
	})
	@Query(“select m from Member m where m.id = :id”)
	Optional<Member> findByIdForUpdate(@Param(“id”) MemberId memberId);
	// ...
}

문제점

  • 선점 잠금으로 모든 트랜잭션 충돌 문제가 해결되지는 않는다.

  • 아래와 같이 운영자가 배송지 정보를 조회하고 배송 상태로 변경하는 사이에 고객이 배송지를 변경할 수 있다. 이렇게 되면 운영자는 잘못된 배송지에 물건을 발송하게 된다.

Optimistic Lock

  • 동시에 접근하는 것을 막는 대신 변경한 데이터를 실제 DBMS에 반영하는 시점에 변경 가능 여부를 확인하는 방식

  • 애그리거트에 버전으로 사용할 숫자 타입 프로퍼티를 추가해야 한다. 이 때 애그리거트 버전은 수정할 때마다 1씩 증가하게 된다.

  • 업데이트 쿼리는 대략 아래와 같을 것이다. 수정할 애그리거트에 매핑되는 테이블의 버전이 현재 애그리거트 버전과 동일하면 수정할 수 있다.

UPDATE aggtable SET version = version + 1, colx = ?, coly = ?
WHERE aggid = ? and version = 현재버전
  • JPA에서는 버전을 이용한 비선점 잠금 기능을 사용할 수 있도록 @Version 어노테이션을 제공한다. 엔티티가 변경되어 UPDATE 쿼리를 실행할 때, version 필드를 사용해 비선점 락 쿼리를 실행한다.

@Entity
@Table(name = “purchase_order”)
@Access(AccessType.FIELD)
public class Order {
	@EmbeddedId
	private OrderNo number;

	@Version
	private long version;
	// ...
}

트랜잭션과 비선점 락

  • 만약 UPDATE 쿼리가 실행되지 못했다면 누군가 먼저 데이터를 수정한 것이므로 트랜잭션 종료 시점에 예외가 발생한다.

  • 아래와 같이 @Transactional으로 트랜잭션을 설정하고 있고 changeShippingInfo가 실패했다면 OptimisticLockingFailureException이 발생할 것이다.

@Transactional
public void changeShipping(ChangeShippingRequest changeReq) {
	Order order = orderRepository.findById(new OrderNo(changeReq.getNumber()));
	checkNoOrder(order);
	order.changeShippingInfo(changeReq.getShippingInfo());
}
  • 위와 같이 트랜잭션 충돌이 나는 것을 방지하기 위해서는 사용자가 수정 요청을 보낼 때 애그리거트 버전도 함께 보내 만약 현재 애그리거트 버전과 다르다면 아예 수정 요청을 보내지 않도록 한다.

  • 예를 들면 아래와 같은 흐름이 될 것이다.

  • 애그리거트의 루트 엔티티 외에 다른 엔티티의 값이 변경되더라도 버전 값이 증가되어야 논리적으로 변경된 애그리거트를 표현할 수 있다. 하지만 JPA에서는 루트 엔티티가 변경되지 않으면 루트 엔티티의 버전 값을 증가시키지 않는다.

  • 이를 해결하기 위해 JPA는 강제로 버전 값을 증가시키는 LockModeType.OPTIMISTIC_FORCE_INCREMENT 모드를 제공한다.

  • 해당 엔티티의 상태가 변경되었는지에 상관없이 트랜잭션 종료 시점에 버전 값 증가 처리를 하여 애그리거트 루트 엔티티가 아닌 다른 엔티티나 밸류가 변경되더라도 버전 값을 증가시킬 수 있다.

조회 쿼리를 보낼 때마다 항상 버전 값을 증가시키긴 하지만 Update 전용 읽기 메서드나 OptimisticLockMode 전용 읽기 메서드에만 사용한다면 큰 문제는 없을 것으로 보인다.

public Order findByIdOptimisticLockMode(OrderNo id) {
	return entityManager.find(Order.class, id, LockModeType.OPTIMISTIC_FORCE_INCREMENT);
}
  • Spring Data JPA 사용 시 아래와 같이 사용하면 된다.

@Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT)
Optional<Member> findByIdForUpdate(@Param(“id”) MemberId memberId);

Offline Pessimistic Lock

  • 여러 사용자가 동시에 편집할 수 있는 공유 문서 프로그램의 경우 사전에 다른 사용자가 함께 수정하고 있다는 안내 문구를 보여주며 데이터 충돌을 사전에 방지하도록 한다.

  • 오프라인 선점 락 방식은 여러 트랜잭션에 걸쳐 동시 변경을 막는다. 첫 번째 트랜잭션을 시작할 때 오프라인 락을 선점하고, 마지막 트랜잭션에서 락을 해제한다.

  • 예를 들어 아래와 같이 사용자 A가 수정 폼을 요청하고 실제 수정을 요청하는 두 트랜잭션에 걸친 오프라인 선점 락을 걸게 되면, 사용자 B는 사용자 A가 수정 요청을 보내고 수정이 완료되는 시점까지 수정 요청 폼 조차 얻을 수 없게 된다.

  • 만약 사용자 A가 수정 폼 요청만 보내고 수정 요청은 안보낸 채 프로그램을 종료한다면 다른 사용자는 영원히 오프라인 락을 선점할 수 없어 수정 폼 조차 요청할 수 없게 된다.

  • 이를 방지하기 위해 오프라인 선점 방식은 잠금 유효 시간을 가져야 한다.

사용 방법

  • 오프라인 선점 락은 잠금 선점 시도, 잠금 확인, 잠금 해제, 잠금 유효시간 연장의 네 가지 기능이 필요하다.

  • 락을 걸기 위해서는 대상 타입과 식별자를 입력받고, 락 식별자를 반환한다.

  • 락 식별자는 락을 해제하거나 유효성 검사를 하거나 유효 시간을 늘릴 때 필요하다.

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

public class LockId {
	private String value;
	public LockId(String value) {
		this.value = value;
	}
	public String getValue() {
		return value;
	}
}
  • 아래는 LockManager를 통해 락을 관리하는 예제 코드이다.

public DataAndLockId getDataWithLock(Long id) {
	// 1. 오프라인 선점 락 가져오기
	LockId lockId = lockManager.tryLock(“data”, id);
	// 2. 기능 실행
	Data data = someDao.select(id);
	return new DataAndLockId(data, lockId);
}

Last updated