도메인 주도 개발 시작하기 - 8. 애그리거트 트랜잭션 관리

 Date: 2022-10-31

8.1 애그리거트와 트랜잭션
8.2 선점 잠금
8.3 비선점 잠금
8.4 오프라인 선점 잠금

8.1 애그리거트와 트랜잭션

  • 한 주문 애그리거트에 대해 배송 상태를 변경하고 있는 중에 다른 스레드에서 배송지 주소를 변경하면 어떻게 될까?
  • 리포지터리는 트랜잭션마다 새로운 애그리거트 객체를 생성하기 때문에 두 스레드는 서로 영향을 주지 않는다.
  • 두 스레드가 각각 트랜잭션을 커밋하여 DB에 반영할 때 애그리거트의 일관성이 깨지게 된다.
  • 이런 문제를 해결하기 위해 DBMS가 지원하는 트랜잭션과 애그리거트를 위한 추가적인 트랜잭션 처리 기법이 필요하다.
  • 애그리거트에 대해 사용할 수 있는 대표적인 트랜잭션 처리 방식
    • 선점 잠금(Pessimistic Lock)
    • 비선점 잠금(Optimistic Lock)

8.2 선점 잠금

  • 선점 잠금은 먼저 애그리거트를 구한 스레드가 애그리거트 사용이 끝날 때까지 다른 스레드가 해당 애그리거트를 수정하지 못하게 막는 방식이다.
  • 스레드1이 선점 잠금 방식으로 애그리거트를 구한 뒤, 스레드2가 같은 애그리거트를 구하면, 스레드2스레드1이 애그리거트에 대한 잠금을 해제할 때까지 블로킹된다.
  • 스레드1이 트랜잭션을 커밋한 뒤에 스레드2가 애그리거트를 구하게 되므로 스레드2스레드1이 수정한 애그리거트의 내용을 보게 된다.
  • 한 스레드가 애그리거트를 구하고 수정하는 동안 다른 스레드가 수정할 수 없으므로, 동시에 애그리거트를 수정할 때 발생하는 데이터 충돌 문제를 해소할 수 있다.
  • 선점 잠금은 보통 DBMS가 제공하는 행단위 잠금을 사용해서 구현한다.
    • 오라클을 비롯한 다수의 DBMS가 for update와 같은 쿼리를 사용해서 특정 레코드에 한 커넥션만 접근할 수 있는 잠금장치를 제공한다.
  • JPA EntityManager는 LockModeType을 인자로 받는 find() 메서드를 제공한다.
    • LockModeType.PESSIMISTIC_WRITE : 엔티티와 매핑된 테이블을 이용해서 선점 잠금 방식을 적용

      Order order = em.find(Order.class, LockModeType.PESSIMISTIC_WRITE);
      
  • JPA 프로바이더와 DBMS에 따라 잠금 모드 구현이 다른데, 하이버네이트의 경우 PESSIMISTIC_WRITE를 잠금 모드로 사용하면 for update 쿼리를 이용해서 선점 잠금을 구현한다.
  • 스프링 데이터 JPA는 @Lock 애너테이션을 사용해서 잠금 모드를 지정한다.

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

8.2.1) 선점 잠금과 교착 상태

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

      1) 스레드1: A 애그리거트에 대한 선점 잠금 구함
      2) 스레드2: B 애그리거으테 애한 선점 잠금 구함
      3) 스레드3: B 애그리거트에 대한 선점 잠금 시도
      4) 스레드4: A 애그리거트에 대한 선점 잠금 시도
    
  • 교착 상태가 발생하지 않도록 하려면 잠금을 구할 때 최대 대기 시간을 지정해야 한다.
    • JPA에서 선점 잠금을 시도할 때 최대 대기 시간을 지정하려면 힌트를 사용해야 한다.

        Map<String, Object> hints = new HashMap<>();
        hints.put("javax.persistence.lock.timeout", 2000);
        Order order = em.find(Order.class, orderNo, LockModeType.PESSIMISTIC_WRITE, hints);
      
      • 위와 같이 힌트를 사용할 때 주의할 점은 DBMS에 따라 힌트가 적용되지 않을 수도 있다.
    • 스프링 데이터 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);
        }
      
  • DBMS에 따라 교착 상태에 빠진 커넥션을 처리하는 방식이 다르므로, 사용하는 DBMS에 대해 JPA가 어떤 식으로 대기 시간을 처리하는지 확인해야 한다.

