트랜잭션 격리수준에 대해 공부해보자 ❕
1. 트랜잭션 격리수준 (Transaction Isolation Level) 이란?
트랜잭션의 성질로 ACID가 있다. 그 중 Isolation(독립성, 격리성)은 둘 이상의 트랙잭션이 동시에 실행되더라도 다른 트랜잭션에 영향을 받지 않고 독립적으로 실행되도록 보장하는 성질이다. 트랜잭션이 이런 성질을 가지고 있다고 해서 무조건 다른 트랜잭션이 끼어들지 못하는건 아니다. 트랜잭션 격리 수준에 따라 끼어들 수 있게 할 지 끼어들 수 없게 할 지 설정할 수 있다.
⇒ 동시에 여러 트랜잭션이 처리될 때 트랜잭션끼리 얼마나 격리되어 있는가에 대한 수준을 트랜잭션 격리수준이라고 한다.
2. 필요성
트랜잭션은 격리성을 보장하기 위해 다른 트랜잭션이 관여하지 못하도록 막는 것이 필요하다. 그렇다고 트랜잭션들을 무조건 격리시켜 순서대로 처리한다면 기다리는 트랜잭션은 계속해서 쌓이고, 처리되는 대기 시간도 늘어나게 될 것이다. 결국 데이터베이스의 성능은 떨어지게 될 것이다. 그렇다고 독립적으로 실행되어야 할 범위를 줄인다면 잘못된 값이 처리될 수 있는 문제가 있다.
따라서 격리성과 성능의 트레이드 오프를 조율한 격리 수준이 필요하게 된다.
3. 종류
SQL 표준에는 트랜잭션 격리수준을 4단계로 나눴다. 주요 RDBMS는 SQL 표준에 기반해서 격리 수준을 정의하지만 RDBMS마다 제공하는 격리 수준이 다르고 같은 이름의 격리 수준이라도 동작 방식이 다를 수 있다. 따라서 사용하려는 RDBMS의 격리 수준을 확인하고 사용해야 한다.
- READ UNCOMMITTED (레벨 0)
- READ COMMITTED (레벨 1)
- REPEATABLE READ (레벨 2)
- SERIALIZABLE (레벨 3)
이 4단계는 트랜잭션을 사용하는데 발생할 수 있는 세가지 이상 현상(Dirty Read, Non-Repeatable Read, Phantom Read)을 정의하고 어떤 이상 현상을 허용하는지에 따라서 나눠진다.
Isolation level \ anomaly | Dirty Read | Non-Repeatable Read | Phantom Read |
READ UNCOMMITTED | O | O | O |
READ COMMITTED | X | O | O |
REPEATABLE READ | X | X | O |
SERIALIZABLE | X | X | X |
(Serializable은 위 세가지 현상 뿐만 아니라 이상한 현상 자체가 발생하지 않는 격리 수준을 의미한다)
Dirty Read : commit 되지 않은 변화를 읽는 것
DB에 x=5, y=10이 저장되어 있는 상태에서 T1과 T2의 작업이 시작되었다.
T1이 x를 읽고 y를 읽으려는데 T2가 시작돼서 y를 30으로 변경한다.
그 후 T1이 다시 시작돼서 commit되지 않은 변경된 y를 읽는다. 그 후 x의 값을 변경한 후 commit한다.
이 때 T2가 abort 된다면 T1에서 읽은 y에 의해 x에 이상한 값이 저장되게 된다.
Non-Repeatable Read : 같은 데이터의 값이 다른 것
T1이 x를 읽고 다시 한 번 읽으려는데 T2가 시작돼서 x에 값을 변경하고 commit한다.
그 후 T1이 다시 시작돼 x를 읽으면 하나의 트랜잭션 안에서 같아야 될 x의 값이 달라지는 문제가 발생한다.
Phantom Read : 없던 데이터가 생긴 것
T1이 x=5인 데이터를 조회해 읽고 다시 한 번 읽으려는데 T2가 시작돼서 c2의 x값을 변경한다.
그 후 T1이 다시 시작돼 동일한 조건으로 데이터를 조회하면 없던 데이터가 생기는 문제가 발생한다.
^Phantom Read와 Non-Repeatable Read의 다른 점은 Non-Repeatable Read는 행 값이 달라질 수도 있고, Phantom Read는 다른 행이 선택될 수도 있다는 것이다.^
이런 이상 현상들을 모두 발생하지 않게 만들 수는 있지만, 제약사항이 많아져 동시 처리 가능한 트랜잭션 수가 줄어들고 결국 DB의 전체 처리량(throughput)이 떨어지므로 개발하는 시스템에 따라 적절한 격리 수준을 설정해야 한다.
물론 위 3가지 현상 외에도 발생할 수 있는 이상 현상들이 있다.
Dirty write : commit 되지 않은 데이터를 write함
T1에서 x를 5로 변경하고 T2가 시작돼서 x를 10으로 변경한다. 근데 T1에서 어떤 문제로 abort하게 된다. 이때 T1작업을 rollback시킨다면 T2 작업이 손실되고 rollback시키지 않는다면 T2에서 abort되었을 때 x의 값을 어떤 값으로 되돌려야 할 지 문제가 된다.
Lost update : 업데이트를 덮어 씀
T1이 시작되고 x의 값을 바꾸려는데 T2가 시작돼 x를 10으로 변경하고 commit한다.
그 다음 T1이 시작돼 x를 5로 바꾸고 commit하면 T2의 작업이 손실되는 문제가 발생한다.
Read skew : 불일치한 데이터 읽기
T2가 시작해 x의 값을 읽는다. y의 값을 읽으려는데 T1이 시작돼 x -> y에 10을 이체한 후 commit한다.
T2에서 마저 y의 값을 읽으면 x=10, y=10인 잘못된 데이터를 읽게 된다.
Write skew : 불일치한 데이터 쓰기
x와 y의 합이 0 이상이어야 하는 조건이 있다고 하자.
T1이 시작돼 x+y ≥ 0 조건에 통과하고 x=-30으로 변경한다. T2이 시작된 시점에도 x+y ≥ 0 조건을 통과해 y=-40이 된다.
최종 저장되는 값은 x=-30, y=-40이 되어버려 x+y ≥ 0 조건에 위배된다.
+ 앞서 SQL표준에서 나눈 격리 수준은 세가지 이상 현상을 정의하고 어떤 현상을 허용하는지에 따라 4단계 레벨로 나눴다고 했다. 한 논문에서는 이 격리 수준들은 상업적인 DBMS에서 사용되는 방법을 고려해 구분하지 않았다 비판했고, 새로운 격리 수준인 SNAPSHOT ISOLATION을 소개했다. SNAPSHOT ISOLATION은 이상 현상을 얼만큼 허용하는지에 따라 구분하는 기존 격리 수준들과 다르게 동시성 제어가 어떻게 동작할지 구현을 바탕으로 정의한 것이다. ^즉, 구현 방식에 기반해 정의한 레벨이다.^
SNAPSHOT ISOLATION
- 트랜잭션이 시작하는 시점에 스냅샷에 저장한다.
- 트랜잭션 내에서 데이터가 변경될 때, 데이터베이스에 바로 적용하지 않고 스냅샷에 저장한다.
- 같은 데이터에 대해 commit하려는 경우에는 나중에 commit하는 데이터는 폐기된다.
(1) T1이 시작되면 T1스냅샷에 x=50, y= 50을 저장하고 x에서 40을 빼준다.
(2) 그럼 T1스냅샷의 x는 10으로 변경된다.
[1] 이 때 T2가 시작돼 T2스냅샷에 x=50, y=50이 저장되고 y에 100을 더해준다.
[2] 그럼 T2스냅샷의 y=150으로 변경된다.
그리고 T2가 commit되면 테이블 내용이 x=50, y=150으로 변경된다.
(2) 그 후 T1의 작업이 마저 실행되어 y를 읽으면 T1스냅샷에 있는 y를 읽어 y=50을 가져와 40을 더한다.
(3) 그럼 T1스냅샷의 y는 90으로 변경된다.
그리고 T1에서 commit했을 때 요청이 실패하게 된다. (나중에 commit하는 데이터는 폐기)
4. 4단계 격리 수준
이제 4단계 격리 수준에 대해 자세히 알아보자.
주로 MySQL에서 제공하는 격리수준에 대해 정리해보려고 한다. MySQL은 SQL 표준 트랜잭션 격리수준 4단계를 모두 제공한다. (^MySQL의 기본 스토리지 엔진인 InnoDB의 기본 격리 수준은 REPEATABLE READ이다^)
READ UNCOMMITTED (레벨 0)
커밋되지 않은 데이터에 접근할 수 있게 하는 격리 수준
T2가 UPDATE를 실행한 후 아직 commit or rollback되지 않은 상태인데, 이 때 T1이 SELECT를 실행해 T2가 UPDATE한 결과(아직 commit되지 않은 데이터)를 조회한다.
만약 T2에서 어떤 문제로 인해 롤백된다면, T1은 올바르지 않은 데이터를 읽게 되어 버린 것이다. (Dirty Read 현상)
READ COMMITTED (레벨 1)
커밋된 데이터만 조회할 수 있게 하는 격리 수준
T1이 처음 SELECT를 실행하면 x의 값은 5이다.
그 다음 T2가 시작되어 UPDATE를 실행하고 commit되었다.
그 다음 T1이 다시 SELECT를 실행하면 x의 값은 T2가 UPDATE한 결과를 조회한다.
이 경우 하나의 트랜잭션 안에서 같은 실행문이 다른 결과를 반환하는 문제가 있다. (Non-Repeatable Read현상)
Q. 여기서 T2의 작업이 commit 되기 직전에 T1이 SELECT를 실행한다면 x=10이 아닌 기존의 x=5를 읽는다. T1은 어떻게 기존의 데이터를 읽어올 수 있는 걸까?
일반적인 RDBMS는 변경 전의 레코드를 Undo(언두) 영역에 백업해둔다.
지금처럼 T2에 대해 변경 작업이 실행되면 변경 전 x값을 Undo 영역에 복사하고 변경 작업을 실행한다. T2가 아직 commit되지 않은 상태에서 T1이 SELECT를 실행하면 테이블에서 읽는게 아닌, Undo 영역의 데이터를 읽어 기존의 데이터를 조회한다.
⇒ 변경 전 데이터가 저장되는 Undo 영역에서 commit되기 전의 데이터를 읽는다.
+ MVCC(Multi-Version Concurrency Control, 다중 버전 동시성 제어) ?
동시 접근을 허용하는 DBMS에서 동시성 제어를 위해 사용하는 방법 중 하나로, 스냅샷을 이용한다. 스냅샷을 통해 하나의 레코드에 대해 여러 버전이 관리되기 때문에 한 트랜잭션이 데이터를 읽거나 쓸 때 다른 트랜잭션에 영향을 받지 않고 일관된 데이터를 보장한다. (일관성 보장)
스냅샷은 트랜잭션이 시작될 때 현재 상태를 기록한 것인데, 이를 기준으로 해당 트랜잭션이 데이터에 접근할 수 있도록 하고 다른 트랜잭션이 데이터를 변경하더라도 해당 트랜잭션의 스냅샷은 변경되지 않는다.
^MVCC방식은 Lock을 필요로 하지 않기 때문에 일반적인 RDBMS보다 빠르게 작동한다.^ (InnoDB는 Undo 로그를 사용해 MVCC를 구현한다)
x에 5를 더하기 전에 변경 전 데이터를 Undo로그에 저장한다.
이 작업이 commit되기 전에 다른 트랜잭션에서 읽는다면 Undo로그에 있는 데이터를 읽는다.
(디스크에 저장되는 x값은 백그라운드 스레드를 통해 갱신되기 때문에 어떤값이 저장되었는지 정확히 알 수 없다. 하지만 MySQL은 일반적으로 버퍼풀과 디스크에 저장된 x값은 동일한 상태라고 가정해도 된다)
REPEATABLE READ (레벨 2)
커밋된 데이터만 읽을 수 있으며, 자신보다 낮은 번호의 트랜잭션에서 커밋한 데이터만 읽을 수 있는 격리 수준 (각각의 트랜잭션은 고유한 번호가 존재하며, Undo 로그에서 어떤 트랜잭션에 의해 변경되었는지 알 수 있다)
^다만, 새로운 레코드가 추가되는 경우에는 자신보다 높은 트랜잭션 번호를 갖더라도 추가된 데이터를 읽어버린다.^
T1이 먼저 시작돼 SELECT를 실행한 후 그 다음 T2가 시작되었다. (T1의 트랜잭션 번호 < T2의 트랜잭션 번호)
변경 전 데이터를 Undo 로그에 저장하고 UPDATE 후 commit되었다.
T1이 다시 SELECT를 실행했을 때 T1은 자신보다 낮은 트랜잭션 번호의 commit된 내용만 조회할 수 있으므로, T2가 UPDATE한 결과가 아닌 Undo 로그를 참고해 변경 전 데이터를 읽게 된다.
T2가 commit까지 되었지만 T1은 commit된 데이터가 아닌 Undo로그를 참고해 데이터를 읽는 것이다.
Q. 자신의 트랜잭션 번호보다 높은 번호의 트랜잭션에서 새로운 레코드가 추가되는 경우 ?
앞서 기존의 레코드가 변경되는 것이 아닌 새로운 레코드가 추가되는 경우, 자신보다 높은 트랜잭션 번호를 갖더라도 추가된 데이터를 읽을 수 있다고 했다. 따라서 다음과 같이 T1보다 나중에 시작된 T2에서 레코드가 추가되었을 때 T1의 같은 실행문에 대해 각각 다른 결과가 나올 수 있다. (Phantom Read 현상)
하지만 MVCC 덕분에 일반적인 조회에서 Phantom Read현상은 발생하지 않는다. 자신보다 나중에 실행된 트랜잭션이 추가한 레코드는 무시하면 되기 때문이다.
Q. 그럼 언제 Phantom Read 현상이 발생하는 걸까?
Lock이 사용되는 경우이다. 일반적인 DBMS에서 Phantom Read 현상이 발생하는 경우를 확인해보자.
T1는 `FOR UPDATE` 구문을 사용해 Lock을 걸고 SELECT를 실행한다. (`FOR UPDATE` 구문은 *Exclusive Lock이다)
일반적인 DBMS에서는 Gab Lock이 존재하지 않으므로 x가 5인 레코드에 잠금(Record Lock)이 걸린다.
그리고 T2이 INSERT한다면 즉시 실행된다.
그 다음 T1이 `FOR UPDATE` 구문을 사용해 다시 SELECT를 실행한다면 기존의 결과와 달라지게 된다. (Phantom Read 현상)
`FOR UPDATE` 로 Lock을 걸어 SELECT를 실행했을 때, 데이터 조회는 Undo로그가 아닌 테이블에서 수행되기 때문이다. (참고로 Undo 로그는 잠금 장치가 없다)
=> 쓰기 Lock(`FOR UPDATE`)이나 읽기 Lock(`FOR SHARE`)으로 레코드를 조회하는 경우 Undo 영역에 있는 데이터가 아닌 테이블의 레코드를 가져오게 되고, 이로 인해 Phantom Read가 발생하는 것이다.
+ 그러나 위와 동일한 작업을 MySQL의 REFEATABLE READ에서 실행시키면 Phantom Read 현상이 발생하지 않는다 !
MySQL은 `FOR UPDATE`를 통해 Lock을 걸 때 Exclusive Lock이 아닌 *Next Key Lock 방식을 사용하기 때문이다.
T1이 `FOR UPDATE` 구문을 사용해 Lock을 걸고 SELECT를 실행하면, x가 5인 레코드에는 Record Lock, x가 5보다 큰 범위에는 Gap Lock을 건다. 따라서 T2가 x=10인 레코드를 INSERT하면 T1이 종료될 때까지 기다리다가 T1이 완료되야 실행된다.
Q. 그럼 MySQL에서 Phantom Read가 발생하는 경우 ?
T1이 트랜잭션을 시작하고, `FOR UPDATE` 없이 SELECT문으로 데이터를 조회한다. 그 후 T2이 INSERT문을 사용해 데이터를 추가한다. 그 후 T1이 `FOR UPDATE` 로 SELECT를 실행한다면 Undo 로그가 아닌 테이블로부터 데이터를 조회하므로 기존에 없던 데이터가 생겨난다. (Phantom Read가 발생)
* Exclusive Lock (배타적 잠금/쓰기 잠금) ?
Exclusive Lock은 특정 레코드나 테이블에 대해 다른 트랜잭션에서 읽기, 쓰기 작업을 할 수 없도록 하는 Lock이다.
다음과 같이 `FOR UPDATE`를 추가해 쿼리를 작성한다. 그럼 test 테이블에 Lock을 걸고 다른 트랜잭션에서 test 테이블에 읽기, 변경 작업을 할 수 없게 된다.
(사실 다른 트랜잭션에서 SELECT를 실행해보면 읽기 작업이 정상적으로 실행되는데 이는 MVCC 기술을 통해 Undo 영역에서 읽어오기 때문이다)
SELECT *
FROM test
FOR UPDATE
Exclusive Lock을 통해 Non-Repeatable Read 문제를 해결할 수 있다. 하지만 Exclusive Lock은 UPDATE, DELETE에 대한 Lock을 걸어 읽기, 쓰기 작업을 막을 수 있지만 INSERT에 대한 Lock은 걸 수 없다. 조회된 레코드에 대해서만 Exclusive Lock을 걸고, 나중에 추가되는 레코드에 대해서는 Lock을 걸지 않기 때문이다. 따라서 Phantom Read 현상이 발생한다.
* Next Key Lock ?
Next Key Lock도 특정 레코드나 테이블에 대해 다른 트랜잭션에서 읽기, 쓰기 작업을 할 수 없도록 하는 Lock이다. 조회된 레코드에 대한 Lock 뿐만 아니라 실행 쿼리에 대한 범위에 대해서도 Lock이 설정된다. (Record Lock + Gap Lock)
만약 T1이 x가 5 이상인 데이터를 조회할 때 `FOR UPDATE`로 실행하면 x가 5인 레코드에는 Record Lock, x가 5보다 큰 범위에는 Gap Lock을 건다. 따라서 T2가 x=10인 레코드를 INSERT했을 때 T1이 종료될 때까지 대기하고 T1이 완료되야 INSERT가 실행된다.
SERIALIZABLE (레벨 3)
가장 엄격한 격리 수준으로, 트랜잭션을 순차적으로 진행시킨다. 트랜잭션에 진입하면 Lock을 걸어 다른 트랜잭션이 접근하지 못하게 한다. SELECT 실행 시 Read Lock을 걸고 INSERT, UPDATE, DELETE 실행 시 Exclusive Lock(MySQL의 경우 Next Key Lock)을 걸어 접근할 수 없도록 한다.
여러 트랜잭션이 동일한 레코드에 동시 접근할 수 없으므로 부정합 문제가 발생하지 않지만, 동시 처리가 불가능해 처리 속도가 느려질 수 있다.
5. 마무리
트랜잭션 격리 수준과 그 속에서 사용되는 기술들에 대해 공부해봤는데 애매하게 알고 있었던 지식들을 이번 기회에 잘 정리한 것 같다.
실제 쿼리를 짜고 트랜잭션을 설계할 때, 데이터 일관성을 유지하는 방법에 관해 한 번 더 생각하고 개발할 수 있을 것 같다.
참고자료 😃
https://www.youtube.com/watch?v=bLLarZTrebU
https://tlatmsrud.tistory.com/118
https://mangkyu.tistory.com/299
https://dev.mysql.com/doc/refman/8.0/en/innodb-transaction-isolation-levels.html
https://mangkyu.tistory.com/53
'DB' 카테고리의 다른 글
[DB] B-Tree, B+Tree - DB Index (0) | 2024.04.18 |
---|---|
[DB] NoSQL (NotOnly SQL) (1) | 2024.03.28 |
[DB] 데이터베이스 정규화 (Normalization) (1) | 2024.03.26 |
[DB] 트랜잭션 (Transaction) (0) | 2024.03.10 |
[DB] 인덱스 (index) (1) | 2024.03.10 |