모든 데이터베이스는 아래의 트랜젝션 격리 수준을 가진다.
- READ UNCOMMITTED
- READ COMMITTED
- REPEATABLE READ
- SERIALIZABLE
이해를 돕기 위해 A,B 트랜젝션이 동시에 특정 데이터에 진입할 때를 가정한다.
1. READ UNCOMMITTED
트랜젝션간 아무런 격리가 되지 않는 격리레벨이다.
A에서 데이터를 1에서 2로 변경했을 때, B가 이를 읽으면 변경된 값인 2로 읽는 것이 본 격리 수준이다.
즉 데이터를 변경하는 즉시 다른 트랜잭션이 읽으면, 변경되고 난 이후 값을 읽는다.
발생할 수 있는 문제점 : Dirty Read
Dirty Read : A가 값 변경하고 B가 읽었을 때, A가 롤백을 할 경우에 발생한다. 이 때, B는 이미 없는 값으로 로직을 수행하게 되버린다.
2. READ COMMITTED
백업 레코드로 Dirty Read를 방지하는 격리레벨이다.
어떤 트랜잭션의 변경 내용이 COMMIT 되어야만 다른 트랜잭션에서 조회할 수 있다.
다른 트랜잭션에서의 변경 사항이 커밋되지 않은 경우, 실제 테이블의 값이 아닌 백업된 레코드에서 값을 가져오게 된다.
하지만 NON-REPEATABLE READ 문제점 발생.
발생할 수 있는 문제점 : NON-REPEATABLE READ
NON-REPEATABLE READ : 트랜젝션 내, 같은 SELECT 쿼리를 두번 수행할 떄 매번 결과가 달라지는 문제점(중간에 다른 트랜젝션에서 update 커밋했을 떄 발생함)
- 1번과 4번의 쿼리결과가 일치하지 않는다
번호 Tx1 Tx2 1 SELECT * FROM MEMBER 2 UPDATE MEMBER SET a = ‘c’ WHERE a = ‘a’ 3 COMMIT 4 SELECT * FROM MEMBER
- 실험(postgresql)
실험 주의점 : 두 개의 터미널에서 psql을 실행하여 각각의 트랜젝션을 실행해야한다. psql은 기본적으로 sql을 실행할 떄, BEGIN/COMMIT을 뒤에 넣어 싱글 트랜젝션으로 실행하기 떄문이다.
START TRANSACTION; -- transaction id : 1 SELECT * FROM MEMBER; START TRANSACTION; -- transaction id : 2 UPDATE MEMBER SET a = 'c' WHERE a = 'a'; COMMIT; SELECT * FROM MEMBER; COMMIT;1번 쿼리 결과 : a,b 4번 쿼리 결과 : b,c
3. REPEATABLE READ
백업 레코드+중간커밋 제외로 트랜젝션을 제어하는 격리레벨이다.
트랜잭션이 시작되기 전에 커밋된 내용에 대해서만 조회할 수 있는 격리수준. 그래서 같은 쿼리를 날려도 일관된 결과를 보장한다. 문제는 insert로 인해 유령 레코드가 나타나는 PHANTOM READ 문제점이 존재한다(postgres는 이 격리에서 PHANTOM READ 발생 X).
postgresql은 이 격리로 트랜젝션을 진행하면 테이블 자체를 백업으로 가져온다. 그래서 PHANTOM READ같은 문제점이 발생하지 않는다.
즉, DB마다 성향과 에러 핸들링이 다르기때문에 모두 테스트 해봐야한다.
발생할 수 있는 문제점 : PHANTOM READ
PHANTOM READ : 다른 트랜잭션에서 수행한 insert 작업에 의해 레코드가 보였다가 안보였다가 하는 현상
- 실험(MARIA DB)
1번과 5번의 쿼리결과가 일치하지 않는다
번호 Tx1 Tx2 1 select count(*) from Coupon 2 insert into Coupon values (‘c’) 3 COMMIT 4 update Coupon set name = ‘d’ 5 select count(*) from Coupon
하지만 위와 같은 결과는 postgresql에서는 일어나지 않는다.
4. SERIALIZABLE
어떤 트랜잭션이 접근 하는 테이블 자체의 모든 R/W에 Lock을 건다. 그래서 완벽한 일관성 보장.
The transaction waits until rows write-locked by other transactions are unlocked; this prevents it from reading any “dirty” data.
The transaction holds a read lock (if it only reads rows) or write lock (if it can update or delete rows) on the range of rows it affects. For example, if the transaction includes the SQL statement SELECT * FROM Orders, the range is the entire Orders table; the transaction read-locks the table and does not allow any new rows to be inserted into it. If the transaction includes the SQL statement DELETE FROM Orders WHERE Status = ‘CLOSED’, the range is all rows with a Status of “CLOSED”; the transaction write-locks all rows in the Orders table with a Status of “CLOSED” and does not allow any rows to be inserted or updated such that the resulting row has a Status of “CLOSED”.
Because other transactions cannot update or delete the rows in the range, the current transaction avoids any nonrepeatable reads. Because other transactions cannot insert any rows in the range, the current transaction avoids any phantoms. The transaction releases its lock when it is committed or rolled back.
Reference in https://learn.microsoft.com/en-us/sql/odbc/reference/develop-app/transaction-isolation-levels?view=sql-server-ver16
JPA 데이터 Lock
비관적 잠금(Pessimistic Lock)
현재 내가 Read/Write하고 있는 데이터에 Lock을 거는 기능
READ COMMIT을 기본격리로 제공하는 데이터베이스의 경우 백업 레코드 접근을 허용한다. 데이터베이스에 쿼리가 날라가기 전 JPA에서 비관적 잠금으로 설정하게 된다면, 백업레코드 접근이 아닌 실제 레코드에 접근한다.
h2-database/Postgres 는 기본적으로 READ COMMIT 트랜젝션 격리단계를 가진다. 따라서 쿠폰 발급 서비스에 동시에 사용자 요청이 들어왔을 때, 같은 데이터를 들고 수정할 수 있게 되버린다.
JPA는 비관적 잠금을 용도에 따라 두 가지로 제공한다.
- 읽기 잠금(Pessimistic Read Lock)
- 쓰기 잠금(Pessimistic Write Lock)
1. 비관적 읽기 잠금(Pessimistic Read Lock)
트랜젝션이 특정 데이터를 UPDATE로 변경할 때만 잠금한다. 해당 잠금은 READ COMMIT 에서는 기본적으로 제공하기에 실제로는 사용될 일이 거의 없다.
일기 잠금은 만약 SELECT로 읽는다면 Lock하지 않는다. 따라서 다른 트랜젝션도 이를 읽을 수 있다.
2. 비관적 쓰기 잠금(Pessimistic Write Lock)
쿼리가 나갈 때, 뒤에 FOR UPDATE를 붙여주는 기능이다. 즉, 읽기에도 Lock을 걸어주는 기능.
이 잠금은 데이터 정합성을 잘 지킬 수 있지만, DeadLock과 성능이슈를 조심해야한다.
- Dead Lock : 트랜젝션들이 서로 Lock되어있는 로우를 무한히 참조하려하는 문제점이다.
- 성능 이슈 : 간단히 읽을 때도 Lock이 걸리기 때문에 동시사용자가 많은 경우, 사용자들은 오랜시간 기다리게 된다.
오라클은 waiting time도 설정할 수 있어 쉽게 핸들링가능.
물론 트랜젝션이 끝나기 전, 다시 한번더 읽어서 데이터 정합성을 유지하는 방법도 존재한다.
하지만 동시 사용자 수가 월등히 많을 때 이 역시 깨질 수 있기에, 이후 확장성을 고려한다면 비관적 쓰기 잠금을 고려할만하다.
Spring Data JPA에서는 아래와 같이 비관적 쓰기 잠금을 선언할 수 있다.
public interface CouponRepository extends JpaRepository<Coupon, Long> {
...
@Lock(LockModeType.PESSIMISTIC_WRITE) // Pessimistic Lock 설정(베타적 Lock)
@Query("select c from Coupon c where c.couponItem.couponCode = :couponCode and c.userId is NULL")
@QueryHints({@QueryHint(name = "javax.persistence.lock.timeout", value ="3000")}) // 3초 타임아웃(set waiting time)
List<Coupon> findByCouponCode(@Param("couponCode") String couponCode, Pageable pageable); // Pageable = limit
...
}