본문 바로가기

항해 99/Spring

낙관적 락 & 비관적 락

트랜잭션 격리 수준

트랜잭션은 ACID(원자성, 일관성, 격리성, 지속성)을 보장해야 한다.

트랜잭션은 원자성, 일관성, 지속성을 보장하지만 문제는 격리성으로 트랜잭션간 완전한 격리를 보장하기 위해서는 동시성 측면세어 많은 손해를 보게 된다.

 

테이블에 따라서 ANSI 표준에서는 트랜잭션 격리 수준을 4단계로 구분하여 병행성과 격리성을 설정할 수 있는데, 격리성과 병행성은 서로 역비례 관계이므로 무턱대고 격리 수준을 최대로 높이게 되면 성능이 악화될 수 있으므로 적절한 격리 수준 설정이 중요하다.

 

하지만 이런 트랜잭션 격리 수준으로도 해결하지 못하는 문제가 존재한다.

 

두 번의 갱신 분실 문제(Second lost updates problem)

위키백과의 후디라는 문서를 두 유저가 동시에 편집하는 상황을 가정했을 때 최초 문서의 내용은 '후디는 개발자이다'이며, 첫 번째 유저는 이 내용을 '후디는 자바 개발자이다'로 변경하고 싶고, 두 번째 유저는 '후디는 스프링 개발자이다'로 변경하고 싶어한다.

 

유저 1과 유저 2가 동시에 수정화면에 진입해서 원하는 내용을 작성한 뒤 수정 버튼을 클릭했을 때 위 그림과 같이 유저 1의 트랜잭션이 먼저 커밋되고 그 이후에 유저 2의 트랜잭션이 커밋된다.

이 경우 유저 1의 트랜잭션의 변경 내용은 사라지게 되고, 유저 2의 트랜잭션만이 데이터베이스에 반영된다.

 

이 경우 처리 방법은 3가지가 있다.

  1. 마지막 커밋만 인정하기: 유저 1의 변경 내용을 무시하고, 마지막에 커밋한 유저 2의 내용만을 반영한다.
  2. 최초 커밋만 인정하기: 유저 1이 수정을 완료했으므로, 유저 2의 변경 사항에 대해 오류를 발생시킨다.
  3. 충돌하는 내용 병합하기: 유저 1과 유저 2의 변경 사항을 병합한다.

상황에 따라 위 3가지 방법 중 적절한 정책을 선택해야 하는데, 트랜잭션의 격리 수준으로는 '마지막 커밋만 인정하기'외의 정책을 구현할 수 없다.

 

두 번째 사례로는 동시성 이슈에 민감한 송금 관련 트랜잭션이다.

 

계좌에 10,000원이 있는 상황이다. 트랜잭션 1은 50,000원을 입금하고, 트랜잭션 2는 10,000원을 입금하는 상황이다.

입금을 위해서는 현재 계좌의 잔액을 읽어오고 입금액을 더한 값으로 잔액을 갱신해야 하는데, 트랜잭션 1이 먼저 시작되어 잔액 10,000원을 읽어왔고 근소한 차이로 뒤이어 트랜잭션 2가 시작되어 마찬가지로 잔액 10,000원을 읽어왔다.

트랜잭션 1은 읽어온 10,000원에 입금액 50,000원을 더한 60,000원을 잔액으로 갱신하고 커밋하고, 이후 트랜잭션 2는 읽어온 10,000원에 입금액 10,000원을 더한 20,000원으로 잔액을 갱신하고 커밋한다.

 

이 상황에서 트랜잭션 1의 송금 내역은 분실된다.

 

트랜잭션 격리 레벨로 해당 문제를 해결하려면 Serializable을 사용해야 하며, Serializable은 MySQL 기준으로 읽기 작업을 하는 데이터에 대해서도 shared lock을 걸어 일관된 읽기 뿐 아니라 실제 읽고 있는 데이터가 다른 트랜잭션에 의해 변경되지 않도록 보장할 수 있다.

 

위 상황에서는 두 트랜잭션이 같은 데이터에 대해 s-lock을 걸고 그 직후 서로 x-lock을 거는 상황이 되어 데드락이 발생하게 된다(s-lock과 x-lock은 양립할 수 없다). 

두 트랜잭션은 x-lock을 걸기 위해 서로가 s-lock을 해제하는 시점을 무한히 대기하며 타임아웃이 된다.

 

위 같은 문제를 두 번의 갱신 분실 문제라고 부르며 이 경우 트랜잭션으로 처리할 수 있는 범위를 넘어서기 때문에 별도의 방법이 필요하다.

 

낙관적 락과 비관적 락

JPA는 데이터베이스에 대한 동시 접근으로부터 엔티티에 대한 무결성을 유지할 수 있게 해주는 동시성 제어 매커니즘을 지원하며, 이 매커니즘에는 낙관적 락과 비관적 락이 존재한다.

JPA는 데이터베이스의 트랜잭션 격리 레벨을 READ COMMITTED 정도로 가정한다.

 

