본문 바로가기
DDD&MSA

[DDD] 애그리거트 트랜잭션과 Lock 기법

by 덩라 2023. 6. 18.

본 포스팅은 DDD 를 공부하면서 정리하기 위한 포스팅입니다.

출처: 도메인 주도 개발 시작하기 - DDD 핵심 개념 정리부터 구현까지 (저자. 최범균)

0. 트랜잭션과 Lock

트랜잭션과 Lock 이라는 용어는 보통 데이터베이스와 관련된 용어로 많이 사용됩니다.

트랜잭션(transaction)은 데이터베이스에서 발생하는 작업의 최소단위 를 의미하며, ACID 라고 불리우는 속성을 가지고,

Lock은 데이터베이스가 데이터의 무결성와 일관성을 유지하기 위해 다른 트랜잭션이 데이터에 접근하지 못하도록 막는 기법입니다.

 

즉, 어떤 사용자가 특정 데이터를 수정/삭제/추가 하는 과정"데이터의 상태를 변경한다." 라고 했을 때,

데이터의 상태를 변경하는 과정 자체를 "트랜잭션" 이라고 부르고, 이 때 다른 사용자가 해당 데이터에 접근하지 못하도록 하는 것을 "Lock" 이라고 할 수 있겠습니다.

특정 데이터를 조회 하는 행위도 트랜잭션에 포함되는 행위이지만, Lock 은 데이터의 변경 가능성이 전제된 기법이므로 아래 내용부턴 데이터가 변경되는 행위를 트랜잭션으로 이해해주시면 되겠습니다.

 

위와 같은 개념이 적용되는 예를 생각해보면 다음과 같습니다.

  • 조건1: 배송 전 주문에 대해 구매자는 배송지정보를 변경할 수 있다.
  • 상황1: 구매자가 배송 전 주문에 대해 배송지 정보를 변경한다.
  • 상황2: 판매자가 배송 전 주문에 대해 배송상태를 배송 전 에서 배송 중 으로 변경한다.

위 내용을 그림으로 표현하면 아래처럼 표현될 수 있습니다.

위 그림대로 프로그램이 진행된다면, 구매자는 배송지를 A -> B 로 변경했지만, 판매자는 상품을 기존 배송지인 A 로 보낼 수 도 있게 됩니다. 왜냐하면, 실제로 물건을 배송하기 위해 배송장을 택배에 붙이게 될텐데, 그 때 배송장에 적힌 주소가 B 라는 것을 장담할 수 없기 때문입니다.

 

이렇게, 서로 다른 두 과정 속에서 같은 데이터를 변경하는 일이 발생하면 위에 제시한 오류의 가능성이 발생할 수 있습니다.

이를 DDD 관점으로 해석하게 되면, 두 쓰레드가 하나의 애그리거트를 변경하면, 애그리거트의 일관성이 무너질 수 있다는 뜻이 됩니다.

 

이렇게 애그리거트의 일관성을 무너뜨리지 않게 하기 위해, 애그리거트를 LocK(잠금) 하여 보호할 수 있는데, 어떤 Lock 종류가 존재하는지 알아보겠습니다.

 

1. 선점 잠금(Pessimistic Lock)

애그리거트를 보호하기 위한 방법 중 하나로 "애그리거트가 조회되는 경우, 조회 시점에서 해당 애그리거트의 접근을 막는다." 라는 방법이 있을 수 있습니다. 위 방법을 적용한 흐름을 그림으로 표현하면 아래와 같아집니다.

  1. 판매자가 배송상태를 변경하기 위해 주문정보를 조회하면, 해당 주문정보를 조회하지 못하도록 Lock 을 합니다.
  2. 구매자가 배송지 변경을 위해 주문정보를 조회하면, 앞에서 Lock 이 걸린 주문정보이기 때문에, Lock이 풀릴 때 까지 대기합니다.
  3. 판매자가 배송상태 변경을 끝내면, 변경내역을 저장하고, 주문정보에 걸린 Lock을 해제 합니다.
  4. 주문정보의 Lock이 해제되기를 기다린 구매자가, 주문정보를 조회하여 배송지 변경을 시도하지만, 이미 배송상태가 변경된 주문정보이므로 변경에 실패합니다.
  5. 배송지 정보에 실패하여 변경내역이 저장되지 않고, 주문정보에 걸린 Lock이 해제됩니다.

이와 같이 쓰레드가 주문 애그리거트를 조회한 시점에서 다른 쓰레드가 해당 애그리거트에게 접근을 하는 것을 막고, 트랜잭션이 커밋(혹은 롤백) 된 시점에 애그리거트의 잠금을 풀어, 다른 쓰레드가 해당 애그리거트를 사용할 수 있게하는 기법선점잠금(Pessimisitic Lock) 이라고 합니다.

 

