모르는게 많은 개발자

[JPA] 낙관적, 비관적 Lock 개념/예제 본문

스프링

[JPA] 낙관적, 비관적 Lock 개념/예제

Awdsd 2023. 4. 2. 00:19
반응형

이번 포스팅에서는 JPA의 낙관적 잠금과 비관적 잠금을 테스트를 통해 설명하고자 한다

포스팅 내용에 DB Lock 개념도 포함되기 때문에 Lock에 대한 이해가 필요하면 이전 게시글(https://cjw-awdsd.tistory.com/57)을 먼저 읽고 보는 것을 추천합니다.


 

낙관적 잠금(Optimistic Lock)


락 처리 방법 : @Version

JPA의 @Version 어노테이션을 사용해 엔티티 버전을 관리할 수 있다. @Version 적용이 가능한 타입은 long, integer, short, timestamp이다. 아래는 어노테이션 적용 예시 코드이다.

JPA의 @Version 어노테이션을 사용하면 엔티티의 버전을 관리할 수 있다.

@Entity
public class Member {
    @Id
    @GeneratedValue
    private Long id;

    @Version
    Integer version;
}

 

낙관적 락 LockMode

None

별도의 옵션을 사용하지 않아도 Entity에 @Version이 적용된 필드만 있을 때 낙관적 락 적용

  • 트랜잭션 시작후 엔티티를 수정하고 트랜잭션이 종료될 때까지 다른 트랜잭션에서 같은 엔티티가 변경되지 않음을 보장.
  • 엔티티가 조회 후 변경될 때 버전도 같이 증가 → 버전이 조회 시점과 다르면 예외 발생

테스트 코드

@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class LockParent {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "parent_id", nullable = false)
    private Long parentId;
    @Version
    private Integer version;
    private long count;

    public static LockParent ofDefault() {
        return LockParent.builder().count(0L).build();
    }
    public void plusCount() {
        this.count += 1;
    }
}

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class LockTestService {
    private final LockParentRepository lockParentRepository;

    @Transactional
    public void insertLockParent() {
        LockParent lockParent = LockParent.ofDefault();
        this.lockParentRepository.save(lockParent);
    }

    @Transactional
    public void plusCount(Long parentId) {
        LockParent lockParent = this.lockParentRepository.findById(parentId).orElseThrow(() -> new RuntimeException(""));
        lockParent.plusCount();
    }
}

@Repository
public interface LockParentRepository extends JpaRepository<LockParent, Long> {}

@SpringBootTest
public class LockTests {
    @Autowired
    LockTestService lockTestService;

    @Test
    void Parent_삽입() {
        lockTestService.insertLockParent();
    }

    @Test
    void parent_동시_업데이트() {
        CompletableFuture<Void> task1 = CompletableFuture.runAsync(() -> {
            this.lockTestService.plusCount(1L);
        });
        CompletableFuture<Void> task2 = CompletableFuture.runAsync(() -> {
            this.lockTestService.plusCount(1L);
        });
        CompletableFuture<Void> task3 = CompletableFuture.runAsync(() -> {
            this.lockTestService.plusCount(1L);
        });

        CompletableFuture.allOf(task1, task2, task3).join();
    }
}

쿼리 순서를 보면 다음과 같다.

두개의 트랜잭션에서 SELECT가 먼저 수행되고 UPDATE를 처리하는 것을 볼 수 있다.

테스트 수행시 아래처럼 ObjectOptimisticLockingFailureException 발생

  • 어떻게 다른 트랜잭션에서 Version 변경된 것을 체크하나

UPDATE 쿼리를 보면 WHERE문에 verion=?이 포함되어 있다.
트랜잭션1에서 SELECT 조회한 엔티티의 VersionWHERE문에 담는다.
하지만 다른 트랜잭션2에서 만약 UPDATE를 해서 version이 변경되었다면 트랜잭션1의 UPDATE WHERE문은 성립하지 않기(Version이 달라지기에)에 변경되는 데이터가 없게 된다. Exception 확인시 Batch update returned unexpected row count from update [0]; actual row count: 0; expected: 1; 변경된 값이 없어 예외가 난 것을 확인할 수 있다.

 

LockMode.OPTIMISTIC

