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
- https://www.ibm.com/docs/en/rational-clearquest/7.1.0?topic=clearquest-optimistic-pessimistic-record-locking
- https://www.baeldung.com/jpa-pessimistic-locking
Author And Source
이 문제에 관하여(JPA 낙관적 락 과 비관적 락), 우리는 이곳에서 더 많은 자료를 발견하고 링크를 클릭하여 보았다 https://velog.io/@sgwon1996/JPA-낙관적-락-과-비관적-락저자 귀속: 원작자 정보가 원작자 URL에 포함되어 있으며 저작권은 원작자 소유입니다.
우수한 개발자 콘텐츠 발견에 전념 (Collection and Share based on the CC Protocol.)