JPA 낙관적 락 과 비관적 락

배경

  • 앞의 글에서 2개 이상의 트랜잭션이 동시에 실행 될 때 트랜잭션의 종료 시간에 따라 데이터의 값이 변경된다는 것을 살펴보았습니다.
  • 이런 갱신손실 문제를 방지하기 위해서는 낙관적 락, 비관적 락을 통해 충돌 상황을 방지할 수 있습니다.
  • 낙관적 락 : 데이터를 업데이트 할 때 버전을 확인해서 버전이 다를 경우 예외 발생
  • 비관적 락 : 데이터를 가져올 때 락을 걸어서 가져와서 다른 트랜잭션이 접근하지 못하도록 함

낙관적 락

  • 여러 사용자가 동시에 데이터 베이스에 접근 할 수 있습니다.
  • Version을 사용하여 트랜잭션 종료시 데이터베이스에 존재하는 version과 비교하여 충돌 감지
  • 처음 가져온 version과 트랜잭션 종료시 데이터 베이스에 존재하는 version과 다를 시 예외 발생
  • JPA에서는 @Version을 통해 구현
  • 충돌이 많이 발생하지 않을 것으로 기대거나 락을 거는 비용이 클 때 적합

비관적 락

  • 데이터베이스에서 데이터를 가져올 때 락을 걸고 데이터를 가져옵니다.
  • 해당 row에 락이 걸려있을 때 다른 트랜잭션에서 접근할 수 없습니다.
  • JPA에서는 @Lock(LockModeType.*PESSIMISTIC_READ*) 를 통해 구현
  • @Lock(LockModeType.*PESSIMISTIC_WRITE) - 비관적 락 , 쓰기만 락*

Code

Service

		public TaskEntity findTask(Long id) {

        Optional<TaskEntity> findTask = taskRepository.findById(id);
        return findTask.orElse(null);
    }

		public TaskWithVersionEntity findTaskWithVersion(Long id) {

        Optional<TaskWithVersionEntity> findTask = taskWithVersionRepository.findById(id);
        return findTask.orElse(null);
	  }

		@Transactional
    public TaskEntity updateTaskWithPessimisticLock(Long id, TaskDto.CreateUpdate createUpdate, int sleepTime){

        TaskEntity taskEntity = taskRepository.findByIdWithPessimisticLock(id);
        threadSleep(sleepTime);
        taskEntity.updateStatus(createUpdate.getStatus());
        taskEntity.updateTime(createUpdate.getStatus());
        return taskEntity;
    }

		@Transactional
    public TaskWithVersionEntity updateTaskWithOptimisticLock(Long id, TaskDto.CreateUpdate createUpdate, int sleepTime){

        TaskWithVersionEntity taskEntity = taskWithVersionRepository.findById(id).get();
        threadSleep(sleepTime);
        taskEntity.updateStatus(createUpdate.getStatus());
        taskEntity.updateTime(createUpdate.getStatus());
        return taskEntity;
    }

		public void threadSleep(int sleepTime){
        try {
            Thread.sleep(sleepTime);
        } catch (Exception e){
            log.info(e.getMessage());
        }
    }

Repository

public interface TaskWithVersionRepository extends JpaRepository<TaskWithVersionEntity, Long> {
}

public interface TaskRepository extends JpaRepository<TaskEntity, Long> {

    @Query(value = "select task from TaskEntity task where task.id = :id")
    @Lock(LockModeType.PESSIMISTIC_READ)
    @QueryHints({@QueryHint(name = "javax.persistence.lock.timeout", value = "10000")})
    TaskEntity findByIdWithPessimisticLock(@Param("id") Long id);

}

Domain

public class TaskWithVersionEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    private String status;

    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul")
    private LocalDateTime startTime;

    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul")
    private LocalDateTime endTime;

    private Long duration;

    @Version
    private Long version;

	}

낙관적 락

TestCode - 1

		@Test
    @DisplayName("낙관적 락 적용 - Succeeded, Running, Started 순서")
    void test4() throws Exception {

        // given
        TaskWithVersionEntity newTask = TaskWithVersionEntity.builder()
                .status("Created")
                .build();
        taskService.createTask(newTask);

        // when
        final ExecutorService executor = Executors.newFixedThreadPool(3);

        executor.execute(()->{
            assertThrows(ObjectOptimisticLockingFailureException.class, () -> {
                taskService.updateTaskWithOptimisticLock(newTask.getId(), TaskDto.CreateUpdate.builder().status("Started").build(),2000);
            });
        });
        Thread.sleep(500);

        executor.execute(()->{
            assertThrows(ObjectOptimisticLockingFailureException.class, () -> {
                taskService.updateTaskWithOptimisticLock(newTask.getId(), TaskDto.CreateUpdate.builder().status("Running").build(),1500);
            });
        });
        Thread.sleep(500);

        executor.execute(()->taskService.updateTaskWithOptimisticLock(newTask.getId(), TaskDto.CreateUpdate.builder().status("Succeeded").build(),500));

        // Thread 작업이 다 끝날때까지 10초 대기
        executor.shutdown();
        executor.awaitTermination(10, TimeUnit.SECONDS);

        //then
        TaskWithVersionEntity findTask = taskService.findTaskWithVersion(newTask.getId());

        assertAll(
                ()-> assertEquals("Succeeded", findTask.getStatus()),
                ()-> assertNotEquals(null,findTask.getStartTime()),
                ()-> assertNotEquals(null,findTask.getEndTime()),
                ()-> assertNotEquals(null,findTask.getDuration())
        );
    }

