Postgresql 트랜잭션의 격리성(isolation) 레벨

Created
Oct 13, 2020
Created by
Tags
Database
Property
 
트랜잭션의 격리성이란 어떤 트랜잭션이 데이터를 조회하거나 조작하고 있을 때 다른 트랜잭션이 접근할 수 없는 것을 말한다. 트랜잭션의 격리성 레벨이 낮으면 아래와 같은 현상이 발생할 수 있다.
 
 

낮은 단계의 격리성 레벨에서 발생하는 현상들

 

Dirty Read

어떤 트랜잭션이 수정했지만 아직 커밋하지 않은 row를 다른 트랜잭션이 읽는 현상.
  1. T1이 어떤 row를 수정했다.
  1. T1이 수정한 row를 T2가 읽었다.
  1. T1이 rollback을 해서, 수정했던 row는 수정하기 전의 상태로 돌아왔다.
  1. T2는 여전히 수정된(지금은 존재하지 않는 것으로 간주하는) row를 가지고 무언가를 한다.
 

Non-Repeatable Read

1개 트랜잭션이 동일한 쿼리를 2번 실행했는데 그 사이에 다른 트랜잭션이 row를 수정함으로써 두 쿼리가 select한 row의 값이 달라지는 현상.
  1. T1이 어떤 row를 읽었다.
  1. T1이 다른 일을 하는 사이 T2가 해당 row를 수정하고 commit했다.
  1. T1이 1번에서 읽었던 row를 다시 읽었을 때 이전에 읽었던 것과 다른 값이 조회된다.
 

Phantom Read

1개 트랜잭션이 동일한 쿼리를 2번 실행했는데 첫번째 쿼리에 없었던 row들이 두번째 쿼리에서 나타나는 현상.
  1. T1이 어떤 조건을 만족하는 row들을 읽었다.
  1. T2가 조건을 만족하는 새로운 row를 INSERT했다.
  1. T1가 다시 조건을 만족하는 row들을 읽었을 때 1번에서 얻었던 결과에는 없는 row들이 있다.
 
 
 

격리성 레벨

 
 
위와 같은 현상을 방지하기 위해 트랜잭션 격리성 수준은 4단계로 구분하는데, Postgresql을 기준으로 격리성 레벨이 어떻게 적용되는지 알아보자.
참고로 postgresql에 설정된 격리성 레벨은 아래 쿼리를 통해 확인할 수 있다.
SELECT current_setting('transaction_isolation');
notion image
 
 

Read Uncommitted

가장 낮은 수준의 격리 단계로, 사실 상 트랜잭션이 서로 격리되지 않는 단계를 말한다. Postgresql에서는 지원하지 않는 레벨이다. (초기 설정할 때 Read Uncommitted를 선택하면 Read Committed로 적용된다.)
commit 하지 않은 트랜잭션이 수정한 row를 다른 트랜잭션이 읽는 것을 허용한다. 트랜잭션이 share/exclusive lock 중 어느 것도 설정하지 않으므로 Dirty Read, Non-Repeatable Read, Phantom Read를 허용한다.
 
 

Read Committed

Postgresql에 디폴트로 설정되어있는 단계로, committed row에 한해서만 다른 트랜잭션이 row를 읽는 것을 허용한다.
Postgersql의 경우 Read Committed level에서 SELECT를 실행할 때마다 매번 DB에 스냅샷을 떠서 스냅샷의 결과를 본다. 따라서 Dirty Read는 허용하지 않지만 Non-Repeatable Read와 Phantom Read를 허용한다.
뿐만 아니라 하나의 트랜잭션 안에서 row들이 UPDATE/DELETE되었다면, 그 트랜잭션이 commit 또는 rollback되지 않더라도 트랜잭션 내부에서는 UPDATE/DELETE 결과를 그 다음 SELECT으로 볼 수 있다.
UPDATE/DELETE/SELECT ~ FOR UPDATE/SELECT ~ FOR SHARE 쿼리도 위에서 설명한 기준으로 대상 row를 탐색한다.
 
T1이 어떤 테이블의 row를 UPDATE하려고 할 때 T2가 해당 row를 수정하거나 삭제하려고 exclusive lock을 먼저 걸었다고 하자. 아래 그림은 Read Committed 트랜잭션 T1에서 발생하는 Non-Repeatable Read를 나타낸 것이다.
 
notion image
 
  1. T1T2가 commit 또는 rollback 될 때 까지 기다린다.
  1. T2가 row를 UPDATE 하는 게 commit 된 경우 T1T2UPDATE한 row에 UPDATE를 시도할 수 있다. (T2가 row를 DELETE 하는 게 commit 된 경우 T1UPDATE는 무시된다.)
  1. T1 에서는 UPDATE하려는 row가 WHERE 조건에 맞는지 다시 확인한다. 맞으면 UPDATE 할 수 있지만 맞지 않을 경우 Non-Repeatable Read가 발생한 것으로, 처음에 SELECT했던 결과와 다른 결과에 UPDATE를 하게 된다.
 
아래 그림은 Read Committed 트랜잭션 T1에서 발생하는 Phantom Read를 나타낸 것이다.
notion image
 
  1. T1이 어떤 조건을 만족하는 row들을 읽었다. (T1이 share lock을 걸었다.)
  1. T1이 다른 일을 하는 사이 T2가 새로운 row를 만들었다.
  1. T1이 1번과 동일한 WHERE조건으로 다시 SELECT 했을 때 1번에서 얻었던 결과에는 없는 row들이 있다.
 
 

