Programming/Spring Boot

SELECT FOR UPDATE / JPA를 사용한 비관적 잠금

Jan92 2022. 2. 6. 19:02

'동시성(Concurrency)'

웹 서비스에서는 다수의 사용자들이 데이터베이스에 동시에 접근하는 경우가 빈번하게 발생합니다.

때문에 데이터의 일관성에 대한 처리가 필요한데요. 이를 '동시성(Concurrency) 문제'라고 합니다. 동시성 문제란, 공통된 자원에 동시에 들어온 여러 개의 요청이 모두 읽고 쓰는 작업(Read -> Write)을 하려고 하는 경우에 발생할 수 있는 문제를 말합니다.

 

***

동시성 문제는 '완전한 해결'이 아닌 '적절한 해결(제어)'에 더 적합합니다.

'동시성'과 '일관성'은 하나가 증가하면 다른 하나는 감소하는 트레이드오프의 관계이기 때문에 해당되는 로직의 특성에 따라서 적절하게 균형을 설정하는 것이 중요합니다. 

 

 

 

'비관적 락(Pessimistic Lock)'

자원에 대한 동시 요청이 발생하여 일관성에 문제가 생길 것이라고 비관적으로 생각하고 이를 방지하기 위해 트랜잭션이 시작될 때 락을 먼저 거는 방식입니다.

 

비관적 락은 배타 락(exclusive lock)과 공유 락(shared lock)이라는 두 가지 옵션이 있는데요.

공유 락을 걸면 다른 트랜잭션에서 읽기는 가능하지만 쓰기가 불가능합니다. 반면 배타 락을 걸면 다른 트랜잭션에서 읽기와 쓰기가 모두 불가능합니다. 배타 락은 쿼리로 보면 'SELECT ~ FOR UPDATE'로 나타낼 수 있습니다.

 

 


 

 

'SELECT ~ FOR UPDATE'

비관적 락(Pessimistic Lock) 중 배타 락(exclusive lock)을 적용하는 쿼리로 'UPDATE를 하기 위해 SELECT를 한다.' 즉, '이 데이터는 내가 조회하여 수정 중이기 때문에 다른 사람은 건드릴 수 없다.'는 뜻이라고 할 수 있으며, 다르게 이야기하면 동시성 제어를 위해 특정 데이터에 대해서 Lock을 거는 방식입니다.

 

SELECT FOR UPDATE

먼저 락을 걸지 않은 경우 환경에 따라 아래 예시와 같이 'Dirty Read'가 발생할 수 있습니다.

 

1. A가 남은 좌석을 SELECT (= 남은 좌석 10개)

2. 아직 Commit 하지 않은 상태 (예약)

3. B가 남은 좌석을 SELECT, 남은 좌석이 10개로 조회됨

4. A가 좌석을 예약 (Commit)

5. B도 좌석을 예약 (Commit)

=> A, B 모두 좌석을 예약했지만 잔여 좌석은 10개에서 9개로 1개만 줄어드는 상황이 발생할 수 있습니다.  

 

하지만 SELECT FOR UPDATE의 경우 A가 SELECT FOR UPDATE 구문을 실행했을 때 B는 배타 락으로 인해 해당 데이터에 접근할 수 없으며, A의 트랜잭션이 완료(커밋 또는 롤백)된 후 B에서는 해당 데이터로 접근할 수 있게 됩니다.

 

 

***

Transaction을 시작한 뒤, FOR UPDATE 문을 사용하면 조회된 Row에 대해서는 Transaction이 종료(Commit or Rollback)될 때까지 CRUD가 차단됩니다. 

해당 Row에 대한 Access가 발생할 경우, 해당 Request에서는 Lock Wait라는 상황으로 응답하며, Transaction이 종료될 때까지 기다리도록 합니다.

(상황에 따라 Lock이 발생한 Row에 접근하기 위해 무한히 대기하는 상황이 발생하여 Deadlock을 발생시킬 수 있습니다.)

 

***

