효습
8장 애그리거트 트랜잭션 관리 본문
8.1 애그리거트와 트랜잭션
운영자가 배송 상태를 변경할 때, 사용자가 배송지 주소를 수정하는 경우가 발생할 수 있다.
- 트랜잭션마다 리포지터리는 새로운 애그리거트 객체를 생성하므로 운영자 스레드와 고객 스레드는 같은 주문 애그리거트를 나타내는 다른 객체이다.
- 따라서 운영자 스레드는 주문 애그리거트 객체를 배송 상태로 변경하더라도 고객 스레드가 사용하는 객체에는 영향을 주지 않는다.
- 그런데 각각의 트랜잭션이 커밋될 경우, 운영자가 배송 상태를 변경하는 중에 고객에 배송지를 변경했으므로 이를 반영하지 못한다.
- 이렇게 되면 상품이 엉뚱한 곳으로 배송될 수 있고 애그리거트의 일관성이 깨진다.
이를 방지하기 위해서는
- 운영자가 배송 상태를 변경하는 동안 고객은 주문 애그리거트를 수정하지 못하도록 막는다.
- 운영자가 배송지를 조회한 후, 고객이 정보를 변경한다면 운영자가 애그리거트를 다시 조회하고 수정한다.
DBMS가 지원하는 트랜잭션과 함께 애그리거트를 위한 추가적인 트랜잭션 처리 기법이 필요하다.
- 선점(Pessimistic)
- 비선점(Optimistic)
8.2 선점 잠금
먼저 애그리거트를 구한 스레드가 애그리거트 사용이 끝날 때까지 다른 스레드가 해당 애그리거트를 수정하지 못하도록 막는 방식이다.
- 스레드2는 스레드1이 애그리거트에 대한 잠금을 해제할 때까지 블로킹(Blocking)된다.
선점 잠금은 보통 DBMS가 제공하는 행단위 잠금을 사용해서 구현한다.
- 오라클을 비롯한 다수의 DBMS가 for update와 같은 쿼리를 사용해서 특정 레코드에 한 커넥션만 접근할 수 있는 잠금 장치를 제공한다.
- JPA EntityManager는 LockModeType을 인자로 받는 find() 메서드를 제공한다.
- JPA 프로바이더와 DBMS에 따라 잠금 모드 구현이 다르다.
- 하이버네이트의 경우, PESSIMISTIC_WRITE를 잠금 모드로 사용하면
for update
쿼리를 이용하여 잠금을 구현한다. - 스프링 데이터 JPA는
@Lock
애너테이션을 사용하여 잠금 모드를 지정한다.
- 하이버네이트의 경우, PESSIMISTIC_WRITE를 잠금 모드로 사용하면
8.2.1 선점 잠금과 교착 상태
- 선점 잠금 기능을 사용할 때는 잠금 순서에 따른 교착 상태 (deadlock)이 발생하지 않도록 주의해야한다.
- 선점 잠금에 따른 교착 상태는 상대적으로 사용자 수가 많을 때 발생할 가능성이 높고, 사용자 수가 많아지면 교착 상태에 빠지는 스레드는 더 빠르게 증가한다.
- 이런 문제를 방지하기 위해 잠금을 구할 때 최대 대기 시간을 지정해야한다.
JPA에서 선점 잠금을 시도할 때 최대 대기 시간을 지정하려면 힌트를 사용한다.
Map<String, Object> hints = new HashMap<>();
hints.put("javax.persistence.lock.timeout", 2000);
Order order = entityManager.find( Order.class, orderNo,
LockModeType.PRESSIMISTIC_WRITE, hints);
- JPA의
javax.persistence.lock.timeout
힌트는 잠금을 구하는 대기 시간을 밀리초 단위로 지정한다. - 지정한 시간 이내에 잠금이 끝나지 않으면 익셉션을 발생시킨다.
- 이 힌트를 사용할 때 주의할 점은 DBMS에 따라 힌트가 적용되지 않을 수 있다.사용 중인 DBMS가 이를 지원하는지 확인해야함
스프링 데이터 JPA는 @QueryHints
애너테이션을 사용해서 쿼리 힌트를 지정할 수 있다.
@QueryHints({
@QueryHint(name="javax.persistence.lock.timeout", value="2000")
})
8.3 비선점 잠금
비선점 잠금은 동시에 접근하는 것을 막는 대신 변경한 데이터를 실제 DBMS에 반영하는 시점에 변경 가능 여부를 확인하는 방식이다.
비선점 잠금을 구현하려면 애그리거트에 버전으로 사용할 숫자 타입 프로퍼티를 추가해야 한다.
- 애그리거트를 수정할 때마다 버전으로 사용할 프로퍼티 값이 1씩 증가하게 한다.
UPDATE aggtable SET version = version +1, colx=? , coly=?
WHERE aggid = ? and version = 현재버전
- 수정할 애그리거트와 매핑되는 테이블의 버전 값이 현재 애그리거트의 버전과 동일한 경우에만 데이터를 수정하고 수정한 다음 버전 값을 1 증가시킨다.
- JPA는 버전을 이용한 비선점 잠금 기능을 지원한다.
- 응용 서비스는 버전에 대해 알 필요가 없다
- 리포지터리에서 필요한 애그리거트를 구한고 알맞는 기능만 실행하면 된다.
- 비선점 잠금을 위한 쿼리를 실행할 때 쿼리 실행 결과로 수정된 행의 개수가 0이면 이미 누가 앞서 데이터를 수정한 것이다.
- 이는 트랜잭션이 충돌한 것이므로 트랜잭션 종료 시점에 익셉션이 발생한다.
OptimisticLockingFailureException
이 발생한다.
- 비선점 잠금 방식을 여러 트랜잭션으로 확대하려면 애그리거트 정보를 뷰로 보여줄 때 버전 정보도 함께 사용자 화면에 전달해야한다.
- HTML 폼을 생성하는 경우 버전 값을 hidden 타입의 <input>태그를 생성해서 서버에 전달한다.
- 응용 서비스에 전달한 요청 데이터는 사용자가 전송한 버전 값을 포함한다.
- 응용 서비스는 전달받은 버전 값을 이용해서 애그리거트 버전과 일치하는지 확인하고, 일치하는 경우에만 기능을 수행한다.
- 버전이 충돌한 경우 응용 서비스는 익셉션을 발생시켜 표현 계층에 이를 알린다.
비선점 잠금과 관련해서 발생하는 두 개의 익셉션이 있다.
OptimisticLockingFailureException
: 누군가가 동시에 애그리거트를 수정했다는 의미VersionConflictException
: 이미 누군가가 애그리거트를 수정했다는 의미- 버전 충돌 상황에 대한 구분이 명시적으로 필요 없다면 응용 서비스에서 프레임워크용 익셉션을 발생시키는 것도 고려할 수 있다.
8.3.1 강제 버전 증가
애그리거트에 애그리거트 루트 외에 다른 엔티티가 존재하는데 기능 실행 도중 루트가 아닌 다른 엔티티의 값만 변경된다고 하자
- 이 경우 JPA는 루트 엔티티의 버전 값을 증가시키지 않는다.
- 연관된 엔티티의 값이 변경된다고 해도 루트 엔티티 자체의 값은 바뀌는 것이 없어서 갱신하지 않는 거임
- 하지만 애그리거트 관점에서는 구성 요소의 값이 바뀌었으므로 루트 애그리거트의 버전 값이 증가해야 비선점 잠금 방식이 올바르게 동작한다.
LockModeType.OPTIMISTIC_FORCE_INCREMENT
를 사용하면 해당 엔티티의 상태가 변경되었는지에 상관없이 트랜잭션 종료 시점에 버전 값 증가 처리를 한다.
8.4 오프라인 선점 잠금
구글 문서의 경우 누군가가 편집 중이라면 편집중이라는 표시를 해주지만 동시에 수정하는 것을 막지는 않는다.
이는 선점 잠금이나 비선점 잠금으로 구현할 수 없다
→ 오프라인 선점 잠금 방식(Offline Pessimistic Lock)을 사용해야한다.
- 여러 트랜잭션에 걸쳐 동시 변경을 막는다.
- 첫 번째 트랜잭션을 시작할 때 오프라인 잠금을 선점하고
- 마지막 트랜잭션에서 잠금을 해제한다.
- 잠금을 해제하기 전까지는 다른 사용자는 잠금을 할 수 없다.
마지막 트랜잭션을 수행하지 않고 프로그램을 종료하면 영원히 다른 사용자는 본인의 요청을 위한 잠금을 구할 수 없게 된다.
- 이런 사태를 막기 위해 선점 잠금 방식은 유효 시간을 가져야한다.
- 유효 시간이 지나면 자동으로 잠금을 해제해서 다른 사용자가 잠금을 일정 시간 후에 다시 구할 수 있도록 해야 한다.
- 사용자가 유효 시간이 지난 후 마지막 트랜잭션을 수행하면 에러가 난다.
- 일정 주기로 유효 시간을 증가시키는 방법이 필요하다.
8.4.1. 오프라인 선점 잠금을 위한 LockManager 인터페이스와 관련 클래스
오프라인 선점 잠금은 크게 잠금 선점 시도, 잠금 확인, 잠금 해제, 잠금 유효시간 연장의 네 가지 기능이 필요하다.
public interface LockManager{
//잠금을 식별할 때 사용할 LockId를 리턴함
LockId tryLock(String type, String id) throws LockException;
void checkLock(LockId lockId) throws LockException;
void releaseLock(LockId lockId) throws LockException;
void extendLockExpiration(LockId lockId) throws LockException;
}
- tryLock이 리턴한 LockId를 모델로 뷰에 전달하여 보관한다.
8.4.2 DB를 이용한 LockManager 구현
'책 > 도메인 주도 개발 시작하기' 카테고리의 다른 글
10장 이벤트 (0) | 2024.05.07 |
---|---|
9장 도메인 모델과 바운디드 컨텍스트 (2) | 2024.04.08 |
7장 도메인 서비스 (0) | 2024.04.01 |
6장 응용 서비스와 표현 영역 (0) | 2024.03.25 |
5장 스프링 데이터 JPA를 이용한 조회 기능 (1) | 2024.03.25 |