하나의 주문 애그리거트에 대해 운영자는 배송 상태로 변경을, 사용자는 배송지 주소를 변경하면 어떻게 될까?
트랜잭션마다 리포지토리는 새로운 애그리거트 객체를 생성하므로 운영자 스레드와 고객 스레드는 같은 주문 애그리거트를 나타내는 다른 객체를 구하게 된다.
운영자 스레드와 고객 스레드는 개념적으로 동일한 애그리거트지만 물리적으로 서로 다른 애그리거트 객체를 사용한다.
때문에 운영자 스레드가 배송 상태로 변경하더라도 고객 스레드가 사용하는 객체에는 영향을 주지 않는다.
고객 스레드 입장에서 주문 애그리거트 객체는 아직 배송 상태 전이므로 배송지 정보를 변경할 수 있다.
이 상황에서 두 스레드는 각각 트랜잭션을 커밋할 때 수정한 내용을 DB에 반영한다.
이 시점에 배송 상태가 바뀌고, 배송지 정보도 바뀌게 된다.
이 순서의 문제점은 기존 배송지를 이용해 배송 상태를 변경했는데, 그 사이 고객은 배송지 정보를 변경했다는 점이다.
애그리거트의 일관성이 깨졌다.
일관성이 깨지는 문제가 발생하지 않도록 다음 두 가지 중 하나를 꼭 수행해야 한다.
- 운영자가 배송지 정보를 조회하고 상태를 변경하는 동안, 고객이 수정하지 못하게 막는다.
- 운영자가 배송지 정보를 조회한 이후에 고객이 정보를 변경하면, 운영자가 애그리거트를 다시 조회한 뒤 수정하도록 한다.
이 두 가지는 애그리거트 자체의 트랜잭션과 관련이 있다.
DBMS가 지원하는 트랜잭션과 함께 애그리거트를 위한 추가적인 트랜잭션 처리 기법이 필요하다!
대표적인 트랜잭션 처리 방식으로 선점 잠금, Pessimistic Lock과 비선점 잠금, Optimistic Lock이 있다.
선점 잠금은 먼저 애그리거트를 구한 스레드가 애그리거트 사용이 끝날 때까지 다른 스레드가 수정하지 못하게 막는 방식이다.

