본문 바로가기
JAVA/Spring & Java 학습 기록

트랜잭션 이해와 락

by 구본식 2023. 5. 24.

트랜잭션의 이해


트랜잭션(Transaction)이란, 데이터베이스의 상태를 변화시키는 하나의 논리적 기능을 수행하는 작업의 단위이다.

 

트랜잭션은 ACID라는 원자성, 일관성, 격리성, 지속성을 보장하게 된다. 각 특징의 의미를 하나씩 정리해보겠다.

 

원자성(Atomicity)

트랜잭션 내에서 실행한 작업들은 모두 성공하든지, 실패해야만 한다.

 

일관성(Consistency)

모든 트랜잭션은 데이터베이스의 상태를 일관성있게 유지해야된다. 데이터베이스 객체, 참조 무결성 제약조건을 만족해야하는 것이 예로 될 수 있다.

 

격리성(Isolation)

동시에 실행 중인 트랜잭션은 서로 영향을 미치지 않도록 격리된 환경에서 독립적으로 수행되어야 된다. 격리성은 동시성과 관련하여 성능 이슈가 있기 때문에 격리수준을 정할 수 있다.

 

지속성(Durabillity)

트랜잭션이 성공적으로 끝날 경우, 결과가 영구적으로 반영되어야 된다. 항상 결과가 기록되어야 한다는 것이다.

 

트랜잭션은 원자성, 일관성, 지속성은 보장한다. 하지만 트랜잭션 간 격리성을 완벽히 보장하기 위해선, 트랜잭션들을 직렬로 순서대로 실행시켜야 된다. 이러한 구조라면, 동시성 처리 성능이 매우 나빠질 것이다. 

 

이러한 문제로 인해 트랜잭션 경리 수준을 4가지 Level로 나누게 된다. 하나씩 정리해보자.

 

트랜잭션 격리 수준


4가지 경리 수준을 정리하기에 앞서 설명에 사용될 단어들의 의미를 살펴보도록 하자

  • Dirty Read : 트랜잭션1의 커밋되지 않은 데이터들을 트랜잭션2 에서 조회가 가능한 것을 의미한다.
  • Non-Repeatable Read: 같은 데이터를 반복해서 동일하게 읽을 수 없는 것을 의미한다.
  • Phantom Read: 반복 조회 시 결과 집합이 달라지는 것을 의미한다. 

 

1. READ UNCOMMITTED

격리 수준 중 가장 낮은 격리 수준으로, 말 그대로 커밋되지 않은 데이터들도 읽을 수 있는 것을 의미한다. 즉 Dirty Read를 허용하는 것을 의미한다.

 

이 격리 수준은 심각한 문제가 있는데, 만약 트랜잭션 2가 Dirty Read 한 데이터를 읽고 있는데 트랜잭션 1이 Dirty Read 한 데이터를 롤백하기 되면 데이터 정합성에 심각한 문제가 발생한다.

 

2. READ COMMITTED

보편적으로 데이터베이스가 가장 많이 사용하는 격리 수준이며, 의미 그대로 커밋된 데이터만 읽을 수 있는 것을 의미한다.

커밋되지 않은 즉 Dirty Read한 데이터들은 읽을 수 없으므로 Dirty Read는 발생하지 않지만, Non-Repeatable Read 현상이 발생할 수있다.

 

커밋되어 있는 Data1 를 트랜잭션1이 조회 중인 상황에, 트랜잭션2 가 Data1의 정보를 수정 후 커밋하게 되면, Data1에 대해서 트랜잭션1 이 재 조회할 경우 이전과 같은 결과를 얻을 수 없게 된다. 즉 같은 데이터를 반복해서 동일한 결과를 얻을 수 없게 된다.

 

3. REPEATABLE READ

같은 데이터를 반복 조회에도 동일한 결과를 얻을 수 있는 것을 의미한다. 하지만 이 격리 수준은 Phantom Read 현상이 발생할 수있다. 

 

만약 트랜잭션1이 특정 결과 집합을 조회했을 때, 트랜잭션2 가 해당 결과 집합에 데이터를 추가한 후 커밋하게 되면 트랜잭션1이 다시 조회했을 때 이전과 다른 결과 집합이 조회되게 된다.

 

4. SERIALIZABLE

격리수준 중 가장 엄격한 격리 수준에 해당된다. Phantom Read 현상이 발생하지 않지만, 트랜잭션을 마치 직렬화로 처리하기 때문에 동시성 처리에 성능이 급격히 떨어질 수 있다.

 

※ 참고로 격리 수준이 낮을 수록 성능이 뛰어나다.(동시성 처리가 뛰어나다)

 

낙관적 락