Repeatable Read

 
Repeatable Read는 Read Committed 처럼 committed row만 읽는 것을 허용하지만, 트랜잭션이 시작하기 전에 commit된 row만을 읽도록 한다. 즉, 중간에 다른 트랜잭션이 같은 row에 UPDATE/DELETE 하고 commit 했다 하더라도 commit된 row를 읽지 않는다.
Read Committed 는 SELECT를 할 때마다 스냅샷을 매번 땄기 때문에 다른 트랜잭션이 commit한 row를 읽음으로써 Non-Repeatable Read를 허용했다. 하지만 Repeatable Read에서는 트랜잭션을 시작하기 전에 얻은 스냅샷 결과만을 대상으로 SELECT하므로 Non-Repeatable Read를 허용하지 않는다.
다만, 하나의 트랜잭션 안에서 row들이 UPDATE/DELETE되었다면 그 트랜잭션이 commit 또는 rollback되지 않더라도 트랜잭션 내부에서 UPDATE/DELETE 결과를 그 다음 SELECT으로 볼 수 있는 것은 여전하다.
UPDATE/DELETE/SELECT ~ FOR UPDATE/SELECT ~ FOR SHARE 쿼리도 위에서 설명한 기준으로 대상 row를 탐색한다. 아래 그림은 Repeatable Read 트랜잭션 T1SELECT하는 row를 T2UPDATE했을 때의 상황을 나타낸 것이다.
 
notion image
  1. T1T2가 commit 또는 rollback 될 때까지 기다린다.
  1. T2가 rollback되면 T1은 row에 share/exclusive lock을 걸어 원하는 쿼리를 수행할 수 있다.
  1. T2가 commit되면 (row가 수정/삭제되면) T1ERROR: could not serialize access due to concurrent update 메세지와 함께 rollback 된다. Repeatable Read 의 트랜잭션은 다른 트랜잭션이 변형한 row에 lock을 걸거나 변형을 가할 수 없기 때문이다.
 
만약 위와 같은 상황으로 트랜잭션이 rollback된 경우 애플리케이션 서버에서는 해당 트랜잭션을 abort 시키고 트랜잭션을 재시도 해야한다. 재시도한 트랜잭션에서는 다른 트랜잭션이 변형한 row가 스냅샷에 반영될 것이다.
 
한 가지 참고할 것은 다른 DBMS에서 격리성 레벨을 Repeatable Read로 선택하면 Phantom Read를 허용할 수 있겠지만 Postgresql은 Repeatable Read level에서 Dirty Read, Non-Repeatable Read, Phantom Read 모두 허용하지 않는다는 점이다. (SQL 표준에서는 각 격리성 레벨에서 발생해선 안되는 현상만 언급할 뿐, 발생해야 하는 현상에 대해서는 언급이 없다고 한다.)
 

Serializable

 
가장 엄격한 수준의 격리성 단계로, 여러 트랜잭션이 순차적으로 실행되도록 하여(serialize) 동시성이 없는 수준이라고 보면 된다. 하지만 Repeatable Read 처럼 트랜잭션이 rollback 될 경우 애플리케이션 서버에서 재시도를 해야한다.
격리성 레벨을 Serializable 으로 선택하면 Postgresql은 동시에 실행가능한 트랜잭션들을 모니터링하면서 실행할 트랜잭션들을 선별하는데, 이 과정에서 오버헤드가 발생하며 serialization abnormaly를 야기하는 트랜잭션들을 동시에 실행하면 serialization failure를 일으킨다. 한 가지 예시를 보자.
아래와 같이 table에 row들이 있다.
class | value -------+------- 1 | 10 1 | 20 2 | 100 2 | 200
 
아래 그림 처럼 T1T2SELECTINSERT를 시도한다.
notion image
 
SELECT는 상호배타적인 쿼리가 아니기 때문에 동시에 실행할 수 있고 INSERT도 상호배타적이지 않기 때문에 격리성 레벨이 Repeatable Read 였다면 T1T2는 둘 다 commit 될 것이다. 하지만 격리성 레벨이 Serializable 로 되어있다면 그렇지 않다.
T1이 먼저 commit 하고 T2가 실행 되었다면 T2SELECT 결과는 300이 아닌 600이었을 것이고, T2가 먼저 commit 하고 T1이 실행 되었다면 T1SELECT 결과는 30이 아닌 60이었을 것이다. 두 트랜잭션의 실행 순서가 바뀌면 각 트랜잭션의 SELECT 결과도 바뀌기 때문에(serialization abnormaly가 발생하기 때문에) Serializable은 둘 중 하나만 commit 하고 나머지 하나는 rollback 시킨다.
 
 

결론

 
격리성 레벨을 높게 설정하면 데이터의 일관성(consistency)도 높일 수 있지만 트랜잭션들의 동시성(concurrency)을 저하시키기 때문에 DB I/O에 레이턴시가 늘어 사용자 경험이 떨어질 수 있다. 반대로 격리성 레벨을 낮게 설정하면 트랜잭션의 동시성을 높일 수는 있으나 데이터의 일관성은 떨어지게 된다.
 
 
 
참고