<JPA> 낙관적락(Optimistic Lock), 비관적락(Pessimistic Lock)
by BFine가. Lock을 알아보자
a. Lock Lock ~
- lock은 동시성 제어를 위한 상호배제 기법 중 하나이다.
- 기본적으로 어플리케이션에서의 공용자원 관리, DB에서의 행 데이터 관리를 위해 사용한다.
- DB의 row 단위 lock를 살펴보면 Shared Lock, Exclusive Lock 두가지가 있다.
=> 2022.02.12 - [공부(2021)/Database] - 트랜잭션의 Isolation Level 과 동시성 제어
=> 2022.02.27 - [공부(2021)/Database] - 공유락(Shared Lock) & 배타락(Exclusive Lock)
b. JPA에서 Lock이 필요한 이유
- 멀티스레드로 돌아가는 웹어플리케이션 환경에서 lock은 굉장히 중요하다. 동시에 여러 트랜잭션이 발생할 경우 데이터의 정확성에 문제가 발생할 수 있다.
- 아래는 동시성 이슈가 발생하는 예제이다. (이 포스팅에서 계속 사용할 테스트 예제)
@RestController
@RequiredArgsConstructor
public class TestController {
private final InputRepository inputRepository;
@Transactional
@GetMapping("/test")
public void test() throws InterruptedException {
System.out.println("### Test 1 Begin ###");
Input input = inputRepository.findById(1L).orElse(null);
TimeUnit.SECONDS.sleep(5);
input.setCount(input.getCount()+1);
System.out.println("### Test 1 Updated : "+ input);
System.out.println("### Test 1 End ###");
}
@Transactional
@GetMapping("/test2")
public void test2(){
System.out.println("### Test 2 Begin ###");
Input input = inputRepository.findById(1L).orElse(null);
input.setCount(input.getCount()+1);
System.out.println("### Test 2 Update : "+input);
System.out.println("### Test 2 End ###");
}
}
- 첫번째 Thread의 트랜잭션이 끝나기전에 두번째 Thread의 트랜잭션이 실행 & 종료된 이후에 첫번째 트랜잭션이 종료되었다.
- 2번의 업데이트로 count 값은 6이 되어야 하지만 읽는 시점의 차이로 인해 5라는 결과가 나왔다.
- 이렇게 DB에서 SELECT 해온 row 데이터에 어떤 특정 작업을 한 이후에 UPDATE 한다고 했을때 작업에 따라 해당 데이터를 보호해야할수도 있다.
c. JPA의 LockModeType
- Read, Write 은 Optimistic과 동일하며 신규 생성할 경우에 Optimistic을 사용하면 된다.
나. Optimistic Lock(낙관적락)
a. 무엇인가
- DB의 Lock을 사용하는 것이 아닌 JPA가 여러 트랜잭션 사이에서 데이터의 정확성을 위해 제공하는 기능이다.
=> 버저닝을 이용해서 SELECT 했을때와 동일한 버전인지를 확인한다.
b. 설정하기
- 엔티티 클래스에 직접 설정하는 방법과 Repositoy의 메서드에 설정하는 방법 두가지가 있다.
- 먼저 엔티티 클래스에 설정하는 방법부터 살펴보면
- @OptimisticLocking 어노테이션을 이용하여 Optimistic Lock을 설정 할 수 있다.
- 버전을 확인 하기 위한 방법인 OptimisticLockType의 종류로는 3가지가 있다.
=> VERSION: version 필드 이전 값을 사용 , DIRTY : 업데이트 되는 필드의 이전 값을 사용, ALL : 전체 필드의 이전 값을 사용
@Entity
@OptimisticLocking(type = OptimisticLockType.VERSION)
@DynamicUpdate
public class Input {
@Id
@GeneratedValue
private Long id;
private String type;
private Integer count;
@Version
private Long version;
}
- Repositoy의 메서드에 설정하는 방법
public interface InputRepository extends JpaRepository<Input,Long> {
@Override
@Lock(LockModeType.OPTIMISTIC)
Optional<Input> findById(Long aLong);
}
c. DIRTY
- 먼저 version 필드를 사용하지 않는 방법인 DIRTY를 살펴보자 (ALL도 사용하지 않는다.)
@Entity
@OptimisticLocking(type = OptimisticLockType.DIRTY)
@DynamicUpdate
public class Input {
@Id
@GeneratedValue
private Long id;
private String type;
private Integer count;
}
- 단순 Input 테이블의 count 필드만 업데이트 해보고 결과를 살펴보면
private final InputRepository inputRepository;
@Transactional
@GetMapping("/test")
public void test() {
System.out.println("### Test 1 Begin ###");
Input input = inputRepository.findById(1L).orElse(null);
input.setCount(input.getCount()+1);
System.out.println("### Test 1 Updated : "+input);
System.out.println("### Test 1 End ###");
}
- 로그를 살펴보면 update문에서 특이점을 찾을수가 있는데 그것은 where 조건절에 count 필드의 조건이 추가된 것을 볼 수 있다.
- 이렇게 이전 값을 사용하면 다른 트랜잭션에서 만약 count 값을 변경하고 커밋했다면 위의 update 쿼리는 없는 값을 업데이트하는 형태가 된다.
=> 이렇게 0 row Update가 될 경우 JPA는 StaleStateException 발생시킨다.
- 여러 필드를 업데이트하는 경우에 모든 업데이트 대상인 필드가 where 조건에 함께 들어가게 된다.
=> 이때 필드에 @OptimisticLock(excluded = false)를 이용하면 해당 컬럼을 제외시킬 수 있다.
d. VERSION
- 앞서본 Dirty 방식과 다르게 version 컬럼이 추가로 필요하며 @Version을 명시하여 사용한다.
=> @OptimisticLocking 을 명시하지 않아도 @Version이 있으면 Optimistic Lock으로 사용된다.
@Entity
@DynamicUpdate
public class Input {
@Id
@GeneratedValue
private Long id;
private String type;
private Integer count;
@Version
private Long version;
}
- 여기서 Dirty 방식과 다른 부분은 update시에 version 필드를 이전값 +1 하는 것을 볼 수 있다.
=> 즉 모든 쓰기작업에 대해 version 번호를 가지고 있는 셈이 된다.
e. OPTIMISTIC
- Optimistic Lock은 Repository 메서드에도 설정 할 수가 있다.
public interface InputRepository extends JpaRepository<Input,Long> {
@Override
@Lock(LockModeType.OPTIMISTIC)
// @Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT)
Optional<Input> findById(Long aLong);
}
- 위의 OPTIMISTIC 타입은 Entity에 설정한 것과 다른 점은 단순 Read만 있는 경우에도 트랜잭션이 끝날때 version을 확인한다.
- 마지막에 select 쿼리에서 version이 다를경우 OptimisticLockException이 발생한다.
- 맨 위에 있었던 동시성 이슈 예제를 테스트 해보자
1. 첫번째 트랜잭션이 select를 해서 엔티티 데이터를 가져온다. (version=23)
2. 두번째 트랜잭션이 select를 해서 동일하게 엔티티를 가져온다. (version=23)
3. 두번째 트랜잭션이 끝나고 update 쿼리가 flush & 커밋 된다. 이때 version을 증가시킨다. (version=24)
4. 첫번째 트랜잭션이 끝나고 update 쿼리가 flush 되는데 이때 version이 23라는 데이터는 없기 때문에 예외가 발생한다. (StaleStateException)
f. OPTIMISTIC_FORCE_INCREMENT
- OPTIMISTIC_FORCE_INCREMENT 타입은 단순 Read 하는 경우에도 version을 업데이트 한다.
- 단순하게 하나의 필드를 업데이트 해보면 두번의 update 쿼리가 트랜잭션이 끝나는 시점에 flush 된다.
=> 하나의 트랜잭션에 대해 버전 업데이트가 있고 그안에 update 처리하는 쿼리에 대한 업데이트가 있다.
다. Pessimistic Lock (비관적락)
a. 무엇인가
- DB의 Shared Lock, Exclusive Lock을 이용하여 DB 레코드를 제어하는 방법이다.
b. 설정하기
- Repository의 메서드에 @Lock 어노테이션을 명시하여 사용할 수 있다. (Optimistic Lock 동일)
public interface InputRepository extends JpaRepository<Input,Long> {
@Override
@Lock(LockModeType.PESSIMISTIC_READ)
Optional<Input> findById(Long aLong);
}
c. PESSIMISTIC_READ
- DB의 Shared Lock을 사용하는 방법이다. version 필드를 사용하지 않는다.
=> 유의할점은 DB에서 Shared Lock을 지원하지 않으면 Exclusive Lock(for update)으로 대체된다. (Oracle 등)
- 보면 Select 쿼리에 for share로 Shared Lock을 획득하는 것을 볼 수있다.
- 맨 처음 동시성 이슈가 발생했던 테스트했던 예제를 다시 실행해보자
1. 첫번째 트랜잭션이 Shared Lock을 획득하고 뒤이어 두번째 트랜잭션이 Shared Lock을 획득한다.
2. 두번째 트랜잭션은 update 쿼리를 flush 하며 쓰기를 위해 Exclusive Lock을 획득하려고 한다.
=> 이때 첫번째 트랜잭션에 Shared Lock이 걸려있기 때문에 끝날때까지 대기상태가 된다.
3. 첫번째 트랜잭션이 update 쿼리를 flush 하며 마잔가지로 Exclusive Lock을 획득하려고 한다.
=> 이때 두번째 트랜잭션에 여전히 Shared Lock이 걸려있기 때문에 마찬가지로 대기상태가 된다.
4. 첫번쨰 트랜잭션에서 DB는 데드락을 감지하여 Excepction이 발생하며 롤백되며 Lock이 해제된다.
5. 두번째 트랜잭션은 Exclusive Lock을 획득하고 해당 update를 반영한다.
d. PESSIMISTIC_WIRTE
- DB의 Exclusive Lock을 사용하는 방법이다. version 필드를 사용하지 않는다.
=> JPA 쿼리를 보면 select ... for update 형태인 것을 볼 수 있다.
- 위와 똑같은 예제를 실행시켜보면
1. 첫번째 트랜잭션이 Exclusive Lock 획득한다.
2. 두번째 트랜잭션이 Exclusive Lock 획득하려하지만 첫번째 트랜잭션이 Lock을 가지고 있기때문에 대기상태가 된다.
3. 첫번째 트랜잭션이 종료된 이후에야 두번째 트랜잭션이 Lock을 획득하고 update를 처리한다.
e. PESSIMISTIC_FORCE_INCREMENT
- DB의 Exclusive Lock 방식을 사용하면서 Optimistic Lock 처럼 version 필드도 사용한다.
=> mysql 8.0 이상인 경우 for update에 nowait 추가 된다.
- Read 만 하는 경우에도 version을 업데이트 한다.
- 간단하게 필드 하나를 업데이트 해보면 update 쿼리가 총 두번 나가는 것을 볼 수 있다.
=> update가 발생하는 시점에 바로 version 필드의 version을 증가시키고 트랜잭션이 끝날떄 version을 한번 더 증가 시킨다.
- 마찬가지로 이번엔 동시성 이슈 예제를 한번 실행시켜보자.
1. 첫번째 트랜잭션이 Exclusive Lock을 가지고 있기때문에 두번째 트랜잭션의 nowait 인해 바로 오류가 발생한다.
=> nowait 키워드는 S or X Lock에 대해 하나라도 걸려있는 경우 대기없이 즉각 오류를 발생시킨다.
2. 두번째 트랜잭션이 PessimisticLockException을 발생시키면서 롤백되며 첫번째 트랜잭션은 반영 된다.
라. 정리하기
a. Optimistic Lock VS Pessimistic Lock
Optimistic Lock (OPTIMISTIC) | Pessimistic Lock (PESSIMISTIC_WRITE) | |
처리위치 | JPA에서 처리 | DB의 Lock을 활용 |
동시처리 | Exception 발생 | Lock을 획득할때까지 대기 |
Version | O | X |
b. 그러면 어떤 Lock을 사용해야 할까?
- 아래부터는 개인적인 생각을 정리해봐야겠다.
- Optimistic Lock은 낙관적이라는 의미에서부터 회피형이라는 느낌이 드는데 동시처리로 인한 예외에 대한 책임을 전가하는 느낌이다.
=> 예를들어 여러사람이 동시에 클릭했을때 몇몇 사람은 실패한경우 오류메세지를 보여주며 다시시도 하세요 보여주기 등
- 물론 예외가 발생했을경우 재시도 처리한다던지해서 복구 하는 방법도 생각을 해야할 것 같다.
- 트랜잭션이 맞물리는 경우가 많지 않고 바로 반영이 되지않아도 크게 문제없지만 책임 전가 or 복구가 필요한 곳에 사용하면 될 것 같다.
- Pessimistic Lock은 비관적이라는 의미에서부터 책임형이라는 느낌이 드는데 대기를 걸어서라도 중요하니 꼭 업데이트 해야한다라는 느낌이다.
- 물론 어떤 문제가 있어 해당 트랜잭션이 계속 커넥션을 잡고 있는 경우가 발생할 수 있으므로 Timeout 값을 적절하게 사용하고
Timeout이 발생했을때 해당 트랜잭션을 어떻게 복구 할 것 인지에 대해서도 고민이 필요할 것 같다.
- 트래픽이 많고 성능이 중요한 환경에서는 지양 해야할 것 같고 주로 배치 처리 하는 경우에 사용하지 않을까 싶다.
'공부 > JPA' 카테고리의 다른 글
<JPA> @OneToMany 단방향으로 쓰지 않는 이유 (1) | 2024.04.09 |
---|---|
<JPA> 영속성 컨텍스트 (0) | 2021.03.05 |
<Spring & JPA 웹서비스 만들기 > 구현 (5) - 달력 만들기 (0) | 2021.02.12 |
<Spring & JPA 웹서비스 만들기 > 구현 (4) - 화면 구성 (0) | 2021.02.06 |
<Spring & JPA 웹서비스 만들기 > 구현 (3) - Redis 자동완성 (0) | 2021.01.31 |
블로그의 정보
57개월 BackEnd
BFine