대부분 데이터 베이스에서 사용하는 트랜잭션은 READ COMMITTED을 사용한다. 하자민 비지니스 로직마다 조금 더 엄격한 격리 수준인 REPEATABLE READ와 같은 격리 수준이 필요할 때가 있을 것이다. 

 

이처럼, 데이터베이스에 설정된 격리 수준보다 더 높은 격리 수준이 필요할 때 JPA버전 관리 기술을 통해 낙관적 락이라는 방식을 제공해준다.

 

낙관적 락은 트랜재션이 충돌이 발생하지 않는다는 가정하에 진행되기 때문에 커밋 되기 전에 충돌 여부를 알 수 없고,

DB의 락 기능을 사용하는 것이 아니라 JPA가 제공하는 버전 관리 기능인 @Version을 사용하게 된다.

트랜잭션 간 충돌이 많이 발생하지 않는 환경에서 사용하기에 적합하다.

 

주위해야 할 점은, JPA가 제공해주는 기술이기 때문에 엔티티가 아닌 영속성 컨텍스트 관리를 받지 못하는 스칼라 값을 조회 시 해당 기술을 사용하지 못한다.

 

그럼, DB의 락을 거는거 보다 성능이 좋겠네?

그렇다. DB에 직접적인 락을 걸지 않고 어플리케이션을 통해 락 기능을 사용하기 때문에 성능 저하가 거의 없게 된다.

 

"두 번의 갱신 분실 문제"

두 트랜잭션이 같은 데이터를 변경 후 commit 했을 때, 한 트랜잭션의 결과만 남는 현상이다.
이 문제는 데이터베이스의 트랜잭션을 넘어선 범위 문제에 해당된다. 그렇기 때문에 트랜잭션 만으로 이를 해결할 수 는 없다.

이를 보통 해결하는 방법으로는 마지막 커밋만 인정하기, 최초 커밋만 인정하기, 충돌하는 갱신 내용 벙합하기 방법들이 있다.
기본은 마지막 커밋만 인정하기가 사용되지만 상황에 따라 최초 커밋만 인정하기가 더 적합할 수가 있다.
JPA가 제공하는 버전 관리 기술을 통해 이러한 방식들을 유연하게 구현할 수 있다.

 

낙관적 락을 사용하기 위해선 JPA가 제공하는 버전 관리 기술인 @Version을 사용해야 하기 때문에 간단히 동작 방식을 알아보도록 하겠다.

 

@Version


버전 관리 기술을 적용하기 위해선 엔티티에 버전 관리용 필드를 하나 추가하고 @Version 애노테이션을 붙힌다.

 

엔티티를 수정할 때마다 버전 필드의 값이 자동으로 증가한다.

엔티티를 조회할 때 시점의 버전 값수정할 때 시점의 버전 값이 다르게 되면 예외를 발생시키게 된다.

 

그림을 통해 동작 방식을 알아보자.

트랜잭션 1이 조회 한 엔티티를 조회한 후(version=1) 수정하고 있는 중에, 트랜잭션 2가 같은 엔티티를 수정 후 커밋한다.(version=2)

트랜잭션 1이 수정을 완료하고 커밋 시점에 version 정보가 일치하지 않아 예외가 발생한다.

 

그럼 트랜잭션 1은 수정 완료 후 커밋 시점에 version 정보를 어떻게 알까??

트랜잭션 1의 커밋 시점 발생하는 update 쿼리where 절을 통해 버전 비교가 이루어지고, set 절을 통해 일치하는 버전이 있다면 버전 증가를 하게 된다.

 

따라서, 버전 기술(@Version)을 사용하면 최초 커밋만 인정하기 때문에 두 번의 갱신 분실 문제를 예방할 수있다.

 

코드를 통해 테스트 해보도록 하자.

 

JPA 낙관적 락 사용 테스트


테스트에 앞서 낙관적 락을 사용할때,  JPA가 다양한 옵션을 제공해준다. 그 중 대표적인 것만 살펴보자.

락 모드 타입 설명
낙관적 락 OPTIMISTIC 낙관적 락 사용.
엔티티 조회만 해도 버전을 체크한다.
NON-REPEATABLE READ 방지 가능
비관적 락 PESSIMISTIC_WRITER 비관적 락, 쓰기 락 사용.
데이터베이스 select for update를 사용.
(조회 시 락을 거는 방식)

따라서,  NON-REPEATABLE READ를 방지
기타 NONE 락을 걸지 않는다.

조회 한 엔티티 수정할 때 버전을 체크한다.
따라서 두 번의 갱신 분실 문제 방지 가능

JPA가 제공하는 낙관적 락에서 발생하는 예외로는 아래와 같다.

JPA 예외 OptimisticLockException
하이버네이트 예외 StaleObjectStateException
스프링 예외 추상화 ObjectOptimisticLockingFilureException

 