None은 엔티티 수정시 낙관적 잠금이 발생했지만 OPTIMISTIC조회에도 낙관적 잠금이 발생하도록 한다.

  • 조회에도 Version을 체크하고 트랜잭션이 종료될 때까지 다른 트랜잭션에서 변경되지 않음을 보장
  • dirty read, non-repeatable read 를 방지
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Slf4j
public class LockTestService {
    private final LockParentRepository lockParentRepository;

        /**
     * LockParent count 증가
     */
    @Transactional
    public void plusCount(Long parentId) {
        LockParent lockParent = this.lockParentRepository.findById(parentId).orElseThrow(() -> new RuntimeException(""));
        lockParent.plusCount();
                log.info("\nplusCount 종료");
    }

    /**
     * LockParent 조회
     */
    public void findParentById(Long parentId) {
        this.lockParentRepository.findById(parentId);
                //1초후 트랜잭션 종료
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
                log.info("\nfindParentById 종료");
    }
}

@Repository
public interface LockParentRepository extends JpaRepository<LockParent, Long> {
    /**
     * OPTIMISTIC 모드로 조회 
     */
    @Override
    @Lock(LockModeType.OPTIMISTIC)
    Optional<LockParent> findById(Long id);
}

/**
 * Optimistic 테스트
 */
@SpringBootTest
public class LockTests {
    @Autowired
    LockTestService lockTestService;

    @Test
    void Lock_Optimistic_동시_조회_업데이트() {
        CompletableFuture<Void> task2 = CompletableFuture.runAsync(() -> {
            this.lockTestService.findParentById(1L);
        });
        CompletableFuture<Void> task3 = CompletableFuture.runAsync(() -> {
            this.lockTestService.plusCount(1L);
        });

        CompletableFuture.allOf(task2, task3).join();
    }
}

테스트 코드 수행후 발생한 쿼리는 다음과 같다.

findParentById(), plusCount() 모두 트랜잭션 끝날 때 Version을 조회한다는 것을 볼 수 있다.

결과는 아래처럼 예외가 발생한다.

최신 버전이 발견되었다는 내용 즉, Version을 비교할 때 값이 다르기에 예외 발생

 

LockMode.OPTIMISTIC_FORCE_INCREMENT

OPTIMISTIC_FORCE_INCREMENT 모드는 엔티티를 읽기만 해도 Version을 업데이트 한다.

@Repository
public interface LockParentRepository extends JpaRepository<LockParent, Long> {
    @Override
    @Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT)
    List<LockParent> findAll();

    @Query("SELECT lp FROM LockParent lp join fetch lp.lockChildren where lp.parentId = :parentId")
    @Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT)
    LockParent findByIdWithChild(@Param("parentId") Long parentId);

    @Query("SELECT lp FROM LockParent lp WHERE lp.parentId = :parentId")
    @Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT)
    LockParent findByIdForceIncrement(@Param("parentId") Long parentId);
}

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Slf4j
public class LockTestService {
    private final LockParentRepository lockParentRepository;
    private final EntityManager em;

    /**
     * lockParent Version 증가
     * lockChild Version 증가 X
     */
    @Transactional
    public void findParentByIdForceIncrement(Long parentId) {
        LockParent lockParent = this.em.find(LockParent.class, parentId, LockModeType.OPTIMISTIC_FORCE_INCREMENT);
        List<LockChild> lockChildren = lockParent.getLockChildren();
    }

    /**
     * 모든 Parent Version 증가
     */
    @Transactional
    public void findAllForceIncrement() {
        List<LockParent> lockParents = this.lockParentRepository.findAll();
    }

    /**
     * fetch join LockParent Version 같이 증가
     */
    @Transactional
    public void findParentByIdWithChild(Long parentId) {
        LockParent lockParent = this.lockParentRepository.findByIdWithChild(parentId);
    }

    /**
     * 엔티티 값이 변경된 경우 Version 2 증가
     * 값을 업데이트 하면서 한번 증가
     * 트랜잭션이 끝나고 강제 한번 증가
     * 총 2번
     */
    @Transactional
    public void updateParentForceIncrement(Long parentId) {
        LockParent lockParent = this.lockParentRepository.findByIdForceIncrement(parentId);
        lockParent.plusCount();
    }
}

@SpringBootTest
public class LockTests {
    @Autowired
    LockTestService lockTestService;

    @Test
    void Lock_Optimistic_Force_Increment_Find_Parent() {
        this.lockTestService.findParentByIdForceIncrement(1L);
    }

    @Test
    void Lock_Optimistic_Force_Increment_Find_All() {
        this.lockTestService.findAllForceIncrement();
    }

