본문 바로가기

IT/JPA

JPA의 낙관적 락과 비관적 락을 통해 엔티티에 대한 동시성 제어에 대해 알아보자

오늘은 Transaction isolation level에 대해 알아보겠습니다.

트랜잭션 격리수준이란 동시에 여러 트랜잭션을 처리할 때 트랜잭션이 얼마나 서로 고립되어 있는지를 의미합니다. 즉 해당 트랜잭션이 다른 트랜잭션에서 변경한 데이터를 볼 수 있는 기준을 결정하는 것입니다.

- 트랜잭션 레벨에서의 격리

  • READ UNCOMMITED
    • READ_UNCOMMITTED 는 가장 낮은 격리 수준이며 가장 많은 동시 액세스를 허용합니다.
    • 커밋되지 않은 데이터 읽기 가능
    • 반복 불가능한 읽기와 팬텀 읽기가 모두 발생할 수 있습니다.
      따라서 행을 다시 읽거나 범위 쿼리를 다시 실행할 때 다른 결과를 얻을 수 있습니다.
  • READ COMMITTED
    • 커밋된 데이터만 읽기 가능
    • Oracle DBMS에서 표준으로 사용합니다.
    • Lost Update, Write Skew, Read Skew가 발생할 수 있습니다.
  • REPEATABLE READ
    • Mysql에서는 트랜잭션마다 트랜잭션 ID를 부여하여 트랜잭션 ID보다 작은 트랜잭션 번호에서 변경한 것만 읽게 됩니다.
    • Undo 공간에 백업을 해두고 실제 레코드를 반영합니다.
      • 백업된 데이터는 주기적으로 삭제됩니다.
    • Phantom Read가 발생할 수 있습니다.
      • 다른 트랜잭션에서 수행한 변경 작업에 의해 레코드가 보였다가 안 보였다가 하는 현상이 발생할 수 있습니다.
      • InnoDB에서는 넥스트 키 락을 이용해서 Phantom Read 이슈를 해결합니다.
      •  
  • SERIALIZABLE

- 트랜잭션의 레벨로만 처리할 수 없는 범위

  • 두번의 갱신에 의한 분실 문제 발생 (second lost updates problem)

트랜잭션의 격리 수준으로는 해결할 수 없는 문제가 존재합니다. 아래 예에서 그 발생상황을 알아 보겠습니다.

저의 집 강아지 이름은 곰이입니다.

곰이는 포매라니언이고요. 데이터베이스에 곰이의 description에 곰이는 포매라니언이다라고 입력되어 있는 상황에서 곰이는 푸들이다라고 두명의 수정자에게서 동시 수정되는 상황을 예로 들어 보겠습니다.

위에 표를 보시면 알수 있듯이 동시성 문제가 발생합니다.

위의 예는 단순 데이터의 문제지만 금전적으로 연결되는 데이터라면 문제는 심각해집니다.

극단적인 예를 한번 들어 보겠습니다.

  • 내 계좌에 1000,000원이 있습니다.
  • 위 표와 마찬가지로 2개의 트랜잭션이 있습니다. 하나는 500,000원을 입금하는 UPDATE쿼리를 날리고
    두번째 트랜잭션은 1000,000원 Select한 후 5000원을 입금하는 쿼리입니다.

여기서 동시성 이슈가 발생해 500,000입금 UPDATE는 날라가고 내 계좌의 1005,000원이 된다면 생각만해도 엄청난 문제겠죠

생각해보면 이 문제를 해결할 방법은 당연한 얘기겠지만 첫 UPDATE에 대한 커밋만 인정하든지 마지막 커밋만 인정하든지 아니면 병합을 하든지해서 해결이 가능합니다.

하지만 위의 트랜잭션 레벨의 격리로는 마지막 커밋만 인정하기 외의 정책은 불가합니다.

트랜잭션 격리 레벨로 위 해결에 대해 생각해 보시겠습니다.