InnoDB에서는 기본적으로 행 단위의 잠금(Row Level Locking)이 발생합니다. 

즉, 한 테이블 내에서 다른 하나의 Row에 대한 CRUD 작업이 이뤄질 때, 해당 Row에 대한 접근만 불가능할 뿐 다른 Row에 접근하는 것은 허용됩니다. 이러한 특성으로 인해 다중 사용자 환경이 고려된 서비스에서는 일반적으로 InnoDB를 Storage Engine으로 많이 사용하고 있습니다.

 

 


 

 

'JpaRepository Interface를 사용하여 Lock을 거는 방법'

JPA의 기본 동작이 select -> update 이기 때문에 어떤 값을 동시에 여러 스레드에서 변경하려고 할 때 그 값의 적합성을 보장하기 어렵습니다. 때문에 JPA에는 세 가지 방식의 비관적 잠금 모드가 정의되어 있습니다.

(PESSIMISTIC_READ, PESSIMISTIC_WRITE, PESSIMISTIC_FORCE_INCREMENT)

 

public interface TransactionTestRepository extends JpaRepository<TransactionTest, Long> {
  @Lock(LockModeType.PESSIMISTIC_FORCE_INCREMENT)
  @QueryHints({@QueryHint(name = "javax.persistence.lock.timeout", value = "1000")})
  TransactionTest findByIdx(Long idx);
}

 

JpaRepository에서는 이러한 방식으로 Lock을 설정할 수 있으며, 각 옵션에 따른 내용은 다음과 같습니다.

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

  • @Lock(LockModeType.PESSIMISTIC_WRITE)
    해당 리소스에 배타 락을 걸게 됩니다. 다른 트랜잭션에서는 읽기와 쓰기 모두 불가능해집니다.

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

 

LockModeType.PESSIMISTIC_READ

(LockModeType.PESSIMISTIC_READ를 사용했을 때 쿼리문에서 lock in share mode가 붙어서 실행되는 것을 확인할 수 있습니다.)

 

 

LockModeType.PESSIMISTIC_WRITE

(LockModeType.PESSIMISTIC_WRITE를 사용했을 때 쿼리문에서 for update가 붙어서 실행되는 것을 확인할 수 있습니다.)

 

 

 

***

이때 주의할 점으로는 '@Lock 어노테이션이 붙은 메서드 호출은 JPA의 Transaction 내부에서 동작해야 한다는 것'입니다.

JpaRepository 인터페이스를 사용하는 경우 entityManager.getTransaction().begin() 메서드를 사용할 수 없기 때문에 @Transaction 어노테이션을 사용합니다.

@Transaction 어노테이션의 영역(Scope) 밖에서 @Lock 어노테이션이 붙은 메서드를 호출한다면 아래와 같은 에러를 만나게 됩니다.

javax.persistence.TransactionRequiredException: no transaction is in progress

 

***

또 한 가지는 사용하는 데이터베이스에 따라서 'javax.persistence.lock.timeout' 같은 옵션이 작동하지 않을 수 있다는 것입니다.

실제로 MariaDB에서는 해당 옵션이 작동하지 않았습니다.

 

spring.jpa.properties.javax.persistence.query.timeout=5000

timeout 시간을 주기 위해서는 properties 파일에 위와 같은 값을 직접 넣어주었을 때, value 값에 따라 다음과 같은 Exception이 발생하는 것을 확인하였습니다.

 

java.sql.SQLTimeoutException: (conn=408) Query execution was interrupted (max_statement_time exceeded)

 

 

'Lock을 거는 과정에서 발생할 수 있는 예외'

  • PessimisticLockException
    한 번에 하나의 Lock만 얻을 수 있으며, Lock을 가져오는데 실패하면 발생하는 예외입니다.
  • LockTimeoutException
    락을 기다리다가 설정해놓은 wait time을 지났을 경우 발생하는 예외입니다.

  • PersistanceException
    영속성 문제가 발생했을 때 발생하는 예외입니다.