database by narae :p

user level lock 본문

개발 노트

user level lock

dbbymoon 2019. 6. 12. 17:28

트랜잭션 격리 수준 

애플리케이션에서 여러 트랜잭션이 동시에 같은 데이터를 대상으로 작업을 수행할  여러 트랜잭션이 다른 트랜잭션과 어떻게 격리되어야 하는지 분명하게 지정해야 한다. 

 

 READ UNCOMMITTED

  • 트랜잭션 A가 특정 컬럼 데이터를 변경하고 있는 중에(커밋되지 않은 상태) 다른 트랜잭션 B가 read하면 트랜잭션 A가 변경한 데이터(커밋되지 않은 데이터)를 읽어온다.
  • DIRTY READ : 트랜잭션 B가 읽어온 데이터를 사용하는데, 트랜잭션 A가 변경한 데이터를 롤백하면 데이터 정합성에 문제가 발생한다. 

 

 READ COMMITTED

  • 트랜잭션 A가 특정 컬럼 데이터를 변경하고 있는 중에(커밋되지 않은 상태) 다른 트랜잭션 B가 read하면 트랜잭션 A가 변경하기 전 데이터(커밋된 데이터)를 읽어온다. 만약 트랜잭션 A가 데이터를 변경하고 커밋하게 되면 트랜잭션 B는 변경된 데이터를 읽어온다.
  • NON-REPEATABLE READ : 트랜잭션 B가 조회 중인데 트랜잭션 A가 다시 수정하고 커밋하면, 트랜잭션 A가 같은 컬럼을 다시 조회했을 때 수정된 데이터가 조회된다. 반복해서 같은 데이터를 읽을 수 없는 문제가 발생한다.

 

 REPEATABLE READ

  • 한 트랜잭션이 특정 컬럼을 여러 번 읽어도 한 트랜잭션 내부에서는 항상 동일한 값을 읽도록 보장한다. 조회 트랜잭션이 지속되는 동안에는 다른 트랜잭션이 해당 컬럼을 변경할 수 없다.
  • PHANTOM READ : 트랜잭션 A가 테이블에서 여러 로우를 읽은 후 트랜잭션 B가 같은 테이블에 여러 로우를 새로 추가했을 때, 트랜잭션 A가 처음 읽었던 로우들과 달리 새로 추가된 로우가 있음을 감지할 수 있다. 

 

 SERIALIZABLE

  • 전체 테이블에 읽기 잠금을 걸어 모든 문제를 해결
  • 동시성이 가장 낮아, 동시성 처리 성능이 떨어진다.

 

격리 수준이 높을수록 위에서 발생할만한 문제들을 해결할 수 있지만, 그만큼 트랜잭션 동시성이 떨어져 성능이 저하된다. 

 

MySQL의 트랜잭션 격리 수준은 기본적으로 REPEATABLE READ로 설정되어 있고, 대부분의 데이터베이스는 일반적으로 READ COMMITTED로 설정되어 있다. 따라서, 설정된 격리 수준과 락을 함께 사용해 발생할 만한 문제를 방지한다.

 

이 때, JPA는 2개의 락을 제공하는데 이는 낙관적 락, 그리고 비관적 락이다.

 

 

 낙관적  

  • 트랜잭션 대부분은 충돌이 발생하지 않는다고 가정하고, 읽는 시점에는 락을 걸지 않고 수정할 때 검사한다.
  • 트랜잭션을 커밋하기 전까지는 트랜잭션의 충돌을 알 수 없다.
  • JPA 제공하는 버전 관리 기능 사용 (애플리케이션이 제공하는 
  • @Version  
    • 엔티티의 버전 관리용 필드 추가 후 어노테이션을 붙이면 엔티티가 수정될 때 자동으로 버전이 하나씩 증가한다.
    • 엔티티를 수정할 때, 조회 시점의 버전과 수정 시점의 버전이 다른 예외가 발생한다.
    • 예를 들어, 트랜잭션 A가 조회한 엔티티를 수정하고 있는데, 트랜잭션 B에서 같은 엔티티를 수정하고 먼저 커밋해서 version이 증가해버리면, 트랜잭션 A가 커밋할 때 version 정보가 다르므로 예외가 발생한다.
    • 먼저 커밋한 update문만 적용되므로, 기본적으로 최초 커밋만 인정된다.
    • ex : 실제 update 문 (memberId = 1 인 경우, 현재 컬럼의 version이 1인 경우)

       

       

 비관적 

  • 트랜잭션 충돌이 발생한다고 가정하고, 읽는 시점부터 락을 걸고 조회, 갱신이 완료될 때까지 락을 유지한다.
  • 데이터베이스가 제공하는 락 기능 사용
  • ex) select for update 구문
  • 데이터베이스 트랜잭션 범위를 넘어서는 문제 =>  번의 갱신 분실 문제
    • 마지막 커밋만 인정하기
    • 최초 커밋만 인정하기
    • 충돌하는 갱신 내용 병합하기

 

 

 