이와 같은 선점잠금의 대표적인 예로는 DB가 자체적으로 제공하는 행 단위 Lock(select ~ for update) 이 있습니다.

JPA의 대표 구현체인 Hibernate 에선 select~for update 조회를 위해 find 메서드에 선점 잠금 방식으로 조회할지에 대한 파라미터를 추가로 넘길 수 있습니다.

public Order findByIdForUpdate(OrderNo no) {
    return em.find(Order.class, no, LockModeType.PESSIMISTIC_WRITE);
}

위 처럼 LockModeType 을 선점잠금(PESSIMISTIC_WRITE) 로 선언하면, 해당 엔티티를 조회할 때 쿼리에 for update 가 추가됩니다.

 

하지만, 선점잠금을 적용할 때 주의해야하는 상황이 있습니다.

  1. 1번 쓰레드가 A 애그리거트를 구하고, A 애그리거트를 잠금
  2. 2번 쓰레드가 B 애그리거트를 구하고, B 애그리거트를 잠금
  3. 1번 쓰레드가 B 애그리거트 조회를 시도
  4. 2번 쓰레드가 A 애그리거트 조회를 시도

위 상황은 두 쓰레드가 한 트랜잭션 상에서 A,B 애그리거트를 모두 사용하는 경우입니다. 그리고 각자 필요한 애그리거트가 존재하는 타이밍에 애그리거트를 조회할 수 없게 됩니다.

(1번 쓰레드가 B 애그리거트를 구하지 않으면 A 애그리거트 잠금을 해제하지 않는데, 2번 쓰레드는 A 애그리거트를 구하지 않으면 B 애그리거트 잠금을 해제하지 않습니다.)

 

이렇게 작업들끼리 필요한 리소스를 가지고 놔주지 않아, 프로세스가 더이상 진행되지 않는 상태를 교착상태(Dead Lock) 이라고 합니다.

선점 잠금 사용시에는 이러한 교착상태(Dead Lock)에 빠지지 않도록 주의해야 합니다. 

 

이런 상황을 만들지 않기 위해 JPA 에선 애그리거트를 조회할 때, 해당 애그리거트가 잠긴 경우 최대 얼마나 기다릴지 timeout 을 지정할 수 있습니다. 이는 앞서 본 LockModeType 처럼 메서드 파라미터로 제공한다기 보단, 쿼리에 Hint 절을 추가해서 처리할 수 있도록 힌트 자체를 파라미터로 적용하게 됩니다.

public Order findByIdForUpdate(OrderNo no, long timeout) {
    Map<String, Object> hint = new HashMap<>();
    hint.put("javax.persistence.lock.timeout", timeout); // timeout 은 1/1000초 (밀리초) 단위
    return em.find(Order.class, no, LockModeType.PESSIMISTIC_WRITE, hint);
}

 

2. 비선점 잠금(Optimisitic Lock)

앞서 말했던 선점 잠금은 완벽해보이지만, 모든 경우를 해결하지는 못합니다. 아래와 같은 상황이 있다고 가정해보겠습니다.

  1. 판매자가 배송상태 변경을 위해 주문이력을 조회해 놓았는데, 급한 용무가 생겨 잠시 자리를 비움. (이 때, 조회된 배송지는 A)
  2. 구매자가 자신의 배송지 변경을 위해 주문내역을 조회하고, 배송지를 변경함. (이 때, A에서 B로 배송지가 변경됨)
  3. 판매자가 급한 용무를 마무리하고 돌아와서 조회된 주문이력에 대해 배송장을 출력하고 배송중 상태로 수정. (이 때, 기존에 조회한 A배송지 기준으로 배송장이 출력됨.)

 

위 과정 속에서, 선점 잠금을 고려했을 때는 시스템 내에선 전혀 문제가 되지 않습니다.

왜냐하면,

1번 상황에선 조회 직후 화면에 주문이력이 조회되면 트랜잭션이 정상 종료되고,

2번 상황에서 구매자가 배송지 수정을 위해 주문 애그리거트를 조회해도 잠금 상태가 아니기 때문에 정상적으로 애그리거트의 상태를 변경할 수 있고, 

3번 상황에서 판매자가 이미 1번에서 조회한 애그리거트의 상태를 변경합니다. 이 과정에서 애그리거트를 다시 조회해도, 2번 상황에서 이미 트랜잭션이 종료되어 잠금이 해제 되었기 때문에, 문제가 되지 않습니다.

 

이러한 경우를 막을 방법은 배송장을 출력하고 배송상태를 변경하기 전에, 기존에 조회했던 배송지와 현재 배송지가 같은지를 확인하는 방법인데 이 또한 쉽지 않을 수 있습니다. 

이렇게, 선점 잠금 방식으로는 문제가 없음에도 발생되는 문제비선점 잠금 방식(Optimisitic Lock)을 통해 해결할 수 있습니다.

 