낙관적 락(Optimistic Lock)

대부분의 트랜잭션이 충돌이 발생하지 않을 것이라고 낙관적으로 가정하는 방법으로 데이터베이스가 제공하는 락 기능을 사용하지 않고, 엔티티의 버전을 통해 동시성을 제어한다.

즉, 애플리케이션 레벨에서 지원하는 락이다.

 

@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;
}

 

위 Board 엔티티 변경될 때마다 version이 자동으로 하나씩 증가하며, 엔티티를 수정할 때 엔티티를 조회한 시점의 버전과 수정한 시점의 버전이 일치하지 않으면 예외가 발생한다.

 

낙관적 락을 사용하면 '최초 커밋만 인정하기' 정책을 구현할 수 있다.

  • 두 번의 갱신 분실 문제 첫 사례를 예로 들면 트랜잭션 1과 2가 조회한 엔티티 버전이 1일 때 트랜잭션 1이 트랜잭션 2보다 먼저 엔티티를 수정하고 커밋하면 엔티티는 버전 2가 되고, 트랜잭션 2가 수정한 시점의 버전과 조회한 시점의 버전이 불일치해 예외가 발생하게 된다.

버전 정보 비교 방법

JPA가 엔티티를 수정하고 트랜잭션을 커밋하는 시점에, 영속성 컨텍스트를 flush 하면서 아래의 UPDATE 쿼리를 실행한다.

UPDATE BOARD
SET
  title = ?,
  version = ? # 버전 + 1 증가
WHERE
  id = ?,
  and version = ? # 버전 비교

 

위와 같이 데이터가 수정되었을 때, 엔티티 버전 정보를 증가시킨다. 위 쿼리에서 WHERE 절에서 엔티티 조회 시점의 버전으로 데이터를 찾는 조건을 볼 수 있다.

만약 데이터 조회 이후 엔티티가 수정되었다면 위 WHERE 문으로 엔티티를 찾을 수 없으며 이때 JPA가 예외를 던진다.

 

주의점

Embedded 타입의 경우 논리적으로 해당 엔티티의 값이므로 수정하면 엔티티의 버전이 증가한다. 반면 연관관계 필드의 경우 연관관계  필드의 경우 연관관계의 주인 필드를 변경할 때에만 버전이 증가한다.

 

또, @Version으로 추가한 버전 관리 필드는 JPA가 직접 관리하므로 임의로 수정해서는 안 된다. 그런데 벌크 연산의 경우 버전을 무시하므로, 벌크 연산을 수행할 때에는 아래와 같이 버전 필드를 강제로 증가시켜야 한다.

update Member m set m.name = '변경', m.version = m.version + 1

 

낙관적 락의 LockModeType

LockModeType을 통해서 락 옵션을 변경할 수 있다.

  • NONE: 별도로 락 옵션을 지정하지 않아도 엔티티에 @Version을 적용하면 기본으로 적용되는 락 옵션이다
    • 용도: 조회한 엔티티를 수정하는 시점에 다른 트랜잭션으로부터 변경(또는 삭제)되지 않음을 보장한다(조회 시점부터 수정 시점까지를 보장한다).
    • 동작: 엔티티를 수정하는 시점에 엔티티의 버전을 증가시킨다. 이때 엔티티의 버전이 조회 시점과 다르다면 예외가 발생한다.
    • 이점: 두 번의 갱신 분실 문제를 해결한다.
  • OPTIMISTIC: NONE의 경우 엔티티를 수정해야 버전을 체크하지만, 이 옵션은 엔티티를 조회만 해도 버전을 체크한다.(한 번 조회한 엔티티가 트랜잭션 동안 변경되지 않음을 보장한다)
    • 용도: 엔티티의 조회 시점부터 트랜잭션이 끝날 때까지 다른 트랜잭션에 의해 변경되지 않음을 보장한다.
    • 동작: 트랜잭션을 커밋하는 시점에 버전 정보를 체크한다.
    • 이점: 애플리케이션 레벨에서 DIRTY READ와 NON- REPEATABLE READ를 방지한다.
  • OPTIMISTIC FORCEINCREMENT
    • 낙관적 락을 사용하면서 버전 정보를 강제로 증가시킨다. 엔티티가 물리적으로 변경되지 않았지만, 논리적으로는 변경되었을 경우 버전을 증가하고 싶을 때 사용한다.
      • 예를 들어 게시물과 첨부파일 엔티티가 1:N 관계로 있다고 가정할 경우, 게시물에 첨부파일이 하나 추가된 상황은 게시물 엔티티의 물리적 변경은 일어나지 않았지만, 논리적인 변경은 일어났으므로 이때 버전을 변경하고 싶으면 해당 락 옵션을 사용하면 된다.
    • 용도: 논리적인 단위의 엔티티 묶음을 관리할 수도 있다.
    • 동작: 엔티티가 직접적으로 수정되지 않아도, 트랜잭션을 커밋할 때 UPDATE 쿼리를 사용해 버전 정보를 강제로 증가시킨다. 이때 엔티티의 버전을 체크하고 일치하지 않으면 예외가 발생한다(추가적으로 엔티티의 정보도 실제로 변경되었으면 2번의 버전 증가가 발생한다).
    • 이점: 강제로 버전을 변경하여 논리적인 단위의 엔티티 묶음을 버전관리할 수 있다.

 