JPA와 스프링 트랜잭션에 대해 공부하며, 트랜잭션 격리 수준과 낙관적 락, 비관적 락에 대해 살펴 보고 제 가계부 프로젝트에서 트랜잭션 동시성 문제가 발생할만한 상황을 찾아보았습니다.

 

 

거래 추가 및 가계부 업데이트

 

저는 가계부 테이블과 해당 가계부에 속하는 거래 테이블을 두었습니다. 

가계부에는 총 지출액, 총 수입액, 총 저축액 이라는 컬럼이 있습니다. 따라서, 거래를 추가할 때마다 거래 타입에 해당하는 가계부의 컬럼의 데이터를 업데이트 해야 했습니다.

 

다음은 TransactionService의 addTransaction 메소드 입니다. 

저는 가계부를 업데이트하는 updateAccountBook(), 거래를 추가하는 transactionRepository.save() 를 하나의 트랜잭션으로 선언했습니다.

 

 

 

 

updateAccountBook 메소드 내부를 살펴보면, 

 

 

위와 같이 

1) 가계부의 최신 상태 가져오기 

2) 가계부 업데이트

3) 업데이트한 가계부 저장

를 수행합니다.

 

 

이 addTransaction이라는 트랜잭션을 테스트하기 위해 저는 IntegrationTest를 작성했습니다.

 

테스트 작성

 

 일반적인 상황에 대한 테스트

일반인 상황을 생각한다면 다음과 같이 두 번의 장부 기입이 

첫 번째 장부 기입(수입 1000원 => 가계부 총 수입액 1000원)을 수행하고 완료되면, 

두 번째 장부 기입(수입 2000원 => 가계부 총 수입액 3000원)을 수행합니다. 

따라서, 다음 테스트 케이스는 당연히 성공합니다.

 

 

 

 

 

 멀티 스레드를 이용한 동시성 테스트 

 

하지만 동시성 문제를 테스트하기 위해, 다음과 같이 addTransaction에서 가계부 업데이트 후에 잠시 현재 스레드를 sleep 시켜 놓았습니다.

그리고 updateAccountBook 메소드 내부에는 다음과 같이 최신 가계부를 가져와서, 얼마 업데이트를 했는지 출력할 수 있도록 했습니다.

 

 

이렇게 한 뒤에, 테스트 케이스를 작성할 때는 두 개의 스레드를 만들고, Thread1을 먼저 실행시킵니다. 이 Thread1은 실행하고 updateAccountBookd을 수행한 후에 잠시 sleep하는 시간이 있을 것입니다.

 

하지만 정상적으로 우리가 원하는대로 동작하는 것은 Thread1의 트랜잭션을 마친 후에 Thread2가 Thread1이 업데이트한 가계부를 정상적으로 가져와 트랜잭션을 수행하는 것입니다.

 

테스트 실행 후 결과를 확인해 보니,

Thread1이 가계부를 업데이트하고 sleep에 들어갑니다.

 

그런데 이 때, Thread2는 Thread1의 트랜잭션이 아직 커밋되지 않았음에도 불구하고 다음과 같이 Thread1이 깨어 트랜잭션을 마칠때까지 기다리지 않고 Thread2가 실행됩니다.

 

 

Thread1의 트랜잭션이 커밋되지 않았으니 당연히 Thread2는 그 이전의 가계부 상태(income=0)를 가져와 그 상태에서 2000원을 업데이트하게 됩니다. 정상적인 상황이라면 income이 1000이고 업데이트를 해서 3000원이 되어야 하는데 말입니다.

 

거래는 이렇게 두 개가 정상적으로 들어가 있지만, 총 income은 두 번째 거래인 2000원만 반영되어 있습니다.

 

 

 

 

