트랜잭션 격리 이야기에서 팬텀 읽기 현상

트랜잭션 격리 이야기에서 팬텀 읽기 현상

트랜잭션 격리 이야기를 하면 빠지지 않고 나오는 것이 격리 수준과 그 수준이 어느 현상까지를 허용하는가?에 대한 이야기다.

간단하게 이야기하면,  일단 트랜잭션의 격리 수준을 지정하는 SET TRANSACTION 명령 도움말을 통해 그 사용법을 보면,

SET TRANSACTION transaction_mode [, ...]
SET TRANSACTION SNAPSHOT snapshot_id
SET SESSION CHARACTERISTICS AS TRANSACTION transaction_mode [, ...]

where transaction_mode is one of:

    ISOLATION LEVEL { SERIALIZABLE | REPEATABLE READ | READ COMMITTED | READ UNCOMMITTED }
    READ WRITE | READ ONLY
    [ NOT ] DEFERRABLE

여기서 격리 수준을 지정하는 transaction_mode  순서 대로 뒤로 갈 수록 트랜잭션의 격리엄격성이 느슨해진다. 즉,  serializable 이 제일 엄격하고, read uncommitted 가 제일 덜 엄격하다. 

즉 제일 엄격한 격리를 원하면, serializable로 지정하면 되고, 최소한의 격리가 필요하면, read uncommitted 를 지정하면 된다. 
그런데, PostgerSQL은 read uncommitted 설정은 read committed 설정으로 간주한다. ANSI 문법 호환성 때문에 read uncommitted 문법을 허용할 뿐 내부적으로 read committed 격리 수준으로 작동한다. 
또한, 이 read committed 격리 수준이 PostgreSQL 기본 격리 수준이기 때문에,  set transaction 명령으로 사용자가 임의로 지정하지 않는 이상 read committed 격리 수준으로 작동한다. 

정리하면, read commited 격리 수준이 어느 수준인지를 알고, 그 외 보다 엄격한 수준이 어떻게 구분되는지를 알아야 적당한 트랜잭션 격리 수준을 지정해서 바르게 사용할 수 있을 것이다. 

트랜잭션 격리

관계형 데이터베이스에 대한 이론적인 이야기를 하다보면, 트랜잭션의 성질 - 트랜잭션은 이러 이러한 모습이어야지 이것을 트랜잭션이라고 할 수 있고, 이런 규칙을 데이터베이스 서버가 지원해야 관계형 데이터베이스라 할 수 있다고 한다.  시험 때 맨날 나오는 바로 그 ACID다. (지겹지도 않는지)

이 트랜잭션의 성질들 가운데 하나인 영문자 I에 해당하는 Isolation - 우리말로 옮기면, 고립, 격리에 해당하는  - 이 있는데, 이 성질은 여러 사용자가 동시에 같은 서버를 사용할 때 동시 처리하는 성능과 직결 되기 때문에, 각 데이터베이스마다 그 격리 수준을 조정할 수 있는 유연성을 가진다. (트랜잭션 고립도가 제일 적당한 용어인듯한데, 잘 안쓴다. 요즘은 다들 격리 수준이라고 하는 듯) 모든 관계형 데이터베이스는 이 수준을 트랜잭션 작업 전에 지정할 수 있도록 해서 그 수준을 바꿀 수 있게한다. 그 표준 명령어가 SET TRANSACTION 명령이다.

PostgreSQL에서는 SET TRANSACTION 명령은 트랜잭션 블럭 내에서 사용할 수 있다. 

$ psql
psql (13.2 (Debian 13.2-1.pgdg100+1))
도움말을 보려면 "help"를 입력하십시오.

postgres=# SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
경고:  SET TRANSACTION 명령은 트랜잭션 블럭에서만 사용될 수 있음
SET
postgres=# show transaction_isolation ;
 transaction_isolation 
-----------------------
 read committed
(1개 행)
이처럼 트랜잭션 블럭 밖에서 이 명령을 사용하면, 격리수준이 바뀌지 않는다. 그래서, 기본 격리 수준 자체를 바꾸고자 한다면, 
postgres=# SET SESSION CHARACTERISTICS 
AS TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SET
postgres=# show transaction_isolation ;
 transaction_isolation 
-----------------------
 serializable
