객체지향

[DDD, 도메인 주도 개발] 8.애그리거트와 트랜잭션

마디니 2023. 4. 16. 18:36
반응형

애그리거트의 트랜잭션

운영자와 고객이 동시에 한 주문 애그리거트를 수정하는 경우 문제점 살펴보자.

아래 두 쓰레드는 각각 트랜잭션을 커밋할 때 수정한 내용을 DB에 반영한다. 

 

[문제점]

애그리 거트의 일관성이 깨질 수 있다.

(운영자가 기존 배송지 정보를 이용해서 배송상태를 변경했는데 그 사이에 고객이 배송지를 변경 할 수 있다.)

 

[해결]

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

-> DBMS가 지원하는 트랜잭션과 함께 애그리거트를 위한 추가적인 트랜잭션 처리 기법(선점 잠금/비선점 잠금)이 필요하다.

 

 

애그리거트 잠금기법

선점 잠금(Pessimistic Lock)

[선점 잠금 정의]

먼저 애그리거트를 구한 스레드가 애그리거트 사용이 끝날 때까지 다른 스레드가 해당 애그리거트를 수정하지 못하게 막는 방식

트랜잭션 잠금 처리 기법 선점 잠금

[선점 잠금 동작 방식]

스레드1이 애그리거트 선점 

-> 스레드 2는 스레드 1이 애그리 거트에 대한 잠금을 해제할 때까지 블로킹

-> 스레드 1이 애그리거트 사용 후 트랜잭션 커밋하면 잠금 해제

-> 대기중인 스레드 2가 애그리거트 접근 후 사용 (스레드 1 이 수정한 상태의 애그리거트다)

동시에 두 스레드 접근이 안되므로 데이터 충돌 문제를 방지 할 수 있다. 

 

[선점 잠금 적용]

  • 보통 DBMS가 제공하는 행단위 잠금을 사용해서 구현한다. 
  • JPA EntityManager의 경우 find메소드에 LockModeType.PESSIMISTIC_WRITE를 파라미터 값으로 전달하여 선점 잠금을 적용 한다. (하이버네이트의 경우 선점잠금 모드사용시 'for update'쿼리를 이용해 선점잠금을 구현한다)
  • 스프링 데이터 JPA는 @Lock 애너테이션을 사용해 잠금모드를 지정한다. 예) @Lock(LockModeType.PESSIMISTIC_WRITE)

[선점 잠금과 교착 상태]

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

 

교착상태 발생 과정

1.스레드1: A애그리거트 선점 잠금

2.스레드2: B애그리거트 선점 잠금

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

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

 

잠금을 구할 때 최대 대기 시간을 지정하여 교착상태를 방지한다.

  • JPA : find 메소드 파라미터에 힌트 옵션 사용
  • 스프링 데이터  JPA: @QueryHints 애너테이션을 사용해서 쿼리 힌트 지정
💡 DBMS에 따라 교착 상태인 커넥션을 처리하는 방식이 다르니(쿼리 /커넥션별 대기 시간 지정)선점 잠금 사용시 사용하는 DBMS에 대해 JPA가 어떤 식으로 대기 시간을 처리하는지 반드시 확인해야 한다.

비선점 잠금(Optimistic Lock)

[비선점 잠금 정의]

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

[선점 잠금으로 해결할 수 없는 상황]

1. 운영자가 주문 정보 조회

2. 고객이 배송지를 변경한다.

3. 운영자가 1번에 조회한 정보를 기준으로 배송지를 정하고 배송 상태를 변경한다.

 

[비선점 잠금 구현]

  • 애그리거트에 버전으로 사용할 숫자 타입 프로퍼티를 추가
  • JPA사용 시 버전 프로퍼티에 @Version 애노테이션 지정 후 매핑 테이블에 버전을 저장할 컬럼 추가
  • 애그리거트를 수정할 때마다 버전으로 사용할 프로퍼티 값이 1씩 증가하는데 이때 다음과 같은 쿼리를 사용한다.
UPDATE aggtable SET version = version + 1, colx= ?, coly = ?
WHERE aggid = ? and version = 현재버전
  • 수정에 성공하면 버전값을 1 증가 시킨다. 
  • 수정할 애그리거트와 매핑되는 테이블의 버전값이 현재 애그리거트의 버전과 동인한 경우에만 데이터를 수정한다.
  • 비선점 잠금을 위한 쿼리를 실행할 때 쿼리 실행 결과로 수정된 행의 개수가 0이면 이미 누군가 수정했다는 것이고, 트랜잭션이 충돌한 것이므로 트랜잭션 종료 시점에 OptimisticLockingFailureException이 발생한다.

비선점 잠금 동작 방식

[비선점 잠금 동작 방식]

스레드 1,2 가 같은 버전(5)의 애그리거트를 읽어와 수정

-> 스레드 1이 먼저 커밋 하면 버전이 5이므로 수정 성공 -> 버전 6으로 업데이트

-> 스레드 2가 수정 후 커밋 시도 -> 가지고 있는 버전(5)과 현재 애그리거트 버전(6)이 달라 수정 실패

 

