Posts [도메인주도개발시작하기] Chapter8-애그리거트 트랜잭션 관리
Post
Cancel

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

애그리거트와 트랜잭션

  • 한 주문 애그리거트에 대해 운영자는 배송 상태로 변경할 때 사용자는 배송지 주소를 변경하면 어떻게 될까?
  • 아래 이미지는 발생할 수 있는 다양한 경우 중 한 가지를 시간 순서로 표시한 것이다.

image

  • 운영자 스레드와 고객 스레드는 개념적으로 동일한 애그리거트지만 물리적으로 서로 다른 애그리거트 객체를 사용한다.
  • 때문에 운영자 스레드가 주문 애그리거트 객체를 배송 상태로 변경하더라도 고객 스레드가 사용하는 주문 애그리거트 객체엔 영향을 주지 않는다.
  • 고객 스레드 입장에서 주문 애그리거트 객체는 아직 배송 상태 전이므로 배송지 정보를 변경 가능하다.
  • 그렇기 때문에 애그리거트의 일관성이 깨지게 되는 것이다.
  • 이런 문제가 발생하지 않도록 하려면 다음의 두 가지 중 하나를 해야 한다.
    • 1)운영자가 배송지 정보를 조회하고 상태를 변경하는 동안 고객이 애그리거트를 수정하지 못하게 막는다.
    • 2)운영자가 배송지 정보를 조회한 이후에 고객이 정보를 변경하면 운영자가 애그리거트를 다시 조회한 뒤 수정하도록 한다.
  • 위 두 가지는 애그리거트 자체의 트랜잭션과 관련이 있다.
  • DBMS가 지원하는 트랜잭션과 함께 애그리거트를 위한 추가적인 트랜잭션 처리 기법이 필요하다.
  • 애그리거트에 대해 사용할 수 있는 대표적인 트랜잭션 처리 방식에는 선점(Pessimistic) 잠금과 비선점(Optimistic) 자금의 두 가지 방식이 있는데 이어서 살펴보자.

Note: Pessimistic Lock 과 Optimistic Lock을 비관적 잠금과 낙관적 잠금이라 많이 표현하는데 의미가 조금 더 가까운 선점 잠금과 비선점 잠금이란 용어를 저자는 사용하였다.

선점 잠금(Pessimistic Lock)

  • 선점 잠금은 애그리거트를 먼저 구한 스레드가 애그리거트 사용이 끝날 때까지 다른 스레드가 해당 애그리거트를 수정하는 것을 막는 방식이다.

image

  • 스레드1이 선점 잠금 방식으로 애그리거트를 구한 후 스레드2는 스레드1인 애그리거트에 대한 잠금을 해제할 때 까지 블로킹된다.
  • 이러한 선점 잠금을 사용하면, 한 스레드가 애그리거트를 구하고 수정하는 동안 다른 스레드가 수정할 수 없으므로 동시에 애그리거트를 수정할 때 발생하는 데이터 충돌 문제를 해소할 수 있다.
    • 앞서 배송지 정보 수정과 배송 상태 변경을 동시에 하는 문제에 선점 잠금을 적용하면 아래 이미지와 같이 동작한다.

image

  • 운영자 스레드가 먼저 선점 잠금 방식으로 주문 애그리거트를 구한 경우 운영자 스레드가 잠금을 해제할 때까지 고객 스레드는 대기 상태가 된다.
  • 운영자 스레드가 배송 상태로 변경한 뒤 트랜잭션을 커밋하면 잠금을 해제한다.
  • 잠금이 해제된 시점에 고객스레드가 구하는 주문 애그리거트는 운영자 스레드가 수정한 배송 상태의 주문 애그리거트이다.
  • 배송 상태이므로 배송지 변경시 에러를 발생하고 트랜잭션은 실패하게 된다.
  • 이 시점에 고객은 ‘이미 배송이 시작되어 배송지를 변경할 수 없습니다’ 와 같은 안내 문구를 보게 될 것이다.
  • 선점 잠금은 보통 DBMS가 제공하는 행 단위 잠금을 사용해서 구현한다.
  • 오라클을 비롯한 다수 DBMS가 for update 와 같은 쿼리를 사용해서 특정 레코드에 한 사용자만 접근할 수 있는 잠금 장치를 제공한다.
  • JPA의 EntityManager 는 LockModeType 을 인자로 받는 find() 메서드를 제공하는데, LockModeType.PESSIMISTIC_WRITE를 값으로 전달하면 해당 엔티티와 매핑된 테이블을 이용해서 선점 잠금 방식을 적용할 수 있다.
