Backend/JPA

JPA의 낙관적 락과 비관적 락

olsohee 2024. 2. 7. 19:27

JPA의 낙관적 락과 비관적 락

JPA에서 @Transactional(isolation = Isolation.DEFAULT) 로 설정하면 DBMS에 설정한 격리 수준을 따른다. MySQL은 REPEATABLE READ가 기본 설정이다. 그리고 JPA는 영속성 컨텍스트와 1차 캐시를 통해 REPEATABLE READ 격리 수준을 데이터베이스가 아닌 애플리케이션 차원에서 제공한다.

 

그런데 일부 로직에서 더 높은 격리 수준이 필요할 때가 있다. 따라서 이때 JPA가 제공하는 낙관적 락과 비관적 락 옵션을 사용하면 된다.

  • 낙관적 락: 대부분의 트랜잭션이 충돌이 발생하지 않을 것이라고 낙관적으로 가정하는 방법이다. 따라서 데이터베이스가 제공하는 락 기능을 사용하지 않고, JPA가 제공하는 버전 관리 기능을 사용한다. 쉽게 이야기해서 애플리케이션이 제공하는 락이다. 엔티티의 버전을 통해 동시성을 제어한다.
  • 비관적 락: 트랜잭션의 충돌이 발생한다고 가정하고 우선 락을 걸고 보는 방법이다. 이것은 데이터베이스가 제공하는 락 기능을 사용한다. 

두 번의 갱신 분실 문제

만약 사용자 A와 B가 동시에 제목이 같은 공지사항을 수정한다고 가정하자. 둘이 동시에 수정 화면을 열어서 내용을 수정하는 중에 사용자 A가 먼저 수정완료 버튼을 눌렀다. 그리고 잠시 후에 사용자 B가 수정완료 버튼을 눌렀다. 결과적으로 먼저 완료한 사용자 A의 수정사항은 사라지고 나중에 완료한 사용자 B의 수정사항만 남게 된다. 이를 두 번의 갱신 분실 문제라고 한다.

 

두 번의 갱신 분실 문제는 데이터베이스 트랜잭션의 범위를 넘어선다. 따라서 트랜잭션만으로는 문제를 해결할 수 없다. 이때는 다음 3가지 방법이 있다.

  • 마지막 커밋만 인정하기: 사용자 A의 내용은 무시하고 마지막에 커밋한 사용자 B의 내용만 인정한다.
  • 최초 커밋만 인정하기: 사용자 A가 이미 수정을 완료했으므로 사용자 B가 수정을 완료할 때 오류가 발생한다.
  • 충돌하는 갱신 내용 병합하기: 사용자 A와 사용자 B의 수정사항을 병합한다.

기본은 마지막 커밋만 인정하기가 사용된다. 하지만 상황에 따라 최초 커밋만 인정하기가 더 합리적일 수 있는데, JPA가 제공하는 버전 관리 기능을 사용하면 손쉽게 최초 커밋만 인정하기를 구현할 수 있다.

@Version: 버전 관리를 통한 낙관적 락

JPA가 제공하는 낙관적 락을 사용하려면 @Version 어노테이션을 사용해서 버전 관리 기능을 추가해야 한다.  @Version 적용이 가능한 타입은 Long(long), Integer(int), Short(short), Timestamp이다. 

 

버전 관리 기능을 적용하려면 다음과 같이 엔티티에 버전 관리용 필드를 추가하고 @Version을 붙이면 된다. 

@Entity
public class Board {

    @Id
    private String id;
    private String title;
    
    @Version
    private Integer version;
}

 

이를 통해 엔티티를 수정할 때마다 버전이 하나씩 자동으로 증가한다. 그리고 엔티티를 수정할 때 조회 시점의 버전과 수정 시점의 버전이 다르면 예외가 발생한다. 예를 들어 트랜잭션 1이 조회한 엔티티를 수정하고 있는데 트랜잭션 2에서 같은 엔티티를 수정하고 커밋해서 버전이 증가해버리면, 트랜잭션 1이 커밋할 때 조회 시점의 엔티티 버전과 수정 시점의 엔티티 버전이 다르므로 예외가 발생한다. 따라서 버전 정보를 사용하면 최초 커밋만 인정하기가 적용된다.

// 트랜잭션 1이 엔티티 조회 (버전=1)
Board board = em.find(Board.class, id);

// 트랜잭션 2가 엔티티 수정 후 커밋 (버전=2)

board.setTitle(...); // 트랜잭션 1이 데이터 수정