REPEATABLE_READ 격리

  • REPEATABLE_READ
    • 업데이트 손실을 방지하기 위해 필요한 가장 낮은 수준입니다.
    • 더티 및 반복 불가능한 읽기를 방지합니다.
    • 행에 대한 동시 엑세스를 전혀 허용하지 않습니다.

REPEATABLE_READ 는 Mysql의 기본 수준입니다. 오라클은 REPEATABLE_READ 를 지원하지 않습니다 .

SERIALIZABLE 격리

  • SERIALIZABLE
    • SERIALIZABLE은 가장 높은 격리 수준입니다.
    • 언급된 모든 동시성 부작용을 방지하지만 동시 호출을 순차적으로 실행하기 때문에 동시 액세스 비율이 가장 낮을 수 있습니다.
    • 직렬화 가능한 트랜잭션 그룹의 동시 실행은 직렬로 실행하는 것과 동일한 결과를 가집니다.

트랜잭션 레벨의 REPEATABLE_READSERIALIZABLE 동시성 이슈는 해결될수 있으나 데드락이 발생하게 되기 때문에 정상적인 해결 방법은 아닙니다.

- 낙관적 락과 비관적 락

JPA는 DB에 대한 동시 접근으로부터 Entity의 무결성을 유지할 수 있는 동시성 제어 메커니즘을 제공합니다.

낙관적 락 (Optimistic Locking in JPA)

한국어 용어 번역이 어색하긴 하지만 말 그대로 대부분의 트랜잭션이 충돌을 일으키지 않을 것이라고 낙관적으로 가정합니다

이 방법은 DB가 제공하는 락 기능을 사용하지 않고, 엔티티의 버전을 통해 동시성을 제어합니다. 애플리케이션 레벨에서 지원하는 락입니다.

낙관적 잠금을 사용하려면 @Version 주석이 있는 속성을 포함하는 엔티티가 있어야 합니다. 이를 사용하는 동안 데이터를 읽는 각 트랜잭션은 버전 속성의 값을 보유합니다.

트랜잭션이 업데이트를 수행하기 전에 version속을 다시 확인합니다.

그 사이에 값이 변경되면 OptimisticLockException 이 발생합니다. 그렇지 않으면 트랜잭션이 업데이트를 커밋하고 값 버전 속성을 증가시킵니다.

@Version

version 속성은 낙관적 잠금을 활성화하는데 필요합니다.

@Entity
class Gommi(
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long,
      val name: String,

      @Version
      val version: Int
)

Version속성의 규칙

  • 각 엔티티 클래스에는 Version특성이 하나만 있어야 한다.
  • 여러 테이블에 매핑된 엔티티의 기본 테이블에 배치해야 합니다.
  • 버전 속성의 유형은 int , Integer , long , Long , short , Short , java.sql.Timestamp 중 하나여야 합니다 .

엔티티를 통해 version 속성의 값을 검색할 수 있지만 이를 업데이트하거나 증가시켜서는 안 된다는 것을 알아야 합니다.

영속성 제공자만이 이를 수행함으로써 일관성이 유지됩니다.

여기서 위의 표에서 곰이 설명 텍스트를 변경하는 부분에 대해서 낙관적 락으로 해결하는 법에 대해 생각해 보겠습니다.

  1. 트랜잭션 A, 트랜잭션 B를 조회하는 시점의 Version은 V1이라고 하겠습니다.
  2. 트랜잭션 A가 트랜잭션 B보다 먼저 엔티티를 수정하고 커밋했다. 엔티티는 V2이 됩니다.
  3. 트랜잭션 B가 엔티티를 수정하고 커밋하려고 하면 Version은 V2이기 때문에 버전의 불일치로 예외가 발생합니다.
  4. 이로 '최초 커밋만 인정하기' 설정을 적용할 수 있게 됩니다.

버전정보 비교

JPA가 엔티티를 수정, 커밋하는 시점에 Persistant Context를 flush하면서 Update 쿼리를 날립니다.

