ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [도메인 주도 개발 시작하기] 8. 애그리거트 트랜잭션 관리
    STUDY/DDD 2022. 12. 19. 23:22

    1. 애그리거트와 트랜잭션

     

    한 애그리거트를 두 사용자가 동시에 변경할 때 트랜잭션이 필요하다.

     

    운영자와 고객이 동시에 한 주문 애그리거트를 수정할 경우, 트랜잭션마다 리포지터리는 새로운 애그리거트 객체를 생성하므로 운영자 스레드와 고객 스레드는 같은 주문 애그리거트를 나타내는 다른 객체를 구하게 된다.

     

    운영자 스레드와 고객 스레드는 개념적으로 동일한 애그리거트지만 물리적으로 서로 다른 애그리거트 객체를 사용하므로 서로 영향을 주지 않는다. 예를들어 운영자가 주문 애그리거트 객체를 배송 상태로 변경하더라도 고객 스레드에서 사용하는 객체에 영향을 주지 않으므로, 고객 입장에서는 배송 상태 전이므로 배송지 정보를 변경할 수 있다. 이 상황에서 두 스레드는 각각 트랜잭션을 커밋할 때 수정한 내용을 DB에 반영한다, 이 시점에 배송 상태로 바뀌고 배송지 정보도 바뀌게 된다. 여기서 문제는 운영자는 기존 배송 정보를 이용해서 배송 상태로 변경했는데 그 사이 고객은 배송지 정보를 변경했다는 점이다. 즉, 애그리거트의 일관성이 깨지는 문제가 발생한다.

     

    애그리거트의 일관성이 깨지지 않기 위해서는

    • 운영자가 배송지 정보를 조회하고 상태를 변경하는 동안, 고객이 애그리거트를 수정하지 못하게 막는다.
    • 운영자가 배송지 정보를 조회한 이후에 고객이 정보를 변경하면, 운영자가 애그리거트를 다시 조회한 뒤 수정하도록 한다.

    두 가지 중 하나를 해야 한다. 이 두가지는 애그리거트 자체의 트랜잭션과 관련이 있다. DBMS가 지원하는 트랜잭션과 함께 애그리거트를 위한 추가적인 트랜잭션 처리 기법이 필요하다. 애그리거트에 대해 사용할 수 있는 대표적인 트랜잭션 처리 방식에는 선점(Pessimistic) 잠금과 비선점(Optimistic) 잠금 방식이 있다.

     

    2. 선점 잠금

     

    선점(Pessimistic) 잠금은 먼저 애그리거트를 구한 스레드가 애그리거트 사용이 끝날 때까지 다른 스레드가 해당 애그리거트를 수정하지 못하게 막는 방식이다.

     

    선점 잠금의 동작 방식

     

     

    스레드1이 선점 잠금 방식으로 애그리거트를 구한 뒤 이어서 스레드2가 같은 애그리거트를 구하고 있다. 이때 스레드2는 스레드1이 애그리거트에 대한 잠금을 해제할 때까지 블로킹(Blocking)된다. 

     

    스레드1이 애그리거트를 수정하고 트랜잭션을 커밋하면 잠금을 해제한다. 이 순간 대기하고 있던 스레드2가 애그리거트에 접근하게 된다. 스레드1이 트랜잭션을 커밋한 뒤에 스레드2가 애그리거트를 구하게 되므로 스레드2는 스레드1이 수정한 애그리거트의 내용을 보게 된다.

     

    한 스레드가 애그리거트를 구하고 수정하는 동안 다른 스레드가 수정할 수 없으므로 동시에 애그리거트를 수정할 때 발생하는 데이터 충동 문제를 해소할 수 있다.

     

    선점 잠금은 보통 DBMS가 제공하는 행단위 잠금을 사용해서 구현한다. 오라클을 비롯한 다수의 DBMS가 for update와 같은 쿼리를 사용해서 특정 레코드에 한 커넥션만 접근할 수 있는 잠금장치를 제공한다. JPA EntityManager는 LockModeType을 인자로 받는 find() 메서드를 제공한다. LockModeType.PESSIMISTIC_WRITE를 값으로 전달하면 해당 엔티티와 매핑된 테이블을 이용해서 선점 잠금 방식을 적용할 수 있다.

     

    Order order = entityManager.find(Order.class, orderNo, LockModeType.PESSIMISTIC_WRITE);

     

    선점 잠금 기능을 사용할 때는 잠금 순서에 따른 교착 상태(deadlock)가 발생하지 않도록 주의해야 한다. 예를 들어, 다음과 같은 순서로 두 스레드가 잠금 시도를 한다고 해보자.

     

    1. 스레드1 : A 애그리거트에 대한 선점 잠금 구함

    2. 스레드2 : B 애그리거트에 대한 선점 잠금 구함

    3. 스레드1 : B 애그리거트에 대한 선점 잠금 시도

    4. 스레드2 : A 애그리거트에 대한 선점 잠금 시도

     

    이 순서에 따르면 스레드1은 영원히 B 애그리거트에 대한 선점 잠금을 구할 수 없다. 왜냐하면 스레드2가 B 애그리거트에 대한 잠금을 이미 선점하고 있기 때문이다. 동일한 이유로 스레드2는 A 애그리거트에 대한 잠금을 구할 수 없다. 두 스레드는 상대방 스레드가 먼저 선점한 잠금을 구할 수 없어 더 이상 다음 단계를 진행하지 못하는 교착 상태에 빠진다.

     

    선점 잠금에 따른 교착 상태는 상대적으로 사용자 수가 많을 때 발생할 가능성이 높고, 사용자 수가 많아지면 교착 생태에 빠지는 스레드는 더 빠르게 증가한다. 더 많은 스레드가 교착 상태에 빠질수록 시스템은 아무것도 할 수 없는 상태가 된다. 이런 문제가 발생하지 않도록 하려면 잠금을 구할 때 최대 대기 시간을 지정해야 한다.

     

    3. 비선점 잠금

     

    선점 잠금이 강력해 보이긴 하지만 선점 잠금으로 모든 트랜잭션 충돌 문제가 해결되는 것은 아니다.

     

     

    1. 운영자는 배송을 위해 주문 정보를 조회한다. 시스템은 정보를 제공한다.

    2. 고객이 배송지 변경을 위해 변경 폼을 요청한다. 시스템은 변경 폼을 제공한다.

    3. 고객이 새로운 배송지를 입력하고 폼을 전송하여 배송지를 변경한다.

    4. 운영자가 1번에서 조회한 주문 정보를 기준으로 배송지를 정하고 배송 상태 변경을 요청한다.

     

    여기서 문제는 운영자가 배송지 정보를 조회하고 배송 상태로 변경하는 사이에 고객이 배송지를 변경한다는 것이다. 운영자는 고객이 변경하기 전 배송지 정보를 이용하여 배송 준비를 한 뒤에 배송 상태로 변경하게 된다. 즉, 배송 상태 변경 전에 배송지를 한 번 더 확인하지 않으면 운영자는 다른 배송지로 물건을 발송하게 되고, 고객은 배송지를 변경했음에도 불구하고 엉뚱한 곳으로 주문한 물건을 받는 상황이 발생한다.

     

    이런 문제는 선점 잠금 방식으로는 해결할 수 없고, 이 때 필요한 것이 비선점 잠금이다. 비선점 잠금은 동시에 접근하는 것을 막는 대신 변경한 데이터를 실제 DBMS에 반영하는 시점에 변경 가능 여부를 확인하는 방식이다.

     

    비선점 잠금을 구현하려면 애그리거트에 버전으로 사용할 숫자 타입 프로퍼티를 추가해야 한다. 애그리거트를 수정할 때마다 버전으로 사용할 프로퍼티 값이 1씩 증가하게 된다.

    UPDATE aggtable SET version = version + 1, colx = ?, coly = ?
    WHERE addid = ? and version = 현재버전

     

    해당 쿼리는 수정할 애그리거트와 매핑되는 테이블의 버전 값이 현재 애그리거트의 버전과 동일한 경우에만 데이터를 수정한다. 그리고 수정에 성종하면 버전 값을 1 증가시킨다. 다른 트랜잭션이 먼저 데이터를 수정해서 버전 값이 바뀌면 데이터 수정에 실패하게 된다.

     

     

    비선점 잠금과 관련해서 두 개의 익셉션이 발생할 수 있다. 하나는 스프링프레임워크가 발생시키는 OptimisticLockingFailureException이고 다른 하나는 응용 서비스 코드에서 발생시키는 VersionConflictException이다. VersionConflictException은 이미 누군가 애그리거트를 수정했다는 것을 의미하고, OptimisticLockingFailureException은 누군가가 거의 동시에 애그리거트를 수정했다는 것을 의미한다. 버전 충돌 상황에 대한 구분이 명시적으로 필요 없다면 응용 서비스에서 프레임워크용 익셉션을 발생시키는 것도 고려할 수 있다.

     

    애그리거트에 애그리거트 루트 외에 다른 엔티티가 존재할 때, 기능 실행 도중 루트가 아닌 다른 엔티티의 값만 변경될 경우 루트 엔티티의 버전 값을 증가시키지는 않는다. 연관된 엔티티의 값이 변경돼도 루트 엔티티 자체의 값이 바뀌는 것이 없으므로 루트 엔티티의 버전 값은 갱신하지 않는 것이다. 하지만 루트 엔티티의 값이 바뀌지 않았더라도 애그리거트의 구성요소 중 일부 값이 바뀌면 논리적으로 그 애그리거트는 바뀐 것으로 볼 수 있으므로, 애그리거트 내에 어떤 구성요소의 상태가 바뀌면 루트 애그리거트의 버전 값이 증가해야 비선점 잠금이 올바르게 동작한다.

     

    이런 문제를 처리할 수 있도록 JPA는 강제로 버전 값을 증가시키는 잠금 모드를 지원한다. LockModeType.OPTIMISTIC_FORCE_INCREMENT를 사용하면 해당 엔티티의 상태가 변경되었는지에 상관없이 트랜잭션 종료 시점에 버전 값 증가 처리를 한다. 이 잠금 모드를 사용하면 애그리거트 루트 엔티티가 아닌 다른 엔티티나 밸류가 변경되더라도 버전 값을 증가시킬 수 있으므로 비선점 잠금 기능을 안전하게 적용할 수 있다.

     

    4. 오프라인 선점 잠금

     

    단일 트랜잭션에서 동시 변경을 막는 선점 잠금 방식과 달리 오프라인 선점 잠금은 여러 트랜잭션에 걸쳐 동시 변경을 막는다. 첫 번째 트랜잭션을 시작할 떄 오프라인 잠금을 선점하고, 마지막 트랜잭션에서 잠금을 해제한다. 잠금을 해제하기 전까지 다른 사용자는 잠금을 구할 수 없다.

     

     

    위와 같은 상황에서 만약 사용자A가 3번 수정 요청을 수행하지 않고 프로그램을 종료하는 경우 잠금을 해제하지 않으므로 다른 사용자는 영원히 자금을 구할 수 없는 상황이 발생한다. 이런 상태를 방지하기 위해 오프라인 선점 방식은 잠금 유효 시간을 가져야 한다. 유효 시간이 지나면 자동으로 잠금을 해제해서 다른 사용자가 잠금을 일정 시간 후에 다시 구할 수 있도록 해야한다. 하지만 사용자가 유효 시간 이후 요청을 하는 경우가 발생할 수 있으므로, 일정 주기로 유효 시간을 증가시켜줘야 한다.

    • 잠금 유효 시간이 지났으면 이미 다른 사용자가 잠금을 선점한다.
    • 잠금을 선점하지 않은 사용자가 기능을 실행했다면 기능 실행을 막아야 한다.

     

    오프라인 선점 잠금은 크게 잠금 선점 시도, 잠금 확인, 잠금 해제, 잠금 유효시간 연장의 네 가지 기능이 필요하다.

    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;
    }

     

    출처 : 최범균, 『도메인 주도 개발 시작하기 DDD 핵심 개념 정리부터 구현까지』, 한빛미디어(2022), p246-p273

Designed by Tistory.