(1개 행)
SET SESSION CHARACTERISTICS AS TRANSACTION ... 명령을 사용한다. 물론 이 설정은 현재 세션에 한정된다. 접속을 끊었다가 다시 접속한다면, 기본 격리 수준으로 바뀐다. 데이터베이스 전역으로 이 격리 수준을 바꾸고자 한다면, transaction_isolation 환경 설정 매개 변수값을 바꾸면 된다. 

유령 읽기 PHANTOM READ

앞에서 이야기했듯이, PostgreSQL에서는 이 수준을 serializable 이나, repeatable read 두 수준으로 임의로 설정한다. 

이 두 수준의 차이는 트랜잭션 커밋 순서를 직렬화로 보장하느냐이다. 
이 말은 아주 단순하다. 먼저 커밋한 놈만 정상 트랜잭션으로 간주하겠다는 것이다. 
(물론 각 트랜잭션 내에서 자료 읽기 독립성이 보장된 상태에 한정 해서 말이다. 자료 읽기 독립성 - 한 트랜잭션이 한번 읽은 자료는 그 자료를 그 트랜잭션 내에서 바꾸지 않는한 다른 세션이 그 자료를 조작해서 커밋을 했더라도, 항상 같은 값으로 보여야한다.)

문제는 PostgreSQL 공식 문서에 소개하고는 repeatable read 격리 수준 상태에서의 phantom read 현상 이야기가 많이 친절하지 못하다. 
(그래서, 한국어 페이지 번역도 포기한 상태였다.)

이 글은 이 유령 읽기에 대한 꼼꼼한 기록이다. 

먼저 위키피디아에서 소개하고 있는 유령 읽기 이야기를 소개하면,  [링크]

이 현상이 일어나는 이유는 내가 조회한 자료 값을 내가 바꾸지 않는 이상 트랜잭션 내에서 언제나 조회해도 항상 같은 값을 제공한다는 repeatable read 격리 수준에서 그 조회한 값이 만일 어떤 범위라면, 그 범위 전체를 보관하지 못해서 발생하는 문제라고 소개하고 있다.

다른 인터넷 문서에서 소개하고 있는 유령 읽기 현상은 항상 다른 세션에서 insert 를 했다거나, delete 를 했다거나 해서 자신의 세션에서 조회한 자료 세트 범위가 다른 세션에 의해서 변경 될 경우 그것이 자신의 자료 세트가 자기가 조작하지 않았음에도 불구하고, 유령처럼 바뀌는 것이다고 소개하고 있다.

가장 쉬운 표현으로 내가 딱히 새로 만들거나 지운 것도 아닌데,  없던 놈이 보이고, 있던 놈이 사라지는 것이다. 

아쉽게도 PostgreSQL repeatable read 격리 수준에서 그 현상을 볼 수가 없다.  read committed 수준에서는 이 현상이 나타난다.
즉, PostgreSQL에서는 자신이 조회한 자료가 하나의 로우 자료가 되든 여러 로우 세트가 되든 repeatable read 격리 수준에서는 자신이 그 자료를 변경하지 않는 이상 처음 조회한 그 형상 그대로를 유지한다. 설령 다른 세션에서 그 자료를 조작하고 커밋을 했다고 하더라도 말이다.

처음부터 PostgreSQL을 써왔고, 딱히 트랜잭션 격리 수준을 바꾸지 않고 사용했왔던 사용자에게는 이 '유령 읽기' 현상과 이 보다 좀 더 느슨한 격리 수준에서 보이는 'Nonrepeatable Read - 반복되지 않는 읽기(마이크로소프트사 번역)' 현상을 명확하게 구분하기가 힘들다.

(처음부터 구분이 안되었으니, 정확하게 설명하기가 힘들었다. - 글쓴이 핑계, 시간 날 때 꼭 격리 수준 관련 공식 설명서 번역본을 가장 읽기 편하도록 바꿔 놓겠다.)

MariaDB 10.5와 PostgreSQL 13의 격리수준에 따른 격리 현상 비교

1. MariaDB의 커밋되지 않는 자료 읽기 read uncommited 격리 수준에서 허용되는 dirty read

시간흐름 첫번째 세션 두번째 세션
1
begin;
 
2  
begin;
3
show variables like 'tx_isolation';
+---------------+------------------+
| Variable_name | Value            |
+---------------+------------------+
| tx_isolation  | READ-UNCOMMITTED |
+---------------+------------------+
 
4  
show variables like 'tx_isolation';
+---------------+------------------+
| Variable_name | Value            |
+---------------+------------------+
| tx_isolation  | READ-UNCOMMITTED |
+---------------+------------------+
5
select * from t;
Empty set (0.000 sec)
 