UPDATE Gommi
SET
  description = ?,
  version = ?
WHERE
  id = ?,
  and version = ?

업데이트 쿼리를 날릴때 버전정보가 조건이 됩니다. 당연하 버전이 증가되어 쿼리문은 쿼리를 날리지 못합니다.

주의할 점

Embedded타입은 값타입이므로 Update하면 Version속성값이 증가합니다.

반면 연관관계에 있는 필드의 경우 연관관계의 주인 필드를 변경할때만 버전이 증가한다는 것을 알아 두세요

그리고 벌크 업데이트 처리가 있을때는 version 필드를 강제적으로 업데이트 시켜 줘야 합니다.

UPDATE Gommi g
SET
  description = '사랑해 곰이야',
  g.version = g.version +1
//WHERE
  // 조건이 있어도 되고 없어도 됩니다.

낙관적 락 옵션변경

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

  • None
    • 조회한 엔티티를 수정하는 시점에 다른 트랜잭션으로부터 변경 또는 삭제 되지 않음을 보장합니다.
      조회 시점부터 수정 시점까지를 보장됩니다.
    • 엔티티를 수정하는 시점에 엔티티의 버전을 증가시켜 줍니다.
      수정시점에 엔티티의 버전이 조회 시점과 다르다면 예외가 발생합니다.
    • 두 번의 갱신 분실 이슈 해결이 가능합니다.
  • @Version 애노테이션만 적용시킨 옵션입니다.
  • OPTIMISTIC
    속성을 포함하는 모든 엔터티에 대해 낙관적 읽기 잠금을 얻습니다.
    이 옵션은 엔티티를 조회만 해도 버전을 체크합니다. 그래서 한번 조회한 엔티티가 트랜잭셩 동안 변경되지 않음을 보장합니다.
    • 앞서 말했듯이 엔티티의 조회 시점부터 트랜잭션이 끝날 때 까지 다른 트랜잭션에 의해 변경되지 않음을 보장합니다.
    • 트랜잭션을 커밋하는 시점에 버전정보를 체크합니다.
    • 애플리케이션 레벨에서 DIRTY READ와 NON-REPEATABLE READ를 방지해 줍니다.
  • OPTIMISTIC_FORCE_INCREMENT
    OPTIMISTIC 동일한 낙관적 잠금을 획득 하고 추가로 버전 속성 값을 증가시킵니다.
    결과적으로 UPDATE가 두번 일어나고 Version은 1이 아닌 2가 증가됩니다.
    • 논리적인 단위의 엔티티 묶음을 관리할 수 있습니다.
    • 엔티티가 직접적으로 수정되어 있지 않아도, 트랜잭션을 커밋할 때 UPDATE 쿼리를 사용해 버전 정보를 강제로 증가시키면서 엔티티의 버전을 체크하고 일치하지 않으면 예외가 발생합니다. 이때 위에서 말한대로 엔티티의 정보도 실제로 변경되었다면 2번의 버전 증가가 발생한다.
    • 강제로 버전을 변경하여 논리적인 단위의 엔티티 묶음을 버전관리할 수 있습니다.

비관적 락 (Pessimistic Locking in JPA)

데이터베이스에서 데이터를 검색할때 데이터베이스 메커니즘을 사용하는 비관적 락을 사용할 수 있습니다.

비관적 락을 사용하여 다른 트랜잭션이 예약된 데이터를 수정하거나 삭제할 수 없도록 하는 것입니다.

유지할 수 있는 락에는 배타적 락과 공유락 두 가지 유형이 있습니다. 다른 사람이 공유 락을 보유하고 있을 때 데이터를 읽을 수는 있지만 쓸 수는 없습니다. 예약된 데이터를 수정하거나 삭제하려면 배타적 락이 필요합니다.

배타 락은 쿼리로 보면 'SELECT ~ FOR UPDATE'로 나타낼 수 있습니다.

