make it simple
article thumbnail

위와 같이 한 애그리거트에서 두 사용자가 동시에 변경할 경우는 어떻게 해야할까?

  • 주문 애그리거트에서 운영자와 고객이 동시에 수정하는 과정이다. 
    • 운영자는 배송 상태를 변경
    • 고객은 배송지를 변경

위와 같은 경우에서 문제점은 운영자는 기존 배송지 정보를 이용해 배송 상태를 변경하고 그 사이에 고객은 배송지 정보를 변경했다. 이럴 경우에는 두 가지중 하나를 해야한다.

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

결국에는 하나가 실행된 후에 다른 하나가 실행되야한다. DBMS가 지원하는 트랜잭션과 함꼐 애그리거트를 위한 트랜잭션 처리 기법을 사용해 처리해야한다. 대표적인 트랜잭션 처리 방식에는 선점 잠금과 비선점 잠금의 두가지 방식이 있다.


선점잠금

  • 먼저 애그리거트를 구한 스레드가 애그리거트 사용이 끝날 때까지 다른 스레드가 해당 애그리거트를 수정하지 못하게 막는 방식
  • 밑과 같이 운영자 스레드가 애그리거트에 대한 잠금을 해제할 때까지 고객 스레드는 해당 애그리거트를 사용못하도록 블로킹(blocking)된다.

운영자 스레드가 먼저 선점 잠금 방식으로 주문 애그리거트를 구하면 고객 스레드는 운영자 스레드가 잠금을 해제하기 전까지 대기 상태가된다. 잠금이 해제된 시점에 고객 스레드가 구하는 주문 애그리거트는 운영자 스레드가 수정한 배송 상태의 주문 애그리거트이므로 고객이 배송지 변경시 에러를 발생하고 트랜잭션을 실패한다. 

 

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

 

Spring Data JPA는 @Lock(LockModeType.PESSIMISTIC_WRITE) 옵션을 통해 선점 잠금을 구현한다.

import org.springframework.data.jpa.repository.Lock;
import javax.persistence.LockModeType;

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. 스레드 1:  A 애그리거트 선점 잠금
    2. 스레드 2: B 애그리거트 선점 잠금
    3. 스레드 1: B 애그리거트 선점 잠금 시도
    4. 스레드 2: A 애그리거트 선점 잠금 시도

이런식이면 서로 선점 잠금을 시도하려하여 교착 상태에 빠진다. 보통 사용자 수가 많을 때 발생할 가능성이 높고, 사용자 수가 많아지면 교착 상태에 빠지는 쓰레드는 더 빠르게 증가한다. -> 시스템 붕괴

 

해결점

  • 잠금을 구할 때 최대 대기 시간을 지정하는게 좋다.

JPA 사용시

Map<String,Object> hints = new HashMap<>();
hints.put("javax.persistence.lock.timeout", 2000);
Order oder = entityManager.find(Order.class, orderNo, LockModeType.PeSIMISTIC_WRITE, hints);

javax.persistence.lock.timeout 과 시간(밀리초 단위)을 지정후애 entityManager에 넘겨주면된다. -> DBMS마다 다르다.

 

Spring Data JPA 사용시

import org.springframework.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);
}

@QueryHints를 사용해 쿼리 힌트를 지정할 수 있다.


비선점 잠금

  • 선점 잠금을 사용하면 동시에 접근하는 것을 막을 수 있지만 DBMS에서 만영하는 시점에서 변경했는지 확인하긴 힘들다. 밑과 같은식으로 선점 잠금을 통해 운영자와 고객이 서로 충돌없이 한 애그리거트에서 정보는 변경했지만 운영자가 배송지 정보를 조회하고 배송상태를 변경(대기 -> 배송중)하는 사이에 고객이 배송지를 변경할 수 있다는 것이다. 그러면 잘못된 주소로 배송이된다. 이럴때 비선점 잠금을 사용해야한다.

JPA에서 제공해주는 @Version을 사용하자.

@Entity
@Access(AccessType.FIELD)
public class Order{
    @EmbeddedId
    private OrderNo number;
    
    @Version
    private long version;
}
  • JPA는 엔티티가 변경되어 UPDATE 쿼리를 실행할때 @Version 어노테이션을 이용해 비선점 잠금 쿼리를 실행한다.무조건 @Transactional 을 사용하여 트랜잭션 범위를 설정해야한다. 애그리거트 객체의 버전이 10이면 UPDATE 쿼리를 실행할 때 10 버전이랑 일치해야 수정된다. 혹시 중간에 애그리거트가 수정되어 버전이 올라간 경우에는 수정이 실패하고 OptimisticLockingFailureException이 발생된다.

해당 버전이 있는 엔티티에 값이 변경될때마다 version이 올라간다. 하지만 루트 엔티티가 아닌 다른 엔티티나 밸류가 변경되더라고 버전 값을 증가시키고 싶은 경우 비선점 잠금에서 이야기한 @Lock(LockModeType.PESSIMISTIC_WRITE)을 사용하면 강제적으로 버전이 업데이트된다.


결론

  • 동시성 관련해서 보통은 컬럼에 버전만 두고 개발해왔다. 이번 챕터를 통해 스프링 데이터 jpa을 활용해서 조금 더 편하고 확장성있게 동시성 충돌을 피하고 구현할 수 있을거같다. 비선점 잠금으로 구현할지 선점잠금으로 구현할지는 정책에 따라 다를거같다. 하지만 보통 연관관계가 많거나 대부분의 애그리거트 루트같은경우에는 변경시점에 변경이 이미 되어있는지 확인하는 비선점 잠금 방식이 많이 유용할거 같다.

reference

profile

make it simple

@keep it simple

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!