따라서, 트랜잭션이 커밋되지 않았을 때는 아예 AccountBook 테이블의 해당 가계부 로우에 접근할 수 없도록 Row Level Lock을 걸게 되었습니다.

 

 

 

User Level Lock

 

User Level Lock 이란 사용자가 특정 문자열에 거는 Lock 입니다. 

 

 

 GET_LOCK & RELEASE_LOCK

이 User Level Lock의 Locking Function에는

 

  • GET_LOCK(str, timeout) : 문자열 str 에 해당하는 Lock 획득



    • Lock 획득 성공 시 return 1
    • Lock 획득 실패 시 return 0
    • 에러 발생 시 return null

 

  • RELEASE_LOCK(str) : 문자열 str에 걸려있는 Lock 해제



    • Lock 해제 성공 시 return 1
    • Lock 해제 실패 시 return 0
    • 에러 발생 시 return null
  •  

가 있습니다. 이외에도 IS_FREE_LOCK과 같이 Lock이 해제되어 있는지 묻는 함수도 있지만, GET_LOCK과 RELEASE_LOCK만 이용하게 되었습니다.

 

GET_LOCK을 이용하기 위해서는 SELECT GET_LOCK(str, timeout) 이라는 쿼리를,

RELEASE_LOCK을 이용하기 위해서는 SELECT RELEASE_LOCK(str) 이라는 쿼리를 통해 사용할 수 있습니다.

따라서, 다음과 같이 preparedStatement를 통해 MySQL에서 쿼리를 실행할 수 있게 합니다. 

 

 

 

 

 transactionServiceWithLock

그리고 getLock()과 releaseLock()이라는 메소드를 이용해, 다음과 같이 transactionServiceWithLock이라는 메소드를 작성합니다.

getLock을 수행하고, 원하는 함수(여기에서는 addTransaction)를 실행하고, relaseLock을 수행합니다.

이 메서드는 거래 추가, 거래 수정, 거래 삭제와 같이 가계부 업데이트와 함께 수행되는 거래 관련 서비스에 사용할 것입니다.

 

 

 

 transactionServiceWithLock을 적용한 메소드 호출

transactionService의 addTransaction을 호출하던 원래 코드입니다.

 

이를, 다음과 같이 transactionWithLock과 함께 addTransaction을 호출합니다.

 

 

저는 AccountBook 테이블 전체를 잠그려는 것이 아니라, AccountBook 테이블의 하나의 가계부 로우를 잠그려는 것입니다.

따라서, User Level Lock 의 잠금을 위한 문자열은 accountBookId로 하였습니다. 

그리고 timeout은 잠금 획득(GET_LOCK)을 시도하는 시간입니다. 이 시간만큼 잠금을 획득할 때까지 대기합니다.

그리고 다음으로는 User Level Lock과 addTransaction을 함께 실행할 수 있게 합니다.

 

 

User Level Lock을 적용한 동시성 테스트

앞서 실행했던 동시성 테스트와 같은 환경에서, 함수를 호출하는 부분만 User Level Lock 과 함께 addTransaction을 호출합니다.

 

 

 

 

실행 결과

실행 결과는 아까와는 달리,

 

Thread1이 가계부를 업데이트하고 sleep 하는 동안 Thread2가 끼어들지 않고, Thread1은 깨어납니다. 그리고 Transaction 테이블에 거래를 추가하고 Thread1의 트랜잭션을 커밋하게 됩니다.

 

그러면 그 이후에,

 

Thread2는 Thread1이 커밋한 업데이트된 가계부 상태(income=1000)를 받아와 정상적으로 가계부를 업데이트하고 거래를 추가합니다.

 

이는, Thread1의 addTransaction이라는 트랜잭션이 UserLevelLock과 함께 실행되었기 때문에 트랜잭션을 마치지 않으면 Lock은 잠금 상태입니다.

따라서, Thread1이 sleep하여 Thread2가 실행되었는데 잠금이 되어있기 때문에 아직 accountBook을 가져올 수 없습니다.

그래서 Thread1의 트랜잭션을 마치고 커밋이 된 후에 accountBookId에 해당하는 User Level Lock의 잠금이 해제된 다음,

Thread2는 실행하기 때문에 Thread2에서는 업데이트된 가계부 최신 상태를 정상적으로 받아올 수 있습니다.