ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 8장 - 애그리거트 트랜잭션 관리
    📕 book/도메인 주도 개발 시작하기 2023. 1. 12. 22:32

    📌 애그리거트와 트랜잭션


    한 애그리거트를 두 사용자가 동시에 변경할 경우 애그리거트의 일관성이 깨질 수 있다.

     

    일관성이 깨지는 문제가 발생하지 않도록 하려면 아래 두 가지 중 하나를 해야 한다.

    1. 사용자 1이 정보를 조회하고 상태를 변경하는 동안 사용자2가 애그리거트를 수정하지 못하게 막는다
    2. 사용자1이 정보를 조회한 후 사용자2가 정보를 변경하면, 사용자1이 애그리거트를 다시 조회한 뒤 수정하도록 한다.

    DBMS가 지원하는 트랜잭션과 함께 애그리거트를 위한 추가적인 트랜잭션 처리 기법이 필요하다!!

     

    대표적인 트랜잭션 처리 방식에는 선점, 비선점 잠금의 두 가지 방식이 있다.

     

    🙅🏻 선점 잠금


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

     

    한 스레드가 애그리거트를 구하고 수정하는 동안 다른 스레드가 수정할 수 없다.

    애그리거트 수정 시 발생하는 데이터 충돌 문제를 해소할 수 있다.

     

    선점 잠금은 보통 DBMS가 제공하는행단 위 잠금을 사용해 구현한다.

    • 특정 레코드에 한 커넥션만 접근할 수 있다.

     

    JPA EntityManagerLockModeType을 인자로 받는 find() 메서드를 제공한다.

    • LockModeType.PESSIMISTIC_WRITE 를 값으로 전달 시 해당 엔티티와 매핑된 테이블을 이용해 선점 잠금 방식을 적용할 수 있다.
    Order order = entityManager.find(
            Order.class, orderNo, LockModeType.PESSIMISTIC_WRITE);

     

    JPA 프로바이더, DBMS에 따라 잠금 모드 구현이 다르다!

    • 하이버네이트 - PESSIMISTIC_WRITE 를 잠금모드로 사용하면 ‘for update’ 쿼리를 이용해 구현

     

    스프링 데이터 JPA는 @Lock 애너테이션을 사용해 잠금 모드를 지정한다.

    public interface MemberRepository extends JpaRepository<Member, MemberId> {
    
      @Lock(LockModeType.PESSIMISTIC_WRITE)
      @Query("SELECT m FROM Memeber m where m.id = :id")
      Optional<Member> findByIdForUpdate(@Param("id") MemberId memberId);
    }

     

    선점 잠금과 교착 상태

    🔥 선점 잠금 기능을 사용 시 잠금 순서에 따른 교착상태(deadlock)가 발생하지 않도록 주의해야 한다.

     

    선점 잠금에 따른 교착 상태는 사용자 수가 많을 때 발생할 가능성이 높고 사용자 수가 많아지면 교착 상태에 빠지는 스레드는 더 빠르게 증가한다.

     

    이런 문제가 발생하지 않도록 하려면 잠금을 구할 때 최대 대기 시간을 지정해야 한다.

    ⇒ JPA에선 최대 대기 시간을 지정하기 위해 힌트를 사용한다.

     

    Map<String, Object> hints = new HashMap<>();
    hints.put("javax.persistence.lock.timeout", 2000);
    Order order = entityManager.find(
          Order.class, orderNo, LockModeType.PESSIMISTIC_WRITE, hints);
    
    • “javax.persistence.lock.timeout” 힌트 - 잠금을 구하는 대기 시간을 밀리초 단위로 지정
    • 지정 시간이내에 잠금을 구하지 못하면 exception 발생

     

    ⚠️ DBMS에 따라 힌트가 적용되지 않을 수도 있다.

    • 사용하는 DBMS에 따라 JPA가 어떤 식으로 대기 시간을 처리하는지 반드시 확인해야 한다.

     

    스프링 데이터 JPA는 @QueryHints 애너테이션을 사용해 쿼리 힌트를 지정할 수 있다.

    public interface MemberRepository extends Repository<Member, MemberId> {
        Optional<Member> findById(MemberId memberId);
    
        @Lock(LockModeType.PESSIMISTIC_WRITE)
        @QueryHints({
                @QueryHint(name = "javax.persistence.lock.timeout", value = "3000")
        })
        @Query("select m from Member m where m.id = :id")
        Optional<Member> findByIdForUpdate(@Param("id") MemberId memberId);
    
        void save(Member member);
    
    }

     

    🙆🏻‍♂️ 비선점 잠금


    선점 잠금으로 모든 트랜잭션 충돌 문제가 해결되는 것은 아니다!!

     

    📖 비선점 잠금

    • 동시에 접근하는 것을 막는 대신 변경한 데이터를 실제 DBMS에 반영하는 시점에 변경 가능 여부를 확인하는 방식

     

    비선점 잠금을 구현하려면 애그리거트에 버전으로 사용할 숫자 타입 프로퍼티를 추가해야 한다.

    → 애그리거트 수정할 때마다 버전으로 사용할 프로퍼티 값이 1씩 증가

     

    버전 프로퍼티 값 증가 쿼리

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

     

    수정할 애그리거트와 매핑되는 테이블의 버전 값현재 애그리거트의 버전과 동일한 경우에만 데이터를 수정

    수정에 성공하면 버전 값을 1 증가

    • 다른 트랜잭션이 먼저 데이터를 수정해 버전 값이 바뀌면 데이터 수정에 실패 ❌

     

    JPA버전을 이용한 비선점 잠금 기능을 지원한다.

    • 버전으로 사용할 필드에 @Version 애너테이션을 붙이고 매핑되는 테이블에 버전을 저장할 컬럼을 추가
    @Entity
    @Table(name = "purchase_order")
    @Access(AccessType.FIELD)
    public class Order {
    
      @EmbeddedId
      private OrderNo number;
    
      @Version
      private long version;
    }
    • UPDATE 쿼리를 실행할 때 @Version에 명시한 필드를 이용해 비선점 잠금 쿼리를 실행
    UPDATE purchase_order SET ..., version = version + 1
    WHERE number = ? and version = {현재 버전}
    

     

    응용 서비스는 버전에 대해 알 필요가 없다.

    • 기능 실행 과정에서 애그리거트 데이터가 변경되면 JPA는 트랜잭션 종료 시점에 비선점 잠금을 위한 쿼리를 실행
    @Service
    public class ChangeShippingService {
        
      ...
    
      @Transactional
      public void changeShipping(ChangeShippingRequest changeReq) {
        Optional<Order> orderOpt = orderRepository.findById(new OrderNo(changeReq.getNumber()));
        checkNoOrder(order);
        order.changeShippingInfo(changeReq.getShippingInfo());
      }
    
    }

     

    비선점 잠금을 위한 쿼리 실행 시 쿼리 실행 결과로 수정된 행의 개수가 0이라면 이미 누군가 데이터를 수정한 것.

    ⇒ 트랜잭션이 충돌한 경우, 트랜잭션 종료 시점에 exception 발생

    • OptimisticLockingFailureException

     

    강제 버전 증가

    ❗애그리거트에서 루트 엔티티가 아닌 다른 엔티티의 값만 변경되는 경우

    • JPA는 루트 엔티티의 버전 값을 증가시키지 않는다.
    • 루트 엔티티 자체의 값은 바뀌는 것이 없으므로 버전 값을 갱신하지 않는 것이다.

     

    ⇒ 애그리거트의 구성요소 중 일부 값이 바뀌었으므로 논리적으로 그 애그리거트는 바뀐 것이다.

    • 따라서, 루트 애그리거트의 버전 값이 증가해야 비선점 잠금이 올바르게 동작한다.

     

    이런 문제를 해결하기 위해 JPA는 강제로 버전 값을 증가시키는 잠금 모드를 지원한다.

    @Repository
    public class JpaOrderRepository implements OrderRepository {
    
      @PersistenceContext
      private EntityManager entityManager;
    
      @Override
      public Order findByIdOptimisticLockMode(OrderNo id) {
        return entityManager.find(
            Order.class, id, **LockModeType.OPTIMISTIC_FORCE_INCREMENT**);
      }
    
    }

    LockModeType.OPTIMISTIC_FORCE_INCREMENT 를 사용하면 해당 엔티티의 상태가 변경되었는지 상관없이 트랜잭션 종료 시점에 버전 값 증가 처리를 한다

     

    스프링 데이터 JPA를 사용 시 @Lock 애너테이션을 이용해 지정하면 된다.

     

    📌 오프라인 선점 잠금


    💡 오프라인 선점 잠금(Offline Perssimistic Lock)
     - 단일 트랜잭션에서 동시 변경을 막는 선점 잠금 방식과 달리 오프라인 선점 잠금은 여러 트랜잭션에 걸쳐 동시 변경을 막는다. 
    1. 첫 번째 트랜잭션 시작 시 오프라인 잠금을 선점
    2. 마지막 트랜잭션에서 잠금을 해제

     

    잠금 해제하기 전까지 다른 사용자는 잠금을 구할 수 없다.

    • 잠금을 해제하지 않으면 다른 사용자는 영원히 잠금을 구할 수 없는 상황이 발생한다.

     

    이런 사태를 방지하기 위해 오프라인 선점 방식은 잠금 유효 시간을 가져야 한다.

    • 유효 시간이 지나면 자동으로 잠금 해제
    • 일정 주기로 유효 시간을 증가시키는 방식도 필요

     

    오프라인 선점 잠금을 위한 LockManager 인터페이스, 그와 관련된 클래스

    오프라인 선점 잠금은 크게 4가지 기능이 필요

    1. 잠금 선점 시도
    2. 잠금 확인
    3. 잠금 해제
    4. 잠금 유효시간 연장

     

    LockManager 인터페이스

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

    tryLock()

    • type과 id를 파라미터로 갖는다.
    • 잠글 대상 타입과 식별자
    • 잠금을 식별할 때 사용할 LockId 리턴 (잠금 성공 시)
    • 잠금을 선점하는 데 실패하면 LockException 발생

     

    LockId 클래스

    public class LockId {
    
      private String value;
      
      public LockId(String value) {
        this.value = value;
      }
    
      public String getValue() {
        return value;
      }
    }
    
    • LockId를 어딘가 보관해야 한다(이게 없다면 잠금을 해제할 수 없다)

     

    잠금 해제 코드

    // 컨트롤러 - 서비스를 호출 시 잠금 ID 함께 전달
    @RequestMapping(value = "/some/edit/{id}", method = RequestMethod.POST)
    public String edit(@PathVariable("id") Long id,
          @ModelAttribute("editReq") EditRequest editReq,
          @RequestParam("lid") String lockIdValue) {
    
      editReq.setId(id);
      someEditService.edit(editReq, new LockId(lockIdValue);
      model.addAttribute("data", data);
      return "editSuccess";
    } 
    
    // 서비스 - 잠금 선점 확인 후 해제
    public void edit(EditRequest editReq, LockId lockId) {
      // 잠금 선점 확인
      lockManager.checkLock(lockId);
    
      // 기능 실행
       ...
      // 잠금 해제
      lockManager.releaseLock(lockId);
    }

    checkLock()

    • 잠금 선점한 이후에 실행하는 기능들은 반드시 주어진 lockId를 갖는 잠금이 유효한지 확인해야 한다.

     

    다음과 같은 상황이 발생할 수 있다.

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

     

    DB를 이용한 LockManager 구현

    잠금 정보를 저장할 테이블과 인덱스를 생성

    // MySQL 용 
    create table locks (
      `type` varchar(255),
      id varchar(255),
      lockid varchar(255),
      expiration_time datetime,
      primary key (`type`, id)
    ) character set utf8;
    
    create unique index locks_idx ON locks (lockid);
    • type, id 컬럼을 primary key로 지정해 동시에 두 사용자가 특정 타입 데이터에 대한 잠금을 구하는 것을 방지
    • 잠금마다 새로운 LockId 사용 → lockid 필드를 유니크 인덱스로 설정
    • 잠금 유효 시간 - expiration_time 컬럼 사용

    l

    ocks 테이블의 데이터를 담을 LockData 클래스

    public class LockData {
        private String type;
        private String id;
        private String lockId;
        private long timestamp;
    
        public LockData(String type, String id, String lockId, long timestamp) {
            this.type = type;
            this.id = id;
            this.lockId = lockId;
            this.timestamp = timestamp;
        }
    
        public String getType() {
            return type;
        }
    
        public String getId() {
            return id;
        }
    
        public String getLockId() {
            return lockId;
        }
    
        public long getTimestamp() {
            return timestamp;
        }
    
        // 유효시간 지났는지 판단할 때 사용
        public boolean isExpired() {
            return timestamp < System.currentTimeMillis();
        }
    }
    

     

    Locks 테이블을 이용한 LockManger 구현 코드

    1. JdbcTemplate를 이용한 SpringLockManager의 tryLock()
    @Component
    public class SpringLockManager implements LockManager {
        private int lockTimeout = 5 * 60 * 1000;
        private JdbcTemplate jdbcTemplate;
    
        // locks 테이블에서 조회한 데이터를 LockData로 매핑하기 위한 RowMapper
        private RowMapper<LockData> lockDataRowMapper = (rs, rowNum) ->
                new LockData(rs.getString(1), rs.getString(2),
                        rs.getString(3), rs.getTimestamp(4).getTime());
    
        public SpringLockManager(JdbcTemplate jdbcTemplate) {
            this.jdbcTemplate = jdbcTemplate;
        }
    
        // type과 id에 대한 잠금을 시도
        @Transactional(propagation = Propagation.REQUIRES_NEW)
        @Override
        public LockId tryLock(String type, String id) throws LockException {
            checkAlreadyLocked(type, id); // 해당 type, id에 잠금 존재하는지 검사
            LockId lockId = new LockId(UUID.randomUUID().toString()); // 새로운 LockId 생성
            locking(type, id, lockId); // 잠금 생성
            return lockId; // lockId 리턴
        }
    
        // 이미 잠금이 존재하는지 검사
        private void checkAlreadyLocked(String type, String id) {
            List<LockData> locks = jdbcTemplate.query(
                    "select * from locks where type = ? and id = ?",
                    lockDataRowMapper, type, id);
            Optional<LockData> lockData = handleExpiration(locks);
            // 유효기간이 지나지 않은 잠금이 있다면 exception 발생
            if (lockData.isPresent()) throw new AlreadyLockedException();
        }
    
        // 잠금 유효 시간이 지나면 해당 데이터 삭제 후 값이 없는 Optional 리턴
        // 지나지 않았다면 해당 LockData를 가진 Optional 리턴
        private Optional<LockData> handleExpiration(List<LockData> locks) {
            if (locks.isEmpty()) return Optional.empty();
            LockData lockData = locks.get(0);
            if (lockData.isExpired()) {
                jdbcTemplate.update(
                        "delete from locks where type = ? and id = ?",
                        lockData.getType(), lockData.getId());
                return Optional.empty();
            } else {
                return Optional.of(lockData);
            }
        }
        
        // 잠금을 위해 locks 테이블에 데이터를 삽입
        private void locking(String type, String id, LockId lockId) {
            try {
                int updatedCount = jdbcTemplate.update(
                        "insert into locks values (?, ?, ?, ?)",
                        type, id, lockId.getValue(), new Timestamp(getExpirationTime()));
                if (updatedCount == 0) throw new LockingFailException();
            } catch (DuplicateKeyException e) {
                throw new LockingFailException(e);
            }
        }
    
        // 현재 시간 기준으로 lockTimeout 이후 시간을 유효 시간으로 생성
        private long getExpirationTime() {
            return System.currentTimeMillis() + lockTimeout;
        }
      ...
    }

     

    SpringLockManager 의 나머지 구현코드

    // 잠금이 유효한지 검사
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    @Override
    public void checkLock(LockId lockId) throws LockException {
        Optional<LockData> lockData = getLockData(lockId);
        if (!lockData.isPresent()) throw new NoLockException();
    }
    
    // lockId에 해당하는 LockData를 구한다.
    private Optional<LockData> getLockData(LockId lockId) {
        List<LockData> locks = jdbcTemplate.query(
                "select * from locks where lockid = ?",
                lockDataRowMapper, lockId.getValue());
        return handleExpiration(locks);
    }
    
    // lockId에 해당하는 잠금 유효 시간을 Inc 만큼 늘린다.
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    @Override
    public void extendLockExpiration(LockId lockId, long inc) throws LockException {
        Optional<LockData> lockDataOpt = getLockData(lockId);
        LockData lockData =
                lockDataOpt.orElseThrow(() -> new NoLockException());
        jdbcTemplate.update(
                "update locks set expiration_time = ? where type = ? AND id = ?",
                new Timestamp(lockData.getTimestamp() + inc),
                lockData.getType(), lockData.getId());
    }
    
    // lockId에 해당하는 잠금 데이터를 locks 테이블에서 삭제
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    @Override
    public void releaseLock(LockId lockId) throws LockException {
        jdbcTemplate.update("delete from locks where lockid = ?", lockId.getValue());
    }
    
    public void setLockTimeout(int lockTimeout) {
        this.lockTimeout = lockTimeout;
    }

     

    참고자료

    http://www.yes24.com/Product/Goods/108431347

    댓글

Designed by Tistory.