한 스레드가 애그리거트를 구하고 수정하는 동안 다른 스레드가 수정할 수 없으므로 동시성, 데이터 충돌 문제를 해소할 수 있다.
운영자가 스레드를 먼저 선점 잠금 방식으로 애그리거트를 구하면 운영자 스레드가 잠금을 해제할 때까지 고객 스레드는 대기 상태가 된다.
운영자 스레드가 배송 상태를 변경한 뒤 트랜잭션을 커밋하면 잠금을 해제한다.
잠금이 해제된 시점에 고객 스레드가 구하는 주문 애그리거트는 운영자 스레드가 수정한 배송 상태의 애그리거티이다.
배송 상태이므로 주문 애그리거트는 배송지 변경 시에 에러를 발생하고 트랜잭션은 실패하게 된다.
이 시점에 고객은 '이미 배송이 시작되어 배송지를 변경할 수 없습니다' 같은 안내 문구를 보게 된다.
선점 잠금은 보통 DBMS가 제공하는 행단위 잠금을 사용해서 구현한다.
다수의 DBMS는 for update 같은 쿼리를 사용해서 특정 레코드에 한 커넥션만 접근할 수 있는 잠금 장치를 제공한다.
EntityManager는 LockModeType 을 인자로 받는 find() 를 제공한다.
인자에 타입을 전달하면 해당 엔티티와 매핑된 테이블을 이용해서 선점 잠금 방식을 적용할 수 있다.
Order order = entityManager.find(Order.class, OrderNo, LockModeType.PESSIMISTIC_WRITE);
스프링 데이터 JPA는 @Lock 에노테이션을 사용해서 잠금 모드를 지정한다.
public interface MemberRepository extends Repository<Member, MemberId>() {
@Lock(LockModeType.PESSIMISTTIC_WRITE)
@Query("select m from Member m where m.id = :id")
Optional<Member> findByIdForUpdate(@Param("id") MemberId memberId);
...
}
선점 잠금 기능을 사용할 때는 잠금 순서에 따른 데드락이 발생하지 않도록 주의해야 한다.
두 스레드가 상대방 스레드가 먼저 선점하고 있는 잠금을 구하고자 할 때 더 이상 다음 단계를 진행하지 못하게 됨으로써 교착 상태에 빠진다.
선점 잠금에 따른 데드락은 상대적으로 사용자 수가 많을 때 발생할 가능성이 높고, 사용자 수가 많아지면 데드락에 빠지는 스레드는 더 빠르게 증가한다.
더 많은 스레드가 데드락에 걸릴수록 시스템은 아무것도 할 수 없는 상태가 된다.
이런 문제가 발생하지 않도록 하기 위해 잠금을 구할 때 최대 대기 시간을 지정해야 한다.
// JPA에서 선점 잠금을 시도하기 위해 최대 대기 시간 지정
Map<String, Object> hints = new HashMap<>();
// "javax.persistence.lock.timeout" 힌트는 잠금을 구하는 대기 시간을 밀리초 단위로 지정
// 지정한 시간 이내에 잠금을 구하지 못하면 Exception 발생
// DBMS에 따라 힌트가 적용되지 않을 수 있으므로 관련 기능을 지원하는지 확인
hints.put("javax.persistence.lock.timeout", 2000);
Order order = entityManager.find(Order.class, orderNo, LockModeType.PESSIMISTIC_WRITE, hints);
스프링 데이터 JPA는 @QueryHints 에노테이션을 사용해서 쿼리 힌트를 지정할 수 있다.
public interface MemberRepository extends Repository<Member, MemberId>() {
@Lock(LockModeType.PESSIMISTTIC_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가 어떤 방식으로 대기 시간을 처리하는지 반드시 확인하자.
선점 잠금이 강력해 보이긴 하지만 모든 트랜잭션 충돌 문제를 해결할 수 있는 것은 아니다.
다음과 같은 실행 순서가 있다.
1. 운영자는 배송을 위해 주문 정보를 조회한다. 시스템은 정보를 제공한다.
2. 고객이 배송지 변경을 위해 변경 폼을 요청한다. 시스템은 변경 폼을 제공한다.
3. 고객이 새로운 배송지를 입력하고 폼을 전송하여 배송지를 변경한다.
4. 운영자가 1번에서 조회한 주문 정보를 기준으로 배송지를 정하고 배송 상태 변경을 요청한다.
문제는 운영자가 배송지 정보를 조회하고 배송 상태를 변경하는 사이에 고객이 배송지를 변경한다는 것이다.
운영자는 고객이 변경하기 전 배송지 정보를 이용해 배송 상태를 변경하게 된다.
즉, 배송 상태 변경 전에 배송지를 한 번 더 확인하지 않으면 엉뚱한 곳으로 주문한 물건을 받는 상황이 발생하게 된다.
이 문제는 선점 잠금 방식으로는 해결할 수 없으며, 이 때 필요한 것이 비선점 잠금이다.
비선점 잠금은 동시에 접근하는 것을 막는 대신, 변경한 데이터를 실제 DBMS에 반영하는 시점에 변경 가능 여부를 확인하는 방식이다.
비선점 잠금을 구현하려면 애그리거트 버전으로 사용할 숫자 타입 프로퍼티를 추가해야 한다.
애그리거트를 수정할 때마다 버전으로 사용할 프로퍼티 값이 1씩 증가하는데, 다음과 같은 쿼리를 사용한다.
UPDATE aggtable SET version = version + 1, colx = ?, coly = ? WHERE aggid = ? and version = 현재버전
해당 쿼리는 수정할 애그리거트와 매핑되는 테이블의 버전 값이 현재 애그리거트의 버전과 동일한 경우에만 데이터를 수정한다.
그리고 수정에 성공하면 버전 값을 1 증가시킨다.
다른 트랜잭션이 먼저 데이터를 수정햇 버전 값이 바뀌면 데이터 수정에 실패한다.
아틀라시안의 Confluence는 문서를 편집할 떄 누군가 먼저 편집을 하는 중이면 다른 사용자에 의해 문서가 수정중이라는 안내 문구를 띄운다.
이런 안내를 통해 여러 사용자가 동시에 한 문서를 수정할 때 발생하는 충돌을 사전에 방지할 수 있게 해준다.
그러나 사전에 충돌 여부를 알려주지만 동시에 수정하는 것을 막지는 않는다.
더 엄격하게 데이터 충돌을 막고 싶다면 누군가 수정 화면을 보고 있을 때 수정 화면 자체를 실행하지 못하게 해야 한다.
한 트랜잭션 범위에서만 적용되는 선점 잠금, 나중에 버전 충돌을 확인하는 비선점 잠금 방식으로는 이를 구현할 수 없다.
이때 필요한 것이 오프라인 선점 잠금, Offline Pessimistic Lock 이다.
단일 트랜잭션에서 동시 변경을 막는 선점 잠금과 달리, 여러 트랜잭션에 걸쳐 동시 변경을 막는다.
첫 번째 트랜잭션을 시작할 때 오프라인 잠금을 선점하고, 마지막 트랜잭션에서 잠금을 해제한다.
잠금을 해제하기 전까지 다른 사용자는 잠금을 구할 수 없다.
만약 사용자가 잠금을 해제하지 않고 프로그램을 종료한다면 다른 사용자는 영원히 잠금을 구할 수 없는 상황이 발생한다.
이런 상태를 방지하기 위해 오프라인 선점 방식은 잠금 유효 시간을 가져야 한다.
유효 시간이 지나면 자동으로 잠금을 해제하고 다른 사용자가 일정 시간 후에 잠금을 다시 구할 수 있도록 해야 한다.
사용자 A가 잠금 유효 시간이 지난 후 1초 뒤에 수정 요청을 수행했다고 가정하자.
잠금이 해제되어 사용자 A는 수정에 실패하게 된다.
이런 상황을 방지하귀 위해 일정 주기로 유효 시간을 증가시키는 방식이 필요하다.
예를 들어 수정 폼에서 1분 단위로 Ajax 호출을 해서 잠금 유효 시간을 1분씩 증가시키는 방법이 있다.