save(board);
tx.commit(); // 커밋 시 예외 발생 (데이터베이스의 엔티티 버전=2, 조회한 엔티티 버전=1)

 

JPA가 버전을 비교하는 방법은 단순하다. 엔티티를 수정하고 트랜잭션을 커밋하면 영속성 컨텍스트가 플러시되면서 update 쿼리를 실행한다. 이때 버전을 사용하는 엔티티이면 다음과 같이 검색 조건에 버전 정보를 추가한다.

UPDATE BOARD
SET
    TITLE=?
    VERSION=? (버전 + 1 증가)
WHERE
    ID=?
    AND VERSION=? (버전 비교)

 

만약 버전이 이미 증가해서 커밋하려는 엔티티 버전과 데이터베이스에 저장된 엔티티 버전이 다르면 update 쿼리의 where문에서 version 값이 다르므로 수정할 대상이 없다. 따라서 이때는 이미 버전이 증가한 것으로 판단해서 JPA가 예외를 발생시킨다.

@LOCK의 락 옵션

낙관적 락

낙관적 락은 데이터베이스가 제공하는 락 기능을 사용하지 않고 @Version 어노테이션을 함께 사용하여 JPA가 제공하는 버전 관리 기능을 사용하는 방법이다. 버전 관리를 사용하면 트랜잭션을 커밋할 때 엔티티 버전을 확인하기 때문에 커밋 시 충돌을 알 수 있다

NONE

  • 락 옵션을 적용하지 않아도 엔티티에 @Version이 적용된 필드만 있으면 낙관적 락이 적용된다.
  • 엔티티를 수정하고 커밋할 때 버전을 체크해서 데이터베이스의 버전 값과 현재 버전 값이 다르면 예외가 발생한다. 따라서 조회한 엔티티를 수정할 때 다른 트랜잭션에 의해 변경되지 않은 엔티티이어야 정상 커밋이 된다.
  • 두 번의 갱신 분실 문제를 예방할 수 있다(최초 커밋만 인정하기).

OPTIMISTIC

  • @Version만 적용했을 때는 엔티티를 수정하고 커밋할 때만 버전을 체크한다. (엔티티를 수정하고 커밋하면 update 쿼리가 실행되는데, 이때 "update ... where version=?" 쿼리가 실행된다. 즉, 조회 시점의 버전을 가진 엔티티를 수정하려고 시도한다. 해당 버전의 엔티티가 존재하지 않으면 이미 버전이 증가한 것으로 판단하여 JPA가 예외를 발생시킨다.)
  • 그러나 OPTIMISTIC 옵션을 사용하면 엔티티를 조회만 해도 조회 후 커밋할 때 버전을 체크한다. 
// 트랜잭션 1이 엔티티 조회 (버전=1)
Board board = em.find(Board.class, id);

// 트랜잭션 2가 엔티티 수정 후 커밋 (버전=2)

tx.commit(); // 커밋 시 예외 발생 (데이터베이스의 엔티티 버전=2, 조회한 엔티티 버전=1)
  • 엔티티를 조회만 한 후 트랜잭션을 커밋할 때 select 쿼리를 사용해서 버전 정보를 조회한다. 만약 데이터베이스에 현재 엔티티 버전이 저장되어 있지 않으면, 다른 트랜잭션에 의해 엔티티가 수정되고 버전이 증가한 것이므로 예외가 발생한다.
  • 따라서 한 번 조회한 엔티티는 트랜잭션을 종료할 때까지 다른 트랜잭션에 의해 변경되지 않음을 보장한다. 만약 다른 트랜잭션에서 변경했다면 예외가 발생한다.
  • OPTIMISTIC 옵션은 DIRTY READ와 NON-REPEATABLE READ를 방지한다. (낙관적 락은 데이터베이스의 락을 사용하지 않는다. 즉, JPA가 제공하는 버전 관리 기능을 사용함으로써  DIRTY READ와 NON-REPEATABLE READ를 방지하는 것이다.)