    @Test
    void Lock_Optimistic_Force_Increment_Find_Fetch_Join() {
        this.lockTestService.findParentByIdWithChild(1L);
    }

    @Test
    void Lock_Optimistic_Force_Increment_Update() {
        this.lockTestService.updateParentForceIncrement(1L);
    }
}

Lock_Optimistic_Force_Increment_Find_Parent()

  • Parent Version 1 증가

Lock_Optimistic_Force_Increment_Find_Fetch_Join

  • Fetch Join을 통해 Parent, Child Version 1씩 증가

Lock_Optimistic_Force_Increment_Update

  • Version 2 증가

Lock_Optimistic_Force_Increment_Find_All

  • 가져온 엔티티의 모든 Version 증가

OPTIMISTIC_FORCE_INCREMENT 사용 이유에 대한 stackoverflow
https://stackoverflow.com/questions/13581603/jpa-and-optimistic-locking-modes

 

비관적 잠금(Pessimistic Lock)


비관적 잠금 모드

LockModeType.PESSIMISTIC_READ

SELECT 구문에 FOR SHARE 구문을 추가하여 S-Lock 획득

아래 동시성 상황에서 DeadLock 이 발생할 수 있다.

@Repository
public interface LockParentRepository extends JpaRepository<LockParent, Long> {

    @Query("SELECT lp FROM LockParent lp where lp.parentId = :parentId")
    @Lock(LockModeType.PESSIMISTIC_READ)
    LockParent findByIdPessimisticRead(@Param("parentId") Long parentId);
}

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Slf4j
public class LockTestService {
    private final LockParentRepository lockParentRepository;
    private final EntityManager em;

    /**
     * PESSIMISTIC_READ 모드로 LockParent 읽은 후
     * 0.5초 대기후 count Update

     */
    @Transactional
    public void findByParentIdPessimisticReadAndUpdate(Long parentId) {
        LockParent lockParent = this.lockParentRepository.findByIdPessimisticRead(parentId);
        //0.5초 대기
        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        lockParent.plusCount();
    }
}

@SpringBootTest
public class LockTests {

    @Autowired
    LockTestService lockTestService;

    /**
     * PESSIMISTIC_READ 모드로 LockParent 읽은 후
     * 0.5초 대기후 count Update
     * 
     * 1. Transaction1 Select For Share 구문 수행
     * 2. Transaction2 Select For Share 구문 수행
     * 3. Transaction1 Update 수행 (Transaction2의 Shared Lock으로 인해 대기)
     * 4. Transaction2 Update 수행 (Transaction1의 Shared Lock으로 인해 대기)
     * 결과: 데드락 발생
     */
    @Test
    void Lock_Pessimistic_Read_Find_And_Update() {
        CompletableFuture<Void> task1 = CompletableFuture.runAsync(() -> {
            this.lockTestService.findByParentIdPessimisticReadAndUpdate(1L);
        });
        CompletableFuture<Void> task2 = CompletableFuture.runAsync(() -> {
            this.lockTestService.findByParentIdPessimisticReadAndUpdate(1L);
        });

        CompletableFuture.allOf(task1, task2).join();
    }
}

아래처럼 SELECT 구문에 FOR SHARE 구문이 추가되고 DeadLock 발생하는 것을 확인할 수 있다.

Rollback된 트랜잭션을 제외한 다른 트랜잭션 UPDATE만 수행된다.

 

LockModeType.PESSIMISTIC_WRITE

SELECT 구문에 FOR UPDATE 구문을 추가하여 X-Lock 획득

PESSIMISTIC_READ와 달리 SELECT 구문에서 X-Lock을 획득하기에 하나의 트랜잭션에서 SELECT FOR UPDATE가 수행되면 다른 트랜잭션에서는 SELECT FOR UPDATE 구문을 날릴 때 대기하게 된다.

@Repository
public interface LockParentRepository extends JpaRepository<LockParent, Long> {
    @Query("SELECT lp FROM LockParent lp where lp.parentId = :parentId")
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    LockParent findByIdPessimisticWrite(@Param("parentId") Long parentId);
}

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Slf4j
public class LockTestService {
    private final LockParentRepository lockParentRepository;
    private final EntityManager em;

