동시에 실행되는 두 트랜잭션은 서로의 중간 상태를 얼마나 봐야 할까. 완벽하게 격리하면 한 트랜잭션이 끝나기 전까지 다른 트랜잭션은 그 변경을 전혀 볼 수 없다. 모든 트랜잭션이 순차적으로 실행된 것과 같은 결과가 나오고, 정확성 측면에서는 가장 강한 보장이다. 그런데 그렇게 하면 동시성이 거의 사라진다. 한 번에 하나씩만 처리하면 처리량이 급격히 떨어진다.
그래서 RDB 는 격리를 단순 on/off 가 아니라 단계로 제공한다. 격리 수준(Isolation Level)을 처음 배우면 4단계를 O/X 표로 외우게 되는데, 정작 각 단계가 왜 그 현상을 허용하거나 막는지는 표에 드러나지 않는다. 각 단계는 “어떤 이상 현상(Anomaly)까지 허용할 것인가” 의 선택이고, 더 강한 격리는 더 적은 이상 현상을 허용하는 대신 더 큰 동시성 비용을 치른다. 그 트레이드오프가 이 글의 주제다.
이상 현상
격리가 느슨할 때 동시 트랜잭션 사이에서 생기는 대표적인 현상이 넷 있다.
Dirty Read. 트랜잭션 T1 이 값을 수정하고 아직 커밋하지 않은 상태에서, T2 가 그 값을 읽는다. T1 이 롤백하면 T2 는 존재한 적 없는 값을 근거로 로직을 수행한 셈이 된다.
sequenceDiagram
participant T1
participant DB
participant T2
T1->>DB: UPDATE balance = 500 (커밋 전)
T2->>DB: SELECT balance
DB-->>T2: 500 (커밋되지 않은 값)
T1->>DB: ROLLBACK
Note over T2: 500 은 무효한 값이 된다
Non-Repeatable Read. 한 트랜잭션 안에서 같은 행을 두 번 조회했는데 결과가 다르다. 두 조회 사이에 다른 트랜잭션이 그 행을 수정하거나 삭제하고 커밋한 것이다. 하나의 트랜잭션 안에서는 데이터가 일관되게 보여야 한다는 전제가 깨진다.
Phantom Read. 특정 조건으로 범위를 조회했는데, 두 조회 사이에 다른 트랜잭션이 그 범위에 해당하는 행을 새로 삽입하고 커밋한다. 다시 조회하면 이전에 없던 행이 결과에 나타난다. Non-Repeatable Read 와 비슷하지만, 특정 행의 값이 바뀐 게 아니라 결과 집합의 행 개수가 달라진다는 점이 다르다.
Lost Update. 두 트랜잭션이 같은 값을 읽고 각자 계산해서 쓰면, 한쪽의 갱신이 다른 쪽에 덮여 사라진다. 재고 100 을 둘이 동시에 읽고 각자 하나씩 팔아 99 로 쓰면, 실제로는 둘이 팔렸는데 재고는 99 로 남는다.
sequenceDiagram
participant T1
participant DB
participant T2
T1->>DB: SELECT stock → 100
T2->>DB: SELECT stock → 100
T1->>DB: UPDATE stock = 100 - 1 = 99
T2->>DB: UPDATE stock = 100 - 1 = 99
Note over DB: 둘이 팔았지만 stock 은 99
Lost Update 는 뒤에 볼 ANSI 표준 표에는 들어 있지 않지만, 실무에서 가장 자주 마주치는 현상이다. 앞의 셋이 “읽기가 무엇을 보느냐” 의 문제라면, Lost Update 는 “쓰기가 서로를 덮느냐” 의 문제라 성격이 조금 다르다.
ANSI 격리 수준 4단계
ANSI 표준은 격리를 네 단계로 정의한다. 각 단계는 앞의 세 이상 현상(Dirty Read, Non-Repeatable Read, Phantom Read)을 하나씩 더 막는 구조로 쌓인다.
| 격리 수준 | Dirty Read | Non-Repeatable Read | Phantom Read |
|---|---|---|---|
| Read Uncommitted | 허용 | 허용 | 허용 |
| Read Committed | 방지 | 허용 | 허용 |
| Repeatable Read | 방지 | 방지 | 허용 |
| Serializable | 방지 | 방지 | 방지 |
Read Uncommitted 는 커밋되지 않은 값도 읽는다. 세 현상을 모두 허용하므로 실무에서 거의 쓰이지 않는다.
Read Committed 는 커밋된 값만 읽어 Dirty Read 를 막는다. Oracle, PostgreSQL 의 기본값이다. 다만 조회 시점마다 최신 커밋 상태를 읽으므로, 한 트랜잭션 안에서 두 번 조회하면 그 사이 커밋된 변경이 반영돼 Non-Repeatable Read 가 남는다.
Repeatable Read 는 한 트랜잭션 안에서 같은 행을 반복 조회해도 같은 값을 보장해 Non-Repeatable Read 까지 막는다. 표준 정의상으로는 범위에 새 행이 삽입되는 Phantom Read 가 아직 남는다.
Serializable 은 모든 현상을 막는다. 트랜잭션을 순차 실행한 것과 동일한 결과를 보장하는 대신, 동시성 비용이 가장 크다.
단계가 올라갈수록 허용하는 이상 현상은 줄고 격리는 강해지지만, 그만큼 트랜잭션이 서로를 기다리는 비용이 커진다. 각 단계는 직전 단계에서 어떤 현상을 하나 더 막았는지로 이어진다.
MVCC
여기까지가 표준의 정의다. 그런데 MySQL 의 InnoDB 스토리지 엔진은 이 표를 그대로 따르지 않는다. 그 차이의 핵심에 MVCC(Multi-Version Concurrency Control)가 있다.
MVCC 는 데이터를 덮어쓰는 대신 버전을 여러 개 유지한다. 트랜잭션이 조회할 때, 그 트랜잭션이 봐야 할 시점의 스냅샷(snapshot)을 읽는다. 다른 트랜잭션이 그 사이에 값을 바꿔 커밋해도, 내 스냅샷은 그 변경을 보지 않는다.
이 방식의 이점은 읽기가 락을 걸지 않는다는 데 있다. 일반 SELECT 는 스냅샷을 읽을 뿐이므로, 같은 행을 수정 중인 쓰기 트랜잭션과 서로를 막지 않는다. 읽기와 쓰기가 경합하지 않으니 동시성을 유지하면서도 일관된 읽기를 제공한다.
스냅샷을 언제 잡느냐는 격리 수준에 따라 다르다. Read Committed 에서는 조회할 때마다 새 스냅샷을 잡는다. 그래서 한 트랜잭션 안에서도 매 조회가 그 시점의 최신 커밋을 본다. Repeatable Read 에서는 트랜잭션의 첫 조회 시점에 스냅샷을 한 번 잡고, 이후의 모든 일반 조회가 그 스냅샷을 재사용한다. 같은 트랜잭션 안의 여러 SELECT 가 서로 일관된 결과를 보는 이유다.
InnoDB Repeatable Read
표준 정의에서 Repeatable Read 는 Phantom Read 를 허용한다. 그런데 InnoDB 의 Repeatable Read 는 Phantom Read 를 상당 부분 막는다. InnoDB 의 기본 격리 수준이 Repeatable Read 이므로, 표준 정의와 실제 동작이 갈리는 지점이다.
막는 방식은 조회 종류에 따라 갈린다.
일반 SELECT 는 앞서 본 스냅샷 읽기로 처리된다. 트랜잭션 첫 조회 시점의 스냅샷을 재사용하므로, 그 뒤에 다른 트랜잭션이 새 행을 삽입하고 커밋해도 내 스냅샷에는 보이지 않는다. 이 경로에서는 Phantom Read 가 자연히 사라진다.
SELECT ... FOR UPDATE 나 FOR SHARE 같은 locking read 는 스냅샷이 아니라 최신 데이터를 읽고 락을 건다. 여기서는 스냅샷이 Phantom Read 를 막아주지 못하므로 InnoDB 가 다른 방식을 쓴다. 조회한 인덱스 범위에 next-key lock(레코드 락 + gap lock)을 걸어, 다른 트랜잭션이 그 범위 안에 새 행을 삽입하는 것 자체를 차단한다. 삽입이 막히니 Phantom Read 도 생기지 않는다.
두 경로를 합치면, InnoDB 의 Repeatable Read 는 표준이 허용하는 Phantom Read 를 일반 조회와 locking read 양쪽에서 막는 셈이 된다. “상당 부분” 이라고 표현하는 건, 스냅샷 읽기와 locking read 가 한 트랜잭션에 섞일 때처럼 경계 조건이 남기 때문이다. 그 경우를 빼면 표준 정의보다 강하게 동작한다.
Serializable 은 여기서 한 걸음 더 나간다. InnoDB 는 이 수준에서 autocommit 이 꺼져 있으면 일반 SELECT 를 암묵적으로 SELECT ... FOR SHARE 로 승격시킨다. 모든 읽기가 락을 잡으므로 트랜잭션이 서로 더 강하게 직렬화되고, 동시성 비용도 그만큼 커진다.
정리
격리 수준은 정책 선택이다. 완벽한 격리는 순차 실행과 같은 정확성을 주지만 동시성을 없애고, 약한 격리는 이상 현상을 허용하는 대신 처리량을 얻는다. ANSI 4단계는 그 사이에서 “어떤 현상까지 허용할 것인가” 를 고르는 기준이다.
표준 4단계는 기준일 뿐, 실제 엔진은 그보다 강하게 동작하기도 한다. InnoDB 는 MVCC 로 읽기와 쓰기의 경합을 줄이면서 Repeatable Read 에서 Phantom Read 까지 상당 부분 막는다. 그래서 표만 외우면 실제 동작을 놓친다. 쓰는 DB 엔진이 각 격리 수준을 어떻게 구현하는지 확인해야 한다.
격리 수준을 어디에 둘지는 워크로드가 결정한다. 결제처럼 정확성이 우선이면 강한 격리와 명시적 락으로 기울고, 조회가 대부분이면 약한 격리로 동시성을 얻는다. 약한 격리의 기본 읽기로는 자동으로 막지 못하는 Lost Update 같은 현상을 애플리케이션이 어떻게 메우는지 — 낙관적 락과 비관적 락 — 는 다음 글에서 본다.
참고
- RDB Transaction 의 ACID 가 실제로 보장하는 것 — 이 글이 구체화하는 I(Isolation) 트레이드오프의 출발점