도메인 주도 개발 시작하기 - 8. 애그리거트 트랜잭션 관리
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');