요청이 많은 서버에서 여러 트랜잭션이 동시에 같은 데이터에 업데이트를 발생시킬 경우에 일부 요청이 유실되는 경우가 발생하여 장애로 이어질 수 있다.
그 만큼 엔터프라이즈 애플리케이션의 경우 데이터베이스에 대한 동시 액세스(concurrency)를 적절하게 관리하는 것이 중요하다.
여러곳에서 동시에 발생하는 트랜잭션을 처리 할 수 있어야 하며 동시 읽기/업데이트 간에 데이터가 일관되게 유지되도록 해야한다.
데이터 갱신시 충돌이 발생하지 않을 것이라고 낙관적으로 보고 잠금을 거는 기법
디비에 락을 걸기보다는 충돌 방지(Conflict detection)에 가깝다고 볼 수 있음
동시성 처리를 위해 JPA 에서는 낙관적 잠금(Optimistic Lock)을 손쉽게 사용할 수 있도록 제공
동시에 동일한 데이터에 대한 여러 업데이트가 서로 간섭하지 않도록 방지하는 version이라는 속성을 확인하여 Entity의 변경사항을 감지하는 메커니즘
@Version
이 붙은 Int, Long Type의 변수를 구현하여줌으로써 간단하게 구현이 가능하다.@Entity
public class Student {
@Id
private Long id;
private String name;
private String lastName;
@Version
private Integer version;
}
int
, Integer
, long
, Long
, short
, Short
, java.sql.Timestamp
중 하나 여야합니다.만약 @Version을 명시한 변수의 타입이 자동으로 지원하는 타입이 아니라면 다음과 같은 오류가 발생한다.
class org.hibernate.type.StringType cannot be cast to class org.hibernate.type.VersionType
이때 org.hibernate.type.VersionType를 상속받아
seed(초기값), next(증가하는 로직), getComparator(버전 비교 함수)을 구현하여주면 커스텀하게 버저닝 사용이 가능하다.
OptimisticLockException
이 발생하고 변경되지 않으면 트랜잭션은 버전속성을 증가하는 업데이트 하게 된다.별도의 옵션을 사용하지 않아도 Entity에 @Version이 적용된 필드만 있으면 낙관적 잠금이 적용된다. (하단에 암시적 잠금 참조)
Entity 수정시에만 발생하는 낙관적 잠금이 읽기 시에도 발생하도록 설정한다.
읽기시에도 버전을 체크하고 트랜잭션이 종료될 때까지 다른 트랜잭션에서 변경하지 않음을 보장한다.
이를 통해 dirty read와 non-repeatable read를 방지한다.
entityManager.find(Student.class,studentId,LockModeType.OPTIMISTIC);
낙관적 잠금을 사용하면서 버전 정보를 강제로 증가시키는 옵션이다.
READ는 OPTIMISTIC과 같은 역할을하며 WRITE는 OPTIMISTIC_FORCE_INCREMENT와 같은 역할을 한다.
JPA 1.0의 호환성을 유지하기 위해서 존재하는 옵션이다.
Persistence Provider가 Entity에서 낙관적 잠금 충돌을 감지하면 OptimisticLockException을 발생시키게 되고 트랜잭션은 롤백을 처리한다.
권장되는 예외처리 방법에서는 Entity를 다시 로드하거나 새로고침하여 업데이트를 재 시도하는 방법이다.
예외처리시에 발생되는 Exception에서 충돌되는 Entity를 제공해주고 있어 쉽게 처리가 가능하도록 되어있다.
PersistenceException
이 발생dirty read가 발생하지 않을 때마다 Shared Lock을 획득하고 데이터가 UPDATE, DELETE 되는 것을 방지 할 수 있다.
PESSIMISTIC_READ를 지원하지 않는 데이터 베이스도 있지만 그럴 경우에는 PESSIMISTIC_WRITE로 대체된다.
배타적 잠금(Exclusive Lock)을 획득하고 데이터를 다른 트랜잭션에서 READ, UPDATE, DELETE 하는것을 방지 할 수 있다.
이 잠금은 PESSIMISTIC_WRITE와 유사하게 작동 하지만 @Version
이 지정된 Entity와 협력하기 위해 도입되어 PESSIMISTIC_FORCE_INCREMENT 잠금을 획득할 시 버전이 업데이트 된다.
@Version
이 지정된 엔티티의 모든 업데이트는 PESSIMISTIC_FORCE_INCREMENT 잠금을 확보하기 전에 업데이트가 될 수도 있습니다.
잠금은 Shared Lock 또는 Exclusive Lock 둘중에 하나만 획득할 수 있으며 그 락을 획득하는데 실패하면 발생되는 예외입니다.
락을 대기하다 설정해놓은 wait time이 초과되었을때 발생하는 예외입니다.
LockTime: 트랜잭션 처리중 lock이 걸렸을 시, 롤백 될때까지 대기하는 시간
현재 DB에 설정되어있는 LockTimeout을 보여주는 명령어(MySQL 기준)
select @@innodb_lock_wait_timeout
NoResultException , NonUniqueResultException, LockTimeoutException 및 QueryTimeoutException을 제외한 PersistanceException 예외에 대해서는 트랜잭션에 롤백을 마킹합니다.
기본값으로써 해당 entity만 잠금이 설정됩니다.
@Inheritance(strategy = InheritanceType.JOINED)
와 같이 조인 상속을 사용하면 부모도 함께 잠금이 설정됩니다.
@ElementCollection
, @OneToOne
, @OneToMany
등 연관된 entity들도 잠금이 설정됩니다.
@Version
이 붙은 필드가 존재하거나 @OptimisticLocking
어노테이션이 설정되어 있을 경우 자동적으로 충돌감지를 위한 잠금이 실행Student student = entityManager.find(Student.class, id);
entityManager.lock(student, LockModeType.OPTIMISTIC);
Student resultStudent = entityManager.find(Student.class, studentId);
entityManager.lock(resultStudent, LockModeType.PESSIMISTIC_WRITE);
@Entity
@Getter
@NoArgsConstructor
public class Job {
@Id
@GeneratedValue
private Long id;
private String name;
private Integer salary;
public Integer increaseSalary(Integer salary){
return this.salary += salary;
}
}
public interface JobRepository extends JpaRepository<Job, Long> {
Job findByName(String name);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select j from Job j where j.name = :name")
Job findByNameWithPessimisticLock(@Param("name") String name);
}
@Service
@RequiredArgsConstructor
@Slf4j
@Transactional
public class JobService {
private final JobRepository jobRepository;
public Integer currentSalary(String name) {
Job job = jobRepository.findByName(name);
return job.getSalary();
}
public Integer increaseSalary(String name, Integer salary) {
Job job = jobRepository.findByName(name);
job.increaseSalary(salary);
return job.getSalary();
}
public Integer increaseSalaryWithPessimisticLock(String name, Integer salary) {
Job job = jobRepository.findByNameWithPessimisticLock(name);
job.increaseSalary(salary);
return job.getSalary();
}
}
public class JobController {
private final JobService jobService;
@GetMapping("/increase")
public String increaseSalary(
@RequestParam(value = "name") String name,
@RequestParam(value = "salary") Integer salary
) {
String result;
jobService.increaseSalary(name, salary);
result = "현재 월급 : " + jobService.currentSalary(name);
log.info(result);
return result;
}
@GetMapping("/increase/pessimisticLock")
public String increaseSalaryWithPessimisticLock(
@RequestParam(value = "name") String name,
@RequestParam(value = "salary") Integer salary
) {
String result;
jobService.increaseSalaryWithPessimisticLock(name, salary);
result = "현재 월급 : " + jobService.currentSalary(name);
log.info(result);
return result;
}
}
누를 때마다 월급이 올랐으면 하는 소망으로, 호출할 때마다 월급이 올라가도록 만든 API이다.
curl http://localhost:8080/job/increase/pessimisticLock\?name\=developer\&salary\=10 & curl http://localhost:8080/job/increase/pessimisticLock\?name\=developer\&salary\=10 & curl http://localhost:8080/job/increase/pessimisticLock\?name\=developer\&salary\=10 & curl http://localhost:8080/job/increase/pessimisticLock\?name\=developer\&salary\=10 & curl http://localhost:8080/job/increase/pessimisticLock\?name\=developer\&salary\=10
비관적 락인 경우, 5번 연속 호출한 경우 5번 호출한 결과와 같다.
curl http://localhost:8080/job/increase/pessimisticLock\?name\=developer\&salary\=10 & curl http://localhost:8080/job/increase/pessimisticLock\?name\=developer\&salary\=10 & curl http://localhost:8080/job/increase/pessimisticLock\?name\=developer\&salary\=10 & curl http://localhost:8080/job/increase/pessimisticLock\?name\=developer\&salary\=10 & curl http://localhost:8080/job/increase/pessimisticLock\?name\=developer\&salary\=10