8장: 애그리거트 트랜잭션 관리
Last updated
Last updated
운영자와 고객이 동시에 한 애그리거트를 수정하는 상황이 발생할 수 있다.
이 때 트랜잭션마다 리포지터리는 새로운 애그리거트 객체를 생성하므로 운영자 스레드와 고객 스레드는 같은 주문 애그리거트를 나타내는 다른 객체를 가져와 사용하게 된다.
두 스레드는 각각 트랜잭션을 커밋할 때 수정한 내용을 DB에 반영하는데, 만약 판매자가 배송이 되었다는 버튼을 눌러 배송 상태를 변경하는 사이에 고객이 배송지를 변경했다면 문제가 발생할 것이다.
이를 해결하기 위해서는 선점 락(Pessimisitic Lock) 혹은 비선점 락 기법(Optimistic Lock)을 사용할 수 있다.
애그리거트를 먼저 조회한 스레드가 애그리거트 사용이 끝날 때까지 다른 스레드는 해당 애그리거트를 수정하지 못하게 막는 방식
한 스레드가 먼저 애그리거트를 수정하고 있다면, 다른 스레드는 애그리거트에 대한 잠금이 해제될 때 까지 블로킹된다.
이 방식은 동시에 애그리거트를 수정할 때 발생하는 데이터 충돌 문제를 원천적으로 방지한다.
예를 들면 아래와 같이 운영자 스레드가 먼저 주문 애그리거트에 대해 수정을 하려고 했다면, 고객 스레드가 주문 애그리거트를 동시에 수정하지 못하도록 락을 건다. 고객 스레드는 주문 애그리거트를 구할 수 없기 때문에 기다리거나, 바로 에러를 반환할 수 있다.
DBMS는 행 단위 잠금을 제공하여 특정 레코드에 하나의 커넥션만 접근할 수 있는 잠금장치를 제공한다.
JPA의 EntityManager에서는 LockModeType을 인자로 받는 find()
메서드를 제공하기 때문에 아래와 같이 Pessimistic lock을 적용할 수 있다.
Spring Data JPA에서는 @Lock
어노테이션을 제공하여 잠금 모드를 지정할 수 있다.
하이버네이트의 경우 PESSIMISTIC_WRITE를 잠금 모드로 사용하면 for update
쿼리를 이용해 선점 잠금을 구현한다.
여러 스레드 간 락에 의한 데드락이 발생하지 않도록 주의해야 한다.
만약 스레드1이 A 애그리거트 → B 애그리거트
순서로 수정하고 스레드2는 B 애그리거트 → A 애그리거트
순서로 수정하는 상황에서 스레드1은 A 애그리거트에 대한 락을 갖고 스레드2는 B 애그리거트에 대한 락을 가지고 있다면 두 스레드는 데드락에 걸려 영원히 멈춰있게 된다.
이러한 문제를 방지하려면 잠금 시 최대 대기 시간을 지정해야 한다.
JPA에서는 find 메서드의 인자로 hints Map 객체를 입력해 최대 대기 시간을 지정할 수 있다.
Spring Data JPA에서는 @QueryHints
어노테이션을 사용해 최대 대기 시간을 지정할 수 있다.
선점 잠금으로 모든 트랜잭션 충돌 문제가 해결되지는 않는다.
아래와 같이 운영자가 배송지 정보를 조회하고 배송 상태로 변경하는 사이에 고객이 배송지를 변경할 수 있다. 이렇게 되면 운영자는 잘못된 배송지에 물건을 발송하게 된다.
동시에 접근하는 것을 막는 대신 변경한 데이터를 실제 DBMS에 반영하는 시점에 변경 가능 여부를 확인하는 방식
애그리거트에 버전으로 사용할 숫자 타입 프로퍼티를 추가해야 한다. 이 때 애그리거트 버전은 수정할 때마다 1씩 증가하게 된다.
업데이트 쿼리는 대략 아래와 같을 것이다. 수정할 애그리거트에 매핑되는 테이블의 버전이 현재 애그리거트 버전과 동일하면 수정할 수 있다.
JPA에서는 버전을 이용한 비선점 잠금 기능을 사용할 수 있도록 @Version
어노테이션을 제공한다. 엔티티가 변경되어 UPDATE 쿼리를 실행할 때, version 필드를 사용해 비선점 락 쿼리를 실행한다.
만약 UPDATE 쿼리가 실행되지 못했다면 누군가 먼저 데이터를 수정한 것이므로 트랜잭션 종료 시점에 예외가 발생한다.
아래와 같이 @Transactional으로 트랜잭션을 설정하고 있고 changeShippingInfo가 실패했다면 OptimisticLockingFailureException이 발생할 것이다.
위와 같이 트랜잭션 충돌이 나는 것을 방지하기 위해서는 사용자가 수정 요청을 보낼 때 애그리거트 버전도 함께 보내 만약 현재 애그리거트 버전과 다르다면 아예 수정 요청을 보내지 않도록 한다.
예를 들면 아래와 같은 흐름이 될 것이다.
애그리거트의 루트 엔티티 외에 다른 엔티티의 값이 변경되더라도 버전 값이 증가되어야 논리적으로 변경된 애그리거트를 표현할 수 있다. 하지만 JPA에서는 루트 엔티티가 변경되지 않으면 루트 엔티티의 버전 값을 증가시키지 않는다.
이를 해결하기 위해 JPA는 강제로 버전 값을 증가시키는 LockModeType.OPTIMISTIC_FORCE_INCREMENT
모드를 제공한다.
해당 엔티티의 상태가 변경되었는지에 상관없이 트랜잭션 종료 시점에 버전 값 증가 처리를 하여 애그리거트 루트 엔티티가 아닌 다른 엔티티나 밸류가 변경되더라도 버전 값을 증가시킬 수 있다.
조회 쿼리를 보낼 때마다 항상 버전 값을 증가시키긴 하지만 Update 전용 읽기 메서드나 OptimisticLockMode 전용 읽기 메서드에만 사용한다면 큰 문제는 없을 것으로 보인다.
Spring Data JPA 사용 시 아래와 같이 사용하면 된다.
여러 사용자가 동시에 편집할 수 있는 공유 문서 프로그램의 경우 사전에 다른 사용자가 함께 수정하고 있다는 안내 문구를 보여주며 데이터 충돌을 사전에 방지하도록 한다.
오프라인 선점 락 방식은 여러 트랜잭션에 걸쳐 동시 변경을 막는다. 첫 번째 트랜잭션을 시작할 때 오프라인 락을 선점하고, 마지막 트랜잭션에서 락을 해제한다.
예를 들어 아래와 같이 사용자 A가 수정 폼을 요청하고 실제 수정을 요청하는 두 트랜잭션에 걸친 오프라인 선점 락을 걸게 되면, 사용자 B는 사용자 A가 수정 요청을 보내고 수정이 완료되는 시점까지 수정 요청 폼 조차 얻을 수 없게 된다.
만약 사용자 A가 수정 폼 요청만 보내고 수정 요청은 안보낸 채 프로그램을 종료한다면 다른 사용자는 영원히 오프라인 락을 선점할 수 없어 수정 폼 조차 요청할 수 없게 된다.
이를 방지하기 위해 오프라인 선점 방식은 잠금 유효 시간을 가져야 한다.
오프라인 선점 락은 잠금 선점 시도, 잠금 확인, 잠금 해제, 잠금 유효시간 연장의 네 가지 기능이 필요하다.
락을 걸기 위해서는 대상 타입과 식별자를 입력받고, 락 식별자를 반환한다.
락 식별자는 락을 해제하거나 유효성 검사를 하거나 유효 시간을 늘릴 때 필요하다.
아래는 LockManager를 통해 락을 관리하는 예제 코드이다.