6  
insert into t values (1,1);
Query OK, 1 row affected (0.000 sec)
7
select * from t;
+---+------+
| a | b    |
+---+------+
| 1 |    1 |
+---+------+
1 row in set (0.000 sec)
 
8  
rollback;
Query OK, 0 rows affected (0.046 sec)
9
select * from t;
Empty set (0.000 sec)
 

노란색 칠한 부분이 바로 커밋 되지 않은 자료 읽기 dirty read  현상이다.
PostgreSQL에서는 이것을 재현할 수 있는 방법이 없다.

2. read committed 격리 수준에서 허용되는 nonrepeatable read 현상 비교

기본적으로 다른 세션에서 커밋한 영향(insert, update, delete)을 자신의 트랜잭션 상태 안에서 반영된다는 점은 MariaDB나 PostgreSQL 둘 다 같다.

기억해야 할 것은 MariaDB는 격리 수준의 기본값이 repeatable read 이고, PostgreSQL은 read committed 라는 점이다. 그래서, MariaDB의 기본 트랜잭션 환경에서는 앞에서 이야기한 다른 세션의 commit 영향을 확인할 수 없다.

비교를 하려면 set transaction 명령(정확히는 set session transaction isolation level ... 구문이다)을 먼저 사용해서 격리 수준을 바꾸고 비교해 보아야한다.

read committed 격리 수준에서 재미난 비교는 어느 시점에서 작업을 시작했고, 그에 따라 그 대상이 어느 자료가 되느냐라는 부분이 두 데이터베이스가 다르다.

2.1 MariaDB

시간흐름 첫번째 세션 두번째 세션
1
> begin;
> show variables like 'tx_iso%';
+---------------+----------------+
| Variable_name | Value          |
+---------------+----------------+
| tx_isolation  | READ-COMMITTED |
+---------------+----------------+
 
2  
> begin;
> show variables like 'tx_iso%';
+---------------+----------------+
| Variable_name | Value          |
+---------------+----------------+
| tx_isolation  | READ-COMMITTED |
+---------------+----------------+
3
> select * from t;
+---+------+
| a | b    |
+---+------+
| 1 |    1 |
| 2 |    2 |
+---+------+
 
4
> update t set b = b + 1;
Query OK, 2 rows affected (0.000 sec)
Rows matched: 2  Changed: 2  Warnings: 0
 
5  
> delete from t where b = 2;
여기서 이 작업은 멈춘다
6
> commit;
 
7  
Query OK, 1 row affected (5.139 sec)
첫번째 세션의 update 작업이 commit되면 delete 작업이 실행된다.
8  
> select * from t;
+---+------+
| a | b    |
+---+------+
| 2 |    3 |
+---+------+

두번째 세션의 delete 작업 결과를 주목해야한다. 작업 결과는 a = 1 자료가 첫번째 세션 작업으로 b 값이 2로 바뀌었고, 그것이 커밋되었기에, 커밋된 자료 (read committed) 기준으로 a = 1, b = 2 자료가 삭제되었다.

2.2 PostgreSQL

시간흐름 첫번째 세션 두번째 세션
1
> begin;
> show transaction_isolation ;
 transaction_isolation
-----------------------
 read committed
 
2  
> begin;
> show transaction_isolation ;
 transaction_isolation
-----------------------
 read committed
3
> select * from t;
 a | b
---+---
 1 | 1
 2 | 2
 
4
> update t set b = b + 1;
UPDATE 2
 
5  
> delete from t where b = 2;
여기서 이 작업은 멈춘다
6
> commit;
 
7  
DELETE 0
8  
> select * from t;
 a | b
---+---
 1 | 2
 2 | 3

PostgreSQL에서는 a=1 자료가 지워지지 않는다!
delete 작업의 시작 시점을 해당 작업을 시작하는 시점 (delete 작업을 시작하는 시점)으로 보고, 작업이 실제로 시작되는 시점은 자기가 잠금을 획득하는 시점(첫번째 세션이 commit 한 뒤)으로 보기 때문이다. 즉, delete 작업을 시작하는 시점에는 b=2 자료인 a=2 자료를 작업대상으로 했는데,  첫번째 세션이 commit 되면서 a=2 자료는 b=3으로 바뀌어버렸기 때문에, 삭제할 자료가 사라진 샘이다.