도식화

  • 전부 낙관적 락을 적용 시킨 후 “Started”, “Running”, “Succeeded” 상태 업데이트가 짧은 시간내에 순서대로 들어오는 상황을 가정
  • 각각의 트랜잭션의 진행되고 첫번째 Test는 “Succeded”, “Running”, “Started” 순서로 트랜잭션이 종료됬다고 가정
  • 3가지 트랜잭션 모두 “Created” 상태를 지니고 Version이 0인 Task를 읽어옵니다.
  • “Succeeded” 상태가 먼저 업데이트 되고 Version을 1 증가
  • “Running”, “Succeeded” 상태를 업데이트 하는 트랜잭션은 업데이트 쿼리를 날리기 직전 DB의 버전(1)과 자신이 가지고 있는 버전(0)이 일치하는지 확인하고 일치하지 않기 때문에 Optimistic Locking Failure 예외 발생
  • JPA에서는 @ VERSION 을 통해 손쉽게 낙관적 락을 구현할 수 있으며 최초 커밋만 인정하는 방법으로 이를 해결 했습니다.

Test Code - 2

@Test
    @DisplayName("낙관적 락 적용 - Running, Started, Succeded 순서")
    void test5() throws Exception {

        // given
        TaskWithVersionEntity newTask = TaskWithVersionEntity.builder()
                .status("Created")
                .build();
        taskService.createTask(newTask);

        // when
        final ExecutorService executor = Executors.newFixedThreadPool(3);

        executor.execute(()->{
            assertThrows(ObjectOptimisticLockingFailureException.class, () -> {
                taskService.updateTaskWithOptimisticLock(newTask.getId(), TaskDto.CreateUpdate.builder().status("Started").build(),2000);
            });
        });
        Thread.sleep(500);

        executor.execute(()->taskService.updateTaskWithOptimisticLock(newTask.getId(), TaskDto.CreateUpdate.builder().status("Running").build(),1000));
        Thread.sleep(500);

        executor.execute(()->{
            assertThrows(ObjectOptimisticLockingFailureException.class, () -> {
                taskService.updateTaskWithOptimisticLock(newTask.getId(), TaskDto.CreateUpdate.builder().status("Succeeded").build(),2000);
            });
        });

        // Thread 작업이 다 끝날때까지 10초 대기
        executor.shutdown();
        executor.awaitTermination(10, TimeUnit.SECONDS);

        //then
        TaskWithVersionEntity findTask = taskService.findTaskWithVersion(newTask.getId());

        assertAll(
                ()-> assertEquals("Running", findTask.getStatus()),
                ()-> assertNotEquals(null,findTask.getStartTime()),
                ()-> assertEquals(null,findTask.getEndTime()),
                ()-> assertEquals(null,findTask.getDuration())
        );
    }

도식화

  • 각각의 트랜잭션의 진행되고 첫번째 Test는 “Running”, “Started”, “Succeeded” 순서로 트랜잭션이 종료됬다고 가정
  • “Running” 상태가 먼저 업데이트 되고 Version 1이 증가
  • “Started”, “Succeeded” 상태를 업데이트 하는 트랜잭션은 업데이트 쿼리를 날리기 직전 DB의 버전(1)과 자신이 가지고 있는 버전(0)이 일치하는지 확인하고 일치하지 않기 때문에 Optimistic Locking Failure 예외 발생
  • DB에 최종적으로 저장되는 상태는 “Running”이 됨
  • Task의 마지막 상태를 DB에 저장해야 하기 때문에 낙관적 락을 통해 최초 커밋만 인정하는 방법을 사용할 경우 트랜잭션이 시작된 순서에 따라 Task의 상태를 순서대로 업데이트 할 수 없습니다.

비관적 락

Test Code - 1

