동시성 제어하기 위한 수단으로 Lock을 보통 사용한다. Thread 간의 경쟁 상태를 해결하기 위한 뮤텍스,세마포어 모니터 등의 방식도 있고, DBMS에서 동시성을 제어하기 위한 Lock도 존재한다. JPA에서는 동시 요청을 처리할 수 있는 방법으로 낙관적 락과 비관적 락을 제공하는데 탐구하면서 각각의 기능을 알아보자.
사용자 A와 B가 동시에 같은 자원을 수정을 했을 때 발생하는 문제
예를 들어 A는 글의 제목을 제목A
로 변경하고 B는 제목을 제목B
로 변경한다 했을 때 동시에 작업이 일어나면, A의 수정사항은 사라지고 나중에 완료한 사용자 B의 수정사항만 남게 된다.
두번의 갱실 분실 문제는 트랜잭션의 범위를 넘어서기 때문에 트랜잭션 만으로는 해결할 수 없다.
가능한 3가지 선택지는
1. 마지막 커밋만 인정하기 : A의 내용을 무시하고, B의 내용만 반영
2. 최초 커밋만 인정하기 : A가 수정을 했으므로, B가 수정할 때 오류를 발생
3. 충돌하는 갱신 내용 병합하기 : A와 B의 수정사항을 병합한다.
기본값은 마지막 커밋만 인정하기가 사용된다. 만약 최초 커밋만 인정하기를 사용하려면 JPA가 제공하는 버전 관리 기능을 통해서 가능하다.
게시글 Board의 제목을 수정한다고 하자.
@Entity
@Getter
public class Board {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column
private String name;
protected Board() {
}
public Board(String name) {
this.name = name;
}
public void update(String name) {
this.name = name;
}
}
@Test
void test() throws InterruptedException {
Thread thread1 = new Thread(() -> boardService.update(1L, "NameA"));
Thread thread2 = new Thread(() -> boardService.update(1L, "NameB"));
thread1.start();
thread2.start();
thread1.join();
thread2.join();
Board updateBoard = boardRepository.findById(1L).orElseThrow();
System.out.println("updateBoard.getName() = " + updateBoard.getName());
}
---
updateBoard.getName() = NameB
Thread의 시차를 두지 않았기 때문에 그때마다 결과값이 다르지만, 마지막 커밋만 인정되어 반영되어있음을 알 수 있다.
마지막 커밋을 반영하는 것이 아니라 초기값이 반영되도록 구현하려면 락을 이용할 수 있다.
가장 먼저 낙관적 락을 사용하려면 JPA가 제공하는 @Version
애노테이션을 사용해서 버전 관리 기능을 추가해야 한다.
@Entity
public class Board {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column
private String name;
@Version
private Long version;
protected Board() {
}
public Board(String name) {
this.name = name;
}
public void update(String name) {
this.name = name;
}
}
아래와 같이 코드를 실행시키면 낙관적 락이 작동한다.
정상 수행됐던 로직에서 ObjectOptimisticLockingFailureException
이 발생하는 것을 확인할 수 있다.
해당 예외를 자세히 보면 TransactionalInterceptor에서 예외를 처리하고 있는 것을 볼 수 있는데, try-catch 문을 통해 Service 내부에서 예외를 잡으려고 했는데 실패했다. @ControllerAdvice
를 이용해서 예외를 잡을 수 있었다. 따라서 Transaction 프록시 객체가 예외를 반환해준다는 것을 알 수 있었다.
추가로 작성한 version이 값이 수정됨에 따라 0에서 1로 변경된 것을 같이 확인할 수 있었다.
version은 엔티티의 값이 변경되면 증가한다고 하는데 확인해보고 싶어서 테스트를 돌려봤다.
이름을 똑같이 변경시키면 version은 변하지 않는다.
for (int i = 0; i < 20; i++) {
Board board = boardRepository.findById(1L).get();
boardService.update(1L, "name");
System.out.println("board.getVersion() = " + board.getVersion());
}
---
<최종결과>
board.getVersion() = 1
이름을 변경 시키면 version이 계속 증가한다.
for (int i = 0; i < 20; i++) {
Board board = boardRepository.findById(1L).get();
boardService.update(1L, "name" + i);
System.out.println("board.getVersion() = " + board.getVersion());
}
---
<최종결과>
board.getVersion() = 20
낙관적 락에서 발생하는 예외는 다음과 같다.
락 옵션을 따로 지정하지 않고 @Version
만 있어도 낙관적 락이 적용된다.
추가로 옵션을 통해서 낙관적 락을 관리할 수 있는데 알아보자
@Version
만 적용하면 작동한다.
@Version
만 적용했을 때는 엔티티를 조회할 때 버전 체크를 하지 않는다. 하지만 Optimistic 옵션을 주면 조회시에 버전을 체크한다.
None은 수정 시점까지의 일관성을 보장한다면, Optimistic은 트랜잭션이 끝날때까지 일관성을 보장한다.
이 옵션을 주면 Dirty Read와 Non Repeatable Read를 방지한다.
public interface BoardRepository extends JpaRepository<Board, Long> {
@Lock(LockModeType.OPTIMISTIC)
Optional<Board> findById(Long id);
}
버전이 증가할거라고 생각했는데, 버전은 증가하지 않고 체크만 됐다. 그도 그럴것이 Select 쿼리에 Update 쿼리가 발생해야하니 문제가 될 수도 있겠다고 생각한다.
해당 옵션을 줬더니 테스트가 작동하지 않았다. 추후 확인해봐야겠다.
JPA가 애플리케이션 단에서 관리하는 락에 대해서 알아봤다. 다음에는 비관적 락을 어떻게 관리하는지 알아보자.
자바 ORM 표준 JPA 프로그래밍 - 김영한 지음