도메인 주도 개발 시작하기 책 정리
애그리거트와 트랜잭션
✅ 동시에 한 애그리거트 수정
ex) 하나의 주문 애그리거트에 대해 운영자는 배송 상태로 변경하는 동시에, 고객은 배송지 주소를 변경하면?
고객은 배송 상태인 주문 애그리거트의 배송지 주소를 변경할 수 없기 때문에,
일관성이 깨지는 문제가 발생하지 않도록 하려면 다음 두 가지 중 하나를 해야 한다.
- 운영자가 배송지 정보를 조회하고 상태를 변경하는 동안, 고객이 애그리거트를 수정하지 못하게 막는다. (선점 잠금)
- 운영자가 배송지 정보를 조회한 이후에 고객이 정보를 변경하면, 운영자가 애그리거트를 다시 조회한 뒤 수정한다. (비선점 잠금)
-> 이와 관련해서 이번 장에는 애그리거트에 대해 사용할 수 잇는 트랜잭션 처리 방식인 선점(Pessimistic) 잠금과 비선점(Optimistic) 잠금을 살펴보도록 한다.
선점 잠금
✅ 선점 잠금 설정
먼저 애그리거트를 구한 스레드가 애그리거트 사용이 끝날 때까지 다른 스레드가 해당 애그리거트를 수정하지 못하게 막는 방식.
선점 잠금은 보통 DBMS가 제공하는 행단위 잠금을 사용한다. (for update 쿼리)
<JPA - LockModeType>
Order order = entityManager.find(
Order.class, orderNo, LockModeType.PESSIMISTIC_WRITE);
<스프링 데이터 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);
}
선점 잠금과 교착 상태
✅ 교착 상태
선점 잠금 기능을 사용할 때는 잠금 순서에 따른 교착 상태가 발생하지 않도록 주의해야 한다.
ex)
- 스레드1: A 애그리거트에 대한 선점 잠금 구함
- 스레드2: B 애그리거트에 대한 선점 잠금 구함
- 스레드1: B 애그리거트에 대한 선점 잠금 구함
- 스레드2: A 애그리거트에 대한 선점 잠금 구함
-> 두 스레드는 상대방 스레드가 먼저 선점한 잠금을 구할 수 없어 교착 상태에 빠진다.
✅ 힌트
교착 상태를 해결하기 위해 잠금을 구할 때, 힌트를 통해 최대 대기 시간을 지정해야 한다.
<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>
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에 따라 힌트가 적용되지 않을 수도 있다는 것이다.
-> 힌트를 이용할 때는 사용 중인 DBMS가 관련 기능을 지원하는지 확인해야 한다.
+) MySQL innoDB 엔진의 경우,
innoDB 엔진의 시스템 변수인 'innodb_lock_wait_timeout'의 기본값은 보통 50초인데,
쿼리 힌트로 시스템 변수 기본값보다 작은 timeout을 지정한다면,
해당 쿼리에 대해서만 락 대기 시간을 javax.persistence.lock.timeout으로 지정하고,
대기 시간동안 락을 얻지 못하면 트랜잭션이 롤백된다.
비선점 잠금
✅ 버전
동시에 접근하는 것을 허용하는 대신 변경한 데이터를 실제 DBMS에 반영하는 시점에 변경 가능 여부를 확인하는 방식
비선점 잠금을 구현하려면 애그리거트에 버전으로 사용할 숫자 타입 프로퍼티를 추가해야 한다.
-> 애그리거트를 수정할 대마다 버전으로 사용할 프로퍼티 값이 1씩 증가한다.
UPDATE aggtable SET version = version + 1, colx = ?, coly = ?
WHERE aggid = ? and version = 현재버전
ex) 만약 동시에 1 버전 애그리게잇을 조회한 두 스레드 중
한 스레드가 먼저 UPDATE 되어 버전 2로 변경하면,
나머지 스레드는 자신의 버전 1과 DB의 버전 2가 일치하지 않아 커밋이 실패하게 된다.
✅ 비선점 잠금 설정
<JPA - @Version>
@Entity
@Table(name = "purchase_order")
@Access(AccessType.FIELD)
public class Order {
@EmbeddedId
private OrderNo number;
@Version
private long version;
...
}
-> 해당 엔티티가 변경되어 UPDATE 쿼리를 실행할 때, @Version에 명시한 필드를 이용해서 비선점 잠금 쿼리를 실행한다.
응용서비스는 버전에 대해 알 필요가 없다. 단지 애그리거트가 변경되면 JPA가 트랜잭션 종료 시점에 비선점 잠금을 위한 쿼리를 실행한다.
@Service
public class ChangeShippingService {
@Transactional
public void changeShipping(ChangeShippingRequest changeReq) {
Order order = orderRepository.findById(new OrderNo(changeReq.getNumber()));
checkNoOrder(order);
order.changeShippingInfo(changeReq.getShippingInfo());
}
}
비선점 잠금을 위한 쿼리를 실행할 때 쿼리 실행 결과로 수정된 행의 개수가 0이면,
이미 누군가가 앞서 데이터를 수정한 것이다.
-> 이는 트랜잭션 종료 시점에 OptimisticLockingFailureException이 발생한다.
표현 영역은 이 익셉션이 발생했는지에 따라 트랜잭션 충돌이 일어났는지 확인할 수 있다.
@Controller
public class OrderController {
private ChangeShippingService changeShippingService;
@PostMapping("/changeShipping")
public String changeShipping(ChangeShippingRequest changeReq) {
try {
changeShippingService.changeShipping(changeReq);
return "changeShippingSuccess";
} catch (OptimisticEntityLockException exception) {
return "changeShippingTxConflict";
}
}
}
✅ 비선점 잠금을 여러 트랜잭션으로 확장
지금까지는 하나의 트랜잭션에서 애그리게잇을 조회하고 수정할 때 비선점 잠금을 적용하는 방법을 알아봤다.
여기서는 여러 트랜잭션에서 비선점 잠금을 적용하는 예시를 알아본다.
ex) 주문 정보 수정을 위해 사용자가 폼을 작성하는 상황
- 주문 데이터 요청(트랜잭션) -> 사용자에게 폼 형태로 제공
- 배송 상태 변경 요청(트랜잭션) -> 비선점 잠금 필요
-> 포인트는 조회를 하든, 수정을 하든 항상 버전 정보와 함께 데이터를 제공해야 한다는 것이다.
즉, 비선점 잠금 방식을 여러 트랜잭션으로 확장하려면 애그리거트 정보를 폼으로 보여줄 때 버전 정보도 함께 사용자 화면에 전달해야 한다.
HTML 폼을 생성하는 경우 버전 값을 갖는 hidden(화면에 보이지 않는) 타입 <input> 태그를 생성한다.
<form th:action="@{/startShipping}" method="post">
<input type="hidden" name="version" th:value="${orderDto.version}">
...
<input type="submit" value="배송 상태로 변경하기">
</form>
응용 서비스에 전달할 요청 데이터는 사용자가 전송한 버전 값을 포함한다.
public class StartShippingRequest {
private String orderNumber;
private long version;
...
}
응용 서비스는 전달 받은 버전 값을 이용해서 애그리거트 버전과 일치하는지 확인하고,
일치하는 경우에만 기능을 수행한다.
@Transactional
public void startShipping(StartShippingRequest req) {
Order order = orderRepository.findById(new OrderNo(req.getOderNumber()));
checkOrder(order);
if (!order.matchVersion(req.getVersion())) {
throw new VersionConflictException();
}
order.startShipping();
}
표현 계층은 버전 충돌 익셉션이 발생하면 버전 충돌을 사용자에게 알려 사용자가 알맞은 후속 처리를 할 수 있도록 한다.
@Controller
public class OrderController {
private StartShippingService startShippingService;
@PostMapping("/startShipping")
public String startShipping(StartShippingRequest startReq) {
try {
startShippingService.startShipping(startReq);
return "shippingStarted";
} catch (OptimisticEntityLockException | VersionConflictException ex) {
return "startShippingTxConfilct";
}
}
}
두 개의 예외를 처리함으로서 트랜잭션 충돌이 발생한 시점을 명확하게 구분할 수 있다.
VersionConflictException은 커스텀한 예외로 이미 누군가가 애그리거트를 수정했다는 것을 의미하고,
OptimisticEntityLockException은 스프링이 제공하는 예외로, 누군가가 거의 동시에 애그리거트를 수정했다는 것을 의미한다.
강제 버전 증가
✅ 강제로 버전을 증가시켜야 하는 경우
ex) 애그리거트에 애그리거트 루트 외에 다른 엔티티가 존재할 때,
기능 실행 도중 루트가 아닌 다른 엔티티만 변경된다면,
루트 엔티티의 버전 값은 갱신되지 않는다.
-> 그러나 애그리거트 내에 어떤 구성요소의 상태가 바뀌면 루트 애그리거트의 버전이 증가해야 한다.
JPA는 이런 문제를 처리할 수 있도록 강제 버전 증가 잠금 모드를 제공한다.
Order order = entityManager.find(
Order.class, orderNo, LockModeType.OPTIMISTIC_FORCE_INCREMENT);
이러면 해당 엔티티의 상태가 변경되었는지에 상관없이 트랜잭션 종료 시점에 버전 값 증가 처리를 한다.
+) 스프링 데이터 JPA는 @Lock 애너테이션을 이용해서 지정하면 된다.
오프라인 선점 잠금
✅ 여러 트랜잭션에 대한 잠금
만약 어떤 범위에 대해 동시 접근을 못하게 하려면 선점 잠금 방식을 떠올릴 수 있는데,
선점 잠금은 한 트랜잭션 범위에서만 적용된다.
만약 여러 트랜잭션에 걸쳐 동시 변경을 막고 싶다면 오프라인 선점 잠금을 생각해볼 수 있다.
-> 오프라인 선점 잠금은 첫 번째 트랜잭션을 시작할 때 잠금을 선점하고, 마지막 트랜잭션에서 잠금을 해제한다.
✅ 잠금 유효 시간
만약 오프라인 잠금을 선점했는데, 어떤 이유로 마지막 트랜잭션이 수행되기 전 프로그램이 종료된다면?
-> 다른 사용자는 영원히 잠금을 구할 수 없다.
-> 따라서 오프라인 선점 방식은 잠금 유효 시간을 가져야 한다.
-> 또한 유효 시간이 얼마 지나지 않아 마지막 트랜잭션이 수행되는 경우를 대비해, 일정 주기로 유효 시간을 증가시키는 방식도 필요하다.
오프라인 선점 잠금을 위한 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;
}
tryLock()은 잠글 대상 타입과 식별자를 값으로 전달한다.
그리고 LockId를 반환하는데, LockId는 잠금 확인, 해제, 유효시간 연장 때 사용한다.
public class LockId {
private String value;
public LockId(String value) {
this.value = value;
}
public String getValue() {
return value;
}
}
ex)
먼저 LockManager를 활용한 응용 서비스가 있다.
@Service
public class DataService {
public DataAndLockId getDataWithLock(Long id) {
// 1. 오프라인 선점 잠금 시도
LockId lockId = lockManager.tryLock("data", id);
// 2. 기능 실행
Data data = someDao.selecdt(id);
return new DataAndLockId(data, lockId);
}
}
표현 계층은 서비스가 리턴한 잠금ID를 모델로 뷰에 전달한다.
@RequestMapping("/some/edit/{id}")
public String editForm(@PathVariable("id") Long id, ModelMap model) {
DataAndLockId dl = dataService.getDataWithLock(id);
model.addAttribute("data", dl.getData());
model.addAttribute("lockId", dl.getLockId());
return "editForm";
}
잠금을 선점하는데 실패하면 LockException이 발생한다.
이 때는 다른 사용자가 데이터를 수정 중이니 나중에 다시 시도하라는 안내 화면을 보여주면 된다.
수정 폼은 LockId를 다시 전송해서 잠금을 해제할 수 있도록 한다.
<form th:action="@{/some/edit/{id}(id=${data.id})}" method="post">
<input type="hidden" name="lid" th:value="${lockId.value}">
...
</form>
잠금을 해제하는 코드는 다음과 같이 전달받은 LockId를 이용한다.
@Service
public class SomeEditService {
private LockManager lockManager;
public void edit(EditRequest editReq, LockId lockId) {
// 1. 잠금 선점 확인
lockManager.checkLock(lockId);
// 2. 기능 실행
...
//3. 잠금 해제
lockManager.releaseLock(lockId);
}
}
컨트롤러는 서비스를 호출할 때 잠금ID를 함께 전달한다.
@RequestMapping(value = "/some/edit/{id}", method = RequestMethod.POST)
public String edit(
@PathVariable("id") Long id,
@ModelAttribute("editReq") EditRequst editReq,
@RequestParam("lid") String lockIdValue,
ModelMap model
) {
editReq.setId(id);
someEditService.edit(editReq, new LockId(lockIdValue));
model.addAttribute("data", data);
return "editSuccess";
}
DB를 이용한 LockManager 구현
✅ 잠금 정보를 저장할 테이블과 인덱스
LockManager를 DB를 이용해 구현해 보자.
다음은 MySQL용 잠금 테이블과 인덱스다.
create table locks (
`type` varcher(255),
id varcher(255),
lockid varchar(255),
expiration_time datetime,
primary key(`type`, id)
) character set utf8;
create unique index locks_idx ON locks (lockid);
ex) Order 타입의 1번 식별자를 갖는 애그리거트에 대한 잠금을 구하고 싶다면 다음의 insert 쿼리를 이용하면 된다.
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를 구현한 코드는 길이가 다소 길어서 나눠서 표시했다.
DB 연동은 JdbcTemplate을 이용해서 처리했다.
@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;
}
@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();
}
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);
}
}
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;
}
}
- lockDataRowMapper: locks 테이블에서 조회한 데이터를 LockData로 매핑하기 위한 RowMapper.
- tryLock(): 해당 type과 id에 대한 잠금이 존재하는지 검사하고, 새로운 LockId에 대해 잠금을 생성한다.
- checkAlreadyLocked(): type과 id에 대한 데이터를 조회하고, 유효 시간이 지났거나 이미 lock이 존재하면 예외를 발생시킨다.
- handleExpiration(): 잠금 유효 시간이 지나면 해당 데이터를 삭제하고 Optional.empty 반환, 그게 아니면 LockData 반환.
- locking(): locks 테이블에 데이터를 삽입한다. 데이터 삽입 결과가 없거나 동일한 키에 대한 데이터가 존재하면 예외를 발생시킨다.
-> tryLock()을 정리하면 해당 키에 대해 이미 잠금이 선점됐는지 확인하고, 잠금을 선점하는 메서드다.
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();
}
private Optional<LockData> getLockData(LockId lockId) {
List<LockData> locks = jdbcTemplate.query(
"select * from locks where lockid = ?",
lockDataRowMapper, lockId.getValue());
return handleExpiration(locks);
}
@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());
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
@Override
public void releaseLock(LockId lockId) throws LockException {
jdbcTemplate.update("delete from locks where lockid = ?", lockId.getValue());
}
- checkLock(): 잠금이 유효한지 검사한다. 잠금이 존재하지 않으면 예외를 발생시킨다.
- getLockData(): lockId에 해당하는 LockData를 구한다. handleException으로 유효시간이 지난 데이터를 처리한다.
- extendLockExpiration(): lockId에 해당하는 잠금 유효 시간을 inc만큼 늘린다.
- releaseLock(): lockId에 해당하는 잠금 데이터를 locks 테이블에서 삭제한다.
'book > 도메인 주도 개발 시작하기' 카테고리의 다른 글
[DDD Start] 이벤트 (2) | 2023.09.08 |
---|---|
[DDD Start] 도메인 모델과 바운디드 컨텍스트 (0) | 2023.09.02 |
[DDD Start] 도메인 서비스 (0) | 2023.08.18 |
[DDD Start] 응용 서비스와 표현 영역 (0) | 2023.08.12 |
[DDD Start] 스프링 데이터 JPA를 이용한 조회 기능 (0) | 2023.08.12 |