JPA를 사용하게 되면 트랜잭션 격리 수준이 READ COMMITTED 정도가 된다.
격리 수준
- READ UNCOMMITTED (거의 안 써요!)
- 커밋되지 않은 데이터를 읽을 수 있음 -> Dirty Read , Dirty Write 발생 가능
- READ COMMITTED
- 커밋된 데이터만 읽기 -> Read Skew 발생 가능
- REPEATABLE READ
- 트랜잭션 동안 같은 데이터를 읽게 함 -> Lost Update 발생 가능
- SERIALIZABLE
- 모든 트랜잭션을 순서대로 실행 (실제로는 인덱스 잠금이나 조건 기반 잠금을 사용)
트랜잭션 격리 수준이 높아질수록 오버헤드가 커짐으로 잘 선택하여 사용해야 합니다.
JPA를 사용하면서 READ COMMITTED 이상의 격리 수준이 필요할 때 비관적 락 , 낙관적 락을 선택하여 사용하면 됩니다.
비관적 락 , 낙관적 락 그게 뭔데!?
- 낙관적 락(Optimistic Lock)
- 트랜잭션 충돌이 발생하지 않는다는 가정을 합니다. (긍정적으로 생각!)
- DB 가 제공하는 락 기능을 사용하지 않고 JPA 가 제공하는 버전 관리 기능을 사용합니다
- 특징으로는 트랜잭션을 커밋하기 전까지는 트랜잭션의 충돌을 알 수 없습니다.
- 비관적 락(Pessimistic Lock)
- 트랜잭션 충돌이 발생한다고 가정하고 우선 락을 겁니다. (부정적으로 생각!)
- DB 가 제공하는 락 기능을 사용합니다.( 예시:
Select for Update
) - 데이터를 수정 시 즉시 트랜잭션 충돌을 알 수 있습니다.
사용경험
게시판을 가져오는 API에서 조회수를 증가함에 따라
여러 스레드에서 같은 데이터를 읽을 때 변경 유실(조회수 증가 누락)을 경험해보았다.
정상적 작동은 조회수(Count)가 3 이여야 하지만 결과는 2로 나온다.
당시 상황에 대한 재현과 해결방법을 알아보자.
(해당 본문에서는 본문 길이상 모든 예제 코드를 자세히 설명하지 않습니다. 예제 코드 보기 GitHub)
우선 테스트 코드부터 보자.
@Test
@DisplayName("Board 요청을 n 번 호출 하는경우 조회수는 n 이 되어야함.")
void getBoard() {
//given
ExecutorService service = Executors.newFixedThreadPool(10);
//when
for (int i = 0; i < 10; i++) {
service.execute(()->{
try {
boardService.getBoard(1L);
} catch (Exception e) {
e.printStackTrace();
}
});
}
//then
Board board = boardRepository.findById(1L).orElseThrow(RuntimeException::new);
assertThat(board.getBoardCount()).isEqualTo(10);
}
10개의 스레드를 준비하여 해당 예제를 테스트하면 해당 테스트는 실패할 것이다.
쿼리는 정상적으로 생성된다.
비관적 락으로 해결하기!
해당 문제를 해결하기 위해서는 select for update 구문을 이용하여 특정 데이터 ROW에 대해 LOCK 걸어 해결해보겠습니다.
기존 JPQL에서는 아무런 설정을 해주지 않는다면 일반 select 문이 발생하지만
public interface BoardRepository extends JpaRepository<Board, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select b from Board b where b.id = :id")
Board findByIdForUpdate(Long id);
}
이와 같이 @Lock(LockModeType.PESSIMISTIC_WRITE)
설정하고 다시 테스트를 실행해본다면??
다음과 같이 쿼리는 select for update
으로 변경되어 해당 테스트는 통과하게 됩니다.
.
비관적 락 자체는 로우 자체에 락을 실제로 설정함으로 성능상 문제가 발생할 수 있습니다.
따라서 정말 비관적 락이 정답일까?라는 고민을 해봐야 합니다.
낙관적 락 사용해보기
JPA에서 낙관적 락을 사용하기 위해선 @Version
을 엔티티에 추가하여 사용하면 된다.
@Entity
// lombok 생략
public class Board {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
// 생략
@Version // 추가
private Long version;
}
@Version
애노테이션을 사용함으로 이제부 해당 엔티티를 수정할 때마다 version 이 자동으로 하나씩 증가하게 됩니다.
수정할 때 조회 시점의 버전과 수정 시점의 버전이 다르면 ObjectOptimisticLockingFailureException
예외가 발생하게 됩니다.
즉 최초의 커밋만 인정하게 되는데
만약 Count를 모두 정상적으로 증감시키길 원한다면 예외를 잡아서 복구해주면 됩니다.
@Test
@DisplayName("Board 요청을 n 번 호출 하는경우 조회수는 n 이 되어야함.")
void getBoard() throws InterruptedException {
//given
ExecutorService service = Executors.newFixedThreadPool(10);
//when
for (int i = 0; i < 10; i++) {
service.execute(() -> {
try {
Board board = boardService.getBoard(1L);
System.out.println(board);
} catch (ObjectOptimisticLockingFailureException lockingFailureException) {
System.out.println("충돌 !");
// 예외를 잡아 복구작업을 해주면됩니다.
} catch (Exception e) {
e.printStackTrace();
}
});
}
Thread.sleep(1000);
//then
Board board = boardRepository.findById(1L).orElseThrow(RuntimeException::new);
assertThat(board.getBoardCount()).isEqualTo(10);
}
낙관적 락 자체는 트랜잭션 충돌이 거의 발생하지 않을 거라고 생각하고 사용함으로
만약 트랜잭션 충돌이 많아 복구 작업을 많이 해야 하는 로직이라면 낙관적 락을 사용하기보다는 비관적 락을 사용하는 것이 맞는 거 같습니다.
정리
낙관적 락 , 비관적 락의 가장 큰 차이점은
애플리케이션단에서 처리하는가? , DB에 실제로 Lock을 걸어서 해결하는가
라는 관점으로 나뉜다고 생각합니다.
모든 방법은 상황에 맞게 잘 적용해야 한다고 생각합니다.
즉 각자의 애플리케이션에 맞는 방법을 찾아서 상황에 맞게 선택해서 사용하는 게 최선이지 않을까요??
단어 정리
Dirty Read - 커밋되지 않은 데이터 읽기
Dirty Write - 커밋되지 않은 데이터 덮어쓰기
Read Skew - 읽는 동안 데이터 변경
Lost Update - 변경 유실
'Spring > JPA' 카테고리의 다른 글
[JPA] findById() , getById() 에 대한 생각 (1) | 2021.07.27 |
---|---|
[JPA] 순환참조 해결하기(JackSon 동작원리) (0) | 2020.12.30 |