[도메인 주도 개발 시작하기] 8장 애그리거트 트랜잭션 관리

xyzw·2024년 3월 30일
0

DDD

목록 보기
8/11
post-thumbnail

8.1 애그리거트와 트랜잭션

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

트랜잭션마다 리포지터리는 새로운 애그리거트 객체를 생성하므로 운영자 스레드와 고객 스레드는 같은 주문 애그리거트를 나타내는 다른 객체를 구하게 된다.

운영자 스레드와 고객 스레드는 개념적으로 동일한 애그리거트지만 물리적으로 서로 다른 애그리거트 객체를 사용한다. 따라서 운영자 스레드에서의 변경사항은 고객 스레드가 사용하는 주문 애그리거트 객체에 영향을 주지 않는다.

두 스레드는 각각 트랜잭션을 커밋할 때 수정한 내용을 DB에 반영한다. 이때 애그리거트의 일관성이 깨질 수 있다.

이를 방지하려면 다음 두 가지 중 하나를 해야 한다.

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

이 두 가지는 애그리거트 자체의 트랜잭션과 관련이 있다.
애그리거트에 대해 사용할 수 있는 대표적인 트랜잭션 처리 방식에는 선점(Pessimistic, 비관적) 잠금과 비선점(Optimistic, 낙관적) 잠금이 있다.

8.2 선점 잠금

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

스레드2는 스레드1이 애그리거트에 대한 잠금을 해제할 때까지 블로킹(blocking)된다.

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

선점 잠금은 보통 DBMS가 제공하는 행단위 잠금을 사용해서 구현한다. 오라클을 비롯한 다수의 DBMS가 for update와 같은 쿼리를 사용해서 특정 레코드에 한 커넥션만 접근할 수 있는 잠금장치를 제공한다.

JPA EntityManager는 LockModeType을 인자로 받는 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 Repository<Member, MemberId> {

	@Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select m from Member 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);

JPA의 'javax.persistence.lock.timeout' 힌트는 잠금을 구하는 대기 시간을 밀리초 단위로 지정한다. 지정한 시간 이내에 잠금을 구하지 못하면 익셉션을 발생시킨다.
이 힌트를 사용할 때 DBMS에 따라 힌트가 적용되지 않을 수도 있다는 것을 주의해야 한다.

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

public interface MemberRepository extends Repository<Member, MemberId> {

	@Lock(LockModeType.PESSIMISTIC_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);
}

8.3 비선점 잠금

선점 잠금 방식으로 해결할 수 없는 트랜잭션 충돌 문제를 해결하기 위해 비선점 잠금이 필요하다. 비선점 잠금은 동시에 접근하는 것을 막는 대신 변경한 데이터를 실제 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;
    ...

JPA는 엔티티가 변경되어 UPDATE 쿼리를 실행할 때 @Version에 명시한 필드를 이용해서 비선점 잠금 쿼리를 실행한다. 즉, 애그리거트 객체의 버전이 10이면 UPDATE 쿼리를 실행할 때 다음과 같은 쿼리를 사용해서 버전이 일치하는 경우에만 데이터를 수정한다.

UPDATE purchase_order SET ..., version = version + 1
WHERE number = ? and version = 10

응용 서비스는 버전에 대해 알 필요가 없다. 리포지터리에서 필요한 애그리거트를 구하고 알맞은 기능만 실행하면 된다. 기능 실행 과정에서 애그리거트 데이터가 변경되면 JPA는 트랜잭션 종료 시점에 비선점 잠금을 위한 쿼리를 실행한다.

비선점 잠금을 위한 쿼리를 실행할 때 쿼리 실행 결과로 수정된 행의 개수가 0이면 이미 누군가 앞서 데이터를 수정하여 트랜잭션이 충돌한 것이므로 트랜잭션 종료 시점에 익셉션이 발생한다.
표현 영역 코드는 이 익셉션이 발생했는지에 따라 트랜잭션 충돌이 일어났는지 확인할 수 있다.

비선점 잠금과 관련해서 발생하는 익셉션

  • OptimisticLockingFailureException: 스프링 프레임워크가 발생시키며, 누군가 거의 동시에 애그리거트를 수정했다는 것을 의미한다.
  • VersionConflictException: 응용 서비스 코드에서 발생시키며, 이미 누군가 애그리거트를 수정했다는 것을 의미한다.

강제 버전 증가

애그리거트 루트 외의 다른 엔티티의 값만 변경될 때, JPA는 루트 엔티티의 버전 값을 증가시키지 않는다.
그런데 애그리거트 관점에서 보면 루트 엔티티의 값이 바뀌지 않았더라도 애그리거트의 구성요소 중 일부 값이 바뀌면 그 애그리거트는 바뀐 것이므로, 애그리거트 내의 어떤 구성요소의 상태가 바뀌면 루트 애그리거트의 버전 값이 증가해야 비선점 잠금이 올바르게 동작한다.

JPA는 이런 문제를 처리할 수 있도록 EntityManager.find() 메서드로 엔티티를 구할 때 강제로 버전 값을 증가시키는 잠금 모드를 지원한다.
LockModeType.OPTIMISTIC_FORCE_INCREMENT를 사용하면 해당 엔티티의 상태가 변경되었는지에 상관없이 트랜잭션 종료 시점에 버전 값을 증가시킨다.

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

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

8.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를 갖는 잠금이 유효한지 확인해야 한다.

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

DB를 이용한 LockManager 구현

잠금 정보를 저장할 테이블과 인덱스를 다음과 같이 생성한다.

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 컬럼을 주요키로 지정해서 동시에 두 사용자가 특정 타입 데이터에 대한 잠금을 구하는 것을 방지했다.
  • 각 잠금마다 새로운 LockId를 사용하므로 Lockid 필드를 유니크 인덱스로 설정했다.
  • 잠금 유효 시간을 보관하기 위해 expiration_time 칼럼을 사용한다.

locks 테이블의 데이터를 담을 LockData 클래스를 다음과 같이 작성한다.

@Getter
public class LockData {
	private String type;
    private String id;
    private String lockId;
    private long expirationTime;
    
    public LockData(String type, String id, String lockId, long expirationTime) {
    	this.type = type;
        this.id = id;
        this.lockId = lockId;
        this.expirationTime = expirationTime;
    }
    
    public boolean isExpired() {
    	return expirationTime < System.currentTimeMillis();
    }
}
  • isExpired() 메서드는 유효 시간이 지났는지를 판단할 때 사용한다.

locks 테이블을 이용해서 LockManager를 구현한 코드를 살펴보자.

@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);
    
    // 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.randomUUIDO.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();

	// 잠금 유효 시간이 지나면 해당 데이터를 삭제하고 값이 없는 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;
	}
    
    // 잠금이 유효한지 검사한다. 존재하지 않으면 익셉션을 발생한다.
    @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) {
		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());
	}

	@Autowired
	public void setJdbcTemplate(JdbcTemplate jdbcTemplate) {
		this.jdbcTemplate = jdbcTemplate;
	}
}

0개의 댓글