NONE 옵션 테스트

해당 옵션은 엔티티를 수정해야지만 버전을 체크하는 방식이다

참고로 엔티티의 버전으로 사용할 필드에 @Version 어노테이션을 선언했다면  해당 옵션을 생략해도된다.

락 위치를 설정하는 곳이 다양하게 있지만, @NamedQuery에 선언하여 사용하는 방식으로 진행하겠다.

스레드 풀을 이용하여 멀티 스레드 방식을 통해 테스트 해보았다.

 

결과를 예상해보자면 2개의 스레드로 가정했을 때, 

각각의 스레드가 해당 데이터를 검색했을 때 초기 버전의 값이 1이라면, 하나의 스레드가 값을 수정 후 커밋했을 때 버전의 값이 2로 증가할 것이다. 다른 스레드도 수정 후 커밋 시점에 초기 버전의 값과 달라 ObjectOptimisticLockingFilureException예외가 발생할 것이다.

 

 

 

앞서 설명한 @Version의 동장 방식 처럼,

각각의 스레드의 update 쿼리버전 비교버전 증가가 이루어지는 것을 볼 수 있다.

 

하지만, 두번째 스레드의 update 쿼리 시 초기 검색 버전와 일치하지 않았고,

충돌 감지 메시지가 출력된 것을 보아 ObjectOptimisticLockingFilureException 예외가 발생한 것을 알 수 있다. 

 

그리고 DB의 version 값을 확인했을 때 수정 사항을 최초 커밋한 스레드만 인정되어

version 값이 1 증가한 것 을 볼 수 있다.

 

 

 

 

 

 

 

OPTIMISTIC 옵션 테스트

해당 옵션은 엔티티를 조회만 해도 버전을 체크하는 방식이다. 그러므로 NONE(@Version 기술만 사용) 옵션과 동작 방식에 차이가 있다.

OPTIMISTIC 옵션에서는 엔티티를 조회한 트랜잭션이 커밋 시점에 버전 정보를 조회하는 별도의 쿼리를 통해 버전 비교가 이루어지며, 같지 않을 시 예외가 발생한다.

 

직접 코드를 통해 테스트 해보자.

마찬가지로 @NamedQuery에 선언하여 진행해보았다.

스레드1은 데이터를 OPTIMISTIC 조회하고 난 뒤, Sleep()를 이용해서 commit 전 스레드 2를 통해해당 데이터가 변경되도록 했다.

결과를 한번 보자.

스레드 1의 트랜잭션이 커밋전 실제 version을 조회하는 쿼리가 발생하는 것을 볼 수있다.

또한, 조회 한 데이터 버전과 커밋 전 데이터 버전이 일치하지 않아 ObjectOptimisticLockingFilureException예외가 발생하는 것을 볼 수 있다.

 

비관적 락


데이터베이스가 제공하는 락 기능을 사용하는 방식이다. DB select for update 구문을 사용하며, 버전 정보는 관리하지 않는다.

또한, 데이터를 수정만 해도 즉시 트랜잭션 충돌을 알 수 있으며, 락을 획득할 때까지 나머지 트랜잭션들은 대기하게 된다. 

 

그리고 낙관적 락과 다르게 DB의 락 기능을 사용하기 때문에, 엔티티가 아닌 스칼라 타입을 조회할 때도 사용이 가능하다.

 

기본적으로 충돌이 많이 발생하는 환경을 가정하기 때문에, 트랜잭션 간 충돌이 많이 발생하는 환경에서 사용하기 적합하다. 하지만 데이터베이스의 락 기능을 사용하는 것이기 때문에 성능면으로는 좋지 못하다.

 

비관적 락의 옵션도 다양하지만 대표적인 PESSIMISTIC_WRITE 옵션을 사용하여 테스트를 해보고자 한다.

 

PESSIMISTIC_WRITE 옵션 테스트

앞서 None 옵션 테스트 처럼 5개의 멀티 스레드를 통해 테스트를 진행해보았다.(초기 version=1)

 

해당 데이터를 처음 조회 한 트랜잭션 이외에 트랜잭션들은 락을 획득할 때까지 대기하고, 락을 획득하면 작업을 수행하게 된다. 현재 5개의 스레드가 데이터를 업데이트하기 때문에 최종 version 필드의 값은 6이 되게 된다.

 

 

오른쪽 결과와 같이 실제 select for update 구문을 사용하는 것을 볼 수 있고, 최종 version 의 값도 6이 되는 것을 확인할 수 있다.

 

자료


  • 김영한 스프링 DB 1편-데이터 접근 핵심 원리
  • 자바 ORM 표준 JPA 프로그래밍