책/DDD: 도메인 주도 개발

[도메인주도개발] Ch8 : 애그리거트 트랜잭션 관리

금호박 2024. 4. 1. 22:24

8.1 애그리거트와 트랜잭션

  한 애그리거트를 두 사용자가 동시에 변경할 때 트랜잭션과 함께 애그리거트를 위한 추가적인 트랜잭션 처리 기법이 필요하다. 대표적인 트랜잭션 처리 방식으로는 선점 잠금비선점 잠금의 두 가지 방식이 있다.

 

  아래의 그림은 운영자와 고객이 동시에 한 주문 애그리거트를 수정하는 과정을 보여준다.

  트랜잭션마다 리포지터리는 새로운 애그리거트 객체를 생성하기에 운영자 스레드와 고객 스레드는 같은 주문 애그리거트를 나타내는 다른 객체를 구하게 된다. 물리적으로 서로 다른 애그리거트 객체를 사용하기 때문에 각 객체의 상태 변경이 서로에게 영향을 주지 않는다. 그리고 두 스레드는 각각 트랜잭션을 커밋할 때 수정한 내용을 DB에 반영한다. 이때 배송 상태와 배송지 정보가 바뀌는데, 애그리거트의 일관성이 깨지는 상황이 발생할 수 있다.

예를 들어 운영자는 기존 배송지 정보를 이용해서 배송 상태로 변경했는데, 그 사이 고객은 배송지 정보를 변경할 수 있다.

  

  이런 일관성이 개지는 문제를 막기 위해서는 다음 두 가지 중 하나를 해야 한다.

  1. 운영자가 배송지 정보를 조회, 변경하는 동안 고객이 애그리거트를 수정하지 못하게 한다.
  2. 운영자가 배송지 정보를 조회한 이후 고객이 정보를 변경하면, 운영자가 애그리거트를 다시 조회한 뒤 수정하도록 한다.

이 두 가지는 애그리거트 자체의 트랜잭션과 관련이 있고, 추가적인 트랜잭션 처리 기법을 필요로 한다.

 

8.2 선점 잠금

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

 

 선점 잠금의 동작 방식은 다음과 같다.

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

 

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

 

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

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

 

 

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

 

  스프링 데이터 JPA 는 @Lock 애너테이션을 사용해서 잠금 모드를 지정한다.

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

 

 

8.2.1 선점 잠금과 교착 상태

  선점 잠금 기능을 사용할 때는 잠금 순서에 따른 교착 상태(deadlock)가 발생하지 않도록 주의해야 한다. 선점 잠금에 의한 교착 상태는 사용자 수가 많아질수록 발생할 가능성이 높고, 사용자 수가 많아지면 교착 상태에 빠지는 스레드는 더 빠르게 증가한다. 이런 문제를 막기 위해 잠금을 구할 때 최대 대기 시간을 지정해야 할 필요가 있다.

 

 예를 들어 다음과 같은 상황에 교착 상태가 발생할 수 있다.

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

 

  이런 문제가 발생하지 않도록 하려면 잠금을 구할 때 최대 대기 시간을 지정해야 하는데, JPA에서 선점 잠금을 시도할 때 최대 대기 시간을 지정하려면 다음과 같이 힌트를 사용한다.

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

 

스프링 데이터 JPA는 @QueryHints 애너테이션을 사용해서 쿼리 힌트를 지정할 수 있다.

 

8.3 비선점 잠금

 비선점 잠금은 동시에 접근하는 것을 막는 대신 변경한 데이터를 실제 DBMS에 반영하는 시점에 변경 가능 여부를 확인하는 방식이다. 비선점 잠금을 구현하려면 애그리거트에 버전으로 사용할 숫자 타입 프로퍼티를 추가한다. 그리고 수정할 애그리거트와 매핑되는 테이블의 버전 값이 현재 애그리거트의 버전과 동일한 경우에만 데이터를 수정하도록 허용한다.

 

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

 위의 경우 운영자가 배송지 정보를 조회하고 배송 상태로 변경하는 사이에 고객이 배송지를 변경한다. 즉 배송 상태 변경 전에 배송지를 또 확인하지 않으면 고객은 배송지를 변경했음에도 다른 주소로 주문한 물건을 받는 상황이 발생한다. 이 경우 필요한 것이 비선점 잠금 방식이다.

 

 비선점 잠금을 구현하기 위해 애그리거트에 버전으로 사용할 숫자 타입 프로퍼티를 추가해야 하는데 다음과 같은 쿼리를 사용한다.

(애그리거트를 수정할 때마다 버전으로 사용할 프로퍼티 값이 1씩 증가한다.)

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

 이 쿼리는 수정한 애그리거트와 매핑되는 테이블의 버전 값이 현재 애그리거트의 버전과 동일한 경우에만 데이터를 수정하고, 수정에 성공하면 버전 값을 1 증가시킨다. 이를 그림으로 표현하면 다음과 같다.

 

 

JPA는 버전을 이용한 비선점 잠금 기능을 제공한다. 사용할 필드에 @Version 애너테이션을 붙이고, 매핑되는 테이블에 버전을 저장할 칼럼을 추가한다.

@Entity
@Table
@Access(AccessType.FEILD)
public class Order{
	@EmbeddedId
    private OrderNo number;
    
    @Version
    private long version;
    
    ...
}

 

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

 

수정 예정

 

 

8.4 오프라인 선점 잠금

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

 

  컨플루언스는 문서를 편집할 때 누군가 먼저 편집을 하는 중이면 다른 사용자가 문서를 수정하고 있다는 안내 문구를 보여주는데, 이런 안내를 통해 여러 사용자가 동시에 한 문서를 수정할 때 발생하는 충돌을 사전에 방지할 수 있게 해준다. 컨플루언스는 사전에 충돌 여부를 알려주지만 동시에 수정하는 것을 맞기는 않는데, 더 엄격하게 충돌을 막고 싶다면 누군가 수정 화면을 보고 있을 때 다른 사람은 수정 화면에 접근하지 못하도록 해야 할 것이다. 이는 한 트랜잭션 점위에서만 적용되는 선점 잠금 방식과 나중에 버전 충돌을 확인하는 비선점 잠금 방식으로는 구현할 수 없다.

 

  이 때 필요한 것이 오프라인 선점 잠금 방식이다. 오프라인 선점 잠금 방식의 과정은 다음과 같다.

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

 

수정 예정