8.3 비선점 잠금

  • 선점 잠금으로 모든 트랜잭션 충돌 문제가 해결되지 않는다.
    • 운영자가 배송지 정보를 조회하고 배송 상태 변경하는 사이에에 고객이 배송지를 변경한 경우, 고객은 배송지를 변경했음에도 이전 배송지로 물건을 발송하는 경우가 생길 수 있다.
  • 비선점 잠금은 동시에 접근하는 것을 막는 대신 변경한 데이터를 실제 DBMS에 반영하는 시점에 변경 가능 여부를 확인하는 방식이다.
  • 비선점 잠금을 구현하려면 애그리거트에 버전으로 사용할 숫자 타입 프로퍼티를 추가해야 한다.
    • 애그리거트를 수정할 때마다 버전으로 사용할 프로퍼티 값이 1씩 증가한다.

        UPDATE aggtable SET version = version + 1, colx = ?, coly = ?
        WHERE aggid = ? and version = 현재버전
      
      • 이 쿼리는 수정할 애그리거트와 매핑되는 테이블의 버전 값이 현재 애그리거트의 버전과 동일한 경우에만 데이터를 수정한다.
      • 수정에 성공하면 버전 값을 1 증가시킨다.
      • 따라서 다른 트랜잭션이 먼저 데이터를 수정해서 버전 값이 바뀌면 데이터 수정에 실패하게 된다.
  • JPA는 버전을 이용한 비선점 잠금 기능을 지원한다.
    • 버전으로 사용할 필드에 @Version 애너테이션을 붙이고 매핑되는 테이블에 버전을 저장할 칼럼을 추가하면 된다.

        @Entity
        @Table(name = "purchase_order")
        @Access(AccessType.FIELD)
        public class Order {
            @EmbeddedId
            private OrderNo orderNo;
            
            @Version
            private long version;
               
        }
      
    • JPA 엔티티가 변경되어 UPDATE 쿼리를 실행할 때 @Version에 명시한 필드를 이용해서 비선점 잠금 쿼리를 실행한다.

        UPDATE purchase_order SET ... 생략, version = version + 1
        WHERE number = ? and version = 10
      
    • 비선점 잠금을 위한 쿼리를 실행할 때 쿼리 실행 결과로 수정된 행의 개수가 0이면, 트랜잭션이 충돌한 것이므로 트랜잭션 종료 시점에 OptimisticLockingFailureException이 발생한다.

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

        @PostMapping("/chageShipping")
        public String changeShipping(ChangeShippingRequest req) {
            try {
                changeShippingService.changeShipping(req);
            } catch(OptimisticLockingFailureException ex) {
                // 누군가 먼저 같은 주문 애그리거트를 수정했으므로 트랜잭션이 충돌했다는 메시지를 보여준다.
                return "changeShippingTxConflict";
            }
        }
      
    • 비선점 잠금을 사용할 때 사용자에게 수정 폼과 함께 버전을 제공하고, 사용자가 전송한 버전과 애그리거트 버전이 동일한 경우에만 수정 기능을 수행하도록 하면 트랜잭션 충돌 문제를 해소할 수 있다.

8.3.1) 강제 버전 증가

  • 애그리거트 루트와 연관된 엔티티의 값이 변경된다고 해도 루트 엔티티의 버전 값은 갱신되지 않는다.
  • 하지만 애그리거트 내에 어떤 구성요소의 상태가 바뀌면 루트 애그리거트의 버전 값이 증가해야 비선점 잠금이 올바르게 동작한다.
  • JPA는 EntityManager#find() 메서드로 엔티티를 구할 때 강제로 버전 값을 증가시키는 잠금 모드를 지원한다.

      @Repository
      public class JpaOrderRepository implements OrderRepository {
          @PersistenceContext
          private EntityManager em;
        
          @Override
          public Order findByIdOptimisticLockMode(OrderNo id) {
              return em.find(
                  Order.class, id, LockModeType.OPTIMISTIC_FORCE_INCREMENT
              );
          }
      }
    
    • LockModeType.OPTIMISTIC_FORCE_INCREMENT를 사용하면 해당 엔티티의 상태가 변경되었는지에 상관없이 트랜잭션 종료 시점에 버전 값 증가 처리를 한다.

8.4 오프라인 선점 잠금

  • 단일 트랜잭션에서 동시 변경을 막는 선점 잠금 방식과 달리 오프라인 선점 잠금은 여러 트랜잭션에 걸쳐 동시 변경을 막는다.
  • 첫 번째 트랜잭션을 시작할 때 오프라인 잠금을 선점하고, 마지막 트랜잭션에서 잠금을 해제한다.
  • 먼저 오프라인 잠금을 선점한 사용자가 잠금을 해제하기 전에 프로그램을 종료할 수 있으므로, 오프라인 선점 잠금 방식은 잠금 유효 시간을 가져야 한다.

8.4.1) 오프라인 선점 잠금을 위한 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;
      }
    

8.4.2) DB를 이용한 LockManager 구현

  • 잠금 정보를 저장하기 위한 테이블 생성 쿼리

      create table locks (
          `type` varchar(255),
          id varchar(255),
          lockid varchar(255),
          expiration_time datetime,
          primary key(`type`, id)
      ) character set utf8
        
      create unique index locks_idx on (lockid);
    
  • Order 타입의 1번 식별자를 갖는 애그리거트에 대한 잠금

    insert into locks values ('Order', '1', '생성한 lockid', '2022-10-31');