비관적 락의 LockModeType

1.LockModeType.PESSIMISTIC_WRITE

비관적 Lock을 말하면 해당 옵션을 의미합니다.

SELECT ~ FOR UPDATE

비관적 락중 UPDATE하기 위해서 SELECT를 한다. 이것은 이 데이터는 내가 조회해서 수정 중이기 때문에 건드릴 수 없습니다.라는 의미이며 동시성 제어를 위해 특정 데이터에 락을 건다는 의미입니다.

상품 구매시 재고수량의 예를 한번 들어 보겠습니다.

Lock이 걸지 않은 경우

먼저 락을 걸지 않은 경우에는 Dirty Read가 발생할 수 있습니다.

  1. 고객 A가 티셔츠를 구매하기 위해 SELECT 합니다. 이때 티셔츠 재고는 1입니다.
  2. Commit 전 상태
  3. 고객 B가 티셔츠를 구매하기 위해 SELECT 합니다. 이때 티셔츠 재고는 1입니다.
  4. 고객 A 구매 Commit
  5. 고객 B 구매 Commit

이때 고객 A, 고객 B 둘 다 구매진행을 했지만 둘 중 한명만 구매되게 됩니다. 위의 예에서는 재고 수량이 1이라

당연한 거겠지만 재고가 2라고 해도 한 명만 구매 되게 됩니다.

Select for Update로 락이 걸린 경우

  1. SELECT FOR UPDATE의 경우 고객 A가 SELECT FOR UPDATE 구문을 실행합니다.
  2. B는 배타 락으로 인해 해당 데이터에 접근할 수 없습니다.
  3. A의 트랜잭션이 완료 커밋 또는 롤백된 후 B에서는 해당 데이터로 접근할 수 있게 됩니다.

Select for Update경우 고객A가 트랜잭션의 완료된후에 고객 B가 해당 데이터에 접근이 가능하게 됩니다.

2. LockModeType.PESSIMISTIC_READ

해당 리소스에 공유 락을 걸게 됩니다. 다른 트랜잭션에서 읽기는 가능하지만 쓰기는 불가능해집니다

3. LockModeType.PESSIMISTIC_FORCE_INCREMENT

PESSIMISTIC_WRITE와 유사하게 작동하지만 추가적으로 낙관적 락처럼 버저닝을 하게 됩니다. 따라서 버전에 대한 칼럼이 필요합니다.

Hibernate에서 nowait를 지원하는 데이터베이서에 대해서는 FOR UPDATE NOWAIT를 그렇지 않는 경우 FOR UPDATE를 적용합니다.

JPA 동시성 제어 메커니즘 트랜잭션 격리 수준 요약

  • JPA의 동시성 제어 메커니즘은 특정 엔티티의 동시 접근을 막기 위해 사용합니다.
  • 트랜잭션 격리 수준은 트랜잭션 실행 동안의 일관된 데이터 읽기를 고려하기 위해 적용합니다.
  • 데이터베이스마다 격리 구현은 다르지만 LOCK대신 버저닝을 사용하는 경우가 많다. 단 Serializable은 조회 중인 데이터를 다른 트랜잭션이 변경하려고 할때 Lock을 사용합니다.
  • 낙관적 비관적 락은 그 관심사가 엔티티의 동시 접근 제어입니다. 트랜잭션이 특정 엔티티에 사용할때 다른 트랜잭션이 해당 엔티티를 변경할 수 없도록 락 또는 버저닝을 하여 제어합니다.

 

정리

오늘은 동시성 접근에 대한 제어에 대해 알아보면서 트랜잭션 레벨 수준과 같이 알아보고 그 해결법과 옵션에 대해 알아보았습니다.

어떤 비즈니스 로직이 됐던간에 동시성 이슈 문제는 무조건 있다고 생각되니 그에 대해 한번 알아두고 공부해두는 것이 좋을것 같습니다.