비관적 락(Pessimistic Lock)

비관적 락은 실제로 데이터베이스의 락을 사용하여 동시성을 제어하는 방법이다.주로 쿼리에 SELECT ... FOR UPDATE 구문을 사용하고, 버전 정보는 사용하지 않는다. 락을 직접 걸기 때문에 두 가지 특징이 있다.

  1. 엔티티가 아닌 스칼라 타입을 조회할 때도 사용할 수 있다.
  2. 데이터를 수정하는 즉시 트랜잭션의 충돌을 감지할 수 있다.

비관적 락의 LockModeType

  • PESSIMISTIC_WRITE : 비관적 락의 일반적인 옵션
    • 용도/동작 : 데이터베이스에 SELECT ... FOR UDADTE를 사용하여 배타 락을 건다.
    • 이점 : NON-REPEATABLE READ를 방지한다.
  • PESSIMISTIC_READ : 데이터를 반복 읽기만 하고 수정하지 않을 때 사용한다. 일반적으로 잘 사용하지 않는다(데이터베이스 대부분은 방언에 의해 PESSIMISTIC_WRITE로 동작한다.
    • 동작 : SELECT ... FOR SHARE (LOCK IN SHARE MODE)
  • PESSIMISTICEFORINCREMENT
    • 비관적 락 중 유일하게 버전 정보를 사용한다. 비관적 락이지만 버전 정보를 강제적으로 증가시킨다. 하이버네이트의 경우 nowait를 지원하는 데이터베이스에 대해서 FOR UPDATE NOWAIT 옵션을 적용하고, 그렇지 않다면 FOR UPDATE를 적용한다.

낙관적 락과 비관적 락 장단점 비교

낙관적 락(Optimistic Lock)

  • 장점
    • DB 단에서 별도의 Lock을 설정하지 않기 때문에 하나의 트랜잭션 작업이 길어질 때 다른 작업이 영향받지 않아서 성능이 좋을 수 있다.
  • 단점
    • 버전이 맞지 않아서 예외가 발생할 때 재시도 로직을 구성해야 한다.
    • 버전이 맞지 않는 일이 여러 번 발생한다면 재시도를 여러 번 거치기 때문에 성능이 좋지 않다.
    • 충돌이 발생했을 때 롤백을 수동으로 해야 한다.

비관적 락(Pessimistic Lock)

  • 장점
    • Race Condition이 빈번하게 일어난다면 낙관적 락보다 성능이 좋다.
    • DB 단의 Lock을 통해서 동시성을 제어하기 때문에 확실하게 데이터 정합성이 보장된다.
  • 단점
    • DB 단의 Lock을 설정하기 때문에 한 트랜잭션 작업이 정상적으로 끝나지 않으면 다른 트랜잭션 작업들이 대기해야 하므로 성능 저하가 발생한다.
    • 읽기가 많이 이루어지면 성능상 문제가 발생할 수 있다.

 

동시성 제어 메커니즘과 트랜잭션 격리 수준의 차이점

JPA의 동시성 제어 메커니즘은 특정 엔티티에 대한 동시 접근을 막기 위해 사용하지만, 트랜잭션 격리 수준은 트랜잭션 동안의 일관된 데이터 읽기를 고려하기 위해 적용한다.

 

DBMS에 따라 격리 레벨에 대한 세부 구현은 다르겠지만, 대부분의 데이터베이스는 트랜잭션 격리 레벨을 구현할 때 락을 사용하지 않는다고 한다.

MySQL의 경우 트랜잭션 내부에서 일관된 읽기를 구현하기 위해 락 대신 MVCC(Multi Version COncurrency Control)을 사용한다.

특별한 예외로는 SERIAILIZABLE은 조회 중인 데이터를 다른 트랜잭션이 변경하려고 할 때 락을 획득한다.

 

반면 낙관락/비관락은 그 관심사가 엔티티에 대한 동시 접근에 대한 처리로 한 트랜잭션이 특정 엔티티에 접근하고 있을 때 다른 트랜잭션이 해당 엔티티를 변경할 수 없도록 버전을 사용하거나 락을 걸어 해결한다.

즉, 트랜잭션 격리 수준과는 관계가 없다.

'항해 99 > Spring' 카테고리의 다른 글

POJO  (0) 2024.04.22
Spring boot 모니터링 with Prometheus, Grafana  (1) 2024.04.20
Spring Actuator  (1) 2024.04.18
즉시로딩, 지연로딩, N+1 문제  (0) 2024.04.13
Spring PSA  (0) 2024.04.12