[비선점 잠금을 이용한 트랜잭션 충돌 방지를 여러 트랜잭션으로 확장]

  • 여러 트랜잭션이 데이터에 접근해 변경을 시도할 경우에는 애그리거트 정보를 뷰로 보여줄 때 버전 정보도 함께 전달해 버전 체크를 진행한다.
  • 처음 조회했을 때의 버전과 수정 직전에 버전을 한번 더 조회해 비교하는 과정 필요. -> 이 때 버전이 안 맞는 경우VersionConfilctException 처리
VersionConfilctException: 이미 누군가 애그리거트를 수정함
OptimisticLockingFailureException : 누군가 거의 동시에 애그리거트를 수정

[강제 버전 증가]

  • 애그리거트 루트 외에 다른 엔티티의 값만 변경된다면 이 경우  JPA는 루트 엔티티 버전값을 증가 시키지 않는다.
  • 애그리거트 관점에서 보면 애그리거트가 바뀐 것이기 때문에 버전 값을 증가시켜야 비선점 잠금이 올바르게 동작한다.
  • JPA는 LockModeType.OPTIMISTIC_FORCE_INCREMENT를 사용해 트랜잭션 종료 시점에 버전 값 증가처리를 강제 할 수 있다.

오프라인 선점 잠금

[오프라인 선점 잠금이란]

오프라인 선점 잠금은 여러 트랜잭션에 걸쳐 동시 변경을 막는다. 잠금을 해제하기 전까지 다른 사용자는 잠금을 구할 수 없다.

첫번째 트랜잭션을 시작할 때 오프라인 잠금을 선잠하고, 마지막 트랜잭션에서 잠금을 해제한다.

 

오프라인 선점 잠금 동작 방식

[오프라인 선점 잠금 동작 방식]

첫번째 트랜잭션은 폼을 보여주고, 두번째 트랜잭션은 데이터를 수정한다.

-> 폼 요청 과정에서 잠금을 선점한다.

-> 그 사이에 오는 폼 요청은 락 선점에 실패 한다. (에러 화면 노출)

-> 수정 후 잠금을 해제 한다.

 

※ 잠금을 선점후 수정 요청을 하지 않고 프로그램을 종료하면 영원히 잠금을 구할 수 없게 되므로,  오프라인 선점 방식은 잠금 유효 시간이 필요하다.

 

[오프라인 선점 잠금을 위한 LockManager 인터페이스과 관련 클래스]

LockManager 인터페이스 

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

LockId 클래스

// LockId 클래스
public class LockId {
  private String value;

  public LockId(String value) {
    this.value = value;
  }

	public String getValue() {
	  return value;
  }
}

LockManager 인터페이스는 오프라인 선점 잠금을 위한 네가지 기능을 제공한다.

  • 선점 시도 [tryLock(Strin type, Stirng id)]
    • 잠금을 식별 할 때 사용할 LockId를 리턴한다.
    • LockId는 잠금 유효 검사나, 잠금 해제시 사용된다.
    • LockId를 어딘가(수정폼) 보관하여 잠금 선점에 대한 유효 검사를 진행한다. -> 잠금 선점 실패시 LockException 발생
  • 잠금 확인[checkLock(LockId lockId)]
  • 잠금 해제[releaseLock(LockId lockId)]
  • 잠금 유효시간 연장 [extendLockExpiration(LockId lockId, long inc)]

 

[DB를 이용한 LockManager 구현]

  • 잠금 정보를 저장하기 위한 테이블 생성
    • 타입과 아이디를 기본키로 지정해 동시에 두 사용자가 특정 타입 데이터 잠금 구하는 것을 방지
    • 각 잠금마다 새로운 LockId를 사용하므로 lockId 컬럼은 유니크 인덱스로 설정
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);
  • Order 타입의 1번 식별자를 갖는 애그리거트에 대한 잠금을 구하고 싶다면 다음의 insert 쿼리로 locks 테이블에 데이터 삽입
insert into locks values ('Order', '1', '생성한lockid', '2016-03-28 09:10:00');
  • locks 테이블의 데이터를 담을 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 테이블을 이용해서 LockManager를 구현
@Component
public class SpringLockManager implements LockManager {
  private int lockTimeout = 5 * 60 * 1000;
  private JdbcTemplate jdbcTemplate;

  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);
      LockId lockId = new LockId(UUID.randomUUID().toString());
      locking(type, id, lockId);
      return lockId;
  }

  /**
   * type과 id에 대한 잠금이 존재하는지 검사
   */
  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);
      if (lockData.isPresent()) throw new AlreadyLockedException();
  }

  /**
   * 유효시간이 지난 락인지 아닌지 판단 후 유효시간 내 락이면 리턴
   */
  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 테이블에 잠금 데이터를 삽입
   * 데이터 삽입 결과가 없거나 DuplicateKeyException이 발생하면 LockingFailException을 익셉션을 발생시킨다
   */
  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);
      }
  }

  /**
   * 잠금 유효 시간 생성
   */
  private long getExpirationTime() {
      return System.currentTimeMillis() + lockTimeout;
  }

  /**
   * 잠금이 유효한지 검사
   */
  @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;
  }
}

 

Reference

도메인 주도개발 시작하기, 최범균 저

 

 

반응형