본문은 [도메인 주도 개발 시작하기: DDD 핵심 개념 정리부터 구현까지]를 읽고 간단하게 정리한 글입니다. 필요에 따라 생략/수정된 부분이 있을 수 있으며, 내용이 추후 변경될 수 있습니다.
8.1 애그리거트와 트랜잭션
위 예시에서 두 스레드는 개념적으로 동일하지만 물리적으로 서로 다른 애그리거트 객체를 사용한다
이 경우 일관성이 깨지는 문제가 발생할 수 있다
이 문제를 해결하기 위해선 DBMS가 지원하는 트랜잭션과 함께 애그리거트를 위한 추가적인 트랜잭션 처리 기법이 필요하다
애그리거트에 대해 사용할 수 있는 대표적인 트랜잭션 처리 방식에는 선점 잠금(Pessimistic Lock)과 비선점 잠금(Optimistic Lock)의 두 가지 방식이 있다
8.2 선점 잠금
선점 잠금은 먼저 애그리거트를 구한 스레드가 애그리거트 사용이 끝날 때까지 다른 스레드가 해당 애그리거트를 수정하지 못하게 막는 방식이다
[그림 8.2]는 선점 잠금의 동작 방식을 보여준다
스레드1이 선점 잠금 방식으로 애그리거트를 구한 뒤 스레드2가 같은 애그리거트를 구한다
이때 스레드2는 스레드1이 애그리거트에 대한 잠금을 해제할 때까지 블로킹된다
스레드1이 트랜잭션을 커밋하면 잠금이 해제된다
선점 잠금은 보통 DBMS가 제공하는 행단위 잠금을 사용해서 구현한다
오라클을 비롯한 다수의 DBMS가 for update와 같은 쿼리를 사용해서 특정 레코드에 한 커넥션만 접근할 수 있는 잠금장치를 제공한다
JPA EntityManger의 find() 메서드에 LockModeType.PESSIMISTIC_WRITE를 값으로 전달하면 해당 엔티티와 매핑된 테이블을 이용해서 선점 잠금 방식을 적용할 수 있다
Order order = entityManager.find(Order.class, orderNo, LockModeType.PERSSIMISTIC_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);
1) 선점 잠금과 교착 상태
선점 잠금 기능을 사용할 때는 잠금 순서에 따른 교착 상태(deadlock)가 발생하지 않도록 주의해야 한다
교착 상태를 피하기 위해선 잠금을 구할 때 최대 대기 시간을 지정해야 한다
JPA에서 선점 잠금을 시도할 때 최대 대기 시간을 지정하려면 다음과 같이 힌트를 사용한다
지정한 시간 이내에 잠금을 구하지 못하면 익셉션을 발생시킨다
DBMS에 따라 힌트가 적용되지 않을 수 있으므로 주의해야 한다
Map<String, Object> hints = new HashMap<>();
hints.put("javax.persistence.lock.timeout", 2000);
Order order = entityManager.find(Order.class, orderNo, LockModeType.PERSSIMISTIC_WRITE);
스프링 데이터 JPA는 @QueryHints 애너테이션을 사용해서 쿼리 힌트를 지정할 수 있다
public interface MemberRepository extends Repository<Member, 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);
DBMS에 따라 교착 상태에 빠진 커넥션을 처리하는 방식이 다르므로 선점 잠금을 사용하려면 DBMS에 대해 JPA가 어떤 식으로 대기 시간을 처리하는지 반드시 확인해야 한다
쿼리별로 대기 시간 지정 vs 커넥션 단위로만 대기 시간 지정
8.3 비선점 잠금
선점 잠금으로 모든 트랜잭션 충돌 문제가 해결되는 것은 아니다
[그림 8.4] 과 같이 한 스레드에서 데이터를 조회한 후 변경하기 전에 다른 스레드에서 데이터를 변경하면 문제가 발생할 수 있다
이때 필요한 것이 비선점 잠금이다
비선점 잠금은 동시에 접근하는 것을 막는 대신 변경한 데이터를 실제 DBMS에 반영하는 시점에 변경 가능 여부를 확인하는 방식이다
비선점 잠금에선 버전을 이용해 데이터를 관리한다
구현을 위해선 애그리거트에 버전으로 사용할 숫자 타입 프로퍼티를 추가해야 한다
애그리거트를 수정할 때마다 버전으로 사용할 프로퍼티 값이 1씩 증가한다
애그리거트와 매핑되는 테이블 버전 값이 현재 애그리거트의 버전과 동일할 때만 데이터를 수정한다
UPDATE aggtable SET version = version + 1, colx = ?, coly = ?
WHERE aggid = ? and version = 현재버전
JPA는 버전을 이용한 비선점 잠금 기능을 지원한다
다음과 같이 버전으로 사용할 필드에 @Version 애너테이션을 붙이고 매핑되는 테이블에 버전을 저장할 칼럼을 추가하면 된다
엔티티가 변경되는 시점에 다음과 같은 비선점 잠금 쿼리가 실행된다
@Entity
@Table(name = "purchase_order")
@Access(AccessType.FIELD)
public class Order {
@EmbeddedId
private OrderNo number;
@Version
private long version;
...
UPDATE purchase_order SET ...생략, version = version + 1
WHERE number = ? and version = 10
응용 서비스는 버전에 대해 알 필요가 없다
리포지터리에서 필요한 애그리거트를 구하고 알맞은 기능만 실행하면 된다
애그리거트 데이터가 변경되면 JPA는 트랜잭션 종료 시점에 비선점 잠금을 위한 쿼리를 실행한다
비선점 잠금을 위한 쿼리를 실행할 때 쿼리 실행 결과로 수정된 행의 개수가 0이면 이미 누군가가 앞서 데이터를 수정한 것이다
이는 트랜잭션이 충돌한 것이므로 트랜잭션 종료 시점에 익셉션(OptimisticLockingFailureException)이 발생한다
응용 서비스 또는 표현 영역에서 해당 예외를 적절하게 처리한다
@Service
public class ChangeShippingService {
@Transactional
public void changeShipping(ChangeShippingRequest changeReq) {
Optional<Order> orderOpt = orderRepository.findById(new OrderNo(changeReq.getNumber()));
Order order = orderOpt.orElseThrow(() -> new NoOrderException());
order.changeShippingInfo(changeReq.getShippingInfo());
}
...
}
[그림 8.6]처럼 사용자가 전송한 버전과 애그리거트 버전이 동일한 경우에만 애그리거트 수정 기능을 수행하도록 함으로써 트랜잭션 충돌 문제를 해소할 수 있다
이 경우 뷰단에도 버전 정보가 함께 제공되고, 반대로 응용 서비스에 전달할 요청 데이터는 사용자가 전송한 버전 값을 포함한다
public class StartShippingRequest {
private String orderNumber;
private long version;
...
응용 서비스는 전달받은 버전 값을 이용해서 애그리거트 버전과 일치하는지 확인하고, 일치하는 경우에만 기능을 수행한다
matchVersion 메서드는 현재 애그리거트의 버전과 인자로 전달받은 버전이 일치하면 true를, 아니라면 false를 리턴하도록 구현한다
VersionConflictException은 이미 누군가가 애그리거트를 수정했다는 것을 의미하고, OptimisticLockingFailureException은 이미 누군가가 거의 동시에 애그리거트를 수정했다는 것을 의미한다
public class StartShippingService {
@Transactional
public void startShipping(StartShippingRequest req) {
Optional<Order> orderOpt = orderRepository.findById(new OrderNo(req.getOrderNumber()));
Order order = orderOpt.orElseThrow(() -> new NoOrderException());
if (order.matchVersion(req.getVersion())) {
throw new VersionConflictException(); // 이미 누군가가 애그리거트를 수정했다면 익셉션 발생
}
order.startShipping();
}
...
1) 강제 버전 증가
애그리거트에 애그리거트 루트 외에 다른 엔티티가 존재하는데 기능 실행 도중 루트가 아닌 다른 엔티티의 값만 변경된다면 JPA는 루트 엔티티의 버전 값을 증가시키지 않는다
tryLock() 메서드는 type과 id를 파라미터로 갖고, 각각 잠글 대상 타입과 식별자를 값으로 전달하면 된다
tryLock()은 잠금을 식별할 때 사용할 LockId를 리턴한다
public class LockId {
private String value;
public LockId(String value) {
this.value = value;
}
public String getValue() {
return value;
}
}
오프라인 선점 잠금이 필요한 코드는 LockManager#tryLock()을 이용해 잠금을 시도한다
잠금에 성공하면 tryLock()은 LockId를 리턴한다
LockId는 다음에 잠금을 해제할 때 사용하며, 어딘가에 보관해야 한다
// 서비스: 서비스는 잠금 ID를 리턴한다
public DataAndLockId getDataWithLock(Long id) {
// 1. 오프라인 선점 시도
LockId lockId = lockManager.tryLock("data", id);
// 2. 기능 실행
Data data = someDao.select(id);
return new DataAndLockId(data, lockId);
}
서비스는 DAO를 통해 조회한 데이터와 LockId를 함께 반환한다
컨트롤러는 서비스가 리턴한 LockId를 모델로 뷰에 전달한다
이 부분은 MVC로 구현되어 있어 코드를 생략했다
잠금을 선점하는 데 실패하면 LockException이 발생한다
// 서비스: 잠금을 해제한다.
public void edit(EditRequest editReq, LockId lockId) {
// 1. 잠금 선점 확인
lockManager.checkLock(lockId);
// 2. 기능 실행
...
// 3. 잠금 해제
lockManager.releaseLock(lockId);
}
LockId는 뷰와 컨트롤러를 거쳐 잠금을 해제하는 서비스 코드에 전달된다
잠금을 선점한 이후에 실행하는 기능은 다음과 같은 상황을 고려하여 반드시 주어진 LockId를 갖는 잠금이 유효한지 확인해야 한다
잠금 유효 시간이 지났으면 이미 다른 사용자가 잠금을 선점한다
잠금을 선점하지 않은 사용자가 기능을 실행했다면 기능 실행을 막아야 한다
2) DB를 이용한 LockManager 구현
DB를 이용해 LockManager를 구현할 수도 있다
잠금 정보를 저장할 테이블과 인덱스를 다음 쿼리와 같이 생성한다(MySQL 기준)
type과 id 칼럼을 주요키로 지정해서 동시에 두 사용자가 특정 타입 데이터에 대한 잠금을 구하는 것을 방지했다
각 잠금마다 새로운 LockId를 사용하므로 lockid 필드를 유니크 인덱스로 설정했다
잠금 유효 시간을 보관하기 위해 expiration_time 칼럼을 사용한다
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 클래스를 다음과 같이 작성한다
isExpend() 메서드는 유효 시간이 지났는지를 판단할 때 사용한다
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를 구현한 코드는 다음과 같다
checkAlreadyLocked() 메서드를 이용해서 이미 잠금이 선점됐는지 확인하고, locking() 메서드로 잠금을 선점한다
@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;
}
/**
* 잠금이 존재하는지 검사한다
*/
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 테이블에 데이터를 삽입한다
* 데이터 삽입 결과가 없으면 익셉션을 발생시킨다
* 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);
}
}
/**
* 현재 시간 기준으로 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) 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;
}
}