1
Order order = entityManager.find(Order.class, orderNo, LockModeType.PESSIMISTIC_WRITE;
  • JPA 프로바이더와 DBMS에 따라 잠금 모드의 구현이 다른데, 하이버네이트의 경우 PESSIMISTIC_WRITE를 잠금 모드로 사용하면 for update 쿼리를 사용해서 선점 잠금을 구현한다.
  • 스프링 데이터 JPA는 @Lock 어노테이션을 통해 잠금 모드를 지정한다.
1
2
3
4
5
6
7
8
9
import org.springframework.data.jpa.repository.Lock;
import javax.persistence.LockModeType;

public interface MemberRepository extends Repository<Member, MemberId> {

  @Lock(LockModeType.PESSIMITIC_WRITE
  @Query("select m from Member m where m.id = :id")
  Optional<Member> findByIdForUpdate(@Param("id") MemberId memberId);
}

선점 잠금과 교착 상태

  • 선점 잠금 기능을 사용할 땐 잠금 순서에 따른 교착 상태(deadlock)가 발생하지 않도록 주의해야 한다.
  • 예를 들어, 다음과 같은 수서로 두 스레드가 잠금 시도를 한다 해보자.
1
2
3
4
1. 스레드1: A 애그리거트에 대한 선점 잠금 구함
2. 스레드2: B 애그리거트에 대한 선점 잠금 구함
3. 스레드1: B 애그리거트에 대한 선점 잠금 시도
4. 스레드2: A 애그리거트에 대한 선점 잠금 시도
  • 위 순서에 따르면 스레드1, 2 모두 영원히 B, A 애그리거트에 대한 선점 잠금을 구할 수 없다.
  • 두 스레드는 상대방 스레드가 먼저 선점한 잠금을 구할수 없어 더 이상 다음 단계를 진행하지 못하게 되어 교착상태에 빠지게 된다.
  • 선점 잠금에 따른 교착 상태는 상대적으로 사용자 수가 많을 때 발생할 가능성이 높고, 사용자수가 많아지면 교착 상태에 빠지는 스레드는 더 빠르게 증가한다.
  • 이런 문제가 발생하지 않도록 하려면 잠금을 구할 때 최대 대기 시간을 지정해야 한다. JPA에서 선점 잠금을 시도할 떄 최대 대기 시간을 지정하려면 다음과 같이 힌트를 사용한다.
1
2
3
Map<String, Object> hints = new HashMap<>();
hints.put("javax.persistence.lock.timeout", 2000);
Order order = entityManager.find(Order.class, orderNo, LockModeType.PESSIMISTIC_WRITE, hints);
  • ‘javax.persistence.lock.timeout’ 힌트는 잠금을 구하는 대기 시간을 밀리초 단위로 지정한다.
  • 지정한 시간 이내에 잠금을 구하지 못하면 익셉션을 발생시킨다.
  • 이 힌트를 사용할 때 주의할 점은 DBMS에 따라 힌트가 적용되지 않을 수도 있따는 걳이다.
    • 힌트를 이용할 땐 사용중인 DBMS가 관련 기능을 지원하는지 확인해야 한다.
  • 스프링 데이터 JPA 는 @QueryHints 어노테이션을 사용해서 쿼리 힌트를 지정할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
import org.sprintframework.data.jpa.repository.QueryHints;
import javax.persistence.QueryHint;

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

Note: DBMS에 따라 교착 상태인 커넥션을 처리하는 방식이 다르니(쿼리 /커넥션별 대기 시간 지정) 선점 잠금 사용시 사용하는 DBMS에 대해 JPA가 어떤 식으로 대기 시간을 처리하는지 반드시 확인해야 한다.

비선점 잠금

  • 선점 잠금이 강력해9 보이긴 하지만 모든 트랜잭션 충돌 문제가 해결되는 것은 아니다.

image

  • 위 이미지의 실행 순서는 아래와 같다.
1
2
3
4
1. 운영자는 배송을 위해 주문 정보를 조회한다. 시스템은 정보를 제공한다.
2. 고객이 배송지 변경을 위해 변경 폼을 요청한다. 시스템은 변경 폼을 제공한다.
3. 고객이 새로운 배송지를 입력 후 폼을 전송하여 배송지를 변경한다.
4. 운영자가 1번에서 조회한 주문 정보를 기준으로 배송지를 정하고 배송 상태 변경을 요청한다.
  • 여기서 문제는 운영자가 배송지 정보를 조회하고 배송 상태로 변경하는 사이에 고객이 배송지를 변경한다는 것이다.
  • 운영자는 고객이 변경하기 전 배송지 정보를 이용하여 배송 준비를 한 뒤에 배송 상태로 변경하데 된다.
  • 즉, 배송 상태 변경 전에 배송지를 한 번 더 확인하지 않으면 운영자는 다른 배송지로 물건을 발송하게 되고, 고객은 배송지를 변경했음에도 불구하고 엉뚱한 곳으로 주문한 물건을 받는 상황이 발생한다.
  • 이 문제는 선점 잠금 방식으로는 해결할 수 없다. 이때 필요한 것이 비선점 잠금이다.
    • 비선점 잠금은 동시에 접근하는 것을 막는 대신 변경한 데이터를 실제 DBMS에 반영하는 시점에 변경 가능여부를 확인하는 방식이다.
  • 비선점 잠금을 구현하려면 애그리거트에 버전으로 사용할 숫자 타입 프로퍼티를 추가해야 한다.
  • 애그리거트를 수정할 때마다 버전으로 사용할 프로퍼티 값이 1씩 증가하는데 이떄 다음과 같은 쿼리를 사용한다.
1
2
UPDATE aggtable SET version = version + 1, colx = ?, coly = ?
WHERE aggid = ? and version = 현재 버젼
  • 이 쿼리는 수정할 애그리거트와 매핑되는 테이블의 버전 값이 현재 애그리거트의 버전과 동일한 경우에만 데이터를 수정한다.
  • 그리고 수정에 성공하면 버전 값을 1 증가시킨다.
  • 다른 트랜잭션이 먼저 데이터를 수정해서 버전 값이 바뀌면 데이터 수정에 실패하게 된다.

image

  • 스레드1과 2는 같은 버전을 갖는 애그리거트를 읽어와 수정하는데 스레드1이 먼저 수정 및 커밋 후 스레드2가 수정을 하게 되면 버전이 달라지기 때문에 데이터 수정에 실패하게 된다.
  • JPA는 버전을 이용한 비선점 잠금 기능을 지원한다.
  • 다음과 같이 버전으로 사용할 필드에 @Version 어노테이션을 붙이고 매핑되는 에티읍ㄹ에 버전을 젖아할 칼럼을 추가하면 된다.
1
2
3
4
5
6
7
8
9
10
11
12
@Entity
@Table(name = "purchase_order")
@Access(AccessType.FIELD)
public class Order {
  @EmbeddedId
  private OrderNo number;

  @Version
  private long version;

  ...
}
  • JPA는 엔티티가 변경되어 update 쿼리를 날릴 때 @Version에 명시한 필드를 이용해서 비선점 잠금 쿼리를 실행한다.
  • 즉, 애그리거트 객체의 버전이 10일 경우 update 쿼리를 실행할 때 아래와 같은 쿼리를 사용해서 버전이 일치하는 경우에만 데이터를 수정한다.
1
2
UPDATE purchagse_order SET ...생략, version = version + 1
WHERE number = ? and version = 10
  • 응용 서비스는 버전에 대해 알 필요가 없다.
  • 기능 실행 과정에서 애그리거트 데이터가 변경되면 JPA는 트랜잭션 종료 시점에 비선점 잠금을 위한 쿼리를 실행한다.
  • 비선점 잠금을 위한 쿼리를 실행할 때 쿼리 실행 결과로 수정된 행의 개수가 0이면 이미 누군가 앞서 데이터를 수정한 것이다.
  • 이는 트랜잭션이 충돌한 것이므로 트랜잭션 종료 시점에 익셉션이 발생한다. (OptimisticLockingFailureException)
  • 표현 영역의 코드는 이 익셉션의 발생 여부에 따라 트랜잭션 충돌이 일어났는지 확인할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Controller
public class OrderController {
	...
	@RequestMapping(value = "/changeShipping", method = RequestMethod.POST)
	public String changeShipping(ChangeShippingRequest changeReq) {
		try {
			changeShippingService.changeShipping(changeReq);
			return "changeShippingSuccess";
		} catch(optimisticLockingFailureException ex) {
				// 누군가 먼저 같은 주문 애그리거트를 수정했으므로, 
				// 트랜잭션 충돌이 일어났다는 메시지를 보여준다. 
				return "changeShippingExConflic";
		}
}

image

  • 시스템은 사용자에게 수정 폼을 제공시에 애그리거트 버전을 함께 제공하고, 폼을 서버에 전송시 이 버전을 함께 전송한다.
    • 이때 사용자가 전송한 버전과 애그리거트 버전이 동일한 경우에만 수정 기능을 실행하도록 하여 트랜잭션 충돌문제를 해소할 수 있다.
  • 비선점 잠금 방식을 여러 트랜잭션으로 확장하려면 애그리거트 정보를 뷰로 보여줄 때 버전 정보도 함께 사용자 화면에 전달해야 한다.
  • HTML 폼을 생성하는 경우 버전 값을 갖는 hidden 타입 <input> 태그를 생성해서 폼 전송시 버전 값이 서버에 함께 전달되도록 한다.
1
2
3
4
5
6
7
<!- 애그리거트 정보를 보여줄   코드는 버전 값을 함꼐 전송한다. ->
<form th:action="@{startShipping}" method="post">
  <input type="hidden" name="version" th:value="${orderDto.version}">
  <input type="hidden" name="orderNumber" th:value="${orderDto.orderNumber}">
  ...
  <input type="submit" value="배송 상태로 변경하기">
</form>
  • 응용 서비스에 전달할 요청 데이터는 사용자가 전송한 버전값을 포함한다. 예를 들어, 배송 상태 변경을 처리하는 응용 서비스가 전달받는 데이터는 다음과 같이 주문 번호와 함께 해당 주문을 조회한 시점의 버전 값을 포함해야 한다.
1
2
3
4
5
6
7
public class StartShippingRequest {
  private String orderNumber;
  private long version;


  ...생성자, getter
}
  • 응용 서비스는 전달받은 버전 값을 이용해서 애그리거트의 버전과 일치하는지 확인하고 일치하는 경우에만 요청한 기능을 수행한다.
1
2
3
4
5
6
7
8
9
10
11
12
public class StartShippingService {
  @PreAuthorize("hasRole('ADMIN')")
  @Transactional
  public void startShipping(StartShippingRequest req) {
    Order order = orderRepository.findById(new OrderNo(req.getOrderNumber()));
    checkOrder(order);
    if (!order.matchVersion(req.getVersion())) {
      throw new VersionConfilictException();
    }
    order.startShipping();
  }
}
  • matchVersion 메서드는 현재 애그리거트 버전과 인자로 전달받은 버전의 결과에 따라 분기를 태운다.
  • 만약 다를 경우 버전이 충돌했다는 익셉션을 발생시켜 표현 계층에 이를 알린다.
  • 표현 계층은 버전 충돌 익셉션이 발생하면 버전 충돌을 사용자에게 알려주고 사용자가 알맞은 후속 처리를 할 수 있도록 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Controller
public class OrderAdminController {
	private StartShippingService startShippingService;

	@RequestMapping(value = "/startShipping", method = RequestMethod.POST)
	public String startShipping(StartShippingRequest startReq) {
		try {
			startShippingService.startShipping(startReq);
			return "shippingStarted";
		} catch(OptimisticLockingFailureException | VersionConflicException ex) {
			// 트랜잭션 충돌
			return "startShippingTxConflict";
		}
	}
	... 
  • 위 코드는 비선점 잠금과 관련해서 발생하는 두 개의 익셉션을 처리하고 있다.
  • 하나는 스프링 프레임워크가 발생시키는 OptimisticLockingFailureException 이고 다른 하나는 으용 서비스 코드에서 발생시키는 VersionConfilctException 이다.
  • 이 두 익셉션은 개발자 입장에선 트랜잭션 충돌이 발생한 신점을 명확히 구분한다.
    • VersionConfilctException 은 이미 누군가 애그리거트를 수정했다는 것을 의미
    • OptimisticLockingFailureException 은 누군가가 거의 동시에 애그리거트를 수정했따는 것을 의미
  • 버전 충돌 상황에 대한 구분이 명시적으로 필요없다면 응용 서비스에서 프레임워크용 익셉션을 발생시키는 것도 고려 할 수 있다.
1
2
3
4
5
6
7
8
9
public void startShipping(StartShippingRequest req) {
  Order order = orderRepository.findById(new OrderNo(req.getOrderNumber()));
  checkOrder(order);
  if (!order.matchVersion(req.getVersion())) {
    // 프레임워크가 제공하는 비선점 트랜잭션 충돌 관련 익셉션 사용
    throw new OptimisticLockingFailureException("version conflict);
  }
  order.startShipping();
}

강제 버전 증가

  • 애그리거트 루트 외에 다른 엔티티의 값만 변경된다면 이 경우 JPA는 루트 엔티티 버전값을 증가 시키지 않는다.
  • 애그리거트 관점에서 보면 애그리거트가 바뀐 것이기 때문에 버전 값을 증가시켜야 비선점 잠금이 올바르게 동작한다.
  • JPA는 LockModeType.OPTIMISTIC_FORCE_INCREMENT를 사용해 엔티티를 구할때 강제로 버전 값을 증가시키는 잠금 모드를 지원하고 있다.
    • 이를 통해 해당 엔티티의 상태 변경 여부와 상관 없이 트랜잭션 종료 시점에 버전 값 증가처리를 강제 할 수 있다.
    • 이 잠금 모들르 사용시 애그리거트 루트 엔티티가 아닌 다른 엔티티나 밸류가 변경되더라도 버전 값을 증가시킬 수 있으므로 비선점 잠금 기능을 안전하게 적용할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
@Repository
public class JpaOrderRepository implements OrderRepository {
	@PersistenceContext
	private EntityMangager entityManager;

	@Override
	public Order findbyIdOptimisticLockMode(OrderNo id) {
		return entityManager.find(Order.class, id
				LockModeType.OPTIMISTTIC_FORCE_INCREMENT);
	}
...
  • 스프링 데이터 JPA를 사용하면 앞서 살펴본 @Lock 어노테이션을 이용해서 지정하면 된다.

오프라인 선점 잠금

  • 아틀라시안의 컨플루언스(Confluence)는 문서를 편집시 누군가 먼저 편집을 하는 중이면 다른 사용자가 문서를 수정하고 있다는 안내 문구를 보여준다.
  • 이런 안내를 통해 여러 사용자가 동시에 한 문서를 충돌을 사전에 방지할 수 있게 해준다.
  • 만약, 더 엄격하게 데이터 충돌을 막고 싶다면 누군가 수정화면을 보고 있을때 수정화면 자체를 실행하지 못하도록 해야 한다.
  • 한 트랜잭션 범위에서만 적용되는 선점 잠금 방식이나 나중에 버전 충돌을 확인하는 비선점 잠금 방식으론 이를 구현할 수 없다.
  • 이때 필요한 것이 오프라인 선점 잠금(Offline Pessimistic Lock)이다.

  • 단일 트랜잭션에서 동시 변경을 막는 선점 잠금 방식과 달리 오프라인 선점 잠금은 여러 트랜잭션에 걸쳐 동시 변경을 막는다.
  • 첫 번째 트랜잭션을 시작할 때 오프라인 잠금을 선점하고, 마지막 트랜잭션에서 잠금을 해제한다.
  • 잠금을 해제하기 전까지 다른 사용자는 잠금을 구할 수 없다.

image

  • 오프라인 선점 잠금을 사용하면 위 이미지의 과정1처럼 폼 요청 과정에서 잠금을 선점하고, 과정3처럼 수정 과정에서 잠금을 해제한다.
  • 이미 잠금을 선점한 상태에서 다른 사용자가 폼을 요청하면 과정2처럼 잠금을 구할 수 없어 에러 화면을 보게 된다.

잠금 유효 시간의 필요성

  • 만약 사용자A가 과정3의 수정 요청을 수행하지 않고 프로그램을 종료하면 어떻게 될까?
    • 이 경우 잠금을 해제하지 않으므로 다른 사용자는 영원히 잠금을 구할 수 없는 상황이 발생한다.
    • 이런 사태를 방지하기 위해 오프라인 선점 방식은 잠금 유효시간을 가져야 한다.
    • 유효시간이 지나면 자동으로 잠금을 해제해서 다른 사용자가 잠금을 일정 시간 후에 다시 구할수 있도록 해야 한다.
    • 사용자A가 잠금 유효시간이 지난 후 1초 뒤에 3번 과정을 수행했다고 가정하자. 잠금이 해제되어 사용자 A는 수정에 실패하게 된다.
    • 이런 상황을 만들지 않으려면 일정 주기로 유효 시간을 증가시키는 방식이 필요하다.
    • 예를 들어, 수정 폼에서 1분 단위로 Ajax 호출을 해서 잠금 유효 시간을 1분씩 증가시키는 방법이 있다.

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

  • 오프라인 선점 잠금은 크게 잠금 선점 시도, 잠금 확인, 잠금 해제, 잠금 유효시간 연장의 네 가지 기능이 필요하다.
  • 이 기능을 위한 LockManager 인터페이스는 아래와 같다.
1
2
3
4
5
6
7
8
9
10
// 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 메서드는 rtype과 id 를 파라미터로 갖는다. 이 두 파라미터엔 각각 잠글 대상 타입과 식별자를 값으로 전달하며 된다.
    • 예를 들어 식별자가 10인 Article 에 대한 잠금을 구하고자 한다면 tryLock 메서드를 실행시 ‘domain.Article’을 type으로 주고 ‘10’을 id값으로 주면 된다.
  • 잠금을 식별할 때 사용하는 LockId는 아래와 같다.
1
2
3
4
5
6
7
8
9
10
11
12
// LockId 클래스
public class LockId {
  private String value;

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

	public String getValue() {
	  return value;
  }
}
  • 컨트롤러가 오프라인 선점 잠금 기능을 이용해서 데이터 수정 폼에 동시에 접근하는 것을 제어하는 코드는 아래와 같다.
    • 수정 폼에서 데이터를 전송할 때 LockId를 전송할 수 있도록 LockId를 모델에 추가했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 서비스: 서비스는 잠금 ID를 리턴한다.
public DataAndLockId getDataWithLock(Long id) {
  // 1. 오프라인 선점 잠금 시도
  LockId locakId = locakManager.tryLock("data", id);
  // 2. 기능 실행
  Data data = someDao.select(id);
  return new DataAndLocakId(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());
    // 3. 잠금 해제에 사용할 LockId를 모델에 추가
    model.addAttribute("lockId", dl.getLockId());
    return "editForm"
}
  • 잠금을 선점하는데 실패하면 LockException이 발생한다.
    • 이때 다른 사용자가 데이터를 수정 중이니 나중에 다시 시도하라는 안내 화면을 보여주면 된다.
  • 수정 폼은 LockId 를 다시 전송해서 잠금을 해제할 수 있도록 한다.
1
2
3
4
5
<form th:action="@{/some/edit/{id}(id=${data.id})}" method="post">
  ...
  <input type="hidden" name="lid" th:value="${locakId.value}">
  ...
</form>
  • 잠금을 해제하는 코드는 다음과 같이 전달받은 LockId를 이용한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 서비스: 잠금을 해제한다.
public void edit(EditRequest editReq, LockId lockId) {
  // 1. 잠금 선점 확인
  lockManager.checkLock(lockId);
  // 2. 기능 실행
  ...
  // 3. 잠금 해제
  lockManager.releaseLock(lockId);
}

@RequestMapping(value = "/some/edit/{id}", method = RequestMethod.POST)
public String edit(@PathVariable("id") Long id, 
                   @ModelAttribute("editReq") EditRequest editReq, 
                   @RequestMapping("lid") String lockIdValue) {
    editReq.setId(id);
    someEditService.edit(editReq, new LockId(lockIdValue));
    model.addAttribute("data", data);

    return "editSuccess";
}
  • 서비스 코드를 보면 checkLock 메서드를 가장 먼저 실행하는데, 잠금을 선점한 이후에 실행하는 기능은 다음과 같은 상황을 고려하여 반드시 주어진 LockId를 갖는 잠금이 유효한지 확인해야 한다.
    • 잠금 유효시간이 지났으면 이미 다른 사용자가 잠금을 선점한다.
    • 잠금을 선점하지 않은 사용자가 기능을 실행했따면 기능 실행을 막아야 한다.

DB 를 이용한 LockManager 구현

  • 잠금 정보를 저장하기 위한 테이블 생성
    • 타입과 아이디를 기본키로 지정해 동시에 두 사용자가 특정 타입 데이터 잠금 구하는 것을 방지
    • 각 잠금마다 새로운 LockId를 사용하므로 lockId 컬럼은 유니크 인덱스로 설정
1
2
3
4
5
6
7
8
9
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 테이블에 데이터 삽입
1
insert into locks values ('Order', '1', '생성한lockid', '2016-03-28 09:10:00');
  • locks 테이블의 데이터를 담을 LockData 클래스 생성
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
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를 구현
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
@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

This post is licensed under CC BY 4.0 by the author.

[도메인주도개발시작하기] Chapter7-도메인 서비스

[JWT] JWT 저장소 위치