read committed 격리 수준에서 read 의 타이밍이 그 작업의 시작 시점이 아니라, 정말 자기가 그 작업을 시작할 수 있는 시점(잠금을 할 수 있는 시점)으로 한다.
독특한 모습이다.
아마 undo 로그에 옛 버전 자료를 보관하는 것과, 해당 테이블에 옛 버전을 그대로 두는 MVCC 처리 방식에서 생긴 차이같다. 더 깊게 안 살펴봐서 모른다.

아무튼 PostgreSQL을 쓴다면, 기억해야하는 부분이다.

3. repeatable read 수준에서 유령 읽기 실제 현상

이 현상은 PostgreSQL에서는 나타나지 않기 때문에, MariaDB 경우를 먼저 소개하고, 똑 같은 작업을 PostgreSQL에서 실행할 경우 어떻게 서버가 반응하는지를 소개한다.

3.1 MariaDB

시간흐름 첫번째 세션 두번째 세션
1
begin;
 
2  
begin;
3
> show variables like 'tx_iso%';
+---------------+-----------------+
| Variable_name | Value           |
+---------------+-----------------+
| tx_isolation  | REPEATABLE-READ |
+---------------+-----------------+
 
4  
> show variables like 'tx_iso%';
+---------------+-----------------+
| Variable_name | Value           |
+---------------+-----------------+
| tx_isolation  | REPEATABLE-READ |
+---------------+-----------------+
5
> insert into t values (3,3);
Query OK, 1 row affected (0.000 sec)
 
6  
> select * from t;
+---+------+
| a | b    |
+---+------+
| 1 |    1 |
| 2 |    2 |
+---+------+
2 rows in set (0.000 sec)

> update t set b = b + 1 ;
자료가 두 개 있는 것을 확인하고(아직 첫번째 세션에서 입력한 자료가 커밋되지 않았기 때문에, 두 개의 자료가 나온다), 그 전체를 업데이트했으나, 첫번째 세션의 커밋이 되지 않아 기다리게 된다.
7
commit;
 
8   첫번째 세션이 커밋되면, 업데이트 작업 처리 결과가 나온다.
Query OK, 3 rows affected (31.775 sec)
Rows matched: 3  Changed: 3  Warnings: 0
9  
> select * from t;
+---+------+
| a | b    |
+---+------+
| 1 |    2 |
| 2 |    3 |
| 3 |    4 |
+---+------+

두번째 세션의 update 이후 없던 자료(a=3)가 갑자기 나타난다. 이 현상은 오류가 아니다. 표준 규약에서 repeatable read 수준에서 이런 유령 읽기를 허용하기 때문이다.

delete와 update 복합 상황에서는 보다 유령스러운(?) 현상이 나타난다.

시간흐름 첫번째 세션 두번째 세션
1
begin;
 
2  
begin;
3
> show variables like 'tx_iso%';
+---------------+-----------------+
| Variable_name | Value           |
+---------------+-----------------+
| tx_isolation  | REPEATABLE-READ |
+---------------+-----------------+
 
4  
> show variables like 'tx_iso%';
+---------------+-----------------+
| Variable_name | Value           |
+---------------+-----------------+
| tx_isolation  | REPEATABLE-READ |
+---------------+-----------------+
5
> select  * from t;
+---+------+
| a | b    |
+---+------+
| 1 |    2 |
| 2 |    3 |
| 3 |    4 |
+---+------+
3 rows in set (0.000 sec)

> delete from t where a= 1;
Query OK, 1 row affected (0.005 sec)
 
6  
> select  * from t;
+---+------+
| a | b    |
+---+------+
| 1 |    2 |
| 2 |    3 |
| 3 |    4 |
+---+------+
3 rows in set (0.000 sec)

> delete from t where a= 2;
Query OK, 1 row affected (0.005 sec)
각 세션에서 각자의 자료를 지웠다. (row exclusive lock 작업이기 때문에, 다른 세션이 커밋하지 않아도 자신의 작업은 기다림 없이 실행된다.)
7
commit;
 
8  
> select * from t;
+---+------+
| a | b    |
+---+------+
| 1 |    2 |
| 3 |    4 |
+---+------+
2 rows in set (0.000 sec)

> update t set b = b + 1 where a=1;
Query OK, 0 rows affected (0.000 sec)
Rows matched: 0  Changed: 0  Warnings: 0

> select * from t;
+---+------+
| a | b    |
+---+------+
| 1 |    2 |
| 3 |    4 |
+---+------+
2 rows in set (0.000 sec)
남은 자료를 확인하고, 첫번째 세션에서 지운(이미 커밋한) a=1 자료를 업데이트 했으나 반영 되지 않고, 유령으로 남아있는다.

