LabHub

데이터베이스 개념 · 트랜잭션과 ACID · 이론

트랜잭션 — 표에 없는 열이 실무의 대부분이다

LabHub 에서 이어서 보기

한 줄 요약

교과서의 격리 수준 표는 세 가지 이상 현상만 다루지만, 실무 동시성 버그의 대부분은 그 표에 없는 네 번째 현상인 쓰기 편향이다.

왜 이게 필요했나

동시성 버그를 만나면 흔히 나오는 처방이 있다. "격리 수준을 반복 읽기로 올려라." 그리고 상당수의 경우 그 처방은 문제를 고치지 못한다. 잔고가 음수가 되고, 재고가 초과 판매되고, 중복 예약이 계속 생긴다.

이유는 두 가지다. 첫째, 표로 배운 정의와 실제 구현이 다르다. 둘째, 네 가지 수준 중 어느 것도 잡지 못하는 이상 현상이 하나 있다.

어떻게 동작하나

ACID 부터 정리하면 이렇다. 원자성은 전부 반영되거나 전부 취소되는 성질, 일관성은 제약을 지키는 성질, 격리성은 동시 실행이 순차 실행처럼 보이는 성질, 지속성은 커밋된 결과가 사라지지 않는 성질이다. 지속성은 대개 선행 기록 로그(WAL) 로 보장한다. 데이터 페이지를 고치기 전에 변경 내용을 로그에 먼저 기록하고 그 로그를 디스크에 내린다. 크래시 후에는 로그를 재생해 복구한다.

표준이 정의한 격리 수준과 이상 현상은 이렇다.

| 격리 수준 | dirty read | non-repeatable read | phantom read | write skew |
| --- | --- | --- | --- | --- |
| Read Uncommitted | 표준상 허용 | 허용 | 허용 | 허용 |
| Read Committed | 불가 | 허용 | 허용 | 허용 |
| Repeatable Read | 불가 | 불가 | 표준상 허용 | 허용 |
| Serializable | 불가 | 불가 | 불가 | 불가 |

오른쪽 끝 열이 핵심이다. Serializable 을 제외한 모든 수준이 쓰기 편향을 허용하고, 대부분의 서비스는 Read Committed 에서 돌아간다.

구현의 차이도 알아 둘 만하다. PostgreSQL 은 MVCC 로 격리를 구현하므로 Read Uncommitted 를 요청해도 Read Committed 로 동작한다. 문법은 받아 주지만 dirty read 는 구조적으로 불가능하다. "격리 수준을 낮춰 성능을 올리자"는 제안이 PostgreSQL 에서는 아무 효과가 없는 이유다. 반대로 PostgreSQL 의 Repeatable Read 는 스냅샷 격리라서 표준보다 강하고 팬텀도 보이지 않는다. 대가는 쓰기 충돌 시 트랜잭션이 오류로 죽는다는 것이다. 격리 수준을 올리면 애플리케이션이 재시도할 책임을 지게 된다. 재시도 로직 없이 수준만 올리는 것은 사용자에게 오류를 더 자주 보여 주는 변경일 뿐이다.

현장에서 만나는 모습

쓰기 편향의 고전적 예가 당직 의사 문제다. 규칙은 "당직자가 최소 한 명은 남아야 한다"이다. 두 의사가 동시에 트랜잭션을 열어 각자 count(*) = 2 를 읽고 조건을 통과한 뒤, 각각 서로 다른 행을 갱신한다. 쓰기가 겹치지 않으므로 스냅샷 격리는 아무 충돌도 감지하지 못한다. 둘 다 성공하고 당직자는 0명이 된다.

같은 구조의 버그가 반복된다. 재고 확인 후 차감, 좌석 중복 예약 검사, 유니크 제약 없는 중복 가입 검사가 모두 쓰기 편향이다. 격리 수준을 한 단계 올려도 하나도 고쳐지지 않는다.

선택지는 세 가지다. 잠글 대상 행이 명확하면 SELECT ... FOR UPDATE 같은 비관적 잠금이 가장 단순하고 예측 가능하다. 충돌이 드물고 사용자 상호작용이 중간에 끼면 버전 컬럼을 쓰는 낙관적 잠금이 낫다. 불변식이 여러 행이나 여러 표에 걸쳐 잠글 대상을 특정할 수 없으면 Serializable 이 답이다.

그리고 가능하다면 격리 수준으로 지키던 불변식을 제약으로 옮기는 것이 가장 견고하다. 유니크 인덱스나 배제 제약은 격리 수준과 무관하게 항상 성립하고, 재시도도 필요 없으며, 나중에 합류한 팀원이 실수로 우회할 수도 없다.

이어지는 퀴즈에서 확인할 것

네 가지 이상 현상을 구분하고, 어떤 상황에 어떤 도구를 골라야 하는지 판단할 수 있는지 확인한다.