OPTIMISTIC_FORCE_INCREMENT

  • 낙관적 락을 사용하면서 버전 정보를 강제로 증가한다.
  • 엔티티를 수정하지 않아도 트랜잭션을 커밋할 때 update 쿼리를 사용해서 버전 정보를 강제로 증가시킨다. 그리고 이때 데이터베이스의 엔티티 버전과 버전이 다르면 예외가 발생한다. 엔티티를 수정하면 수정 시 버전 update가 별도로 발생한다. 따라서 총 2번의 버전 증가가 일어날 수 있다.
  • 따라서 강제로 버전을 증가시켜 논리적인 단위의 엔티티 묶음을 버전 관리할 수 있다.
    • 원래는 양방향 연관관계애서 외래 키를 관리하는 연관관계의 주인 필드를 수정할 때만 버전이 증가한다. 
    • 예를 들어 게시물과 첨부파일이 일대다이고 첨부파일이 연관관계의 주인이라고 가정하자. 게시물을 수정하는 데 단순히 첨부파일만 추가하면 게시물의 버전은 증가하지 않는다. 해당 게시물은 물리적으로는 변경되지 않았지만 논리적으로는 변경되었다.
    • 따라서 이때 게시물의 버전도 강제로 증가하려면 OPTIMISTIC_FORCE_INCREMENT를 사용하면 된다.

비관적 락

비관적 락은 데이터베이스의 락 기능을 사용하는 방법으로, 트랜잭션을 커밋할 때 충돌을 알 수 있는 낙관적 락과 달리 수정 즉시 트랜잭션 충돌을 알 수 있다. 비관적 락을 사용하면 락을 획득할 때까지 트랜잭션이 대기한다. 이때 설정된 타임아웃 시간만큼 기다린다. 만약 타임아웃 시간만큼 기다려도 락을 획득하지 못하면 javax.persistence.LockTimeoutException 예외가 발생한다.

PESSIMISTIC_WRITE

  • 비관적 락이라 하면 일반적으로 이 옵션을 뜻한다.
  • 데이터베이스에 쓰기 락(배타 락)을 건다(select for update). 즉, 다른 트랜잭션에서는 읽기와 쓰기 모두 불가능하다.
  • 따라서 NON-REPEATABLE READ를 방지한다.

PESSIMISTIC_READ

  • 데이터를 반복 읽기만 하고 수정하지 않는 용도로 락을 걸 때 사용하는 옵션으로, 잘 사용하지 않는다.
  • 데이터베이스에 공유 락을 건다(lock in share mode). 즉, 다른 트랜잭션에서는 읽기는 가능하지만 쓰기는 불가능하다.

PESSIMISTIC_FORCE_INCREMENT

  • 비관적 락 중 유일하게 버전 정보를 사용하며 버전 정보를 강제로 증가시킨다.
  • 따라서 엔티티에 @Version이 적용된 필드가 있어야 한다.

락과 트랜잭션 격리 수준의 차이점

MySQL은 일관된 읽기를 위해 락 대신 MVCC를 사용한다. 이와 같이 대부분의 데이터베이스는 트랜잭션 격리 수준을 구현할 때 락을 사용하지 않는다고 한다.

 

그렇다면 JPA가 제공하는 낙관적 락과 비관적 락은 무엇일까? 앞서 설명한대로 낙관적 락은 JPA가 제공하는 락과 유사한 역할을 하는 버전 관리 기능이고, 비관적 락은 JPA를 통해 실제 데이터베이스의 락을 사용하는 것이다. 

 

즉, 락을 사용하지 않고 MVCC를 통해 구현되는 데이터베이스의 트랜잭션 격리 수준과 별개로 JPA가 제공하는 버전 관리 기능 또는 데이터베이스의 락 기능을 사용하여 동시성 이슈를 해결하는 방법이다. 따라서 기본으로 트랜잭션 격리 수준을 설정하고, 일부 로직에서 더 높은 격리 수준을 통해 동시성 문제를 해결하고자 할 때 JPA가 제공하는 낙관적 락과 비관적 락을 사용하면 된다. 

낙관적 락과 비관적 락의 사용 상황

그렇다면 언제 낙관적 락을, 언제 비관적 락을 사용하는 것이 좋을까? 그 기준은 "동시에 수정을 하는 일이 빈번하게 일어나는가?"이다. 동시에 수정하는 일이 빈번하다면 비관적 락을, 그렇지 않다면 낙관적 락을 사용하는 것이 좋다.

 

낙관적 락은 데이터베이스의 락 기능을 사용하지 않는다. 따라서 커밋 시 버전 관리를 통한 충돌이 일어나면 이에 대한 예외 처리를 개발자가 직접 작성해줘야 한다는 단점이 있다. 그러나 데이터베이스에 락을 걸지 않기 때문에 비관적 락보다 성능이 좋다. 

 

반면 비관적 락은 데이터베이스의 락 기능을 사용한다. 따라서 동시에 수정하는 일이 빈번할 때 락이 반납되어 획득될 때까지 기다렸다가 요청을 수행하기 때문에 순차적인 진행이 가능하다. 반면 락을 대기하기 때문에 성능이 낙관적 락보다 떨어진다.


Reference