반응형
애그리거트와 트랜잭션
- 한 애그리거트가 여러곳의 호출에 의해 동시에 변경이 되는 걸 방지하기 위해 트랜잭션 처리 기법이 필요하다.
- 트랜잭션 처리 방식에는 선점(Pessimistic) 잠금과 비선점(Optimistic) 잠금 두 가지 방식이 있다.
선점(Pessimistic) 잠금
- 선점 잠금이란 먼저 애그리거트를 구한 스레드가 애그리거트 사용이 끝날때까지 다른 스레드가 해당 애그리거트를 수정하는 것을 막는 방식이다.
- 스레드1이 애그리거트를 수정하는 동안 블록킹이 되고 트랜잭션이 커밋되는 대기하고 있던 스레드2가 작동한다.
- 선점 잠금은 보통 DBMS가 제공하는 행 단위 잠금을 사용해서 구현한다.
- JPA의 EntityManager는 LockModeType을 인자로 받는 find() 메서드를 제공하는데,
- LockModeType.PESSIMISTIC를 값으로 전달하면 해당 엔티티와 매핑 된 테이블을 이용해서 선점 잠금 방식을 적용할 수 있다.
- JPA 프로바이더와 DBMS에 따라 잠금 모드의 구현이 다른데, 하이버네이트의 경우 PERSSIMISTIC_WRITE를 잠금 모드로 사용하면 'for update' 쿼리를 사용해서 선점 잠금을 구현한다.
선점 잠금과 교착 상태
만약 선점 잠금을 사용하는데 동시에 접근 잠금을 시도할때 두 스레드 모두 다 교착상태에 빠져 다음단계로 넘어가지 못할때가 있다.
- 이는 사용자가 많을때 발생할 가능성이 높다.
- 이를 방지하기 위해 잠금을 구할 때 최대 대기 시간을 지정해야 한다.
Map<String, Object> hints = new HashMap<>();
// 잠금을 구하는 대기 시간을 2초로 설정
hints.put("javax.persistence.lock.timeout", 2000);
Order order = entityManager.find(Order.class, orderNo, LockModeType.PESSIMISTIC_WRITE, hints);
- 지정된 시간 이내 잠금을 구하지 못하면 익셉션을 발생시킨다.
- 이 기능을 사용할 땐 사용중인 DBMS가 관련 기능을 지원하는지 확인이 필요하다.
비선점(Optimistic) 잠금
- A 스레드 → 주문 애그리거트 조회
- B 스레드 → 주문 정보 변경
- A 스레드 → 주문 상태 변경
- 예를 들어 위와 같은 프로세스가 진행 되었을때 A 스레드는 예전 주문 정보를 보고 주문 상태를 변경 했을것이다.
- 이러한 문제들을 방지하기 위해 비선점 잠금을 사용한다.
- 비선점 잠금 방식은 잠금을 해서 동시에 접근하는것을 막는 대신 변경한 데이터를 실제 DBMS에 반영하는 시점에 변경 가능 여부를 확인하는 방식이다.
- 비선점 잠금을 구현하려면 애그리거트에 버전으로 사용할 숫자 타입의 프로퍼티를 추가해야 한다.
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 {
@EmeddedId
private OrderNo orderNo;
@Version
private long version;
...
}
- 응용 서비스를 버전에 대해 알 필요가 없고, 알맞은 비지니스 로직만 구현하면 된다.
- 실행 결과로 변경된 행의 개수가 0이면 OptimisticLockingFailureException을 발생되고 트랜잭션이 종료된다.
비선점 잠금 방식을 위한 View
- 비선점 잠금 방식을 여러 트랜잭션으로 관리하려면 애그리거트 정보를 뷰로 보여줄때 버전 정보도 함께 사용자 화면에 전달해야한다.
- 전달받은 버전 정보를 hidden(input) 타입으로 함께 전송해 애그리거트 수정 처리를 해야한다.
<!-- 버전 정보를 전달하는 View 예시 -->
<form id="startShipping" method="POST">
<input type="hidden" name="version" value="${orderDto.version}">
<input type="hidden" name="orderNumber" value="${orderDto.orderNumber}">
<input type="submit" value="배송 상태로 변경하기"/>
</form>
// 버전 정보 확인 후 애그리거트 상태 변경 예시
public class StartShippingRequest {
private long version;
private String orderNumber;
}
public class StartShippingService {
@PreAuthorize("hasRole('ADMIN')")
@Transactional
public void startShipping(StartShippingRequest request) {
Order order = orderRepository.findById(new OrderNo(request.getOrderNumber()));
checkOrder(order);
if (!order.matchVersion(request.getVersion()) {
throw new VersionConflictException();
}
order.startShipping();
}
...
}
강제 버전 증가
- 애그리거트 내부에는 애그리거트 루트와 다른 엔티티가 존재하는 경우가 있다.
- 이때 다른 엔티티의 정보만 수정될 경우에는 루트 엔티티의 버전이 증가하지 않는다.
- 하지만 논리적으로는 구성 엔티티가 변경되면 루트 엔티티의 버전이 증가해야 하므로 JPA에서는 강제로 버전 값을 증가시키는 잠금 모드를 지원하고 있다.
@Repository
public class JpaOrderReposiory 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을 사용하면 해당 엔티티가 변경되었는지 여부에 상관없이 트랜잭션 종료 시점에 버전 값을 증가한다.
오프라인 선점 잠금
- 만약 두 사용자가 같은 애그리거트를 동시에 수정하려고 할때 충돌이 생기는데
- 한 트랜잭션 범위에서만 적용되는 선점(Pessimistic) 잠금 방식 이나, 나중에 버전 충돌을 확인하는 비선점(Optimistic) 잠금 방식으로는 이를 방지할 수 없다.
- 오프라인 선점 방식은 여러 트랜잭션에 걸쳐 동시 변경을 막는다.
- A 사용자가 수정 페이지에 접근해 저장되어 있는 데이터를 조회 시 오프라인 잠금
- B 사용자가 수정 페이지에 접근해 저장되어 있는 데이터를 조회 할 때 익셉션 발생
- A 사용자가 수정 폼 전송 후 데이터를 변경하면 오프라인 잠금 해체
- 이후 B 사용자가 수정 페이지 접근 가능
- 만약 A 사용자가 수정 페이지 접근 후 수정 요청을 하지 않는다면 다른 사용자는 영원히 해당 애그리거트를 영원히 수정하지 못한다.
- 이러한 문제를 방지하기 위해 잠금에 유효시간을 가져야 한다.
- 수정 폼에서 일정시간 단위로 ajax를 호출해 유효시간을 늘려주는 방법이 있다.
오프라인 선점 잠금을 위한 LockManager 인터페이스와 관련 클래스
- 오프라인 선점 잠금은 크게 잠금 선점 시도, 잠금 확인, 잠금 해제, 락 유효시간 연장의 네 가지 기능을 제공해야 한다.
@Getter
public class LockId {
public LockId(String value) {
this.value = value;
}
private String value;
}
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;
}
- LockManager.tryLock을 이용해 잠금 선점을 시도하고 잠금 된 LockId를 반환한다.
- 반환된 LockId는 잠금 해체를 할때 사용하므로 수정폼(view)에 보관해야 한다.
- 선점 잠금에 실패하면 LockException이 발생하는데 이때 다른 사용자가 데이터를 수정 중이니 나중에 다시 시도해 보라는 안내 화면을 보여주면 된다.
// 수정 폼 접근 예시
@RequestMapping("/some/edit/{id}")
public String editData(@PathVariable("id") Long id, ModelMap model) {
// 오프라인 선점 잠금 시도
LockId lockId = lockManager.tryLock("data", id);
// 기능 실행
Data data = someDao.select(id);
model.addAttribute("data", data);
// 잠금 해제에 사용할 LockId를 모델에 추가
model.addAttribute("lockId", lockId);
return "editData";
}
<!-- editData.jsp -->
<form action="/some/edit/${data.id}" nmethod="post">
...
<input type="hidden" name="lockId" value="${lockId.value}"/>
...
</form>
// 수정 요청 예시
@RequestMapping(value = "/some/edti/{id}", method = RequestMethod.POST)
public String editDataSubmit(@PathVariable("id") Long id,
@ModelAttribute("editReq") EditRequest editReq,
@RequestParam("lockId") String lockIdValue) {
editReq.setId(id);
// 잠금 선점 확인
LockId lockId = new LockId(lockIdValue);
lockManager.checkLock(lockId);
// 기능 실행
someEditService.edit(editReq);
model.addAttribute("data", data);
// 잠금 해제
lockManager.releaseLock(lockId);
return "editSuccess";
}
DB를 이용한 LockManager구현
- DB에 locks 테이블을 생성해 관리한다.
CREATE TABLE locks
(
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '고유번호',
`lock_id` VARCHAR(250) NOT NULL COMMENT '잠금 아이디',
`lock_type` VARCHAR(50) NOT NULL COMMENT '데이터 유형',
`expiration_time` DATETIME NOT NULL COMMENT '유효시간',
CONSTRAINT PRIMARY KEY (`id`, `lock_type`)
);
- Order 타입의 1번 식별자를 갖는 애그리거트에 대한 잠금을 구하는 SQL 예시
INSERT INTO locks(lock_id, lock_type, expiration_time)
VALUES ('생성한LockId', 'Order', '2021-04-25 16:30:25')
- 잠금 해제 시에는 DB상에 해당 데이터를 삭제하여 해제할 수 있다.
- 선점 잠금 확인 시에는 DB상에 데이터 조회 후 있다면 유효시간까지 비교해야 한다.
반응형
'DDD' 카테고리의 다른 글
도메인(Domain) 모델에 지켜야할것! (0) | 2021.09.15 |
---|---|
도메인(Domain) 서비스란? (0) | 2021.09.15 |
애그리거트(Aggregate)간에 참조 및 영속성 전파 (0) | 2021.09.15 |
DIP(역전 의존 원칙)란? (0) | 2021.09.14 |
AttributeConverter를 이용한 밸류 매핑 처리 (0) | 2021.09.14 |