[JPA] JPA의 낙관적 잠금(Optimistic Lock), 비관적 잠금(Pessimistic Lock)

Hocaron·2022년 7월 21일
1

Spring

목록 보기
26/44

요청이 많은 서버에서 여러 트랜잭션이 동시에 같은 데이터에 업데이트를 발생시킬 경우에 일부 요청이 유실되는 경우가 발생하여 장애로 이어질 수 있다.

그 만큼 엔터프라이즈 애플리케이션의 경우 데이터베이스에 대한 동시 액세스(concurrency)를 적절하게 관리하는 것이 중요하다.

여러곳에서 동시에 발생하는 트랜잭션을 처리 할 수 있어야 하며 동시 읽기/업데이트 간에 데이터가 일관되게 유지되도록 해야한다.

JPA의 낙관적 잠금

  • 데이터 갱신시 충돌이 발생하지 않을 것이라고 낙관적으로 보고 잠금을 거는 기법

    디비에 락을 걸기보다는 충돌 방지(Conflict detection)에 가깝다고 볼 수 있음

  • 동시성 처리를 위해 JPA 에서는 낙관적 잠금(Optimistic Lock)을 손쉽게 사용할 수 있도록 제공

  • 동시에 동일한 데이터에 대한 여러 업데이트가 서로 간섭하지 않도록 방지하는 version이라는 속성을 확인하여 Entity의 변경사항을 감지하는 메커니즘

사용방법

  • 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(버전 비교 함수)을 구현하여주면 커스텀하게 버저닝 사용이 가능하다.

동작방식

  1. JPA는 Select시에 트랜잭션 내부에 버전 속성의 값을 보유하고 트랜젝션이 업데이트를 하기 전에 버전 속성을 다시 확인한다.
  2. 그 동안에 버전 정보가 변경이 되면 OptimisticLockException이 발생하고 변경되지 않으면 트랜잭션은 버전속성을 증가하는 업데이트 하게 된다.

낙관적 잠금의 LockModeType

NONE

별도의 옵션을 사용하지 않아도 Entity에 @Version이 적용된 필드만 있으면 낙관적 잠금이 적용된다. (하단에 암시적 잠금 참조)

OPTIMISTIC (Read)

Entity 수정시에만 발생하는 낙관적 잠금이 읽기 시에도 발생하도록 설정한다.
읽기시에도 버전을 체크하고 트랜잭션이 종료될 때까지 다른 트랜잭션에서 변경하지 않음을 보장한다.
이를 통해 dirty read와 non-repeatable read를 방지한다.

entityManager.find(Student.class,studentId,LockModeType.OPTIMISTIC);

OPTIMISTIC_FORCE_INCREMENT (Write)

낙관적 잠금을 사용하면서 버전 정보를 강제로 증가시키는 옵션이다.

READ, WRITE

READ는 OPTIMISTIC과 같은 역할을하며 WRITE는 OPTIMISTIC_FORCE_INCREMENT와 같은 역할을 한다.
JPA 1.0의 호환성을 유지하기 위해서 존재하는 옵션이다.

OptimisticLockException

persistence provider

Persistence Provider가 Entity에서 낙관적 잠금 충돌을 감지하면 OptimisticLockException을 발생시키게 되고 트랜잭션은 롤백을 처리한다.

권장되는 예외처리 방법에서는 Entity를 다시 로드하거나 새로고침하여 업데이트를 재 시도하는 방법이다.

예외처리시에 발생되는 Exception에서 충돌되는 Entity를 제공해주고 있어 쉽게 처리가 가능하도록 되어있다.

JPA의 비관적 잠금

  • 트랜잭션의 충돌이 발생한다고 가정하고 우선 락을 걸고 보는 방법
  • 트랜잭션안에서 서비스로직이 진행
  • 비관적 잠금 메커니즘은 데이터베이스 수준에서 엔티티 잠금을 포함
  • PersistenceException이 발생

비관적 잠금의 LockModeType

PESSIMISTIC_READ

dirty read가 발생하지 않을 때마다 Shared Lock을 획득하고 데이터가 UPDATE, DELETE 되는 것을 방지 할 수 있다.

PESSIMISTIC_READ를 지원하지 않는 데이터 베이스도 있지만 그럴 경우에는 PESSIMISTIC_WRITE로 대체된다.

PESSIMISTIC_WRITE

배타적 잠금(Exclusive Lock)을 획득하고 데이터를 다른 트랜잭션에서 READ, UPDATE, DELETE 하는것을 방지 할 수 있다.

PESSIMISTIC_FORCE_INCREMENT

이 잠금은 PESSIMISTIC_WRITE와 유사하게 작동 하지만 @Version이 지정된 Entity와 협력하기 위해 도입되어 PESSIMISTIC_FORCE_INCREMENT 잠금을 획득할 시 버전이 업데이트 된다.

@Version이 지정된 엔티티의 모든 업데이트는 PESSIMISTIC_FORCE_INCREMENT 잠금을 확보하기 전에 업데이트가 될 수도 있습니다.

관련 Exception

PessimisticLockException

잠금은 Shared Lock 또는 Exclusive Lock 둘중에 하나만 획득할 수 있으며 그 락을 획득하는데 실패하면 발생되는 예외입니다.

LockTimeoutException

락을 대기하다 설정해놓은 wait time이 초과되었을때 발생하는 예외입니다.

  • LockTime: 트랜잭션 처리중 lock이 걸렸을 시, 롤백 될때까지 대기하는 시간

  • 현재 DB에 설정되어있는 LockTimeout을 보여주는 명령어(MySQL 기준)

select @@innodb_lock_wait_timeout

PersistenceException

NoResultException , NonUniqueResultException, LockTimeoutException 및 QueryTimeoutException을 제외한 PersistanceException 예외에 대해서는 트랜잭션에 롤백을 마킹합니다.

Lock Scope

PessimisticLockScope.NORMAL

기본값으로써 해당 entity만 잠금이 설정됩니다.
@Inheritance(strategy = InheritanceType.JOINED)와 같이 조인 상속을 사용하면 부모도 함께 잠금이 설정됩니다.

PessimisticLockScope.EXTENDED

@ElementCollection, @OneToOne, @OneToMany 등 연관된 entity들도 잠금이 설정됩니다.

암시적 잠금과 명시적 잠금

암시적 잠금 (Implicit Lock)

  • JPA에서는 @Version이 붙은 필드가 존재하거나 @OptimisticLocking 어노테이션이 설정되어 있을 경우 자동적으로 충돌감지를 위한 잠금이 실행
    • Annotation만 설정해주면 별도의 추가 설정이 없어도 JPA에서 자동적으로 실행
    • 추가로 삭제 쿼리가 발생할시에는 암시적으로 해당 로우에 대한 행 배타잠금(Row Exclusive Lock)을 제공

명시적 잠금 (Explicit Lock)

  • 프로그램을 통해 의도적으로 잠금을 실행하는 것이 명시적 잠금
  • JPA에서 EntityManager를 통하여 엔터티를 조회할 때 LockMode를 지정하거나 select for update 쿼리를 통해서 직접 잠금을 지정한다.
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

References

profile
기록을 통한 성장을

0개의 댓글