이렇게 유령 읽기를 허용하는 경우, 자료의 정합성 문제는 없겠지만, 이런 현상을 잘 이해하고 있지 않는다면, 많이 황당할 것 같다.

3.2 PostgreSQL

PostgreSQL 서버는 앞에서 설명했듯이 repeatable read 수준에서도 이런 유령 읽기를 허용하지 않는다.
시간흐름 첫번째 세션 두번째 세션
1
postgres=# begin;
BEGIN
postgres=*# set transaction 
isolation level repeatable read;
SET
 
2  
postgres=# begin;
BEGIN
postgres=*# set transaction 
isolation level repeatable read;
SET
3
postgres=*# insert into t values (3,3);
INSERT 0 1
 
4  
postgres=*# select * from t;
 a | b
---+---
 1 | 1
 2 | 2
(2개 행)

postgres=*# update t set b = b + 1;
UPDATE 2
postgres=*# select * from t;
 a | b
---+---
 1 | 2
 2 | 3
(2개 행)
첫번째 세션의 insert 작업이 커밋되지 않았기에, 두 개의 자료만 보이고, 그것을 update 하면, MariaDB랑 달리 기다림 없이 작업이 바로 성공한다.
5
postgres=*# commit;
COMMIT
 
6  
postgres=*# commit;
COMMIT
7  
postgres=# select * from t;
 a | b
---+---
 3 | 3
 1 | 2
 2 | 3
(3개 행)
각 세션의 각각 자료에 대한 각각 처리였기 때문에, 오류 없이, 처리 되었고, 두번째 세션의 트랜잭션이 끝나기 전까지 이미 첫번째 세션에서 추가한 a=3 자료는 보이지도 않으면, update 작업에 영향을 받지도 않는다.

문제는 다른 세션에서 update, delete 작업을 하고, 커밋한 자료에 대해서 자기가 자료를 조작하려고 할 때, PostgreSQL은 오류를 낸다.
시간흐름 첫번째 세션 두번째 세션
1
postgres=# begin;
BEGIN
postgres=*# set transaction 
isolation level repeatable read;
SET
 
2  
postgres=# begin;
BEGIN
postgres=*# set transaction 
isolation level repeatable read;
SET
3
postgres=*# select * from t;
 a | b
---+---
 3 | 3
 1 | 2
 2 | 3
(3개 행)

postgres=*# delete from t where a = 1;
DELETE 1
 
4  
postgres=*# select * from t;
 a | b
---+---
 3 | 3
 1 | 2
 2 | 3
(3개 행)

postgres=*# delete from t where a = 2;
DELETE 1
각자의 자료를 지웠기 때문에, 별 기다림 없이 작업은 진행된다.
5
postgres=*# commit;
COMMIT
 
6  
postgres=*# select * from t;
 a | b
---+---
 3 | 3
 1 | 2
(2개 행)

postgres=*# update t set b = b + 1 where a = 1;
오류:  동시 삭제 작업 때문에 순차적 액세스가 불가능합니다
첫번째 세션에서 커밋한 뒤에 그 자료를 건드리면, 오류를 낸다.

PostgreSQL에서는 repeatable read 이상의 격리 수준을 사용하는 경우는 위와 같은 오류에 대한 대비책을 반드시 응용프로그램에서 준비해야한다. 아주 중요한 이야기다.

이 글은 유령 읽기에 대한 이야기이기 때문에, serializable 격리 수준에 대한 구체적인 상황과 그 오류들에 대해서는 생략한다. (글 쓰다가 지쳐서, 이 부분은 숙제로 남겨둔다)

마치며

이상 PostgreSQL과 유령 읽기 이야기를 꼼꼼하게 하려고 마음은 먹었으나, 글 쓰다 지쳐 엉성하게 마무리 한다.
실무 상황에서는 이보다 훨씬 다양한 동시 트랜잭션 작업이 발생하며, 다양한 오류를 만들 수 있을 것이다. 이 때, 여기서 소개하고 있는 기본 개념만 잘 기억해 두면, 그 상황을 해결하는데 별로 어렵지는 않을 것이다.
PostgreSQL에서는 앞에서 이야기 했듯이, repeatable read, serializable 격리 수준 상황에서는 반드시 응용프로그램 차원에서 동시 작업 위배 오류에 대한 예외 처리가 꼭 필요하다는 것을 기억두자.