@Test
    @DisplayName("비관적 락 적용 - Succeeded, Running, Started 순서")
    void test2() throws Exception {

        // given
        TaskEntity newTask = TaskEntity.builder()
                .status("Created")
                .build();
        taskService.createTask(newTask);

        // when
        final ExecutorService executor = Executors.newFixedThreadPool(3);

        executor.execute(()->taskService.updateTaskWithPessimisticLock(newTask.getId(), TaskDto.CreateUpdate.builder().status("Started").build(), 2000));
        Thread.sleep(500);

        executor.execute(()->taskService.updateTaskWithPessimisticLock(newTask.getId(), TaskDto.CreateUpdate.builder().status("Running").build(), 1000));
        Thread.sleep(500);

        executor.execute(()->taskService.updateTaskWithPessimisticLock(newTask.getId(), TaskDto.CreateUpdate.builder().status("Succeeded").build(), 500));

        // Thread 작업이 다 끝날때까지 10초 대기
        executor.shutdown();
        executor.awaitTermination(10, TimeUnit.SECONDS);

        //then
        TaskEntity findTask = taskService.findTask(newTask.getId());

        assertAll(
                ()-> assertEquals("Succeeded", findTask.getStatus()),
                ()-> assertNotEquals(null,findTask.getStartTime()),
                ()-> assertNotEquals(null,findTask.getEndTime()),
                ()-> assertNotEquals(null,findTask.getDuration())
        );
    }

도식화

  • 각각의 트랜잭션의 진행되고 첫번째 Test는 “Succeded”, “Running”, “Started” 순서로 트랜잭션의 진행 시간이 짧다고 가정
  • 비관적 락이 적용된 트랜잭션은 select for update 쿼리를 통해 데이터를 얻어오면서 DB에 락을 검
  • 락이 걸려있는 row에 접근하는 트랜잭션은 데이터를 받아오지 못하고 대기
  • T1의 트랜잭션이 종료될 때 까지 T2, T3 트랜잭션은 대기하다가 T1의 트랜잭션이 종료되고 DB에 락을 해제하면 T2에서 데이터를 받아 옵니다.
  • T1, T2, T3 3가지 트랜잭션이 요청된 순서에 따라서 진행이 되고 상태들이 성공적으로 업데이트 됬습니다.

Test Code - 2

@Test
    @DisplayName("비관적 락 적용 - Running, Started, Succeeded 순서")
    void test3() throws Exception {

        // given
        TaskEntity newTask = TaskEntity.builder()
                .status("Created")
                .build();
        taskService.createTask(newTask);

        // when
        final ExecutorService executor = Executors.newFixedThreadPool(3);

        executor.execute(()->taskService.updateTaskWithPessimisticLock(newTask.getId(), TaskDto.CreateUpdate.builder().status("Started").build(), 2000));
        Thread.sleep(500);

        executor.execute(()->taskService.updateTaskWithPessimisticLock(newTask.getId(), TaskDto.CreateUpdate.builder().status("Running").build(), 1000));
        Thread.sleep(500);

        executor.execute(()->taskService.updateTaskWithPessimisticLock(newTask.getId(), TaskDto.CreateUpdate.builder().status("Succeeded").build(), 2000));

        // Thread 작업이 다 끝날때까지 10초 대기
        executor.shutdown();
        executor.awaitTermination(10, TimeUnit.SECONDS);

        //then
        TaskEntity findTask = taskService.findTask(newTask.getId());

        assertAll(
                ()-> assertEquals("Succeeded", findTask.getStatus()),
                ()-> assertNotEquals(null,findTask.getStartTime()),
                ()-> assertNotEquals(null,findTask.getEndTime()),
                ()-> assertNotEquals(null,findTask.getDuration())
        );
    }

도식화

  • 각각의 트랜잭션의 진행되고 첫번째 Test는 “Running”, “Started”, “Succeded” 순서로 트랜잭션의 진행 시간이 짧다고 가정
  • 비관적 락이 적용된 트랜잭션은 select for update 쿼리를 통해 데이터를 얻어오면서 DB에 락을 검
  • 락이 걸려있는 row에 접근하는 트랜잭션은 데이터를 받아오지 못하고 대기
  • T1의 트랜잭션이 종료될 때 까지 T2, T3 트랜잭션은 대기하다가 T1의 트랜잭션이 종료되고 DB에 락을 해제하면 T2에서 데이터를 받아왔습니다.
  • T1, T2, T3 3가지 트랜잭션이 요청된 순서에 따라서 진행이 되고 상태들이 성공적으로 업데이트 됬습니다.
  • 트랜잭션의 진행 시간과 상관없이 요청이 들어온 순서에 따라 순서대로 업데이트를 진행합니다.

정리

  • 낙관적 락은 버전을 통해 DB에 업데이트 할 때 버전을 비교해서 충돌을 감지하고 예외를 발생 시킵니다.
  • 낙관적 락을 통해 최초 커밋만 인정하는 방식으로 동시성 문제를 해결 할 수 있습니다.
  • 비관적 락은 DB에서 데이터를 읽어올 때 락을 걸기 때문에 시간이 오래 걸리지만 들어온 요청의 순서에 따라서 업데이트를 순서대로 진행 시켜줄 수 있습니다.
  • Pipeline의 Task의 상태는 마지막에 들어온 요청(완료 여부)이 가장 중요하기 때문에 성능 상 손해를 보더라도 비관적 락을 통해 업데이트 순서를 보장시키는 방법으로 문제를 해결 했습니다.

Reference

좋은 웹페이지 즐겨찾기