애그리거트와 트랜잭션
한 주문 애그리거트에 대해 운영자는 배송 상태로 변경할 때 사용자는 배송지 주소를 변경하면 어떻게 될까?
그림을 보면 운영자와 고객이 동시에 한 주문 애그리거트를 수정하는 과정을 보여준다.
트랜잭션마다 리포지터리는 새로운 애그리거트 객체를 생성하므로 운영자 스레드와 고객 스레드는 같은 주문 애그리거트를 나타내는 다른 객체를 구하게 된다.
운영자 스레드와 고객 스레드는 개념적으로 동일한 애그리거트지만 물리적으로 서로 다른 애그리거트 객체를 사용한다.
때문에 운영자 스레드가 주문 애그리거트 객체를 배송 상태로 변경하더라도 고객 스레드가 사용하는 주문 애그리거트
객체에는 영향을 주지 않는다.
문제점은 운영자는 기존 배송지 정보를 활용해 배송 상태로 변경했는데 그 사이 고객은 배송지 정보를 변경했다는 점이다.
즉, 애그리거트의 일관성이 깨지는 것이다.
일관성이 깨지는 문제가 발생하지 않도록 하려면 다음 두 가지 중 하나를 해야 한다.
- 운영자가 배송지 정보를 조회하고 상태를 변경하는 동안, 고객이 애그리거트를 수정하지 못하게 막는다.
- 운영자가 배송지 정보를 조회한 이후에 고객이 정보를 변경하면, 운영자가 애그리거트를 다시 조회한 뒤 수정하도록 한다.
선점 잠금(Pessimistic Lock)
선전 잠금은 먼저 애그리거트를 구한 스레드가 애그리거트 사용이 끝날 때까지 다른 스레드가 해당 애그리거트를 수정하지 못하게 막는 방식이다.
스레드 1이 선점 잠금 방식으로 애그리거트를 구한 뒤 이어서 스레드2가 같은 애그리거트를 구하고 있다.
이 때 스레드2는 스레드1이 애그리거트에 대한 잠금을 해제할 때까지 블로킹된다.
한 스레드가 애그리거트를 구하고 수정하는 동안 다른 스레드가 수정할 수 없으므로 동시에 애그리거트를 수정할 때 발생하는 데이터 충돌 문제를 해소할 수 있다.
public interface MemberRepository extends Repository<Member, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Member> findByIdForUpdate(@Param Long memberId);
}
하이버네이트의 경우 PESSIMISTIC_WRITE를 잠금 모드로 사용하면 'for update' 쿼리를 이용해서 선점 잠금을 구현한다.
선점 잠금과 교착 상태
선점 잠금을 사용할 때 잠금 순서에 따른 교착 상태(DeadLock)을 주의해야 한다.
1. 스레드1이 A에 대한 선점 잠금.
2. 스레드2이 B에 대한 선점 잠금.
3. 스레드1이 B에 대한 선점 잠금 시도.
4. 스레드2이 A에 대한 선점 잠금 시도.
이러면 스레드1은 평생 B를 선점 잠금을 할 수 없고 스레드2 역시 평생 A에 대한 선점 잠금을 할 수가 없다.
두 스레드는 상대방 스레드가 먼저 선점한 잠금을 구할 수 없어 더 이상 다음 단계로 나아갈 수가 없다.
즉, 스레드1과 스레드2는 교착 상태에 빠진다.
선점 잠금에 따른 교착 상태는 상대적으로 사용자 수가 많을 때 발생할 가능성이 높고, 사용자 수가 많아지면 교착 상태에 빠지는 스레드는 더 빠르게 증가한다.
더 많은 스레드가 교착 상태에 빠질수록 시스템은 아무것도 할 수 없는 상태가 된다.
해당 문제가 발생하지 않도록 하려면 잠금을 구할 때 최대 대기 시간을 지정해야 한다.
지정한 시간 이내에 잠금을 구하지 못하면 exception을 발생시키는 것이다.
public interface MemberRepository extends Repository<Member, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints({@QueryHint(name="javax.persistence.lock.timeout", value="2000")})
Optional<Member> findByIdForUpdate(@Param Long memberId);
}
비선점 잠금
선점 잠금이 강력해보이지만, 선점 잠금으로 모든 트랜잭션 충돌 문제를 해결할 수 있는 것은 아니다.
해당 그림의 문제가 있다.
운영자가 배송지 정보를 조회하고 배송 상태로 변경하는 사이에 고객이 배송지를 변경한다는 것이다.
즉, 배송 상태 변경 전에 배송지를 한번 더 확인하지 않으면 운영자는 다른 배송지로 물건을 발송하게 되고,
고객은 배송지를 변경했음에도 불구하고 엉뚱한 곳으로 주문한 물건을 받게 된다.
이 문제를 해결할 수 있는 것이 비선점 잠금이다.
비선점 잠금은 동시에 접근하는 것을 막는 것이 아닌, 변경한 데이터를 실제 DBMS에 반영하는 시점에 변경 가능 여부를 확인하는 방식이다.
애그리거트를 수정할 때마다 버전으로 사용할 프로퍼티 값이 1씩 증가하는 쿼리는 다음과 같다.
UPDATE aggtable SET version = version + 1, colx = ?, coly = ?
WHERE aggid = ? and version = 현재버전
수정에 성공하면 버전 값을 1 증가시킨다.
다른 트랜잭션이 먼저 데이터를 수정해서 버전 값이 바뀌면 데이터 수정에 실패하게 된다.
두 스레드 중 스레드1이 먼저 커밋을 시도하는데 해당 시점에 버전은 여전히 5이므로 애그리거트 수정에 성공하고 버전은 6으로 변경된다.
스레드1이 트랜잭션을 커밋한 후에 스레드2가 커밋을 시도하면 이미 애그리거트 버전이 6이므로 스레드2는 데이터 수정에 실패한다.
JPA는 버전을 이용한 비선점 잠금 기능을 지원한다.
@Entity
public class Order {
@Id
private Long id;
@Version
private Long version;
...
}
강제 버전 증가
애그리거트에 애그리거트 루트 외에 다른 엔티티가 존재하는데 기능 실행 도중 루트가 아닌 다른 엔티티의 값만이 변경된다고 가정해보자.
해당 경우 JPA는 루트 엔티티의 버전 값을 증가시키지 않는다.
연관된 엔티티의 값이 변경된다 해서 루트 엔티티 자체의 값은 변경되지 않기에 루트 엔티티의 버전 값은 갱신하지 않는 것이다.
하지만 이건 문제가 된다.
비록 루트 엔티티의 값이 바뀌지 않더라도 애그리거트의 구성 요소 중 일부가 변경되면 논리적으로 그 애그리거트는 바뀐 것이다.
따라서 애그리거트 내에 어떤 구성요소의 상태가 바뀌면 루트 애그리거트의 버전 값이 증가해야 비선점 잠금이 올바르게 작동한다.
public interface MemberRepository extends Repository<Member, Long> {
@Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT)
@QueryHints({@QueryHint(name="javax.persistence.lock.timeout", value="2000")})
Optional<Member> findByIdForUpdate(@Param Long memberId);
}
'책 정리 > 도메인 주도 개발 시작하기' 카테고리의 다른 글
Chapter 10. 이벤트 (1) | 2023.12.22 |
---|---|
Chapter 9. 도메인 모델과 바운디드 컨텍스트 (1) | 2023.12.21 |
Chapter 7. 도메인 서비스 (0) | 2023.12.12 |
Chapter 6. 응용 서비스와 표현 영역 (1) | 2023.12.12 |
Chapter 5. 스프링 데이터 JPA를 이용한 조회 기능 (0) | 2023.12.08 |