비선점 잠금 방식의 가장 큰 특징은 애그리거트에서 버전(Version)을 관리한다는 점 입니다.

버전(Version)은 애그리거트의 상태가 변경되는 시점에 현재 버전값에서 +1 합니다.

update order 
set delivery_place = '변경 배송지', version = version + 1
where no='주문번호' and version=[현재 버전 값]

 

애그리거트에 버전이 추가된 비선점 잠금 방식의 흐름은 아래와 같이 이루어 집니다.

판매자가 배송상태 변경을 위해 애그리거트를 조회한 시점이 Version 1 인 시점에서, 배송상태 수정을 요청하기 전에 구매자가 배송지를 변경함으로써 애그리거트의 Version을 1에서 2로 올린 상황입니다.

판매자가 최초에 조회한 애그리거트의 Version 은 1이었지만, 실제로 배송상태 수정을 하려고 하니 현재 Version 이 1이 아닌 2 입니다.

이 때는 같은 정보를 가지는 애그리거트일 지라도 Version 이 다르기 때문에 판매자가 update에 실패해야 합니다.

 

JPA를 사용해 애그리거트에 Version 을 구현하는건 단순하게 필드변수를 가지게 하는 방법이 있습니다.

public class Order {
    private String orderNo;
    
    @Version
    private long version; // 애그리거트 version
    
    /* -- 이하 생략 -- */
}

위와 같이 @Version 을 붙이면, 애그리거트가 수정될 때 version 필드를 따로 수정하지 않아도, @Version 이 선언된는 칼럼이 자동으로 update 문에 반영되어 실행됩니다.

@Entity
public class Order {
    @Id
    private String no;

    @Version
    private long version;
    
    private String orderStatus;
    
    /* -- 중략 -- */
    
    public void cancel() {
        this.orderStatus = "CANCEL";
    }
}

// 테스트 코드
@DataJpaTest
class JpaOrderRepositoryTest {

    @Autowired
    private EntityManager em;

    private JpaOrderRepository repository;

    @BeforeEach
    void beforeEach() {
        repository = new JpaOrderRepository(em);
    }
    
    @DisplayName("version update 확인")
    @Test
    void version_update() {
        OrderNo orderNo = new OrderNo("test-order-no");
        Order order = new Order(orderNo);
        repository.save(order);

        /* -- insert 쿼리 확인을 위한 flush 강제 호출 */
        em.flush();
        em.clear();

        Order findOrder = repository.findById(orderNo);
        findOrder.cancel();

        /* -- update 쿼리 확인을 위한 flush 강제 호출 */
        em.flush();
        em.clear();
    }
}

위에 생성된 Order 의 cancel 메서드는 orderStatus 의 값만 변경하지, version 필드를 변경하지 않습니다. 

하지만 위 테스트코드를 실행하고 나타나는 update 쿼리에는 vesion 정보도 update 합니다.

 

 

3. 오프라인 선점 잠금(Offline Pessimistic Lock)

오프라인 선점 잠금은 사용자가 특정 데이터를 선점하면 그 데이터를 원하는 시점까지 Lock 하여 데이터 접근을 제한하는 기법입니다.

대표적인 예로 콘서트 티켓팅이 있습니다.

콘서트나 스포츠경기 직관을 위해 예매사이트에서 티켓팅을 하게 되면, 아래와 같은 흐름으로 예매가 이루어집니다.

  1. 좌석을 선택한다.
  2. 결제수단을 선택하고, 결제한다.
  3. 예매가 완료된다.

위 과정은 모두 화면이 다르게 표시되기 때문에, 1번에서 2번 / 2번에서 3번 으로 진행되면서 트랜잭션이 종료되기 때문에, 앞에서 설명한 선점/비선점 방식을 사용하면 내가 먼저 좌석을 선택했어도, 결제를 늦게하면 예매에 실패하는 경우가 발생합니다.

어떤 예매사이트는 앞서 설명한대로 예매를 진행하는 한편, 또 어떤 예매사이트는 좌석을 먼저 선택한 사람이 결제하고 예매가 완료될 때 까지 그 좌석의 선택을 막는 사이트도 있습니다.

예매자1이 이미 결제정보를 조회하는 시점에 응답을 한 번 받았기 때문에, 앞선 두 Lock 기법에 의하면 예매자2가 1번좌석을 선택할 수 있어야 합니다. 하지만 그렇게 되면, 좌석 선택은 예매자1이 먼저 했지만, 예매자2가 먼저 예매확정을 할 수 도 있습니다.

즉, 위 그림처럼 좌석을 선택한 사람에게 결제 우선권을 주기 위해서는 앞서 설명한 2가지 Lock 기법으론 구현이 어렵습니다.

이럴 때 사용하는 Lock 기법이 바로 오프라인 선점 기법 입니다.

댓글