    /**
     * PESSIMISTIC_WRITE 모드로 LockParent 읽은 후
     * 0.5초 대기후 count Update
     */
    @Transactional
    public void findByParentIdPessimisticWriteAndUpdate(Long parentId) {
        LockParent lockParent = this.lockParentRepository.findByIdPessimisticWrite(parentId);
        //0.5초 대기
        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        lockParent.plusCount();
    }
}

@SpringBootTest
public class LockTests {
    @Autowired
    LockTestService lockTestService;

    /**
     * PESSIMISTIC_WRITE 모드로 LockParent 읽은 후
     * 0.5초 대기후 count Update
     *
     * 1.Transaction1 Select For Update 구문 수행
     * 2.Transaction2 Select For Update 구문 수행 (Transaction1 X-Lock으로 인해 대기)
     * 3.Transaction3 Select For Update 구문 수행 (Transaction1 X-Lock으로 인해 대기)
     * 4.Transaction1 Update 수행(X-lock 해제)
     * 5.Transaction2 Update 수행
     * 6.Transaction3 Update 수행
     * 결과: 3개의 트랜잭션 정상 처리
     */
    @Test
    void Lock_Pessimistic_Write_Find_And_Update() {
        CompletableFuture<Void> task1 = CompletableFuture.runAsync(() -> {
            this.lockTestService.findByParentIdPessimisticWriteAndUpdate(1L);
        });
        CompletableFuture<Void> task2 = CompletableFuture.runAsync(() -> {
            this.lockTestService.findByParentIdPessimisticWriteAndUpdate(1L);
        });
        CompletableFuture<Void> task3 = CompletableFuture.runAsync(() -> {
            this.lockTestService.findByParentIdPessimisticWriteAndUpdate(1L);
        });

        CompletableFuture.allOf(task1, task2, task3).join();
    }
}

 

LockModeType.PESSIMISTIC_FORCE_INCREMENT

PESSMISTIC_WRITE와 동일하게 FOR UPDATE 구문이 추가되고 더불어 OPTIMISTIC_FORCE_INCREMENT 모드처럼 버전을 사용하면서 엔티티를 읽기만 해도 Version을 업데이트 한다

@Repository
public interface LockParentRepository extends JpaRepository<LockParent, Long> {

    @Query("SELECT lp FROM LockParent lp join fetch lp.lockChildren where lp.parentId = :parentId")
    @Lock(LockModeType.PESSIMISTIC_FORCE_INCREMENT)
    LockParent findByIdPessimisticForceIncrement(@Param("parentId") Long parentId);
}

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Slf4j
public class LockTestService {
    private final LockParentRepository lockParentRepository;
    private final EntityManager em;

    @Transactional
    public void findByParentByIdWithChildPessimisticForceIncrement(Long parentId) {
        LockParent lockParent = this.lockParentRepository.findByIdPessimisticForceIncrement(parentId);
    }
}

@SpringBootTest
public class LockTests {
    @Autowired
    LockTestService lockTestService;

    @Test
    void Lock_Pessimistic_Force_Increment_Find() {
        this.lockTestService.findByParentByIdWithChildPessimisticForceIncrement(1L);
    }
}

Mysql 8.0 버전부터 nowait을 지원하는데 PESSIMISTIC_FORCE_INCREMENT 모드는 nowait을 지원할 경우

쿼리에 nowait 포함하여 쿼리를 수행한다.

nowait : 쿼리를 실행하며, lock이 걸린 부분이 있다면, 기다리지 않고 실패를 시킨다.

아래는 쿼리를 동시에 3번 수행하고 nowait 에러가 발생하는 상황이다.

@Test
    void Lock_Pessimistic_Force_Increment_Find() {
        CompletableFuture<Void> task1 = CompletableFuture.runAsync(() -> {
            this.lockTestService.findByParentByIdWithChildPessimisticForceIncrement(1L);
        });
        CompletableFuture<Void> task2 = CompletableFuture.runAsync(() -> {
            this.lockTestService.findByParentByIdWithChildPessimisticForceIncrement(1L);
        });
        CompletableFuture<Void> task3 = CompletableFuture.runAsync(() -> {
            this.lockTestService.findByParentByIdWithChildPessimisticForceIncrement(1L);
        });

        CompletableFuture.allOf(task1, task2, task3).join();
    }

 

 

참고

https://willbfine.tistory.com/576?category=971447

https://hudi.blog/jpa-concurrency-control-optimistic-lock-and-pessimistic-lock/

반응형
Comments